mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-22 11:49:03 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b7c642e8 | ||
|
|
88f944fe81 | ||
|
|
39a3667f39 | ||
|
|
2ed70bd693 | ||
|
|
90248ef243 | ||
|
|
7e0e0635b8 | ||
|
|
ec44398b1c | ||
|
|
d556ff8df2 | ||
|
|
3a09d01bbe | ||
|
|
0bdf5002a0 | ||
|
|
a4a592b4e6 | ||
|
|
66a2417229 | ||
|
|
555ef89800 | ||
|
|
01c82d6921 | ||
|
|
68e8afd35c | ||
|
|
5ef8b9b924 | ||
|
|
a576ad5218 | ||
|
|
8ea46e7c6c | ||
|
|
03f4f76b72 | ||
|
|
6ffb9f2c9e | ||
|
|
b470258d95 | ||
|
|
fac11286f5 | ||
|
|
50f7f11f0b | ||
|
|
f96127c264 | ||
|
|
2ae415be6b | ||
|
|
59faa4e088 | ||
|
|
3df7c22f4d | ||
|
|
f4cbbe806d | ||
|
|
cfb1420660 | ||
|
|
161f56b5d4 | ||
|
|
5ddf8bc1b0 | ||
|
|
dc2c48f6d7 | ||
|
|
6e5d55ff64 | ||
|
|
d439b09f90 | ||
|
|
3f7768a48f | ||
|
|
7881bea60f | ||
|
|
b0b00fe1d0 |
@@ -1,401 +0,0 @@
|
||||
# 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")
|
||||
|
||||
# Returning exceptions in JSON responses
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ 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")
|
||||
|
||||
# Safe error responses
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for API request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid request parameters",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## 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
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,439 @@
|
||||
# Agent 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
|
||||
|
||||
### 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/
|
||||
|
||||
# Type check
|
||||
python3 -u -m mypy --config-file frigate/mypy.ini 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
|
||||
|
||||
# E2E: first-time setup
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# E2E: build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
|
||||
# E2E: interactive UI for debugging
|
||||
npm run e2e:ui
|
||||
|
||||
# E2E: run a specific spec
|
||||
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
|
||||
|
||||
# E2E: filter by name, or run only desktop/mobile
|
||||
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
|
||||
npx playwright test --config e2e/playwright.config.ts --project=desktop
|
||||
|
||||
# E2E: regenerate mock data after backend model changes (from repo root)
|
||||
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
|
||||
|
||||
# Regenerate config translations from Pydantic models — outputs to
|
||||
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
|
||||
# JSON files by hand; change the Pydantic field title/description and
|
||||
# re-run this script. (from repo root)
|
||||
python3 generate_config_translations.py
|
||||
|
||||
# Extract i18n keys from source into the locale files after adding
|
||||
# new t() calls. Use the :ci variant to verify the locale files are
|
||||
# in sync with source (fails if extraction would change anything).
|
||||
npm run i18n:extract
|
||||
npm run i18n:extract:ci
|
||||
```
|
||||
|
||||
### 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")
|
||||
|
||||
# Returning exceptions in JSON responses
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ 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")
|
||||
|
||||
# Safe error responses
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for API request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid request parameters",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## WebSocket Broadcasts
|
||||
|
||||
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
|
||||
|
||||
## 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
|
||||
@@ -252,6 +252,7 @@ http {
|
||||
include proxy.conf;
|
||||
|
||||
proxy_cache api_cache;
|
||||
proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_cache_valid 200 5s;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
cuda-python == 12.6.*; platform_machine == 'aarch64'
|
||||
cuda-python == 13.3.*; platform_machine == 'aarch64'
|
||||
numpy == 1.26.*; platform_machine == 'aarch64'
|
||||
|
||||
@@ -67,7 +67,7 @@ Additional cameras are simply added under the camera configuration section.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Management" /> and use the add camera button to configure each additional camera.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the add camera button to configure each additional camera.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
|
||||
|
||||
### 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.
|
||||
Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid.
|
||||
|
||||
- **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.
|
||||
- **Reasoning / 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.
|
||||
Frigate manages reasoning per task automatically:
|
||||
|
||||
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation).
|
||||
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
|
||||
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
|
||||
|
||||
**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.
|
||||
You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions.
|
||||
|
||||
### llama.cpp
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Here are some common starter configuration examples. These can be configured thr
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
@@ -192,7 +192,7 @@ cameras:
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
@@ -270,7 +270,7 @@ cameras:
|
||||
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||
8. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -257,19 +257,38 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Disabling cameras
|
||||
### Camera state
|
||||
|
||||
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
|
||||
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
|
||||
|
||||
:::note
|
||||
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
|
||||
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
|
||||
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
|
||||
|
||||
Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate.
|
||||
#### Turning a camera on or off
|
||||
|
||||
:::
|
||||
Turning a camera off is temporary and does not require a restart. The available controls are:
|
||||
|
||||
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||
- The power button in the single-camera Live view header
|
||||
- The right-click context menu on a camera tile on the Live dashboard
|
||||
- The Camera management settings pane (status set to **Off**)
|
||||
- The mobile settings drawer on the single-camera Live view (admin users only)
|
||||
- The [MQTT topic](/integrations/mqtt#frigatecamera_nameenabledset) `frigate/<camera_name>/enabled/set` with payload `ON` or `OFF`
|
||||
- The Home Assistant integration via the [`camera.turn_on` / `camera.turn_off` actions](/integrations/home-assistant#camera-api)
|
||||
|
||||
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
|
||||
#### Disabling a camera
|
||||
|
||||
Disabling a camera saves the change to your configuration file. Navigate to **Settings → Global configuration → Camera management** and set the camera's status to **Disabled**. Runtime processing stops immediately; the change persists across restarts.
|
||||
|
||||
Re-enabling a disabled camera requires a restart of Frigate so that the ffmpeg processes and other camera-scoped resources can be initialized. The UI will prompt you to restart when you switch a disabled camera back to On.
|
||||
|
||||
#### Restream behavior
|
||||
|
||||
For both Off and Disabled cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||
|
||||
#### Choosing Off versus Disabled
|
||||
|
||||
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
|
||||
|
||||
### Live player error messages
|
||||
|
||||
|
||||
@@ -197,3 +197,7 @@ This option is handy when you want to prevent large transient changes from trigg
|
||||
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
|
||||
|
||||
:::
|
||||
|
||||
## Reviewing Detected Motion
|
||||
|
||||
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](review.md#reviewing-motion) on the Review page.
|
||||
|
||||
@@ -33,10 +33,10 @@ The easiest way to define profiles is to use the Frigate UI. Profiles can also b
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. **Create a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
|
||||
1. **Create a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
|
||||
2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button to clear overrides.
|
||||
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
|
||||
4. **Delete a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
|
||||
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Global configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
|
||||
4. **Delete a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -126,7 +126,9 @@ Only the fields you explicitly set in a profile override are applied. All other
|
||||
|
||||
## Activating Profiles
|
||||
|
||||
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
|
||||
|
||||
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
@@ -135,10 +137,10 @@ A common use case is having different detection and notification settings based
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
|
||||
1. Navigate to <NavPath path="Settings > Global configuration > Profiles" /> and create two profiles: **Home** and **Away**.
|
||||
2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications.
|
||||
3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
|
||||
4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
|
||||
4. Activate the desired profile from <NavPath path="Settings > Global configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -207,3 +209,27 @@ In this example:
|
||||
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
|
||||
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
|
||||
- **No profile active**: All cameras use their base configuration values.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Can I define a zone or mask in a profile but not have it in the base config?
|
||||
|
||||
No. Profiles are pure overrides. Every zone and mask defined under a profile must reference an entry that already exists on the base camera config. Configurations that introduce profile-only zones or masks are rejected at startup.
|
||||
|
||||
If you want a zone or mask to be active only under a specific profile, define it on the base config with `enabled: false`, then enable it in that profile's overrides.
|
||||
|
||||
### How do I revert a profile zone or mask override back to the base configuration?
|
||||
|
||||
Delete the override. In the Frigate UI, edit the profile and use the "Revert override" action (the trash can icon) on the zone or mask. The base entry is left untouched, and once the override is removed the profile inherits the base values for that zone or mask.
|
||||
|
||||
### Can multiple profiles be active at the same time?
|
||||
|
||||
No. Only one profile can be active at a time. Activating a new profile automatically deactivates the current one.
|
||||
|
||||
### What happens to my profile overrides if I delete a zone or mask from the base?
|
||||
|
||||
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
|
||||
|
||||
### Why are some settings missing when I configure a profile override?
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
@@ -840,8 +840,8 @@ cameras:
|
||||
# Required: name of the camera
|
||||
back:
|
||||
# Optional: Enable/Disable the camera (default: shown below).
|
||||
# If disabled: config is used but no live stream and no capture etc.
|
||||
# Events/Recordings are still viewable.
|
||||
# When False, ffmpeg is not started and the camera is hidden from the UI
|
||||
# (except Camera Management). Re-enabling requires a Frigate restart.
|
||||
enabled: True
|
||||
# Optional: camera type used for some Frigate features (default: shown below)
|
||||
# Options are "generic" and "lpr"
|
||||
|
||||
@@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts
|
||||
|
||||
## Alerts and Detections
|
||||
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -130,3 +130,43 @@ By default a review item will be created if any `review -> alerts -> labels` and
|
||||
Because zones don't apply to audio, audio labels will always be marked as a detection by default.
|
||||
|
||||
:::
|
||||
|
||||
## Reviewing Motion
|
||||
|
||||
The Review page also can show periods of motion that didn't produce a tracked object, and provides a way to search past recordings for motion in a specific region. These tools complement the alerts and detections workflow above — see [Tuning Motion Detection](motion_detection.md) for how the underlying motion detector is configured.
|
||||
|
||||
### Motion Previews
|
||||
|
||||
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
|
||||
|
||||
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
|
||||
|
||||
The pane provides a few controls:
|
||||
|
||||
- **Speed** — speeds up or slows down all of the preview clips at once.
|
||||
- **Dim** — controls how strongly non-motion areas are darkened by the heatmap overlay. Higher values increase motion area visibility.
|
||||
- **Filter** — opens a 16×16 grid overlaid on a snapshot of the camera. Select one or more cells to only show clips with motion in those regions. This is helpful for filtering out motion in areas like a busy street while keeping motion in your driveway.
|
||||
|
||||
Clicking a preview clip seeks the recording player to that timestamp so you can review the full footage.
|
||||
|
||||
### Motion Search
|
||||
|
||||
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
||||
|
||||
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
|
||||
1. Pick the camera and time range to scan.
|
||||
2. Draw a polygon on the camera frame to define the region of interest.
|
||||
3. Adjust the search parameters if needed:
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
|
||||
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
|
||||
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
|
||||
| **Maximum Results** | Maximum number of matching timestamps to return. |
|
||||
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
|
||||
|
||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||
|
||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||
|
||||
@@ -144,7 +144,7 @@ At this point you should be able to start Frigate and a basic config will be cre
|
||||
|
||||
### Step 2: Add a camera
|
||||
|
||||
Click the **Add Camera** button in <NavPath path="Settings > Camera configuration > Management" /> to use the camera setup wizard to get your first camera added into Frigate.
|
||||
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate.
|
||||
|
||||
### Step 3: Configure hardware acceleration (recommended)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ For clips to be castable to media devices, audio is required and may need to be
|
||||
|
||||
## Camera API
|
||||
|
||||
To disable a camera dynamically
|
||||
To turn a camera off (pauses Frigate's processing of the stream; does not persist across Frigate restarts; see [Camera state](/configuration/live#camera-state)):
|
||||
|
||||
```
|
||||
action: camera.turn_off
|
||||
@@ -204,7 +204,7 @@ target:
|
||||
entity_id: camera.back_deck_cam # your Frigate camera entity ID
|
||||
```
|
||||
|
||||
To enable a camera that has been disabled dynamically
|
||||
To turn a camera back on:
|
||||
|
||||
```
|
||||
action: camera.turn_on
|
||||
@@ -213,6 +213,12 @@ target:
|
||||
entity_id: camera.back_deck_cam # your Frigate camera entity ID
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
These actions toggle Frigate's runtime On/Off state. To permanently disable a camera, set its status to **Disabled** in **Settings → Camera Management** in the Frigate UI.
|
||||
|
||||
:::
|
||||
|
||||
## Notification API
|
||||
|
||||
Many people do not want to expose Frigate to the web, so the integration creates some public API endpoints that can be used for notifications.
|
||||
|
||||
@@ -306,7 +306,7 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
|
||||
|
||||
- `online`: Stream is running and being processed
|
||||
- `offline`: Stream is offline and is being restarted
|
||||
- `disabled`: Camera is currently disabled
|
||||
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
|
||||
|
||||
### `frigate/<camera_name>/<object_name>`
|
||||
|
||||
@@ -368,11 +368,11 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
|
||||
|
||||
### `frigate/<camera_name>/enabled/set`
|
||||
|
||||
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
|
||||
|
||||
### `frigate/<camera_name>/enabled/state`
|
||||
|
||||
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
|
||||
Topic with current runtime state of processing for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/detect/set`
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ Only one replay session can be active at a time. If a session is already running
|
||||
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
|
||||
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
|
||||
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
|
||||
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
|
||||
|
||||
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
|
||||
|
||||
|
||||
Vendored
+74
@@ -2058,6 +2058,47 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/genai/models:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: List available GenAI models
|
||||
description: Returns available models for each configured GenAI provider.
|
||||
operationId: genai_models_genai_models_get
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
/genai/probe:
|
||||
post:
|
||||
tags:
|
||||
- App
|
||||
summary: Probe a GenAI provider without saving config
|
||||
description: >-
|
||||
Builds a transient client from the request body and returns its
|
||||
available models. Used to validate provider credentials in the UI
|
||||
before saving the configuration. Requires admin role.
|
||||
operationId: genai_probe_genai_probe_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenAIProbeBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/vainfo:
|
||||
get:
|
||||
tags:
|
||||
@@ -7031,6 +7072,39 @@ components:
|
||||
"john_doe": ["face1.webp", "face2.jpg"],
|
||||
"jane_smith": ["face3.png"]
|
||||
}
|
||||
GenAIProbeBody:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum:
|
||||
- openai
|
||||
- azure_openai
|
||||
- gemini
|
||||
- ollama
|
||||
- llamacpp
|
||||
title: Provider
|
||||
description: GenAI provider to probe
|
||||
api_key:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: API Key
|
||||
description: API key for the provider (when applicable)
|
||||
base_url:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Base URL
|
||||
description: Base URL for self-hosted or compatible providers
|
||||
provider_options:
|
||||
type: object
|
||||
title: Provider Options
|
||||
description: Additional provider-specific options
|
||||
default: {}
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
title: GenAIProbeBody
|
||||
GenerateObjectExamplesBody:
|
||||
properties:
|
||||
model_name:
|
||||
|
||||
+162
-13
@@ -34,15 +34,18 @@ from frigate.api.auth import (
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
GenAIProbeBody,
|
||||
MediaSyncBody,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
get_current_media_sync_job,
|
||||
get_media_sync_job_by_id,
|
||||
@@ -59,7 +62,11 @@ from frigate.util.builtin import (
|
||||
process_config_query_string,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import apply_section_update, find_config_file
|
||||
from frigate.util.config import (
|
||||
apply_section_update,
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
@@ -75,6 +82,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.app])
|
||||
|
||||
# Short timeout for the /genai/probe path. The probe is interactive — fail
|
||||
# fast on hung providers rather than holding an API worker thread.
|
||||
_PROBE_TIMEOUT_SECONDS = 10
|
||||
# Outer cap that returns control to the caller even if the underlying sync
|
||||
# HTTP call ignores its timeout. The sync work continues in the background
|
||||
# thread; only the response is bounded.
|
||||
_PROBE_OUTER_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
@router.get(
|
||||
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||
@@ -170,6 +185,95 @@ def genai_models(request: Request):
|
||||
return JSONResponse(content=request.app.genai_manager.list_models())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/genai/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Probe a GenAI provider without saving config",
|
||||
description=(
|
||||
"Builds a transient client from the request body and returns its "
|
||||
"available models. Used to validate provider credentials in the UI "
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
if not provider_cls:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "message": "Unknown provider"},
|
||||
)
|
||||
|
||||
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
# in milliseconds and would clash with the plugin's own default.
|
||||
probe_provider_options: dict[str, Any] = dict(body.provider_options or {})
|
||||
if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai):
|
||||
probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS)
|
||||
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# model is required by the schema but irrelevant for listing.
|
||||
model="probe",
|
||||
roles=[],
|
||||
)
|
||||
except ValidationError:
|
||||
logger.exception("GenAI probe: invalid configuration")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "message": "Invalid provider configuration"},
|
||||
)
|
||||
|
||||
try:
|
||||
client = provider_cls(
|
||||
transient_cfg,
|
||||
timeout=_PROBE_TIMEOUT_SECONDS,
|
||||
validate_model=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GenAI probe: failed to construct client")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Failed to connect to provider",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
models = await asyncio.wait_for(
|
||||
asyncio.to_thread(client.list_models),
|
||||
timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Probe timed out"},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GenAI probe: list_models failed")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Provider returned no models"},
|
||||
)
|
||||
|
||||
if not models:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": (
|
||||
"No models returned. Check the API key, base URL, and "
|
||||
"that the provider is reachable."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "models": models})
|
||||
|
||||
|
||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
@@ -185,26 +289,24 @@ def config(request: Request):
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
|
||||
# remove mqtt credentials
|
||||
config["mqtt"].pop("password", None)
|
||||
config["mqtt"].pop("user", None)
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
|
||||
# remove the proxy secret
|
||||
config["proxy"].pop("auth_secret", None)
|
||||
# redact proxy secret
|
||||
redact_credential(config["proxy"], "auth_secret")
|
||||
|
||||
# remove genai api keys
|
||||
for genai_name, genai_cfg in config.get("genai", {}).items():
|
||||
# redact genai api keys
|
||||
for _genai_name, genai_cfg in config.get("genai", {}).items():
|
||||
if isinstance(genai_cfg, dict):
|
||||
genai_cfg.pop("api_key", None)
|
||||
redact_credential(genai_cfg, "api_key")
|
||||
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
camera_dict = config["cameras"][camera_name]
|
||||
|
||||
# remove onvif credentials
|
||||
# redact onvif credentials
|
||||
onvif_dict = camera_dict.get("onvif", {})
|
||||
if onvif_dict:
|
||||
onvif_dict.pop("user", None)
|
||||
onvif_dict.pop("password", None)
|
||||
redact_credential(onvif_dict, "password")
|
||||
|
||||
# clean paths
|
||||
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
||||
@@ -581,6 +683,10 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -644,6 +750,40 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
settings,
|
||||
)
|
||||
|
||||
# detect resize also republishes motion + objects so other
|
||||
# processes pick up the rebuilt masks, and fires refresh so
|
||||
# the camera maintainer recycles the camera process to pick
|
||||
# up the new ffmpeg cmd / SHM sizing
|
||||
if field == "detect":
|
||||
cam_cfg = config.cameras.get(camera)
|
||||
if cam_cfg is not None:
|
||||
if cam_cfg.motion is not None:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.motion, camera
|
||||
),
|
||||
cam_cfg.motion,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.objects, camera
|
||||
),
|
||||
cam_cfg.objects,
|
||||
)
|
||||
if cam_cfg.zones:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.zones, camera
|
||||
),
|
||||
cam_cfg.zones,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.refresh, camera
|
||||
),
|
||||
cam_cfg,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Config applied in-memory"},
|
||||
status_code=200,
|
||||
@@ -691,6 +831,13 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
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()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -774,6 +921,8 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.config = config
|
||||
for comm in request.app.dispatcher.comms:
|
||||
comm.config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
|
||||
+71
-454
@@ -35,9 +35,13 @@ from frigate.api.defs.response.chat_response import (
|
||||
ToolCall,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
get_tool_definitions,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
@@ -68,390 +72,6 @@ class VLMMonitorRequest(BaseModel):
|
||||
zones: List[str] = []
|
||||
|
||||
|
||||
def get_tool_definitions(
|
||||
semantic_search_enabled: bool = False,
|
||||
) -> 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. When semantic search is enabled, the search_objects
|
||||
tool exposes an additional `semantic_query` parameter for descriptive
|
||||
queries (e.g. "person riding a lawn mower") and find_similar_objects is
|
||||
included.
|
||||
"""
|
||||
search_objects_properties: Dict[str, Any] = {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to filter by (optional).",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Generic object class to filter by — one of the tracked detector "
|
||||
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
|
||||
"this for broad queries like 'show me all cars today'. Combine "
|
||||
"with semantic_query when the user also describes appearance or "
|
||||
"behavior (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower')."
|
||||
),
|
||||
},
|
||||
"sub_label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter by a DISCRETE NAMED entity recognized in the detection. "
|
||||
"Use this for: a known person's name ('John'), a delivery "
|
||||
"company ('Amazon', 'UPS'), a recognized animal species or "
|
||||
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
|
||||
"license plate string. When filtering by a specific name, set "
|
||||
"only sub_label and leave label unset. Do NOT use sub_label "
|
||||
"for descriptions of appearance, clothing, or actions — those "
|
||||
"belong in semantic_query."
|
||||
),
|
||||
},
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
if semantic_search_enabled:
|
||||
search_objects_properties["semantic_query"] = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional natural-language description of a PHYSICAL "
|
||||
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
|
||||
"used to semantically narrow results. Only set this when the "
|
||||
"user describes something beyond what label and sub_label can "
|
||||
"express on their own.\n"
|
||||
"USE for descriptive phrases like: 'riding a lawn mower', "
|
||||
"'wearing a red jacket', 'carrying a package', 'walking a "
|
||||
"dog', 'on a bicycle', 'holding an umbrella'.\n"
|
||||
"DO NOT USE for:\n"
|
||||
"- specific named people, pets, or delivery companies → use sub_label\n"
|
||||
"- animal species or breed names like 'blue jay', 'cardinal', "
|
||||
"'golden retriever' → use sub_label\n"
|
||||
"- license plate strings → use sub_label\n"
|
||||
"- generic object queries like 'all cars today' or 'every "
|
||||
"person' → use label alone with no semantic_query\n"
|
||||
"When set, combine with label/time/camera/zone filters as "
|
||||
"usual (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower', after='2024-05-01T00:00:00Z')."
|
||||
),
|
||||
}
|
||||
|
||||
search_objects_description = (
|
||||
"Search the historical record of detected objects in Frigate. "
|
||||
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
|
||||
"'when was the last car?', 'show me detections from yesterday'. "
|
||||
"Do NOT use this for monitoring or alerting requests about future events — "
|
||||
"use start_camera_watch instead for those. "
|
||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
|
||||
"Choose filters based on what the user is asking for:\n"
|
||||
"- Generic class query ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific NAMED entity (known person, delivery company, animal "
|
||||
"species/breed like 'blue jay' or 'golden retriever', license "
|
||||
"plate): set `sub_label` only and leave `label` unset.\n"
|
||||
)
|
||||
if semantic_search_enabled:
|
||||
search_objects_description += (
|
||||
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
|
||||
"discrete name ('person riding a lawn mower', 'someone in a red "
|
||||
"jacket', 'person carrying a package'): set `semantic_query` with "
|
||||
"the descriptive phrase, optionally alongside `label` for the "
|
||||
"object class. Do NOT put descriptive phrases in sub_label."
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": search_objects_properties,
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
"Find tracked objects that are visually and semantically similar "
|
||||
"to a specific past event. Use this when the user references a "
|
||||
"particular object they have seen and wants to find other "
|
||||
"sightings of the same or similar one ('that green car', 'the "
|
||||
"person in the red jacket', 'the package that was delivered'). "
|
||||
"Prefer this over search_objects whenever the user's intent is "
|
||||
"'find more like this specific one.' Use search_objects first "
|
||||
"only if you need to locate the anchor event. Requires semantic "
|
||||
"search to be enabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The id of the anchor event to find similar objects to.",
|
||||
},
|
||||
"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').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of cameras to restrict to. Defaults to all.",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
|
||||
},
|
||||
"sub_labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of sub_labels (names) to restrict to.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of zones. An event matches if any of its zones overlap.",
|
||||
},
|
||||
"similarity_mode": {
|
||||
"type": "string",
|
||||
"enum": ["visual", "semantic", "fused"],
|
||||
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
|
||||
"default": "fused",
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of matches to return (default: 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
|
||||
"Use camera='*' to apply to all cameras at once. "
|
||||
"Only call this tool when the user explicitly asks to change a camera setting. "
|
||||
"Requires admin privileges."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to target, or '*' to target all cameras.",
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"detect",
|
||||
"record",
|
||||
"snapshots",
|
||||
"audio",
|
||||
"motion",
|
||||
"enabled",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
"improve_contrast",
|
||||
"ptz_autotracker",
|
||||
"motion_contour_area",
|
||||
"motion_threshold",
|
||||
"notifications",
|
||||
"audio_transcription",
|
||||
"review_alerts",
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"profile",
|
||||
],
|
||||
"description": (
|
||||
"The feature to change. Most features accept ON or OFF. "
|
||||
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
|
||||
"motion_contour_area and motion_threshold accept a number. "
|
||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "feature", "value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this 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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
"Start a continuous VLM watch job that monitors a camera and sends a notification "
|
||||
"when a specified condition is met. Use this when the user wants to be alerted about "
|
||||
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
|
||||
"Only one watch job can run at a time. Returns a job ID."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to monitor.",
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language description of the condition to watch for, "
|
||||
"e.g. 'a person arrives at the front door'."
|
||||
),
|
||||
},
|
||||
"max_duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to watch before giving up (minutes, default 60).",
|
||||
"default": 60,
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "condition"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": (
|
||||
"Cancel the currently running VLM watch job. Use this when the user wants to "
|
||||
"stop a previously started watch, e.g. 'stop watching the front door'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
"Get the current profile status including the active profile and "
|
||||
"timestamps of when each profile was last activated. Use this to "
|
||||
"determine time periods for recap requests — e.g. when the user asks "
|
||||
"'what happened while I was away?', call this first to find the relevant "
|
||||
"time window based on profile activation history."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
||||
"Use this after calling get_profile_status to retrieve what happened during "
|
||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
||||
"descriptions when available. Summarize the results for the user."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["alert", "detection"],
|
||||
"description": "Filter by severity level. Omit to include both alerts and detections.",
|
||||
},
|
||||
},
|
||||
"required": ["after", "before"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chat/tools",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
@@ -460,10 +80,13 @@ def get_tool_definitions(
|
||||
)
|
||||
def get_tools(request: Request) -> JSONResponse:
|
||||
"""Get list of available tools for LLM function calling."""
|
||||
semantic_search_enabled = bool(
|
||||
getattr(request.app.frigate_config.semantic_search, "enabled", False)
|
||||
config = request.app.frigate_config
|
||||
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
|
||||
attribute_classifications = get_attribute_classifications(config)
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
@@ -554,11 +177,14 @@ async def _execute_search_objects(
|
||||
elif zones is None:
|
||||
zones = "all"
|
||||
|
||||
attribute = arguments.get("attribute")
|
||||
|
||||
# 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"), # case-insensitive on the backend
|
||||
attributes=attribute if attribute else "all",
|
||||
zones=zones,
|
||||
zone=zones,
|
||||
after=after,
|
||||
@@ -626,6 +252,7 @@ async def _execute_search_objects_semantic(
|
||||
|
||||
label = arguments.get("label")
|
||||
sub_label = arguments.get("sub_label")
|
||||
attribute = arguments.get("attribute")
|
||||
|
||||
zones = arguments.get("zones")
|
||||
if isinstance(zones, list) and zones:
|
||||
@@ -668,6 +295,10 @@ async def _execute_search_objects_semantic(
|
||||
if sub_label:
|
||||
# case-insensitive match to mirror events() behavior
|
||||
clauses.append(fn.LOWER(Event.sub_label.cast("text")) == sub_label.lower())
|
||||
if attribute:
|
||||
attribute_clause = _build_attribute_filter_clause(attribute)
|
||||
if attribute_clause is not None:
|
||||
clauses.append(attribute_clause)
|
||||
if zones:
|
||||
zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones]
|
||||
clauses.append(reduce(operator.or_, zone_clauses))
|
||||
@@ -916,9 +547,21 @@ async def _execute_get_live_context(
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
# Reject wildcards explicitly so models retry with a real camera name
|
||||
# instead of silently fanning out across every camera.
|
||||
if camera in ("*", "all"):
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; wildcards "
|
||||
"are not supported. Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found or access denied",
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in request.app.frigate_config.cameras:
|
||||
@@ -1090,7 +733,14 @@ async def _execute_tool_internal(
|
||||
"Arguments: %s",
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": "Camera parameter is required"}
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; "
|
||||
"wildcards and empty values are not supported. "
|
||||
"Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
||||
elif tool_name == "start_camera_watch":
|
||||
return await _execute_start_camera_watch(request, arguments)
|
||||
@@ -1481,72 +1131,19 @@ async def chat_completion(
|
||||
|
||||
config = request.app.frigate_config
|
||||
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
|
||||
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
|
||||
attribute_classifications = get_attribute_classifications(config)
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
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 = []
|
||||
has_speed_zone = False
|
||||
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()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
)
|
||||
else:
|
||||
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."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
if has_speed_zone:
|
||||
speed_unit = (
|
||||
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||
)
|
||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||
|
||||
semantic_search_section = ""
|
||||
if semantic_search_enabled:
|
||||
semantic_search_section = (
|
||||
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
|
||||
"- Generic class ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
|
||||
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{cameras_section}{speed_units_section}"""
|
||||
system_prompt = build_chat_system_prompt(
|
||||
config=config,
|
||||
allowed_cameras=allowed_cameras,
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
@@ -1595,6 +1192,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
@@ -1607,6 +1205,13 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "reasoning_delta":
|
||||
yield (
|
||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "stats":
|
||||
yield (
|
||||
json.dumps({"type": "stats", **value}).encode("utf-8")
|
||||
@@ -1682,6 +1287,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
@@ -1707,6 +1313,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
async def stream_body() -> Any:
|
||||
if tool_calls:
|
||||
@@ -1721,6 +1328,15 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
@@ -1741,6 +1357,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
|
||||
@@ -86,10 +86,15 @@ class DebugReplayStopResponse(BaseModel):
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
internal_port = request.app.frigate_config.networking.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
source = RecordingDebugReplaySource(
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
internal_port=internal_port,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
|
||||
|
||||
class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
@@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
|
||||
skip_save: bool = False
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
provider_options: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
password: str
|
||||
old_password: Optional[str] = None
|
||||
|
||||
@@ -36,3 +36,10 @@ class ChatCompletionRequest(BaseModel):
|
||||
default=False,
|
||||
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
|
||||
)
|
||||
enable_thinking: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Per-request thinking toggle. None means use the provider default. "
|
||||
"Ignored by providers that do not expose a per-request thinking switch."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,10 @@ class ChatMessageResponse(BaseModel):
|
||||
content: Optional[str] = Field(
|
||||
default=None, description="Message content (None if tool calls present)"
|
||||
)
|
||||
reasoning: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Separated reasoning/thinking trace if the model emitted one",
|
||||
)
|
||||
tool_calls: Optional[list[ToolCallInvocation]] = Field(
|
||||
default=None, description="Tool calls if LLM wants to call tools"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
@@ -50,6 +51,7 @@ class CameraMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
],
|
||||
)
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
@@ -202,6 +204,25 @@ class CameraMaintainer(threading.Thread):
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
|
||||
def __unlink_camera_frame_slots(self, camera: str) -> None:
|
||||
"""Drop the camera's per-frame YUV SHM segments from this
|
||||
process's frame_manager and unlink them at the OS level.
|
||||
|
||||
Safe to call after the camera's capture/processor subprocesses
|
||||
have been joined — they no longer hold mappings, so unlink frees
|
||||
the segments immediately. Other long-lived processes that opened
|
||||
these slots will continue using their existing mappings until
|
||||
they call frame_manager.get with a shape that no longer fits
|
||||
(the get path drops and reopens stale refs).
|
||||
"""
|
||||
prefix = f"{camera}_frame"
|
||||
names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)]
|
||||
for name in names:
|
||||
try:
|
||||
self.frame_manager.delete(name)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not unlink SHM %s: %s", name, exc)
|
||||
|
||||
def __stop_camera_process(self, camera: str) -> None:
|
||||
camera_process = self.camera_processes.get(camera)
|
||||
if camera_process is not None:
|
||||
@@ -253,12 +274,45 @@ class CameraMaintainer(threading.Thread):
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
self.camera_stop_events.pop(camera, None)
|
||||
self.region_grids.pop(camera, None)
|
||||
self.camera_metrics.pop(camera, None)
|
||||
self.ptz_metrics.pop(camera, None)
|
||||
elif update_type == CameraConfigUpdateEnum.refresh.name:
|
||||
# Recycle replay cameras so detect width/height/fps
|
||||
# propagate through ffmpeg args, SHM sizing, and the
|
||||
# region grid. Regular cameras detect change still
|
||||
# requires a full restart.
|
||||
for camera in updated_cameras:
|
||||
if not camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
new_config = self.update_subscriber.camera_configs.get(camera)
|
||||
if new_config is None:
|
||||
# remove arrived in the same batch
|
||||
continue
|
||||
|
||||
if (
|
||||
camera not in self.camera_processes
|
||||
and camera not in self.capture_processes
|
||||
):
|
||||
continue
|
||||
|
||||
# rebuild ffmpeg cmds on the shared config so the
|
||||
# new subprocesses spawn with current args
|
||||
new_config.recreate_ffmpeg_cmds()
|
||||
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
|
||||
self.__start_camera_processor(camera, new_config, runtime=True)
|
||||
self.__start_camera_capture(camera, new_config, runtime=True)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.capture_processes.keys():
|
||||
|
||||
+52
-8
@@ -45,6 +45,7 @@ class CameraState:
|
||||
self.frame_cache: dict[float, dict[str, Any]] = {}
|
||||
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
|
||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||
self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv
|
||||
self.current_frame_lock = threading.Lock()
|
||||
self.current_frame_time = 0.0
|
||||
self.motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
@@ -303,6 +304,42 @@ class CameraState:
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
def _discard_stale_resolution_state(
|
||||
self, current_detections: dict[str, dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Drop tracked state when the camera's detect resolution has
|
||||
changed, and signal the caller to skip this batch if it contains
|
||||
out-of-bounds boxes from the pre-recycle detect process.
|
||||
|
||||
Returns True when the batch should be skipped entirely.
|
||||
"""
|
||||
# detect resolution changed — drop tracked state so old-grid
|
||||
# boxes don't leak through end-callbacks
|
||||
current_shape = self.camera_config.frame_shape_yuv
|
||||
if current_shape != self._last_frame_shape:
|
||||
logger.debug(
|
||||
f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state"
|
||||
)
|
||||
with self.current_frame_lock:
|
||||
self.tracked_objects.clear()
|
||||
self.motion_boxes = []
|
||||
self.regions = []
|
||||
self._last_frame_shape = current_shape
|
||||
|
||||
# drop in-flight batches from the pre-recycle detect process
|
||||
# whose boxes exceed the current detect resolution
|
||||
detect = self.camera_config.detect
|
||||
if detect.width is not None and detect.height is not None:
|
||||
for obj in current_detections.values():
|
||||
box = obj.get("box")
|
||||
if box and (box[2] > detect.width or box[3] > detect.height):
|
||||
logger.debug(
|
||||
f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update(
|
||||
self,
|
||||
frame_name: str,
|
||||
@@ -311,6 +348,9 @@ class CameraState:
|
||||
motion_boxes: list[tuple[int, int, int, int]],
|
||||
regions: list[tuple[int, int, int, int]],
|
||||
) -> None:
|
||||
if self._discard_stale_resolution_state(current_detections):
|
||||
return
|
||||
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
@@ -332,14 +372,18 @@ class CameraState:
|
||||
current_detections[id],
|
||||
)
|
||||
|
||||
# add initial frame to frame cache
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame), # type: ignore[arg-type]
|
||||
"object_id": id,
|
||||
}
|
||||
# Skip caching when the frame buffer isn't readable — e.g.
|
||||
# frame_manager.get returned None because the SHM segment was
|
||||
# unlinked or hasn't been recreated yet during a camera
|
||||
# add/remove cycle.
|
||||
if current_frame is not None:
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame),
|
||||
"object_id": id,
|
||||
}
|
||||
|
||||
# save initial thumbnail data and best object
|
||||
thumbnail_data = {
|
||||
|
||||
+356
-6
@@ -34,6 +34,8 @@ from frigate.const import (
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import User
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +68,7 @@ _WS_VIEWER_TOPICS = frozenset(
|
||||
"audioTranscriptionState",
|
||||
"birdseyeLayout",
|
||||
"embeddingsReindexProgress",
|
||||
"jobState",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -102,6 +105,321 @@ def _check_ws_authorization(
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
|
||||
# ---- Outbound filtering ---------------------------------------------------
|
||||
#
|
||||
# Every WebSocket broadcast is classified into one of a small set of scopes,
|
||||
# then materialized per recipient. Connections with restricted roles only see
|
||||
# data for cameras they are authorized to access; admin and full-access roles
|
||||
# behave as today.
|
||||
|
||||
# Topics that are safe to broadcast to every authenticated client.
|
||||
_WS_GLOBAL_OUTBOUND_TOPICS = frozenset(
|
||||
{
|
||||
"model_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"profile/state",
|
||||
"notifications/state",
|
||||
"notification_test",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics that restricted roles must never receive. Birdseye composites span
|
||||
# all cameras, so the existing JSMPEG policy already restricts birdseye access
|
||||
# to unrestricted roles; the layout broadcast follows the same rule.
|
||||
_WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
|
||||
{
|
||||
"birdseye_layout",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload (parsed as JSON) names a single owning camera at the
|
||||
# given key path. Used to scope events, reviews, triggers, etc.
|
||||
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
|
||||
"events": ("after", "camera"),
|
||||
"reviews": ("after", "camera"),
|
||||
"tracked_object_update": ("camera",),
|
||||
"triggers": ("camera",),
|
||||
"camera_monitoring": ("camera",),
|
||||
}
|
||||
|
||||
# Topics whose payload is a dict keyed by camera name; filter keys per
|
||||
# recipient.
|
||||
_WS_RESHAPE_BY_CAMERA_KEY_TOPICS = frozenset(
|
||||
{
|
||||
"camera_activity",
|
||||
"audio_detections",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload is a dict keyed by job_type, where each entry may
|
||||
# contain a "camera" or "source_camera" field, or a nested ``results.jobs``
|
||||
# list of per-camera sub-jobs (export broadcasts).
|
||||
_WS_RESHAPE_JOB_STATE_TOPICS = frozenset(
|
||||
{
|
||||
"job_state",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload mixes global aggregates with a ``cameras`` sub-dict
|
||||
# keyed by camera name. Aggregates and detector data stay; per-camera entries
|
||||
# are filtered.
|
||||
_WS_RESHAPE_STATS_TOPICS = frozenset(
|
||||
{
|
||||
"stats",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _collect_zone_names(config: FrigateConfig) -> set[str]:
|
||||
"""Return the set of all zone names defined across cameras."""
|
||||
names: set[str] = set()
|
||||
for camera in config.cameras.values():
|
||||
zones = getattr(camera, "zones", None) or {}
|
||||
names.update(zones.keys())
|
||||
return names
|
||||
|
||||
|
||||
def _parse_json_payload(payload: Any) -> Any:
|
||||
"""Return payload parsed as JSON if it is a string, else as-is."""
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _scope_job_entry_to_allowed(entry: Any, allowed: set[str]) -> dict[str, Any] | None:
|
||||
"""Filter a single job_state entry to the recipient's allowed cameras.
|
||||
|
||||
Returns the (possibly reshaped) entry, or None to drop it. Four shapes
|
||||
are handled:
|
||||
|
||||
* Top-level ``camera`` or ``source_camera`` (motion_search, vlm_watch,
|
||||
export sub-job dicts): drop the entry if not allowed.
|
||||
* Nested ``results.jobs`` list of per-camera sub-jobs (the aggregated
|
||||
export broadcast): filter the list; drop the entry if nothing remains.
|
||||
* Nested ``results.camera`` or ``results.source_camera`` (debug_replay,
|
||||
which puts replay-specific fields inside ``results``): drop the entry
|
||||
if not allowed.
|
||||
* No camera anywhere (e.g. ``media_sync``): treat as global and keep.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
|
||||
cam = entry.get("camera") or entry.get("source_camera")
|
||||
|
||||
if cam is None:
|
||||
results = entry.get("results")
|
||||
if isinstance(results, dict):
|
||||
sub_jobs = results.get("jobs")
|
||||
if isinstance(sub_jobs, list):
|
||||
filtered_jobs = [
|
||||
j
|
||||
for j in sub_jobs
|
||||
if isinstance(j, dict)
|
||||
and (j.get("camera") or j.get("source_camera")) in allowed
|
||||
]
|
||||
if not filtered_jobs:
|
||||
return None
|
||||
reshaped = dict(entry)
|
||||
reshaped["results"] = dict(results)
|
||||
reshaped["results"]["jobs"] = filtered_jobs
|
||||
return reshaped
|
||||
|
||||
cam = results.get("camera") or results.get("source_camera")
|
||||
|
||||
if cam is not None:
|
||||
return entry if cam in allowed else None
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _extract_payload_camera(payload: Any, path: tuple[str, ...]) -> str | None:
|
||||
"""Walk the dotted path through a (possibly JSON-encoded) payload."""
|
||||
cur = _parse_json_payload(payload)
|
||||
for key in path:
|
||||
if not isinstance(cur, dict):
|
||||
return None
|
||||
cur = cur.get(key)
|
||||
return cur if isinstance(cur, str) else None
|
||||
|
||||
|
||||
def _classify_outbound(
|
||||
topic: str, all_cameras: set[str], all_zones: set[str]
|
||||
) -> tuple[str, Any]:
|
||||
"""Classify an outbound topic into (kind, extra).
|
||||
|
||||
kind values:
|
||||
- "global" : send to every authenticated client
|
||||
- "drop" : send to nobody (fail-closed for unknowns)
|
||||
- "unrestricted_only" : send only to admin/full-access roles
|
||||
- "camera" : extra is the owning camera name
|
||||
- "payload_camera" : extra is the JSON key path to the camera name
|
||||
- "reshape_by_camera_key"
|
||||
- "reshape_job_state"
|
||||
- "reshape_stats"
|
||||
"""
|
||||
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
|
||||
return ("global", None)
|
||||
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
|
||||
return ("unrestricted_only", None)
|
||||
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
|
||||
return ("reshape_by_camera_key", None)
|
||||
if topic in _WS_RESHAPE_JOB_STATE_TOPICS:
|
||||
return ("reshape_job_state", None)
|
||||
if topic in _WS_RESHAPE_STATS_TOPICS:
|
||||
return ("reshape_stats", None)
|
||||
if topic in _WS_PAYLOAD_CAMERA_TOPICS:
|
||||
return ("payload_camera", _WS_PAYLOAD_CAMERA_TOPICS[topic])
|
||||
|
||||
# Topic-prefix based: first segment names the owning camera or zone.
|
||||
first = topic.split("/", 1)[0]
|
||||
if first in all_cameras:
|
||||
return ("camera", first)
|
||||
if first in all_zones:
|
||||
# Zone aggregates span cameras; restricted users see nothing here.
|
||||
return ("unrestricted_only", None)
|
||||
|
||||
return ("drop", None)
|
||||
|
||||
|
||||
def _ws_role_header(ws: Any) -> str | None:
|
||||
"""Return the HTTP_REMOTE_ROLE header value, if any."""
|
||||
environ = getattr(ws, "environ", None)
|
||||
if not environ:
|
||||
return None
|
||||
value = environ.get("HTTP_REMOTE_ROLE")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _ws_valid_roles(ws: Any, config: FrigateConfig) -> list[str]:
|
||||
"""Return the list of recognized roles for this connection."""
|
||||
header = _ws_role_header(ws)
|
||||
if not header:
|
||||
return []
|
||||
roles = [r.strip() for r in header.split(config.proxy.separator) if r.strip()]
|
||||
return [r for r in roles if r in config.auth.roles]
|
||||
|
||||
|
||||
def _ws_is_unrestricted(ws: Any, config: FrigateConfig) -> bool:
|
||||
"""True when the connection has unrestricted camera access.
|
||||
|
||||
Mirrors the policy in ``frigate.output.ws_auth``: admin or any role with
|
||||
an empty allow-list grants full access.
|
||||
"""
|
||||
roles = _ws_valid_roles(ws, config)
|
||||
if not roles:
|
||||
return False
|
||||
roles_dict = config.auth.roles
|
||||
return any(r == "admin" or not roles_dict.get(r) for r in roles)
|
||||
|
||||
|
||||
def _ws_allowed_cameras(ws: Any, config: FrigateConfig) -> set[str]:
|
||||
"""Return the union of cameras this connection may access across its roles."""
|
||||
roles = _ws_valid_roles(ws, config)
|
||||
if not roles:
|
||||
return set()
|
||||
all_cameras = set(config.cameras.keys())
|
||||
allowed: set[str] = set()
|
||||
for role in roles:
|
||||
if role == "admin" or not config.auth.roles.get(role):
|
||||
return all_cameras
|
||||
allowed.update(User.get_allowed_cameras(role, config.auth.roles, all_cameras))
|
||||
return allowed
|
||||
|
||||
|
||||
def _wrap_envelope(topic: str, inner_payload: Any) -> str:
|
||||
"""Re-serialize a (topic, payload) message after payload reshaping.
|
||||
|
||||
Frigate's wire format keeps payloads as JSON-encoded strings inside the
|
||||
outer envelope, mirroring what producers send today.
|
||||
"""
|
||||
return json.dumps({"topic": topic, "payload": json.dumps(inner_payload)})
|
||||
|
||||
|
||||
def _materialize_for_ws(
|
||||
ws: Any,
|
||||
topic: str,
|
||||
full_message: str,
|
||||
scope: tuple[str, Any],
|
||||
parsed_payload: Any,
|
||||
config: FrigateConfig,
|
||||
) -> str | None:
|
||||
"""Return the JSON string to deliver to ``ws``, or None to skip it."""
|
||||
kind, extra = scope
|
||||
has_role = _ws_role_header(ws) is not None
|
||||
|
||||
if kind == "drop":
|
||||
return None
|
||||
|
||||
if kind == "global":
|
||||
# Globals still require an authenticated connection. Missing role
|
||||
# falls back to viewer semantics (matching the inbound rule).
|
||||
return full_message
|
||||
|
||||
# Beyond globals, an authenticated role header is required (fail-closed).
|
||||
if not has_role:
|
||||
return None
|
||||
|
||||
if kind == "unrestricted_only":
|
||||
return full_message if _ws_is_unrestricted(ws, config) else None
|
||||
|
||||
if kind == "camera":
|
||||
return full_message if ws_has_camera_access(ws, extra, config) else None
|
||||
|
||||
if kind == "payload_camera":
|
||||
camera = _extract_payload_camera(parsed_payload, extra)
|
||||
if camera is None:
|
||||
return None
|
||||
return full_message if ws_has_camera_access(ws, camera, config) else None
|
||||
|
||||
if kind == "reshape_by_camera_key":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
filtered = {cam: data for cam, data in parsed_payload.items() if cam in allowed}
|
||||
if not filtered:
|
||||
return None
|
||||
return _wrap_envelope(topic, filtered)
|
||||
|
||||
if kind == "reshape_job_state":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
filtered_jobs: dict[str, Any] = {}
|
||||
for job_type, job_payload in parsed_payload.items():
|
||||
scoped = _scope_job_entry_to_allowed(job_payload, allowed)
|
||||
if scoped is not None:
|
||||
filtered_jobs[job_type] = scoped
|
||||
if not filtered_jobs:
|
||||
return None
|
||||
return _wrap_envelope(topic, filtered_jobs)
|
||||
|
||||
if kind == "reshape_stats":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
cameras_block = parsed_payload.get("cameras")
|
||||
if isinstance(cameras_block, dict):
|
||||
filtered_cameras = {
|
||||
name: data for name, data in cameras_block.items() if name in allowed
|
||||
}
|
||||
reshaped = dict(parsed_payload)
|
||||
reshaped["cameras"] = filtered_cameras
|
||||
return _wrap_envelope(topic, reshaped)
|
||||
return full_message
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
"""
|
||||
@@ -183,6 +501,10 @@ class WebSocketClient(Communicator):
|
||||
self.websocket_thread.start()
|
||||
|
||||
def publish(self, topic: str, payload: Any, _: bool = False) -> None:
|
||||
if self.websocket_server is None:
|
||||
logger.debug("Skipping message, websocket not connected yet")
|
||||
return
|
||||
|
||||
try:
|
||||
ws_message = json.dumps(
|
||||
{
|
||||
@@ -195,14 +517,42 @@ class WebSocketClient(Communicator):
|
||||
logger.debug(f"payload for {topic} wasn't text. Skipping...")
|
||||
return
|
||||
|
||||
if self.websocket_server is None:
|
||||
logger.debug("Skipping message, websocket not connected yet")
|
||||
all_cameras = set(self.config.cameras.keys())
|
||||
all_zones = _collect_zone_names(self.config)
|
||||
scope = _classify_outbound(topic, all_cameras, all_zones)
|
||||
|
||||
if scope[0] == "drop":
|
||||
return
|
||||
|
||||
try:
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
# Pre-parse payload once for topics that need to read its contents.
|
||||
parsed_payload: Any = None
|
||||
if scope[0] in (
|
||||
"payload_camera",
|
||||
"reshape_by_camera_key",
|
||||
"reshape_job_state",
|
||||
"reshape_stats",
|
||||
):
|
||||
parsed_payload = _parse_json_payload(payload)
|
||||
if parsed_payload is None:
|
||||
# malformed payload — fail closed
|
||||
return
|
||||
|
||||
manager = self.websocket_server.manager
|
||||
with manager.lock:
|
||||
websockets = list(manager.websockets.values())
|
||||
|
||||
for ws in websockets:
|
||||
if getattr(ws, "terminated", False):
|
||||
continue
|
||||
message = _materialize_for_ws(
|
||||
ws, topic, ws_message, scope, parsed_payload, self.config
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
try:
|
||||
ws.send(message)
|
||||
except (ConnectionResetError, BrokenPipeError, ValueError):
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.websocket_server is not None:
|
||||
|
||||
@@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel):
|
||||
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
|
||||
)
|
||||
model: str = Field(
|
||||
default="gpt-4o",
|
||||
default="",
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
object_genai = "object_genai"
|
||||
onvif = "onvif"
|
||||
record = "record"
|
||||
refresh = "refresh" # signals the camera maintainer to recycle the camera process
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
review_genai = "review_genai"
|
||||
@@ -84,8 +85,8 @@ class CameraConfigUpdateSubscriber:
|
||||
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
||||
) -> None:
|
||||
if update_type == CameraConfigUpdateEnum.add:
|
||||
self.config.cameras[camera] = updated_config
|
||||
self.camera_configs[camera] = updated_config
|
||||
shared = self.config.cameras.setdefault(camera, updated_config)
|
||||
self.camera_configs[camera] = shared
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera, None)
|
||||
|
||||
@@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
||||
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
|
||||
for profile_name, profile in camera_config.profiles.items():
|
||||
if profile.zones:
|
||||
for zone_name in profile.zones:
|
||||
if zone_name not in camera_config.zones:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"zone '{zone_name}' that does not exist on the base config"
|
||||
)
|
||||
|
||||
if profile.motion and profile.motion.mask:
|
||||
for mask_name in profile.motion.mask:
|
||||
if mask_name not in camera_config.motion.mask:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"motion mask '{mask_name}' that does not exist on the base config"
|
||||
)
|
||||
|
||||
if profile.objects:
|
||||
for mask_name in profile.objects.mask or {}:
|
||||
if mask_name not in (camera_config.objects.mask or {}):
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"object mask '{mask_name}' that does not exist on the base config"
|
||||
)
|
||||
for label, filter_config in (profile.objects.filters or {}).items():
|
||||
base_filter = (camera_config.objects.filters or {}).get(label)
|
||||
profile_filter_masks = (
|
||||
filter_config.mask if filter_config else None
|
||||
) or {}
|
||||
base_filter_masks = (base_filter.mask if base_filter else None) or {}
|
||||
for mask_name in profile_filter_masks:
|
||||
if mask_name not in base_filter_masks:
|
||||
raise ValueError(
|
||||
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
|
||||
f"object mask '{mask_name}' for '{label}' that does not exist "
|
||||
f"on the base config"
|
||||
)
|
||||
|
||||
|
||||
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
|
||||
"""Verify that required_zones are specified when autotracking is enabled."""
|
||||
if (
|
||||
@@ -952,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||
verify_zone_objects_are_tracked(camera_config)
|
||||
verify_required_zones_exist(camera_config)
|
||||
verify_profile_overrides_match_base(camera_config)
|
||||
verify_autotrack_zones(camera_config)
|
||||
verify_motion_and_detect(camera_config)
|
||||
verify_objects_track(camera_config, labelmap_objects)
|
||||
|
||||
@@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
|
||||
|
||||
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
||||
|
||||
# Attribute & Object constants
|
||||
|
||||
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
||||
|
||||
+20
-3
@@ -169,6 +169,7 @@ class DebugReplayManager:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
frigate_config.cameras.pop(replay_name, None)
|
||||
|
||||
if replay_name is not None:
|
||||
self._cleanup_db(replay_name)
|
||||
@@ -237,6 +238,10 @@ class DebugReplayManager:
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
# Extract LPR and face recognition configs
|
||||
lpr_dict = source_config.lpr.model_dump()
|
||||
face_recognition_dict = source_config.face_recognition.model_dump()
|
||||
|
||||
# Extract motion config (exclude runtime fields)
|
||||
motion_dict = {}
|
||||
if source_config.motion is not None:
|
||||
@@ -245,11 +250,23 @@ class DebugReplayManager:
|
||||
"frame_shape",
|
||||
"raw_mask",
|
||||
"mask",
|
||||
"improved_contrast_enabled",
|
||||
"enabled_in_config",
|
||||
"rasterized_mask",
|
||||
}
|
||||
)
|
||||
|
||||
if source_config.motion.mask:
|
||||
motion_dict["mask"] = {
|
||||
mask_id: (
|
||||
mask_cfg.model_dump(
|
||||
exclude={"raw_coordinates", "enabled_in_config"}
|
||||
)
|
||||
if mask_cfg is not None
|
||||
else None
|
||||
)
|
||||
for mask_id, mask_cfg in source_config.motion.mask.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"ffmpeg": {
|
||||
@@ -274,8 +291,8 @@ class DebugReplayManager:
|
||||
},
|
||||
"birdseye": {"enabled": False},
|
||||
"audio": {"enabled": False},
|
||||
"lpr": {"enabled": False},
|
||||
"face_recognition": {"enabled": False},
|
||||
"lpr": lpr_dict,
|
||||
"face_recognition": face_recognition_dict,
|
||||
}
|
||||
|
||||
def _cleanup_db(self, camera_name: str) -> None:
|
||||
|
||||
@@ -98,10 +98,17 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.face_recognition,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.lpr,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.object_genai,
|
||||
CameraConfigUpdateEnum.review,
|
||||
CameraConfigUpdateEnum.review_genai,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
],
|
||||
)
|
||||
self.enrichment_config_subscriber = ConfigSubscriber("config/")
|
||||
|
||||
+94
-155
@@ -1,21 +1,25 @@
|
||||
"""Generative AI module for Frigate."""
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.data_processing.post.types import ReviewMetadata
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.genai.prompts import (
|
||||
build_object_description_prompt,
|
||||
build_review_description_prompt,
|
||||
build_review_description_response_format,
|
||||
build_review_summary_prompt,
|
||||
)
|
||||
from frigate.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
class GenAIClient:
|
||||
"""Generative AI client for Frigate."""
|
||||
|
||||
def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
genai_config: GenAIConfig,
|
||||
timeout: int = 120,
|
||||
validate_model: bool = True,
|
||||
) -> None:
|
||||
self.genai_config: GenAIConfig = genai_config
|
||||
self.timeout = timeout
|
||||
self.validate_model = validate_model
|
||||
self.provider = self._init_provider()
|
||||
|
||||
def generate_review_description(
|
||||
@@ -61,75 +71,14 @@ class GenAIClient:
|
||||
activity_context_prompt: str,
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity."""
|
||||
context_prompt = build_review_description_prompt(
|
||||
review_data,
|
||||
thumbnails,
|
||||
concerns,
|
||||
preferred_language,
|
||||
activity_context_prompt,
|
||||
)
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
- {concern_list}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_language_prompt() -> str:
|
||||
if preferred_language:
|
||||
return f"Provide your answer in {preferred_language}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_objects_list() -> str:
|
||||
if review_data["unified_objects"]:
|
||||
return "\n- " + "\n- ".join(review_data["unified_objects"])
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
context_prompt = f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
|
||||
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
- **Use the actual timestamp provided in "Activity started at"** below for time of day context—do not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
|
||||
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
|
||||
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
|
||||
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
|
||||
## Sequence Details
|
||||
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
|
||||
|
||||
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
|
||||
{get_objects_list()}
|
||||
|
||||
{get_language_prompt()}
|
||||
"""
|
||||
logger.debug(
|
||||
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
|
||||
)
|
||||
@@ -143,25 +92,7 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
) as f:
|
||||
f.write(context_prompt)
|
||||
|
||||
# Build JSON schema for structured output from ReviewMetadata model
|
||||
schema = ReviewMetadata.model_json_schema()
|
||||
schema.get("properties", {}).pop("time", None)
|
||||
|
||||
if "time" in schema.get("required", []):
|
||||
schema["required"].remove("time")
|
||||
if not concerns:
|
||||
schema.get("properties", {}).pop("other_concerns", None)
|
||||
if "other_concerns" in schema.get("required", []):
|
||||
schema["required"].remove("other_concerns")
|
||||
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "review_metadata",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
response_format = build_review_description_response_format(concerns)
|
||||
|
||||
response = self._send(context_prompt, thumbnails, response_format)
|
||||
|
||||
@@ -240,61 +171,9 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
debug_save: bool,
|
||||
) -> str | None:
|
||||
"""Generate a summary of review item descriptions over a period of time."""
|
||||
time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
timeline_summary_prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
Report Structure - Use this EXACT format:
|
||||
|
||||
# Security Summary - {time_range}
|
||||
|
||||
## Overview
|
||||
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
|
||||
|
||||
### [Time Block Name]
|
||||
|
||||
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
|
||||
- [Event title]: [Clear description incorporating contextual information from the "context" array]
|
||||
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
|
||||
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
|
||||
|
||||
[Repeat for each event in chronological order within the time block]
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
|
||||
|
||||
Guidelines:
|
||||
- List ALL events in chronological order, grouped by time blocks
|
||||
- Threat level indicators: ✓ Normal, ⚠️ Needs review, 🔴 Security concern
|
||||
- Integrate contextual information naturally - use the "context" array to enrich each event's description
|
||||
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
|
||||
- Be concise but informative - focus on what happened and what it means
|
||||
- If contextual information makes an event clearly normal, reflect that in your assessment
|
||||
- Only create time blocks that have events - don't create empty sections
|
||||
"""
|
||||
|
||||
timeline_summary_prompt += "\n\nEvents:\n"
|
||||
for event in events:
|
||||
timeline_summary_prompt += f"\n{event}\n"
|
||||
|
||||
if preferred_language:
|
||||
timeline_summary_prompt += f"\nProvide your answer in {preferred_language}"
|
||||
timeline_summary_prompt = build_review_summary_prompt(
|
||||
start_ts, end_ts, events, preferred_language
|
||||
)
|
||||
|
||||
if debug_save:
|
||||
with open(
|
||||
@@ -326,10 +205,7 @@ Guidelines:
|
||||
) -> Optional[str]:
|
||||
"""Generate a description for the frame."""
|
||||
try:
|
||||
prompt = camera_config.objects.genai.object_prompts.get(
|
||||
str(event.label),
|
||||
camera_config.objects.genai.prompt,
|
||||
).format(**model_to_dict(event))
|
||||
prompt = build_object_description_prompt(camera_config, event)
|
||||
except KeyError as e:
|
||||
logger.error(f"Invalid key in GenAI prompt: {e}")
|
||||
return None
|
||||
@@ -346,8 +222,15 @@ Guidelines:
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to the provider."""
|
||||
"""Submit a request to the provider.
|
||||
|
||||
``enable_thinking`` is honored only by providers that report
|
||||
``supports_toggleable_thinking``. Description-style callers leave it
|
||||
at the default (off) since synthesis tasks don't benefit from
|
||||
reasoning traces.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -359,6 +242,11 @@ Guidelines:
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
"""Whether the configured model exposes a per-request thinking toggle."""
|
||||
return False
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return the list of model names available from this provider.
|
||||
|
||||
@@ -402,6 +290,7 @@ Guidelines:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to LLM with optional tool definitions.
|
||||
@@ -425,11 +314,17 @@ Guidelines:
|
||||
- 'none': Model must not call tools
|
||||
- 'required': Model must call at least one tool
|
||||
- Or a dict specifying a specific tool to call
|
||||
**kwargs: Additional provider-specific parameters.
|
||||
enable_thinking: Per-request thinking toggle. None means use the
|
||||
provider default. Ignored by providers without a per-request
|
||||
toggle (see `supports_toggleable_thinking`).
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- 'content': Optional[str] - The text response from the LLM, None if tool calls
|
||||
- 'reasoning': Optional[str] - The separated reasoning/thinking trace
|
||||
if the model emitted one (e.g. via OpenAI-compatible
|
||||
`reasoning_content`). None when the model does not surface a
|
||||
trace or the provider does not parse it.
|
||||
- 'tool_calls': Optional[List[Dict]] - List of tool calls if LLM wants to call tools.
|
||||
Each tool call dict has:
|
||||
- 'id': str - Unique identifier for this tool call
|
||||
@@ -441,6 +336,14 @@ Guidelines:
|
||||
- 'length': Hit token limit
|
||||
- 'error': An error occurred
|
||||
|
||||
Streaming counterpart `chat_with_tools_stream` yields
|
||||
``(kind, value)`` tuples where ``kind`` is one of:
|
||||
- 'content_delta': value is a string fragment of the answer
|
||||
- 'reasoning_delta': value is a string fragment of the reasoning
|
||||
trace (emitted before content for thinking models)
|
||||
- 'stats': value is a usage stats dict
|
||||
- 'message': value is the final dict shape described above
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the provider doesn't implement this method.
|
||||
"""
|
||||
@@ -451,14 +354,50 @@ Guidelines:
|
||||
)
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
async def chat_with_tools_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Streaming counterpart to `chat_with_tools`.
|
||||
|
||||
Yields ``(kind, value)`` tuples where ``kind`` is one of:
|
||||
- 'content_delta': value is a string fragment of the answer
|
||||
- 'reasoning_delta': value is a string fragment of the reasoning
|
||||
trace (emitted before content for thinking models)
|
||||
- 'stats': value is a usage stats dict
|
||||
- 'message': value is the final dict shape described in
|
||||
`chat_with_tools`
|
||||
|
||||
Argument semantics — including ``enable_thinking`` — match
|
||||
`chat_with_tools`. Providers that don't support streaming should
|
||||
override this and yield an error 'message' event.
|
||||
"""
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__} does not support chat_with_tools_stream. "
|
||||
"This method should be overridden by the provider implementation."
|
||||
)
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def load_providers() -> None:
|
||||
package_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(package_dir):
|
||||
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
|
||||
for filename in os.listdir(plugins_dir):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
module_name = f"frigate.genai.{filename[:-3]}"
|
||||
module_name = f"frigate.genai.plugins.{filename[:-3]}"
|
||||
importlib.import_module(module_name)
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Azure OpenAI Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.openai import _stats_from_openai_usage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.azure_openai)
|
||||
class OpenAIClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
provider: AzureOpenAI
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI | None:
|
||||
"""Initialize the client."""
|
||||
try:
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
if not api_version:
|
||||
logger.warning("Azure OpenAI url is missing API version.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
|
||||
return None
|
||||
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def _send(
|
||||
self,
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Azure OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
try:
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}]
|
||||
+ [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{image}",
|
||||
"detail": "low",
|
||||
},
|
||||
}
|
||||
for image in encoded_images
|
||||
],
|
||||
},
|
||||
],
|
||||
"timeout": self.timeout,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if response_format:
|
||||
request_params["response_format"] = response_format
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||
return None
|
||||
if len(result.choices) > 0:
|
||||
return str(result.choices[0].message.content.strip())
|
||||
return None
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from Azure OpenAI."""
|
||||
try:
|
||||
return sorted(m.id for m in self.provider.models.list().data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list Azure OpenAI models: %s", e)
|
||||
return []
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Get the context window size for Azure OpenAI."""
|
||||
return 128000
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
openai_tool_choice = "none"
|
||||
elif tool_choice == "auto":
|
||||
openai_tool_choice = "auto"
|
||||
elif tool_choice == "required":
|
||||
openai_tool_choice = "required"
|
||||
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if openai_tool_choice is not None:
|
||||
request_params["tool_choice"] = openai_tool_choice
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
if (
|
||||
result is None
|
||||
or not hasattr(result, "choices")
|
||||
or len(result.choices) == 0
|
||||
):
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
choice = result.choices[0]
|
||||
message = choice.message
|
||||
|
||||
content = message.content.strip() if message.content else None
|
||||
|
||||
tool_calls = None
|
||||
if message.tool_calls:
|
||||
tool_calls = []
|
||||
for tool_call in message.tool_calls:
|
||||
try:
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logger.warning(
|
||||
f"Failed to parse tool call arguments: {e}, "
|
||||
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
|
||||
)
|
||||
arguments = {}
|
||||
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tool_call.id if hasattr(tool_call, "id") else "",
|
||||
"name": tool_call.function.name
|
||||
if hasattr(tool_call.function, "name")
|
||||
else "",
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
|
||||
finish_reason = "error"
|
||||
if hasattr(choice, "finish_reason") and choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
elif tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
elif content:
|
||||
finish_reason = "stop"
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
async def chat_with_tools_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for Azure OpenAI models.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
openai_tool_choice = "none"
|
||||
elif tool_choice == "auto":
|
||||
openai_tool_choice = "auto"
|
||||
elif tool_choice == "required":
|
||||
openai_tool_choice = "required"
|
||||
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if openai_tool_choice is not None:
|
||||
request_params["tool_choice"] = openai_tool_choice
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage_stats: Optional[dict[str, Any]] = None
|
||||
|
||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
if tool_calls_by_index:
|
||||
tool_calls_list = []
|
||||
for tc in tool_calls_by_index.values():
|
||||
try:
|
||||
# Parse accumulated arguments as JSON
|
||||
parsed_args = json.loads(tc["arguments"])
|
||||
except (json.JSONDecodeError, Exception):
|
||||
parsed_args = tc["arguments"]
|
||||
|
||||
tool_calls_list.append(
|
||||
{
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": parsed_args,
|
||||
}
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if usage_stats is not None:
|
||||
yield ("stats", usage_stats)
|
||||
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI streaming returned an error: %s", str(e))
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
)
|
||||
@@ -6,7 +6,7 @@ no chat feature is active) are never initialized.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.genai import GenAIConfig, GenAIRoleEnum
|
||||
@@ -108,11 +108,16 @@ class GenAIClientManager:
|
||||
name = self._role_map.get(GenAIRoleEnum.embeddings)
|
||||
return self._get_client(name) if name else None
|
||||
|
||||
def list_models(self) -> dict[str, list[str]]:
|
||||
"""Return available models keyed by config entry name."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for name in self._configs:
|
||||
def list_models(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return per-entry model lists and capabilities, keyed by config entry name."""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for name, genai_cfg in self._configs.items():
|
||||
client = self._get_client(name)
|
||||
if client:
|
||||
result[name] = client.list_models()
|
||||
if not client:
|
||||
continue
|
||||
result[name] = {
|
||||
"models": client.list_models(),
|
||||
"roles": [r.value for r in genai_cfg.roles],
|
||||
"supports_toggleable_thinking": client.supports_toggleable_thinking,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""GenAI provider plugins."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Azure OpenAI Provider for Frigate AI.
|
||||
|
||||
Azure OpenAI exposes the same chat completions API as OpenAI once the
|
||||
client is constructed, so this provider inherits all transport, streaming,
|
||||
reasoning, and tool-calling logic from :class:`OpenAIClient` and only
|
||||
overrides what is genuinely Azure-specific:
|
||||
|
||||
- Client construction: parses ``api-version`` out of the configured
|
||||
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
||||
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
malformed; :class:`GenAIClientManager` catches the exception and
|
||||
disables the provider.
|
||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||
reliably, so we keep the historical 128K default rather than the
|
||||
model-name heuristic used by OpenAI.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import register_genai_provider
|
||||
from frigate.genai.plugins.openai import OpenAIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.azure_openai)
|
||||
class AzureOpenAIClient(OpenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
|
||||
if not api_version:
|
||||
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
||||
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Azure does not reliably surface per-model context size; use 128K."""
|
||||
return 128000
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Gemini Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
@@ -14,6 +16,27 @@ from frigate.genai import GenAIClient, register_genai_provider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _decode_thought_signature(value: Any) -> Optional[bytes]:
|
||||
"""Decode a base64-encoded thought_signature carried across conversation turns."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return base64.b64decode(value)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]:
|
||||
"""Encode bytes thought_signature as base64 so it survives JSON-friendly transport."""
|
||||
if not signature:
|
||||
return None
|
||||
return base64.b64encode(signature).decode("ascii")
|
||||
|
||||
|
||||
def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from a Gemini usage_metadata object."""
|
||||
prompt_tokens = getattr(usage, "prompt_token_count", None)
|
||||
@@ -62,6 +85,7 @@ class GeminiClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Gemini."""
|
||||
contents = [prompt] + [
|
||||
@@ -119,11 +143,14 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to Gemini with optional tool definitions.
|
||||
|
||||
Implements function calling/tool usage for Gemini models.
|
||||
Implements function calling/tool usage for Gemini models. Thinking is
|
||||
configured at the model level for Gemini, so ``enable_thinking`` is
|
||||
accepted for interface parity and ignored.
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
@@ -165,11 +192,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
# Thinking-capable Gemini models require the original
|
||||
# thought_signature to be echoed back on functionCall
|
||||
# parts after a tool response, or the next request
|
||||
# fails with INVALID_ARGUMENT.
|
||||
sig = _decode_thought_signature(tc.get("thought_signature"))
|
||||
if sig:
|
||||
fc_part.thought_signature = sig
|
||||
parts.append(fc_part)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
@@ -248,6 +281,13 @@ class GeminiClient(GenAIClient):
|
||||
if tool_config:
|
||||
config_params["tool_config"] = tool_config
|
||||
|
||||
# Ask thinking-capable models (Gemini 2.5+) to include their
|
||||
# reasoning trace as separate `thought` parts so we can surface
|
||||
# it on the reasoning channel. Older models ignore this field.
|
||||
config_params["thinking_config"] = types.ThinkingConfig(
|
||||
include_thoughts=True
|
||||
)
|
||||
|
||||
# Merge runtime_options
|
||||
if isinstance(self.genai_config.runtime_options, dict):
|
||||
config_params.update(self.genai_config.runtime_options)
|
||||
@@ -262,19 +302,24 @@ class GeminiClient(GenAIClient):
|
||||
if not response or not response.candidates:
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
candidate = response.candidates[0]
|
||||
content = None
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls = None
|
||||
|
||||
# Extract content and tool calls from response
|
||||
# Extract content, reasoning, and tool calls from response
|
||||
if candidate.content and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if part.text:
|
||||
content = part.text.strip()
|
||||
if getattr(part, "thought", False):
|
||||
reasoning_parts.append(part.text)
|
||||
else:
|
||||
content = part.text.strip()
|
||||
elif part.function_call:
|
||||
# Handle function call
|
||||
if tool_calls is None:
|
||||
@@ -294,9 +339,14 @@ class GeminiClient(GenAIClient):
|
||||
"id": part.function_call.name or "",
|
||||
"name": part.function_call.name or "",
|
||||
"arguments": arguments,
|
||||
"thought_signature": _encode_thought_signature(
|
||||
getattr(part, "thought_signature", None)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Determine finish reason
|
||||
finish_reason = "error"
|
||||
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
|
||||
@@ -322,6 +372,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@@ -330,6 +381,7 @@ class GeminiClient(GenAIClient):
|
||||
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@@ -339,6 +391,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@@ -348,11 +401,14 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for Gemini models.
|
||||
``enable_thinking`` is accepted for interface parity; Gemini configures
|
||||
thinking at the model level, so it is ignored here.
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
@@ -394,11 +450,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
# Thinking-capable Gemini models require the original
|
||||
# thought_signature to be echoed back on functionCall
|
||||
# parts after a tool response, or the next request
|
||||
# fails with INVALID_ARGUMENT.
|
||||
sig = _decode_thought_signature(tc.get("thought_signature"))
|
||||
if sig:
|
||||
fc_part.thought_signature = sig
|
||||
parts.append(fc_part)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
@@ -477,12 +539,19 @@ class GeminiClient(GenAIClient):
|
||||
if tool_config:
|
||||
config_params["tool_config"] = tool_config
|
||||
|
||||
# Ask thinking-capable models to include their reasoning trace
|
||||
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
|
||||
config_params["thinking_config"] = types.ThinkingConfig(
|
||||
include_thoughts=True
|
||||
)
|
||||
|
||||
# Merge runtime_options
|
||||
if isinstance(self.genai_config.runtime_options, dict):
|
||||
config_params.update(self.genai_config.runtime_options)
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage_stats: Optional[dict[str, Any]] = None
|
||||
@@ -519,12 +588,16 @@ class GeminiClient(GenAIClient):
|
||||
]:
|
||||
finish_reason = "error"
|
||||
|
||||
# Extract content and tool calls from chunk
|
||||
# Extract content, reasoning, and tool calls from chunk
|
||||
if candidate.content and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if part.text:
|
||||
content_parts.append(part.text)
|
||||
yield ("content_delta", part.text)
|
||||
if getattr(part, "thought", False):
|
||||
reasoning_parts.append(part.text)
|
||||
yield ("reasoning_delta", part.text)
|
||||
else:
|
||||
content_parts.append(part.text)
|
||||
yield ("content_delta", part.text)
|
||||
elif part.function_call:
|
||||
# Handle function call
|
||||
try:
|
||||
@@ -553,6 +626,7 @@ class GeminiClient(GenAIClient):
|
||||
"id": tool_call_id,
|
||||
"name": tool_call_name,
|
||||
"arguments": "",
|
||||
"thought_signature": None,
|
||||
}
|
||||
|
||||
# Accumulate arguments
|
||||
@@ -563,8 +637,16 @@ class GeminiClient(GenAIClient):
|
||||
else str(arguments)
|
||||
)
|
||||
|
||||
# Capture latest thought_signature for this call
|
||||
chunk_sig = getattr(part, "thought_signature", None)
|
||||
if chunk_sig:
|
||||
tool_calls_by_index[found_index][
|
||||
"thought_signature"
|
||||
] = chunk_sig
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
@@ -582,6 +664,9 @@ class GeminiClient(GenAIClient):
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": parsed_args,
|
||||
"thought_signature": _encode_thought_signature(
|
||||
tc.get("thought_signature")
|
||||
),
|
||||
}
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
@@ -593,6 +678,7 @@ class GeminiClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@@ -604,6 +690,7 @@ class GeminiClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@@ -616,6 +703,7 @@ class GeminiClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@@ -122,6 +122,7 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_vision: bool
|
||||
_supports_audio: bool
|
||||
_supports_tools: bool
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
@@ -135,6 +136,7 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_vision = False
|
||||
self._supports_audio = False
|
||||
self._supports_tools = False
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
@@ -150,6 +152,10 @@ class LlamaCppClient(GenAIClient):
|
||||
else:
|
||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||
|
||||
if not self.validate_model:
|
||||
# Probe path
|
||||
return base_url
|
||||
|
||||
configured_model = self.genai_config.model
|
||||
info = self._get_model_info(base_url, configured_model)
|
||||
|
||||
@@ -160,15 +166,17 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_vision = info["supports_vision"]
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._supports_reasoning = info["supports_reasoning"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
self._supports_reasoning,
|
||||
)
|
||||
|
||||
return base_url
|
||||
@@ -196,6 +204,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"supports_vision": False,
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
@@ -275,10 +284,17 @@ class LlamaCppClient(GenAIClient):
|
||||
info["supports_vision"] = bool(modalities.get("vision", False))
|
||||
info["supports_audio"] = bool(modalities.get("audio", False))
|
||||
|
||||
chat_caps = props.get("chat_template_caps") or {}
|
||||
|
||||
if not info["supports_tools"]:
|
||||
chat_caps = props.get("chat_template_caps", {})
|
||||
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
|
||||
|
||||
# llama.cpp does not advertise per-template reasoning support, so
|
||||
# detect it by looking for the `enable_thinking` toggle variable
|
||||
# in the Jinja chat template itself.
|
||||
chat_template = props.get("chat_template") or ""
|
||||
info["supports_reasoning"] = "enable_thinking" in chat_template
|
||||
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
info["media_marker"] = media_marker
|
||||
@@ -296,6 +312,7 @@ class LlamaCppClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to llama.cpp server."""
|
||||
if self.provider is None:
|
||||
@@ -323,7 +340,7 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
|
||||
# Build request payload with llama.cpp native options
|
||||
payload = {
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [
|
||||
{
|
||||
@@ -337,6 +354,9 @@ class LlamaCppClient(GenAIClient):
|
||||
if response_format:
|
||||
payload["response_format"] = response_format
|
||||
|
||||
if self.supports_toggleable_thinking:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
@@ -373,6 +393,10 @@ class LlamaCppClient(GenAIClient):
|
||||
"""Whether the loaded model supports tool/function calling."""
|
||||
return self._supports_tools
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the llama.cpp server."""
|
||||
base_url = self.provider or (
|
||||
@@ -500,6 +524,7 @@ class LlamaCppClient(GenAIClient):
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
tool_choice: Optional[str],
|
||||
stream: bool = False,
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build request payload for chat completions (sync or stream)."""
|
||||
openai_tool_choice = None
|
||||
@@ -515,31 +540,47 @@ class LlamaCppClient(GenAIClient):
|
||||
"messages": messages,
|
||||
"model": self.genai_config.model,
|
||||
}
|
||||
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
payload["timings_per_token"] = True
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
if openai_tool_choice is not None:
|
||||
payload["tool_choice"] = openai_tool_choice
|
||||
|
||||
if enable_thinking is not None and self._supports_reasoning:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
||||
|
||||
provider_opts = {
|
||||
k: v for k, v in self.provider_options.items() if k != "context_size"
|
||||
}
|
||||
payload.update(provider_opts)
|
||||
payload.update(self.genai_config.runtime_options)
|
||||
return payload
|
||||
|
||||
def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse OpenAI-style choice into {content, tool_calls, finish_reason}."""
|
||||
"""Parse OpenAI-style choice into {content, reasoning, tool_calls, finish_reason}.
|
||||
|
||||
llama.cpp's `--reasoning-format` puts the trace in
|
||||
`message.reasoning_content` (preferred) or `message.thinking`; both
|
||||
keys are accepted so different builds work without configuration.
|
||||
"""
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content")
|
||||
content = content.strip() if content else None
|
||||
reasoning = message.get("reasoning_content") or message.get("thinking")
|
||||
reasoning = reasoning.strip() if reasoning else None
|
||||
tool_calls = parse_tool_calls_from_message(message)
|
||||
finish_reason = choice.get("finish_reason") or (
|
||||
"tool_calls" if tool_calls else "stop" if content else "error"
|
||||
)
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@@ -719,6 +760,7 @@ class LlamaCppClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to llama.cpp server with optional tool definitions.
|
||||
@@ -736,7 +778,13 @@ class LlamaCppClient(GenAIClient):
|
||||
"finish_reason": "error",
|
||||
}
|
||||
try:
|
||||
payload = self._build_payload(messages, tools, tool_choice, stream=False)
|
||||
payload = self._build_payload(
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
response = requests.post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
@@ -784,6 +832,7 @@ class LlamaCppClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools via OpenAI-compatible streaming API."""
|
||||
if self.provider is None:
|
||||
@@ -800,8 +849,15 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = self._build_payload(messages, tools, tool_choice, stream=True)
|
||||
payload = self._build_payload(
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=True,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
@@ -831,6 +887,15 @@ class LlamaCppClient(GenAIClient):
|
||||
delta = choices[0].get("delta", {})
|
||||
if choices[0].get("finish_reason"):
|
||||
finish_reason = choices[0]["finish_reason"]
|
||||
# llama.cpp emits separated thinking under
|
||||
# reasoning_content (preferred) or thinking before any
|
||||
# content tokens arrive
|
||||
reasoning_delta = delta.get("reasoning_content") or delta.get(
|
||||
"thinking"
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
if delta.get("content"):
|
||||
content_parts.append(delta["content"])
|
||||
yield ("content_delta", delta["content"])
|
||||
@@ -856,6 +921,7 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index)
|
||||
if tool_calls_list:
|
||||
finish_reason = "tool_calls"
|
||||
@@ -863,6 +929,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@@ -98,6 +98,22 @@ class OllamaClient(GenAIClient):
|
||||
|
||||
provider: ApiClient | None
|
||||
provider_options: dict[str, Any]
|
||||
_supports_thinking_cache: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
if self._supports_thinking_cache is not None:
|
||||
return self._supports_thinking_cache
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
response = self.provider.show(self.genai_config.model)
|
||||
capabilities = response.get("capabilities") or []
|
||||
self._supports_thinking_cache = "thinking" in capabilities
|
||||
except Exception as e:
|
||||
logger.debug("Failed to query Ollama model capabilities: %s", e)
|
||||
self._supports_thinking_cache = False
|
||||
return self._supports_thinking_cache
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
if self.genai_config.api_key:
|
||||
@@ -118,6 +134,9 @@ class OllamaClient(GenAIClient):
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
if not self.validate_model:
|
||||
# Probe path
|
||||
return client
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
@@ -175,6 +194,7 @@ class OllamaClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Ollama"""
|
||||
if self.provider is None:
|
||||
@@ -191,6 +211,8 @@ class OllamaClient(GenAIClient):
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if schema:
|
||||
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
||||
if self.supports_toggleable_thinking:
|
||||
ollama_options["think"] = enable_thinking
|
||||
logger.debug(
|
||||
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
|
||||
"has_format=%s, options=%s",
|
||||
@@ -271,6 +293,7 @@ class OllamaClient(GenAIClient):
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
tool_choice: Optional[str],
|
||||
stream: bool = False,
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build request_messages and params for chat (sync or stream)."""
|
||||
request_messages = []
|
||||
@@ -309,11 +332,14 @@ class OllamaClient(GenAIClient):
|
||||
"model": self.genai_config.model,
|
||||
"messages": request_messages,
|
||||
**self.provider_options,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if stream:
|
||||
request_params["stream"] = True
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if enable_thinking is not None and self.supports_toggleable_thinking:
|
||||
request_params["think"] = enable_thinking
|
||||
return request_params
|
||||
|
||||
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -336,6 +362,9 @@ class OllamaClient(GenAIClient):
|
||||
response.get("done"),
|
||||
)
|
||||
content = message.get("content", "").strip() if message.get("content") else None
|
||||
reasoning = (
|
||||
message.get("thinking", "").strip() if message.get("thinking") else None
|
||||
)
|
||||
tool_calls = parse_tool_calls_from_message(message)
|
||||
finish_reason = "error"
|
||||
if response.get("done"):
|
||||
@@ -348,6 +377,7 @@ class OllamaClient(GenAIClient):
|
||||
finish_reason = "stop"
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@@ -357,6 +387,7 @@ class OllamaClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@@ -369,7 +400,11 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
try:
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=False
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
response = self.provider.chat(**request_params)
|
||||
return self._message_from_response(response)
|
||||
@@ -393,6 +428,7 @@ class OllamaClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
@@ -422,7 +458,11 @@ class OllamaClient(GenAIClient):
|
||||
"Ollama: tools provided, using non-streaming call for tool support"
|
||||
)
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=False
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
@@ -431,6 +471,9 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
response = await async_client.chat(**request_params)
|
||||
result = self._message_from_response(response)
|
||||
reasoning = result.get("reasoning")
|
||||
if reasoning:
|
||||
yield ("reasoning_delta", reasoning)
|
||||
content = result.get("content")
|
||||
if content:
|
||||
yield ("content_delta", content)
|
||||
@@ -441,7 +484,11 @@ class OllamaClient(GenAIClient):
|
||||
return
|
||||
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=True
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=True,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
@@ -449,6 +496,7 @@ class OllamaClient(GenAIClient):
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
final_chunk: Any = None
|
||||
stream = await async_client.chat(**request_params)
|
||||
@@ -456,6 +504,10 @@ class OllamaClient(GenAIClient):
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
reasoning_delta = msg.get("thinking") or ""
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
@@ -463,8 +515,10 @@ class OllamaClient(GenAIClient):
|
||||
if chunk.get("done"):
|
||||
final_chunk = chunk
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
@@ -481,6 +535,7 @@ class OllamaClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": "".join(content_parts).strip() or None,
|
||||
"reasoning": "".join(reasoning_parts).strip() or None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
@@ -38,7 +38,11 @@ class OpenAIClient(GenAIClient):
|
||||
context_size: Optional[int] = None
|
||||
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client."""
|
||||
"""Initialize the client.
|
||||
|
||||
Subclasses (e.g. Azure) should raise on configuration errors; the
|
||||
manager catches construction failures and disables the provider.
|
||||
"""
|
||||
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
|
||||
# It will be used in get_context_size() instead
|
||||
provider_opts = {
|
||||
@@ -57,6 +61,7 @@ class OpenAIClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
@@ -183,11 +188,14 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to OpenAI with optional tool definitions.
|
||||
|
||||
Implements function calling/tool usage for OpenAI models.
|
||||
Implements function calling/tool usage for OpenAI models. The OpenAI
|
||||
chat completions API does not expose a per-request thinking toggle,
|
||||
so ``enable_thinking`` is accepted for interface parity and ignored.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
@@ -236,6 +244,10 @@ class OpenAIClient(GenAIClient):
|
||||
choice = result.choices[0]
|
||||
message = choice.message
|
||||
content = message.content.strip() if message.content else None
|
||||
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
|
||||
message, "reasoning", None
|
||||
)
|
||||
reasoning = raw_reasoning.strip() if raw_reasoning else None
|
||||
|
||||
tool_calls = None
|
||||
if message.tool_calls:
|
||||
@@ -270,6 +282,7 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@@ -278,6 +291,7 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("OpenAI request timed out: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@@ -285,6 +299,7 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@@ -294,11 +309,15 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for OpenAI models.
|
||||
The OpenAI chat completions API does not expose a per-request thinking
|
||||
toggle, so ``enable_thinking`` is accepted for interface parity and
|
||||
ignored.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
@@ -335,6 +354,7 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage_stats: Optional[dict[str, Any]] = None
|
||||
@@ -356,6 +376,15 @@ class OpenAIClient(GenAIClient):
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
|
||||
delta, "reasoning", None
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
@@ -384,6 +413,7 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
@@ -412,6 +442,7 @@ class OpenAIClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@@ -423,6 +454,7 @@ class OpenAIClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@@ -433,6 +465,7 @@ class OpenAIClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@@ -0,0 +1,744 @@
|
||||
"""Prompt and response-format builders for GenAI features.
|
||||
|
||||
Centralizes the per-feature prompt framing and structured-output schema
|
||||
shaping so provider clients in :mod:`frigate.genai.plugins` only handle
|
||||
transport.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.data_processing.post.types import ReviewMetadata
|
||||
from frigate.models import Event
|
||||
|
||||
|
||||
def build_review_description_prompt(
|
||||
review_data: dict[str, Any],
|
||||
thumbnails: list[bytes],
|
||||
concerns: list[str],
|
||||
preferred_language: str | None,
|
||||
activity_context_prompt: str,
|
||||
) -> str:
|
||||
"""Build the prompt for review activity description generation."""
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return (
|
||||
"\n- `other_concerns` (list of strings): Include a list of any of "
|
||||
"the following concerns that are occurring:\n"
|
||||
f" - {concern_list}"
|
||||
)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_language_prompt() -> str:
|
||||
if preferred_language:
|
||||
return f"Provide your answer in {preferred_language}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_objects_list() -> str:
|
||||
if review_data["unified_objects"]:
|
||||
return "\n- " + "\n- ".join(review_data["unified_objects"])
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
return f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
|
||||
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **Treat "Objects in Scene" as the list of tracked subjects to describe.** Do not introduce additional people or vehicles that are not present in this list. You may freely reference other items, surfaces, and environmental details visible in the frames when describing what the listed subjects are doing.
|
||||
- **Describe the most likely activity from visible cues across the sequence** — the subject's path, what they are carrying, and what they interact with. Avoid asserting completed outcomes you do not observe; describe in-progress actions rather than results.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
- **Use the actual timestamp provided in "Activity started at"** below for time of day context—do not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
|
||||
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
|
||||
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
|
||||
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
## Sequence Details
|
||||
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
|
||||
|
||||
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
|
||||
{get_objects_list()}
|
||||
|
||||
{get_language_prompt()}
|
||||
"""
|
||||
|
||||
|
||||
def build_review_description_response_format(concerns: list[str]) -> dict[str, Any]:
|
||||
"""Build the structured-output JSON schema for review descriptions.
|
||||
|
||||
Strips the `time` field (populated server-side) and drops
|
||||
`other_concerns` when no concerns are configured.
|
||||
"""
|
||||
schema = ReviewMetadata.model_json_schema()
|
||||
schema.get("properties", {}).pop("time", None)
|
||||
|
||||
if "time" in schema.get("required", []):
|
||||
schema["required"].remove("time")
|
||||
if not concerns:
|
||||
schema.get("properties", {}).pop("other_concerns", None)
|
||||
if "other_concerns" in schema.get("required", []):
|
||||
schema["required"].remove("other_concerns")
|
||||
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "review_metadata",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_review_summary_prompt(
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
events: list[dict[str, Any]],
|
||||
preferred_language: str | None,
|
||||
) -> str:
|
||||
"""Build the prompt for a multi-event review summary."""
|
||||
time_range = (
|
||||
f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
f" to "
|
||||
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
)
|
||||
prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
Report Structure - Use this EXACT format:
|
||||
|
||||
# Security Summary - {time_range}
|
||||
|
||||
## Overview
|
||||
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
|
||||
|
||||
### [Time Block Name]
|
||||
|
||||
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
|
||||
- [Event title]: [Clear description incorporating contextual information from the "context" array]
|
||||
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
|
||||
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
|
||||
|
||||
[Repeat for each event in chronological order within the time block]
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
|
||||
|
||||
Guidelines:
|
||||
- List ALL events in chronological order, grouped by time blocks
|
||||
- Threat level indicators: ✓ Normal, ⚠️ Needs review, 🔴 Security concern
|
||||
- Integrate contextual information naturally - use the "context" array to enrich each event's description
|
||||
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
|
||||
- Be concise but informative - focus on what happened and what it means
|
||||
- If contextual information makes an event clearly normal, reflect that in your assessment
|
||||
- Only create time blocks that have events - don't create empty sections
|
||||
"""
|
||||
|
||||
prompt += "\n\nEvents:\n"
|
||||
for event in events:
|
||||
prompt += f"\n{event}\n"
|
||||
|
||||
if preferred_language:
|
||||
prompt += f"\nProvide your answer in {preferred_language}"
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def build_object_description_prompt(
|
||||
camera_config: CameraConfig,
|
||||
event: Event,
|
||||
) -> str:
|
||||
"""Build the prompt for a per-object description.
|
||||
|
||||
Pulls the per-label override from `objects.genai.object_prompts`, falling
|
||||
back to the camera default, and interpolates event fields.
|
||||
|
||||
Raises:
|
||||
KeyError: if the user-defined prompt template references an unknown
|
||||
event field.
|
||||
"""
|
||||
template = camera_config.objects.genai.object_prompts.get(
|
||||
str(event.label),
|
||||
camera_config.objects.genai.prompt,
|
||||
)
|
||||
return template.format(**model_to_dict(event))
|
||||
|
||||
|
||||
def get_attribute_classifications(config: FrigateConfig) -> List[Dict[str, Any]]:
|
||||
"""Return enabled custom classification models of `attribute` type.
|
||||
|
||||
Each entry: {"name": <model name>, "objects": [<object label>, ...]}.
|
||||
These models attach attribute metadata to events on the listed object
|
||||
types, which can later be filtered via the search_objects `attribute`
|
||||
field.
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
|
||||
for model_key, model_config in config.classification.custom.items():
|
||||
if not model_config.enabled or model_config.object_config is None:
|
||||
continue
|
||||
|
||||
if (
|
||||
model_config.object_config.classification_type
|
||||
!= ObjectClassificationType.attribute
|
||||
):
|
||||
continue
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": model_config.name or model_key,
|
||||
"objects": list(model_config.object_config.objects or []),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_tool_definitions(
|
||||
semantic_search_enabled: bool = False,
|
||||
attribute_classifications: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> 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. When semantic search is enabled, the search_objects
|
||||
tool exposes an additional `semantic_query` parameter for descriptive
|
||||
queries (e.g. "person riding a lawn mower") and find_similar_objects is
|
||||
included. When attribute classification models are configured, an
|
||||
`attribute` parameter is exposed for filtering by their labels.
|
||||
"""
|
||||
search_objects_properties: Dict[str, Any] = {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to filter by (optional).",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Generic object class to filter by — one of the tracked detector "
|
||||
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
|
||||
"this for broad queries like 'show me all cars today'. Combine "
|
||||
"with semantic_query when the user also describes appearance or "
|
||||
"behavior (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower')."
|
||||
),
|
||||
},
|
||||
"sub_label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter by a DISCRETE NAMED entity recognized in the detection. "
|
||||
"Use this for: a known person's name ('John'), a delivery "
|
||||
"company ('Amazon', 'UPS'), a recognized animal species or "
|
||||
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
|
||||
"license plate string. When filtering by a specific name, set "
|
||||
"only sub_label and leave label unset. Do NOT use sub_label "
|
||||
"for descriptions of appearance, clothing, or actions — those "
|
||||
"belong in semantic_query."
|
||||
),
|
||||
},
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
if attribute_classifications:
|
||||
model_outline = "; ".join(
|
||||
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
|
||||
for m in attribute_classifications
|
||||
)
|
||||
search_objects_properties["attribute"] = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter by a classification attribute label produced by a "
|
||||
"configured attribute classification model. Use this INSTEAD "
|
||||
"of semantic_query when the user's request matches one of "
|
||||
"these classifications. Configured models: "
|
||||
f"{model_outline}. "
|
||||
"Set the value to the attribute label that matches the user's "
|
||||
"phrasing (case-sensitive)."
|
||||
),
|
||||
}
|
||||
|
||||
if semantic_search_enabled:
|
||||
search_objects_properties["semantic_query"] = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional natural-language description of a PHYSICAL "
|
||||
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
|
||||
"used to semantically narrow results. Only set this when the "
|
||||
"user describes something beyond what label and sub_label can "
|
||||
"express on their own.\n"
|
||||
"USE for descriptive phrases like: 'riding a lawn mower', "
|
||||
"'wearing a red jacket', 'carrying a package', 'walking a "
|
||||
"dog', 'on a bicycle', 'holding an umbrella'.\n"
|
||||
"DO NOT USE for:\n"
|
||||
"- specific named people, pets, or delivery companies → use sub_label\n"
|
||||
"- animal species or breed names like 'blue jay', 'cardinal', "
|
||||
"'golden retriever' → use sub_label\n"
|
||||
"- license plate strings → use sub_label\n"
|
||||
"- generic object queries like 'all cars today' or 'every "
|
||||
"person' → use label alone with no semantic_query\n"
|
||||
"When set, combine with label/time/camera/zone filters as "
|
||||
"usual (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower', after='2024-05-01T00:00:00Z')."
|
||||
),
|
||||
}
|
||||
|
||||
search_objects_description = (
|
||||
"Search the historical record of detected objects in Frigate. "
|
||||
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
|
||||
"'when was the last car?', 'show me detections from yesterday'. "
|
||||
"Do NOT use this for monitoring or alerting requests about future events — "
|
||||
"use start_camera_watch instead for those. "
|
||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
|
||||
"Choose filters based on what the user is asking for:\n"
|
||||
"- Generic class query ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific NAMED entity (known person, delivery company, animal "
|
||||
"species/breed like 'blue jay' or 'golden retriever', license "
|
||||
"plate): set `sub_label` only and leave `label` unset.\n"
|
||||
)
|
||||
if semantic_search_enabled:
|
||||
search_objects_description += (
|
||||
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
|
||||
"discrete name ('person riding a lawn mower', 'someone in a red "
|
||||
"jacket', 'person carrying a package'): set `semantic_query` with "
|
||||
"the descriptive phrase, optionally alongside `label` for the "
|
||||
"object class. Do NOT put descriptive phrases in sub_label."
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": search_objects_properties,
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
"Find tracked objects that are visually and semantically similar "
|
||||
"to a specific past event. Use this when the user references a "
|
||||
"particular object they have seen and wants to find other "
|
||||
"sightings of the same or similar one ('that green car', 'the "
|
||||
"person in the red jacket', 'the package that was delivered'). "
|
||||
"Prefer this over search_objects whenever the user's intent is "
|
||||
"'find more like this specific one.' Use search_objects first "
|
||||
"only if you need to locate the anchor event. Requires semantic "
|
||||
"search to be enabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The id of the anchor event to find similar objects to.",
|
||||
},
|
||||
"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').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of cameras to restrict to. Defaults to all.",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
|
||||
},
|
||||
"sub_labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of sub_labels (names) to restrict to.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of zones. An event matches if any of its zones overlap.",
|
||||
},
|
||||
"similarity_mode": {
|
||||
"type": "string",
|
||||
"enum": ["visual", "semantic", "fused"],
|
||||
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
|
||||
"default": "fused",
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of matches to return (default: 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
|
||||
"Use camera='*' to apply to all cameras at once. "
|
||||
"Only call this tool when the user explicitly asks to change a camera setting. "
|
||||
"Requires admin privileges."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to target, or '*' to target all cameras.",
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"detect",
|
||||
"record",
|
||||
"snapshots",
|
||||
"audio",
|
||||
"motion",
|
||||
"enabled",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
"improve_contrast",
|
||||
"ptz_autotracker",
|
||||
"motion_contour_area",
|
||||
"motion_threshold",
|
||||
"notifications",
|
||||
"audio_transcription",
|
||||
"review_alerts",
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"profile",
|
||||
],
|
||||
"description": (
|
||||
"The feature to change. Most features accept ON or OFF. "
|
||||
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
|
||||
"motion_contour_area and motion_threshold accept a number. "
|
||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "feature", "value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a single camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera. "
|
||||
"Operates on one camera at a time; call the tool again for each additional camera. "
|
||||
"Wildcards and empty values are not accepted."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Exact name of a single camera to get live context for. "
|
||||
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
"Start a continuous VLM watch job that monitors a camera and sends a notification "
|
||||
"when a specified condition is met. Use this when the user wants to be alerted about "
|
||||
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
|
||||
"Only one watch job can run at a time. Returns a job ID."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to monitor.",
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language description of the condition to watch for, "
|
||||
"e.g. 'a person arrives at the front door'."
|
||||
),
|
||||
},
|
||||
"max_duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to watch before giving up (minutes, default 60).",
|
||||
"default": 60,
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "condition"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": (
|
||||
"Cancel the currently running VLM watch job. Use this when the user wants to "
|
||||
"stop a previously started watch, e.g. 'stop watching the front door'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
"Get the current profile status including the active profile and "
|
||||
"timestamps of when each profile was last activated. Use this to "
|
||||
"determine time periods for recap requests — e.g. when the user asks "
|
||||
"'what happened while I was away?', call this first to find the relevant "
|
||||
"time window based on profile activation history."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
||||
"Use this after calling get_profile_status to retrieve what happened during "
|
||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
||||
"descriptions when available. Summarize the results for the user."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["alert", "detection"],
|
||||
"description": "Filter by severity level. Omit to include both alerts and detections.",
|
||||
},
|
||||
},
|
||||
"required": ["after", "before"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
config: FrigateConfig,
|
||||
allowed_cameras: List[str],
|
||||
semantic_search_enabled: bool,
|
||||
attribute_classifications: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Build the system prompt for the chat completion endpoint.
|
||||
|
||||
Composes the static framing with conditional sections describing the
|
||||
available cameras, speed units, semantic-search routing guidance, and
|
||||
configured attribute classifications.
|
||||
"""
|
||||
current_datetime = datetime.datetime.now()
|
||||
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
||||
current_time_str = current_datetime.strftime("%I:%M:%S %p")
|
||||
|
||||
cameras_info: List[str] = []
|
||||
has_speed_zone = False
|
||||
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()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
)
|
||||
else:
|
||||
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."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
if has_speed_zone:
|
||||
speed_unit = (
|
||||
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||
)
|
||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||
|
||||
semantic_search_section = ""
|
||||
if semantic_search_enabled:
|
||||
semantic_search_section = (
|
||||
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
|
||||
"- Generic class ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
|
||||
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
|
||||
)
|
||||
|
||||
attribute_classification_section = ""
|
||||
if attribute_classifications:
|
||||
model_lines = "\n".join(
|
||||
f"- {m['name']}: applies to {', '.join(m['objects']) or 'any object'}"
|
||||
for m in attribute_classifications
|
||||
)
|
||||
attribute_classification_section = (
|
||||
"\n\nAttribute classification models are configured for the following object types:\n"
|
||||
f"{model_lines}\n"
|
||||
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
|
||||
)
|
||||
|
||||
return 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.
|
||||
|
||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
|
||||
@@ -69,6 +69,14 @@ def build_assistant_message_for_conversation(
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc.get("arguments") or {}),
|
||||
},
|
||||
# Gemini-only: opaque signature that must be echoed back on
|
||||
# the same functionCall part in the next turn. Other providers
|
||||
# do not set or read this.
|
||||
**(
|
||||
{"thought_signature": tc["thought_signature"]}
|
||||
if tc.get("thought_signature")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for tc in tool_calls_raw
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Debug replay startup job: ffmpeg concat + camera config publish.
|
||||
"""Debug replay startup job: ffmpeg remux + camera config publish.
|
||||
|
||||
The runner orchestrates the async portion of starting a debug replay
|
||||
session. The DebugReplayManager (in frigate.debug_replay) owns session
|
||||
@@ -153,15 +153,22 @@ class DebugReplaySource(ABC):
|
||||
class RecordingDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by the Recordings table.
|
||||
|
||||
Builds a concat playlist of recording files covering the time range
|
||||
and feeds it to ffmpeg's concat demuxer.
|
||||
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
|
||||
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
|
||||
discontinuities.
|
||||
"""
|
||||
|
||||
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
internal_port: int,
|
||||
) -> None:
|
||||
self._camera = source_camera
|
||||
self._start_ts = start_ts
|
||||
self._end_ts = end_ts
|
||||
self._concat_file: Optional[str] = None
|
||||
self._internal_port = internal_port
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
@@ -185,18 +192,16 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
)
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
|
||||
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
|
||||
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
self._concat_file = concat_file
|
||||
return ["-f", "concat", "-safe", "0", "-i", concat_file]
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
if self._concat_file:
|
||||
_remove_silent(self._concat_file)
|
||||
playlist_url = (
|
||||
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
|
||||
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
|
||||
)
|
||||
return [
|
||||
"-protocol_whitelist",
|
||||
"pipe,file,http,tcp",
|
||||
"-i",
|
||||
playlist_url,
|
||||
]
|
||||
|
||||
|
||||
class ExportDebugReplaySource(DebugReplaySource):
|
||||
|
||||
@@ -167,8 +167,9 @@ class DetectorRunner(FrigateProcess):
|
||||
|
||||
# detect and send the output
|
||||
self.start_time.value = datetime.datetime.now().timestamp()
|
||||
mono_start = time.monotonic()
|
||||
detections = object_detector.detect_raw(input_frame)
|
||||
duration = datetime.datetime.now().timestamp() - self.start_time.value
|
||||
duration = time.monotonic() - mono_start
|
||||
frame_manager.close(connection_id)
|
||||
|
||||
if connection_id not in self.outputs:
|
||||
|
||||
+23
-13
@@ -342,20 +342,30 @@ def move_preview_frames(loc: str) -> None:
|
||||
preview_holdover = os.path.join(CLIPS_DIR, "preview_restart_cache")
|
||||
preview_cache = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if loc == "clips":
|
||||
src = preview_cache
|
||||
dst = preview_holdover
|
||||
elif loc == "cache":
|
||||
src = preview_holdover
|
||||
dst = preview_cache
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
if loc == "clips":
|
||||
shutil.move(preview_cache, preview_holdover)
|
||||
elif loc == "cache":
|
||||
if not os.path.exists(preview_holdover):
|
||||
return
|
||||
if not os.path.exists(src):
|
||||
return
|
||||
|
||||
if not os.access(preview_holdover, os.R_OK | os.W_OK):
|
||||
logger.error(
|
||||
"Insufficient permissions on preview restart cache at %s",
|
||||
preview_holdover,
|
||||
)
|
||||
return
|
||||
shutil.move(src, dst)
|
||||
|
||||
shutil.move(preview_holdover, preview_cache)
|
||||
except PermissionError:
|
||||
logger.error(
|
||||
"Insufficient permissions while moving preview restart cache from %s to %s",
|
||||
src,
|
||||
dst,
|
||||
)
|
||||
except shutil.Error:
|
||||
logger.error("Failed to restore preview cache.")
|
||||
logger.error(
|
||||
"Failed to move preview restart cache from %s to %s",
|
||||
src,
|
||||
dst,
|
||||
)
|
||||
|
||||
@@ -1331,6 +1331,8 @@ class PtzAutoTracker:
|
||||
return self.tracked_object[camera]["region"]
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
|
||||
@@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
if chapters_path
|
||||
else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
@@ -71,3 +75,108 @@ class TestHttpApp(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
####################################################################################################################
|
||||
################################### Credential redaction sentinel ################################################
|
||||
####################################################################################################################
|
||||
def test_config_response_redacts_mqtt_password_with_sentinel(self):
|
||||
self.minimal_config["mqtt"]["user"] = "mqttuser"
|
||||
self.minimal_config["mqtt"]["password"] = "supersecret"
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
def test_genai_probe_requires_admin(self):
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_genai_probe_returns_models_from_transient_client(self):
|
||||
class FakeClient(GenAIClient):
|
||||
def list_models(self):
|
||||
return ["fake-model-a", "fake-model-b"]
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: FakeClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://example.invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"success": True,
|
||||
"models": ["fake-model-a", "fake-model-b"],
|
||||
}
|
||||
|
||||
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
# the UI can show a meaningful error.
|
||||
class EmptyClient(GenAIClient):
|
||||
def list_models(self):
|
||||
return []
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: EmptyClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is False
|
||||
assert "message" in payload
|
||||
|
||||
def test_genai_probe_handles_provider_failure(self):
|
||||
class FailingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
raise RuntimeError("provider unreachable")
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: FailingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is False
|
||||
assert "message" in payload
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for CameraMaintainer SHM cleanup on camera remove.
|
||||
|
||||
Regression coverage for the case where a camera is removed and then a
|
||||
new camera is added with the same name. Without unlinking the per-frame
|
||||
YUV SHM slots, the maintainer's frame_manager.create call hits
|
||||
FileExistsError and falls back to reopening the existing segment at the
|
||||
*old* size, which the new ffmpeg process then writes mismatched-size
|
||||
frames into.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.camera.maintainer import CameraMaintainer
|
||||
|
||||
|
||||
class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase):
|
||||
def _make_maintainer(self) -> CameraMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing real
|
||||
FrigateConfig, queues, multiprocessing manager, etc.). We're only
|
||||
exercising the SHM-cleanup helper, so the surrounding init is
|
||||
irrelevant."""
|
||||
maintainer = CameraMaintainer.__new__(CameraMaintainer)
|
||||
maintainer.frame_manager = MagicMock()
|
||||
return maintainer
|
||||
|
||||
def test_unlinks_only_segments_with_matching_prefix(self) -> None:
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
"front_frame2": object(),
|
||||
# Different camera; must not be touched.
|
||||
"side_frame0": object(),
|
||||
# Detector input/output buffers are sized by the model and
|
||||
# cached by the long-lived DetectorRunner — must not be
|
||||
# touched even when their owning camera is removed.
|
||||
"front": object(),
|
||||
"out-front": object(),
|
||||
}
|
||||
|
||||
# __name-mangled access from outside the class.
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list]
|
||||
self.assertEqual(
|
||||
sorted(deleted),
|
||||
["front_frame0", "front_frame1", "front_frame2"],
|
||||
)
|
||||
|
||||
def test_handles_camera_with_no_slots(self) -> None:
|
||||
"""Cameras that were removed before any frame slot was ever
|
||||
created (e.g. cancelled during preparing_clip) should be a no-op."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {"other_frame0": object()}
|
||||
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
maintainer.frame_manager.delete.assert_not_called()
|
||||
|
||||
def test_swallows_delete_errors(self) -> None:
|
||||
"""Unlink failures shouldn't abort the remove loop — best-effort."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
}
|
||||
maintainer.frame_manager.delete.side_effect = OSError("simulated")
|
||||
|
||||
# Both slots are attempted; the OSError on the first doesn't
|
||||
# prevent the second from being tried.
|
||||
with patch("frigate.camera.maintainer.logger"):
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
self.assertEqual(maintainer.frame_manager.delete.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -101,7 +101,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="missing", start_ts=100.0, end_ts=200.0
|
||||
source_camera="missing",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -112,7 +115,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=200.0, end_ts=100.0
|
||||
source_camera="front",
|
||||
start_ts=200.0,
|
||||
end_ts=100.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -126,7 +132,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -156,7 +165,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
):
|
||||
job_id = start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -193,7 +205,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -203,7 +218,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(RuntimeError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -271,7 +289,10 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -342,7 +363,10 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@@ -420,7 +444,10 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
|
||||
@@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**config_data)
|
||||
|
||||
def test_profile_zone_without_base_rejected(self):
|
||||
"""Profile defining a zone not present on the base camera is rejected."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"phantom": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
FrigateConfig(**config_data)
|
||||
self.assertIn("phantom", str(ctx.exception))
|
||||
|
||||
def test_profile_motion_mask_without_base_rejected(self):
|
||||
"""Profile defining a motion mask not present on the base camera is rejected."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"base_mask": {
|
||||
"coordinates": "0,0,100,0,100,100,0,100",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"motion": {
|
||||
"mask": {
|
||||
"phantom_mask": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
FrigateConfig(**config_data)
|
||||
self.assertIn("phantom_mask", str(ctx.exception))
|
||||
|
||||
def test_profile_overrides_matching_base_accepted(self):
|
||||
"""Profile overrides that reference existing base zones/masks parse cleanly."""
|
||||
config_data = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {
|
||||
"armed": {"friendly_name": "Armed"},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
|
||||
},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"tree": {
|
||||
"coordinates": "0,0,100,0,100,100,0,100",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"front_yard": {
|
||||
"coordinates": "0,0,50,0,50,50,0,50",
|
||||
"inertia": 5,
|
||||
},
|
||||
},
|
||||
"motion": {
|
||||
"mask": {
|
||||
"tree": {
|
||||
"coordinates": "0,0,75,0,75,75,0,75",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config = FrigateConfig(**config_data)
|
||||
assert "armed" in config.cameras["front"].profiles
|
||||
|
||||
|
||||
class TestProfileInConfig(unittest.TestCase):
|
||||
"""Test that profiles parse correctly in FrigateConfig."""
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for SharedMemoryFrameManager cache invalidation.
|
||||
|
||||
Covers the case where a SHM segment is unlinked and recreated at a
|
||||
different size across a camera add/remove cycle while a long-lived
|
||||
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
|
||||
the old, smaller segment.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
|
||||
def _fake_shm(size: int) -> SimpleNamespace:
|
||||
"""A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
|
||||
return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGet(unittest.TestCase):
|
||||
def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
|
||||
"""A cached ref to an older smaller segment must be dropped and the
|
||||
current (correctly sized) segment reopened. Without this, np.ndarray
|
||||
would raise "buffer is too small for requested array" when the
|
||||
in-memory cache pointed at an old SHM after a same-name resize."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small = _fake_shm(size=100)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
|
||||
"""Symmetric to the smaller-cache case: when detect resolution drops,
|
||||
the SHM is unlinked and recreated at a smaller size. A cached ref to
|
||||
the old, larger segment still satisfies any size check but points at
|
||||
an orphaned inode whose stale bytes get reinterpreted at the new
|
||||
shape — producing miscolored, distorted YUV frames downstream. Drop
|
||||
the cache so we reopen by name and bind to the current segment."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
old_large = _fake_shm(size=10_000)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = old_large
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_keeps_cached_segment_when_size_matches(self) -> None:
|
||||
"""Don't pay the reopen cost when the cached ref is the right size."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
cached = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = cached
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], cached)
|
||||
|
||||
def test_get_opens_fresh_when_no_cache_entry(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
fresh = _fake_shm(size=2_500)
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], fresh)
|
||||
|
||||
def test_get_returns_none_when_segment_missing(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
side_effect=FileNotFoundError,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
|
||||
def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
|
||||
"""Race during a same-name SHM recreate: cache is stale, we reopen
|
||||
by name, but the maintainer hasn't allocated the new segment yet —
|
||||
the reopened ref is also too small. Skip the frame (return None)
|
||||
rather than crash on np.ndarray."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small_cached = _fake_shm(size=100)
|
||||
still_small_after_reopen = _fake_shm(size=100)
|
||||
manager.shm_store["cam_frame0"] = small_cached
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
return_value=still_small_after_reopen,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
# Don't cache the too-small reopened ref — next call will re-open
|
||||
# once the maintainer has finished recreating the segment.
|
||||
self.assertNotIn("cam_frame0", manager.shm_store)
|
||||
|
||||
def test_get_handles_n_dimensional_shape(self) -> None:
|
||||
"""np.prod must be used (not raw multiplication) for tuple shapes."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
# YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
|
||||
big_enough = _fake_shm(size=3_110_400)
|
||||
manager.shm_store["cam_frame0"] = big_enough
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (1620, 1920))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (1620, 1920))
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
|
||||
"""End-to-end-style: simulates the full unlink-and-recreate cycle."""
|
||||
|
||||
def test_segment_grows_then_get_succeeds(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
# Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
|
||||
small = _fake_shm(size=115_200)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
|
||||
self.assertEqual(arr_small.shape, (360, 320))
|
||||
|
||||
# Phase 2: restart at 1920x1080 — new SHM segment, larger size.
|
||||
large = _fake_shm(size=3_110_400)
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
|
||||
arr_large = manager.get("cam_frame0", (1620, 1920))
|
||||
|
||||
self.assertIsNotNone(arr_large)
|
||||
self.assertEqual(arr_large.shape, (1620, 1920))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,806 @@
|
||||
"""Tests for outbound WebSocket broadcast filtering."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from frigate.comms.ws import (
|
||||
WebSocketClient,
|
||||
_classify_outbound,
|
||||
_collect_zone_names,
|
||||
_extract_payload_camera,
|
||||
_materialize_for_ws,
|
||||
_ws_allowed_cameras,
|
||||
_ws_is_unrestricted,
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
def _build_config(
|
||||
*,
|
||||
extra_roles: dict[str, list[str]] | None = None,
|
||||
extra_cameras: dict[str, dict[str, Any]] | None = None,
|
||||
extra_zones: dict[str, dict[str, dict[str, Any]]] | None = None,
|
||||
) -> FrigateConfig:
|
||||
"""Construct a FrigateConfig used by the outbound filter tests.
|
||||
|
||||
The default fixture has three cameras: front_door, back_door, garage.
|
||||
Restricted role "house_only" sees front_door + back_door but not garage.
|
||||
"""
|
||||
cameras: dict[str, dict[str, Any]] = {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.2:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"garage": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.3:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
}
|
||||
if extra_cameras:
|
||||
cameras.update(extra_cameras)
|
||||
if extra_zones:
|
||||
for cam_name, zones in extra_zones.items():
|
||||
cameras[cam_name]["zones"] = zones
|
||||
|
||||
roles = {"house_only": ["front_door", "back_door"]}
|
||||
if extra_roles:
|
||||
roles.update(extra_roles)
|
||||
|
||||
return FrigateConfig(
|
||||
mqtt={"host": "mqtt"},
|
||||
auth={"roles": roles},
|
||||
cameras=cameras,
|
||||
)
|
||||
|
||||
|
||||
def _ws(role: str | None) -> Any:
|
||||
"""Build a fake ws4py-style websocket exposing ``environ``."""
|
||||
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
|
||||
return SimpleNamespace(environ=environ, terminated=False, sent=[])
|
||||
|
||||
|
||||
class TestClassifyOutbound(unittest.TestCase):
|
||||
"""The pure classifier — bucket every topic into a scope."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.all_cameras = set(self.config.cameras.keys())
|
||||
self.all_zones = _collect_zone_names(self.config)
|
||||
|
||||
def _classify(self, topic: str) -> tuple[str, Any]:
|
||||
return _classify_outbound(topic, self.all_cameras, self.all_zones)
|
||||
|
||||
# --- Global allowlist ---
|
||||
|
||||
def test_model_state_is_global(self):
|
||||
self.assertEqual(self._classify("model_state"), ("global", None))
|
||||
|
||||
def test_profile_state_is_global(self):
|
||||
self.assertEqual(self._classify("profile/state"), ("global", None))
|
||||
|
||||
def test_bare_notifications_state_is_global(self):
|
||||
"""The 2-segment ``notifications/state`` is global; the 3-segment
|
||||
``<camera>/notifications/state`` is camera-scoped (see below)."""
|
||||
self.assertEqual(self._classify("notifications/state"), ("global", None))
|
||||
|
||||
def test_notification_test_is_global(self):
|
||||
self.assertEqual(self._classify("notification_test"), ("global", None))
|
||||
|
||||
# --- Unrestricted-only ---
|
||||
|
||||
def test_birdseye_layout_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
|
||||
|
||||
# --- Camera-prefixed ---
|
||||
|
||||
def test_camera_state_topic_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/detect/state"), ("camera", "front_door")
|
||||
)
|
||||
|
||||
def test_camera_motion_topic_resolves_to_camera(self):
|
||||
self.assertEqual(self._classify("back_door/motion"), ("camera", "back_door"))
|
||||
|
||||
def test_camera_per_notification_topic_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/notifications/state"),
|
||||
("camera", "front_door"),
|
||||
)
|
||||
|
||||
def test_camera_label_counter_resolves_to_camera(self):
|
||||
self.assertEqual(self._classify("front_door/person"), ("camera", "front_door"))
|
||||
|
||||
def test_camera_object_mask_state_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/object_mask/zone_1/state"),
|
||||
("camera", "front_door"),
|
||||
)
|
||||
|
||||
# --- Zone-prefixed ---
|
||||
|
||||
def test_zone_aggregate_topic_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("driveway/person"), ("unrestricted_only", None))
|
||||
|
||||
def test_zone_all_topic_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("driveway/all"), ("unrestricted_only", None))
|
||||
|
||||
# --- Payload-camera ---
|
||||
|
||||
def test_events_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("events"), ("payload_camera", ("after", "camera"))
|
||||
)
|
||||
|
||||
def test_reviews_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("reviews"), ("payload_camera", ("after", "camera"))
|
||||
)
|
||||
|
||||
def test_triggers_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(self._classify("triggers"), ("payload_camera", ("camera",)))
|
||||
|
||||
def test_tracked_object_update_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("tracked_object_update"), ("payload_camera", ("camera",))
|
||||
)
|
||||
|
||||
# --- Reshape ---
|
||||
|
||||
def test_camera_activity_is_reshape_by_camera_key(self):
|
||||
self.assertEqual(
|
||||
self._classify("camera_activity"), ("reshape_by_camera_key", None)
|
||||
)
|
||||
|
||||
def test_audio_detections_is_reshape_by_camera_key(self):
|
||||
self.assertEqual(
|
||||
self._classify("audio_detections"), ("reshape_by_camera_key", None)
|
||||
)
|
||||
|
||||
def test_job_state_is_reshape_job_state(self):
|
||||
self.assertEqual(self._classify("job_state"), ("reshape_job_state", None))
|
||||
|
||||
def test_stats_is_reshape_stats(self):
|
||||
self.assertEqual(self._classify("stats"), ("reshape_stats", None))
|
||||
|
||||
# --- Fail-closed ---
|
||||
|
||||
def test_unknown_topic_is_dropped(self):
|
||||
self.assertEqual(self._classify("some_random_topic"), ("drop", None))
|
||||
|
||||
def test_unknown_camera_prefix_is_dropped(self):
|
||||
self.assertEqual(self._classify("ghost_camera/detect/state"), ("drop", None))
|
||||
|
||||
|
||||
class TestCollectZoneNames(unittest.TestCase):
|
||||
def test_zones_from_all_cameras(self):
|
||||
config = _build_config(
|
||||
extra_zones={
|
||||
"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
"back_door": {"yard": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(_collect_zone_names(config), {"driveway", "yard"})
|
||||
|
||||
def test_no_zones_returns_empty(self):
|
||||
self.assertEqual(_collect_zone_names(_build_config()), set())
|
||||
|
||||
|
||||
class TestExtractPayloadCamera(unittest.TestCase):
|
||||
def test_extract_from_dict_path(self):
|
||||
payload = {"after": {"camera": "front_door"}}
|
||||
self.assertEqual(
|
||||
_extract_payload_camera(payload, ("after", "camera")), "front_door"
|
||||
)
|
||||
|
||||
def test_extract_from_json_string(self):
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertEqual(
|
||||
_extract_payload_camera(payload, ("after", "camera")), "front_door"
|
||||
)
|
||||
|
||||
def test_extract_single_segment_path(self):
|
||||
self.assertEqual(
|
||||
_extract_payload_camera({"camera": "garage"}, ("camera",)), "garage"
|
||||
)
|
||||
|
||||
def test_missing_key_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera({}, ("after", "camera")))
|
||||
|
||||
def test_malformed_json_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera("not-json", ("camera",)))
|
||||
|
||||
def test_non_string_camera_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera({"camera": 42}, ("camera",)))
|
||||
|
||||
|
||||
class TestWsRoleHelpers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = _build_config()
|
||||
|
||||
def test_admin_is_unrestricted(self):
|
||||
self.assertTrue(_ws_is_unrestricted(_ws("admin"), self.config))
|
||||
|
||||
def test_viewer_is_unrestricted(self):
|
||||
self.assertTrue(_ws_is_unrestricted(_ws("viewer"), self.config))
|
||||
|
||||
def test_restricted_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws("house_only"), self.config))
|
||||
|
||||
def test_missing_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws(None), self.config))
|
||||
|
||||
def test_unknown_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws("ghost"), self.config))
|
||||
|
||||
def test_admin_allowed_cameras_is_all(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("admin"), self.config),
|
||||
{"front_door", "back_door", "garage"},
|
||||
)
|
||||
|
||||
def test_restricted_role_allowed_cameras_is_subset(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("house_only"), self.config),
|
||||
{"front_door", "back_door"},
|
||||
)
|
||||
|
||||
def test_missing_role_allowed_cameras_is_empty(self):
|
||||
self.assertEqual(_ws_allowed_cameras(_ws(None), self.config), set())
|
||||
|
||||
def test_multi_role_union_grants_widest(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("house_only,admin"), self.config),
|
||||
{"front_door", "back_door", "garage"},
|
||||
)
|
||||
|
||||
|
||||
class TestMaterializeForWs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.all_cameras = set(self.config.cameras.keys())
|
||||
self.all_zones = _collect_zone_names(self.config)
|
||||
|
||||
def _materialize(self, ws: Any, topic: str, payload: Any) -> str | None:
|
||||
scope = _classify_outbound(topic, self.all_cameras, self.all_zones)
|
||||
from frigate.comms.ws import _parse_json_payload
|
||||
|
||||
parsed = (
|
||||
_parse_json_payload(payload)
|
||||
if scope[0]
|
||||
in (
|
||||
"payload_camera",
|
||||
"reshape_by_camera_key",
|
||||
"reshape_job_state",
|
||||
"reshape_stats",
|
||||
)
|
||||
else None
|
||||
)
|
||||
full = json.dumps({"topic": topic, "payload": payload})
|
||||
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
|
||||
|
||||
# --- Globals: every authenticated client sees them ---
|
||||
|
||||
def test_globals_reach_admin(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("admin"), "model_state", "{}"))
|
||||
|
||||
def test_globals_reach_restricted(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("house_only"), "model_state", "{}"))
|
||||
|
||||
def test_globals_reach_no_role(self):
|
||||
"""A missing role header still gets globals (matches viewer-default
|
||||
for inbound)."""
|
||||
self.assertIsNotNone(self._materialize(_ws(None), "model_state", "{}"))
|
||||
|
||||
# --- Unknown topic dropped for everyone ---
|
||||
|
||||
def test_unknown_topic_dropped_for_admin(self):
|
||||
self.assertIsNone(self._materialize(_ws("admin"), "rogue_topic", "{}"))
|
||||
|
||||
# --- Non-global topics require a role (fail-closed) ---
|
||||
|
||||
def test_no_role_blocked_from_camera_topic(self):
|
||||
self.assertIsNone(self._materialize(_ws(None), "front_door/detect/state", "ON"))
|
||||
|
||||
def test_no_role_blocked_from_events(self):
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertIsNone(self._materialize(_ws(None), "events", payload))
|
||||
|
||||
# --- Camera-prefixed ---
|
||||
|
||||
def test_restricted_role_sees_allowed_camera(self):
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("house_only"), "front_door/detect/state", "ON")
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_from_unallowed_camera(self):
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "garage/detect/state", "ON")
|
||||
)
|
||||
|
||||
def test_admin_sees_all_camera_topics(self):
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("admin"), "garage/detect/state", "ON")
|
||||
)
|
||||
|
||||
# --- Unrestricted-only (zones, birdseye_layout) ---
|
||||
|
||||
def test_zone_aggregate_blocked_for_restricted(self):
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "driveway/person", 3))
|
||||
|
||||
def test_zone_aggregate_visible_to_admin(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("admin"), "driveway/person", 3))
|
||||
|
||||
def test_birdseye_layout_blocked_for_restricted(self):
|
||||
payload = json.dumps(
|
||||
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
|
||||
)
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "birdseye_layout", payload)
|
||||
)
|
||||
|
||||
def test_birdseye_layout_visible_to_admin(self):
|
||||
payload = json.dumps(
|
||||
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("admin"), "birdseye_layout", payload)
|
||||
)
|
||||
|
||||
# --- Payload-camera ---
|
||||
|
||||
def test_events_filtered_by_payload_camera(self):
|
||||
payload = json.dumps({"after": {"camera": "garage"}})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertIsNotNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
def test_events_with_missing_camera_dropped(self):
|
||||
payload = json.dumps({"after": {}})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
def test_triggers_filtered_by_payload_camera(self):
|
||||
payload = json.dumps({"name": "t1", "camera": "garage"})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "triggers", payload))
|
||||
|
||||
# --- Reshape: dict keyed by camera ---
|
||||
|
||||
def test_camera_activity_filtered_to_allowed_keys(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"front_door": {"objects": 1},
|
||||
"back_door": {"objects": 0},
|
||||
"garage": {"objects": 2},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "camera_activity", payload)
|
||||
self.assertIsNotNone(message)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner.keys()), {"front_door", "back_door"})
|
||||
self.assertNotIn("garage", inner)
|
||||
|
||||
def test_camera_activity_unchanged_for_admin(self):
|
||||
payload = json.dumps({"front_door": {}, "back_door": {}, "garage": {}})
|
||||
message = self._materialize(_ws("admin"), "camera_activity", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_camera_activity_with_no_allowed_returns_none(self):
|
||||
payload = json.dumps({"garage": {"objects": 2}})
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "camera_activity", payload)
|
||||
)
|
||||
|
||||
def test_audio_detections_filtered_to_allowed_keys(self):
|
||||
payload = json.dumps({"front_door": {"bark": {}}, "garage": {"speech": {}}})
|
||||
message = self._materialize(_ws("house_only"), "audio_detections", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner.keys()), {"front_door"})
|
||||
|
||||
# --- Reshape: job_state ---
|
||||
|
||||
def test_job_state_admin_sees_full_payload(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"motion_search": {"job_type": "motion_search", "camera": "garage"},
|
||||
"media_sync": {"job_type": "media_sync"},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("admin"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_restricted_keeps_allowed_camera_jobs(self):
|
||||
"""Top-level camera field on a job entry: drop if not allowed."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"motion_search": {"job_type": "motion_search", "camera": "front_door"},
|
||||
"vlm_watch": {"job_type": "vlm_watch", "camera": "garage"},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("motion_search", inner)
|
||||
self.assertNotIn("vlm_watch", inner)
|
||||
|
||||
def test_job_state_export_results_jobs_filtered_per_recipient(self):
|
||||
"""The aggregated export broadcast nests per-camera sub-jobs under
|
||||
``results.jobs``. Restricted users must only see allowed entries."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "front_door", "id": "a"},
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
{"job_type": "export", "camera": "back_door", "id": "c"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("export", inner)
|
||||
kept_cameras = [j["camera"] for j in inner["export"]["results"]["jobs"]]
|
||||
self.assertEqual(kept_cameras, ["front_door", "back_door"])
|
||||
# Sibling fields like ``status`` must survive reshaping.
|
||||
self.assertEqual(inner["export"]["status"], "running")
|
||||
|
||||
def test_job_state_export_entry_dropped_when_no_jobs_allowed(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
|
||||
|
||||
# --- Reshape: stats ---
|
||||
|
||||
def _stats_payload(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"camera_fps": 5.0, "pid": 1234},
|
||||
"back_door": {"camera_fps": 5.0, "pid": 1235},
|
||||
"garage": {"camera_fps": 5.0, "pid": 1236},
|
||||
},
|
||||
"detectors": {"cpu": {"detection_start": 0.0, "inference_speed": 10}},
|
||||
"service": {"uptime": 12345, "version": "0.16.0"},
|
||||
"camera_fps": 15.0,
|
||||
"detection_fps": 6.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_stats_admin_sees_full_payload(self):
|
||||
message = self._materialize(_ws("admin"), "stats", self._stats_payload())
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], self._stats_payload())
|
||||
|
||||
def test_stats_restricted_filters_camera_keys_but_keeps_aggregates(self):
|
||||
message = self._materialize(_ws("house_only"), "stats", self._stats_payload())
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner["cameras"].keys()), {"front_door", "back_door"})
|
||||
self.assertNotIn("garage", inner["cameras"])
|
||||
# Aggregates, detectors, and service block must survive.
|
||||
self.assertEqual(inner["camera_fps"], 15.0)
|
||||
self.assertEqual(inner["detection_fps"], 6.0)
|
||||
self.assertIn("detectors", inner)
|
||||
self.assertIn("service", inner)
|
||||
|
||||
def test_stats_restricted_with_no_allowed_cameras_still_sends_aggregates(self):
|
||||
"""A restricted role whose allow-list contains only nonexistent cameras
|
||||
still gets the global aggregates and service block."""
|
||||
config = _build_config(extra_roles={"empty_role": ["nonexistent"]})
|
||||
from frigate.comms.ws import _parse_json_payload
|
||||
|
||||
payload = self._stats_payload()
|
||||
all_cameras = set(config.cameras.keys())
|
||||
scope = _classify_outbound("stats", all_cameras, _collect_zone_names(config))
|
||||
full = json.dumps({"topic": "stats", "payload": payload})
|
||||
message = _materialize_for_ws(
|
||||
_ws("empty_role"),
|
||||
"stats",
|
||||
full,
|
||||
scope,
|
||||
_parse_json_payload(payload),
|
||||
config,
|
||||
)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(inner["cameras"], {})
|
||||
self.assertEqual(inner["camera_fps"], 15.0)
|
||||
self.assertIn("service", inner)
|
||||
|
||||
def test_stats_without_cameras_key_passes_through(self):
|
||||
"""A malformed stats payload missing the cameras sub-dict shouldn't
|
||||
break delivery for restricted users — fall back to the full message."""
|
||||
payload = json.dumps({"detectors": {}, "service": {}, "detection_fps": 0.0})
|
||||
message = self._materialize(_ws("house_only"), "stats", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_export_entry_unchanged_for_admin(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("admin"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_restricted_keeps_global_jobs(self):
|
||||
"""media_sync has no camera field; restricted users still see it."""
|
||||
payload = json.dumps(
|
||||
{"media_sync": {"job_type": "media_sync", "status": "running"}}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("media_sync", inner)
|
||||
|
||||
def test_job_state_debug_replay_nested_source_camera_filtered(self):
|
||||
"""debug_replay puts ``source_camera`` inside ``results`` (see
|
||||
jobs/debug_replay.py:to_dict). Restricted users must not receive
|
||||
entries whose nested source camera is unauthorized."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"debug_replay": {
|
||||
"id": "bd6dc99d-a7d",
|
||||
"job_type": "debug_replay",
|
||||
"status": "running",
|
||||
"start_time": 1.0,
|
||||
"end_time": None,
|
||||
"error_message": None,
|
||||
"results": {
|
||||
"current_step": "preparing_clip",
|
||||
"progress_percent": 0.0,
|
||||
"source_camera": "garage",
|
||||
"replay_camera_name": "_replay_garage",
|
||||
"start_ts": 0.0,
|
||||
"end_ts": 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
|
||||
|
||||
def test_job_state_debug_replay_nested_source_camera_allowed(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"debug_replay": {
|
||||
"id": "bd6dc99d-a7d",
|
||||
"job_type": "debug_replay",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"source_camera": "front_door",
|
||||
"replay_camera_name": "_replay_front_door",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("debug_replay", inner)
|
||||
self.assertEqual(
|
||||
inner["debug_replay"]["results"]["source_camera"], "front_door"
|
||||
)
|
||||
|
||||
|
||||
class _FakeManager:
|
||||
"""Minimal ws4py manager: holds clients and exposes a lock."""
|
||||
|
||||
def __init__(self, clients: list[Any]) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.websockets = {id(c): c for c in clients}
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, manager: _FakeManager) -> None:
|
||||
self.manager = manager
|
||||
|
||||
|
||||
class _CapturingWs(SimpleNamespace):
|
||||
"""Fake ws4py client that records what was sent."""
|
||||
|
||||
def __init__(self, role: str | None) -> None:
|
||||
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
|
||||
super().__init__(environ=environ, terminated=False)
|
||||
self.sent: list[str] = []
|
||||
|
||||
def send(self, message: str) -> None: # noqa: D401 - matches ws4py API
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
class TestPublishEndToEnd(unittest.TestCase):
|
||||
"""Drive WebSocketClient.publish() against fake clients with different roles."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.admin = _CapturingWs("admin")
|
||||
self.restricted = _CapturingWs("house_only")
|
||||
self.anon = _CapturingWs(None)
|
||||
self.client = WebSocketClient(self.config)
|
||||
self.client.websocket_server = _FakeServer(
|
||||
_FakeManager([self.admin, self.restricted, self.anon])
|
||||
)
|
||||
|
||||
def _payloads(self, ws: _CapturingWs) -> list[Any]:
|
||||
return [json.loads(m)["payload"] for m in ws.sent]
|
||||
|
||||
def test_global_topic_reaches_everyone(self):
|
||||
self.client.publish("model_state", "{}")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
self.assertEqual(len(self.anon.sent), 1)
|
||||
|
||||
def test_camera_topic_filters_restricted_recipient(self):
|
||||
self.client.publish("garage/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_camera_topic_allows_restricted_recipient_for_allowed_camera(self):
|
||||
self.client.publish("front_door/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_events_payload_filtered(self):
|
||||
self.client.publish("events", json.dumps({"after": {"camera": "garage"}}))
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
def test_camera_activity_reshaped_per_recipient(self):
|
||||
self.client.publish(
|
||||
"camera_activity",
|
||||
json.dumps(
|
||||
{
|
||||
"front_door": {"objects": 1},
|
||||
"back_door": {"objects": 0},
|
||||
"garage": {"objects": 2},
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(set(admin_inner.keys()), {"front_door", "back_door", "garage"})
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(set(restricted_inner.keys()), {"front_door", "back_door"})
|
||||
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_birdseye_layout_blocked_for_restricted_and_anon(self):
|
||||
self.client.publish(
|
||||
"birdseye_layout",
|
||||
json.dumps({"front_door": {"x": 0, "y": 0, "width": 1, "height": 1}}),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_zone_aggregate_blocked_for_restricted(self):
|
||||
self.client.publish("driveway/person", 2)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
def test_stats_reshaped_per_recipient(self):
|
||||
self.client.publish(
|
||||
"stats",
|
||||
json.dumps(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"camera_fps": 5.0},
|
||||
"garage": {"camera_fps": 5.0},
|
||||
},
|
||||
"service": {"uptime": 1},
|
||||
"camera_fps": 10.0,
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(set(admin_inner["cameras"].keys()), {"front_door", "garage"})
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(set(restricted_inner["cameras"].keys()), {"front_door"})
|
||||
self.assertEqual(restricted_inner["camera_fps"], 10.0)
|
||||
self.assertIn("service", restricted_inner)
|
||||
|
||||
# Stats requires a role; anonymous gets nothing.
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_export_job_state_filters_results_jobs_per_recipient(self):
|
||||
self.client.publish(
|
||||
"job_state",
|
||||
json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"camera": "front_door", "id": "a"},
|
||||
{"camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(
|
||||
[j["camera"] for j in admin_inner["export"]["results"]["jobs"]],
|
||||
["front_door", "garage"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(
|
||||
[j["camera"] for j in restricted_inner["export"]["results"]["jobs"]],
|
||||
["front_door"],
|
||||
)
|
||||
|
||||
def test_unknown_topic_dropped_for_everyone(self):
|
||||
self.client.publish("some_rogue_topic", "data")
|
||||
self.assertEqual(self.admin.sent, [])
|
||||
self.assertEqual(self.restricted.sent, [])
|
||||
self.assertEqual(self.anon.sent, [])
|
||||
|
||||
def test_terminated_client_is_skipped(self):
|
||||
self.restricted.terminated = True
|
||||
self.client.publish("front_door/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+55
-1
@@ -8,7 +8,7 @@ from typing import Any, Optional, Union
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
@@ -18,6 +18,21 @@ CURRENT_CONFIG_VERSION = "0.18-0"
|
||||
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||
|
||||
|
||||
def redact_credential(obj: dict[str, Any], key: str) -> None:
|
||||
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.
|
||||
|
||||
Used when shaping the /config response so saved credentials never leave
|
||||
the server. The frontend recognizes REDACTED_CREDENTIAL_SENTINEL, renders
|
||||
the field as empty with a "saved — leave blank to keep" placeholder, and
|
||||
/config/set strips it from any incoming payload so the YAML value is
|
||||
preserved when the user doesn't touch the field.
|
||||
"""
|
||||
if obj.get(key):
|
||||
obj[key] = REDACTED_CREDENTIAL_SENTINEL
|
||||
else:
|
||||
obj.pop(key, None)
|
||||
|
||||
|
||||
def find_config_file() -> str:
|
||||
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||
|
||||
@@ -773,6 +788,45 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
|
||||
)
|
||||
camera_config.objects = new_objects
|
||||
|
||||
elif section == "detect":
|
||||
# apply detect first so frame_shape reflects the new resolution
|
||||
# before we rebuild mask-dependent runtime configs below
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
camera_config.detect = current.__class__.model_validate(merged)
|
||||
|
||||
new_frame_shape = camera_config.frame_shape
|
||||
|
||||
# rebuild motion's rasterized_mask at the new frame_shape
|
||||
if camera_config.motion is not None:
|
||||
camera_config.motion = RuntimeMotionConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**camera_config.motion.model_dump(exclude_unset=True),
|
||||
)
|
||||
|
||||
# rebuild per-object filter masks at the new frame_shape
|
||||
for obj_name, filt in camera_config.objects.filters.items():
|
||||
merged_mask = dict(filt.mask)
|
||||
if camera_config.objects.mask:
|
||||
for gid, gmask in camera_config.objects.mask.items():
|
||||
merged_mask[f"global_{gid}"] = gmask
|
||||
|
||||
camera_config.objects.filters[obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
mask=merged_mask,
|
||||
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
|
||||
)
|
||||
|
||||
# Regenerate zone contours and per-zone filter masks at the new
|
||||
# frame_shape so zone outlines and membership stay relative
|
||||
for zone in camera_config.zones.values():
|
||||
if zone.filters:
|
||||
for zone_obj_name, zone_filter in zone.filters.items():
|
||||
zone.filters[zone_obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**zone_filter.model_dump(exclude_unset=True),
|
||||
)
|
||||
zone.generate_contour(new_frame_shape)
|
||||
|
||||
else:
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
setattr(camera_config, section, current.__class__.model_validate(merged))
|
||||
|
||||
+18
-3
@@ -1089,10 +1089,25 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
|
||||
def get(self, name: str, shape) -> Optional[np.ndarray]:
|
||||
try:
|
||||
if name in self.shm_store:
|
||||
shm = self.shm_store[name]
|
||||
else:
|
||||
required = int(np.prod(shape))
|
||||
shm = self.shm_store.get(name)
|
||||
if shm is not None and shm.size != required:
|
||||
# stale cached ref from a same-name recreate — drop and reopen
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shm_store.pop(name, None)
|
||||
shm = None
|
||||
if shm is None:
|
||||
shm = UntrackedSharedMemory(name=name)
|
||||
if shm.size != required:
|
||||
# mid-recreate: OS segment doesn't match shape yet; skip
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
self.shm_store[name] = shm
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -478,7 +478,7 @@ def get_intel_gpu_stats(
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": names.get(pdev) or f"Intel GPU {pdev}",
|
||||
"name": names.get(pdev) or "Intel iGPU",
|
||||
"vendor": "intel",
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
|
||||
@@ -129,8 +129,14 @@ test.describe("Replay — active session @medium", () => {
|
||||
);
|
||||
await actionGroup.first().click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
// On mobile PlatformAwareSheet renders a MobilePage (full-screen panel)
|
||||
// instead of a Radix Dialog, so assert the panel title heading is visible.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /^Configuration$/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Objects tab renders with the camera_activity objects list", async ({
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"plucked_string_instrument": "Instrument de corda pinçada",
|
||||
"guitar": "Guitarra",
|
||||
"electric_guitar": "Guitarra elèctrica",
|
||||
"bass_guitar": "Baix",
|
||||
"bass_guitar": "Guitarra baixa",
|
||||
"acoustic_guitar": "Guitarra acústica",
|
||||
"steel_guitar": "Guitarra steel",
|
||||
"tapping": "Tapping",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"gl": "Galego (Gallec)",
|
||||
"id": "Bahasa Indonesia (Indonesi)",
|
||||
"ur": "اردو (Urdú)",
|
||||
"hr": "Hrvatski (croat)"
|
||||
"hr": "Hrvatski (croat)",
|
||||
"bs": "Bosanski (Bosni)"
|
||||
},
|
||||
"system": "Sistema",
|
||||
"systemMetrics": "Mètriques del sistema",
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres d'àudio",
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
|
||||
"threshold": {
|
||||
"label": "Confiança mínima de l'àudio",
|
||||
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estat d'àudio original",
|
||||
|
||||
@@ -258,6 +258,41 @@
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Màscara en brut"
|
||||
},
|
||||
"filters_attribute": {
|
||||
"label": "Filtres d'atribut",
|
||||
"description": "Filtres aplicats als atributs detectats per reduir falsos positius (àrea, relació, confiança).",
|
||||
"min_area": {
|
||||
"label": "Àrea mínima de l'atribut",
|
||||
"description": "Es requereix una àrea de caixa contenidora mínima (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
|
||||
},
|
||||
"max_area": {
|
||||
"label": "Àrea màxima de l'atribut",
|
||||
"description": "Es permet l'àrea màxima del contenidor (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Relació mínima d'aspecte",
|
||||
"description": "Relació mínima d'amplada/alçada requerida per a la casella contenidora a qualificar."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Relació màxima d'aspecte",
|
||||
"description": "Es permet la relació màxima d'amplada/alçada per a la casella contenidora a qualificar."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Llindar de confiança",
|
||||
"description": "Es requereix un llindar de confiança mitjà per a la detecció perquè l'atribut es consideri un veritable positiu."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Confiança mínima",
|
||||
"description": "Es requereix una confiança mínima de detecció d'un sol fotograma per a associar aquest atribut amb el seu objecte pare."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Màscara de filtre",
|
||||
"description": "Coordenades de polígon que defineixen on s'aplica aquest filtre dins del marc."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Màscara en brut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
@@ -1987,7 +2022,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres d'àudio",
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
|
||||
"threshold": {
|
||||
"label": "Confiança mínima de l'àudio",
|
||||
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estat d'àudio original",
|
||||
@@ -2207,7 +2246,7 @@
|
||||
},
|
||||
"match_distance": {
|
||||
"label": "Distància de la coincidència",
|
||||
"description": "Nombre de desajustos de caràcters permesos quan es comparen les plaques detectades amb les plaques conegudes."
|
||||
"description": "Nombre de discrepàncies de caràcters permesos en comparar les plaques detectades amb les plaques conegudes."
|
||||
},
|
||||
"known_plates": {
|
||||
"label": "Matricules conegudes",
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"royal_mail": "Royal Mail",
|
||||
"school_bus": "Bus escolar",
|
||||
"skunk": "Mofeta",
|
||||
"kangaroo": "Cangur"
|
||||
"kangaroo": "Cangur",
|
||||
"baby": "Nadó",
|
||||
"baby_stroller": "Cotxet",
|
||||
"rickshaw": "Ricksaw",
|
||||
"Rodent": "Rosegador",
|
||||
"rodent": "Rosegador"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,28 @@
|
||||
"show_camera_status": "Quin és l'estat actual de les meves càmeres?",
|
||||
"recap": "Què va passar mentre jo era fora?",
|
||||
"watch_camera": "Vigila la porta d'entrada i fes-me saber si algú apareix"
|
||||
},
|
||||
"new_chat": "Xat nou",
|
||||
"settings": {
|
||||
"title": "Configuració del xat",
|
||||
"show_stats": {
|
||||
"title": "Mostra les estadístiques",
|
||||
"desc": "Mostra la velocitat de generació i la mida del context per a les respostes del xat.",
|
||||
"while_generating": "En generar",
|
||||
"always": "Sempre"
|
||||
},
|
||||
"auto_scroll": {
|
||||
"title": "Desplaçament automàtic",
|
||||
"desc": "Segueix els missatges nous a mesura que arriben."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"context": "{{tokens}} tokens",
|
||||
"tokens_per_second": "{{rate}} t/s"
|
||||
},
|
||||
"reasoning": {
|
||||
"active": "Raonant…",
|
||||
"show": "Mostra el raonament",
|
||||
"hide": "Amaga el raonament"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
"empty": "No hi ha intents recents de reconeixement de rostres",
|
||||
"title": "Reconeixements recents",
|
||||
"aria": "Selecciona els reconeixements recents",
|
||||
"titleShort": "Recent"
|
||||
"titleShort": "Recent",
|
||||
"emptyNoLibrary": {
|
||||
"title": "Puja una cara",
|
||||
"description": "Heu d'afegir com a mínim una cara a la biblioteca perquè el reconeixement de la cara funcioni."
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"addFace": "Afegiu una col·lecció nova a la biblioteca de cares pujant la vostra primera imatge.",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"globalConfig": "Configuració global - Frigate",
|
||||
"cameraConfig": "Configuració de la càmera - Frigate",
|
||||
"maintenance": "Manteniment - Frigate",
|
||||
"profiles": "Perfils - Frigate"
|
||||
"profiles": "Perfils - Frigate",
|
||||
"detectorsAndModel": "Detectors i model - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"ui": "Interfície d'usuari",
|
||||
@@ -90,7 +91,8 @@
|
||||
"regionGrid": "Quadrícula de la regió",
|
||||
"uiSettings": "Paràmetres de la IU",
|
||||
"profiles": "Perfils",
|
||||
"systemGo2rtcStreams": "go2rtc streams"
|
||||
"systemGo2rtcStreams": "go2rtc streams",
|
||||
"systemDetectorsAndModel": "Detectors i model"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@@ -526,7 +528,7 @@
|
||||
},
|
||||
"title": "Afinador de detecció de moviment",
|
||||
"toast": {
|
||||
"success": "Els ajustos de la detecció de moviment s'han desat."
|
||||
"success": "S'han desat els paràmetres del moviment."
|
||||
},
|
||||
"unsavedChanges": "Canvis no desats en l'ajust de moviment {{camera}}"
|
||||
},
|
||||
@@ -724,7 +726,7 @@
|
||||
"trainDate": "Data d'entrenament",
|
||||
"title": "Informació del model",
|
||||
"supportedDetectors": "Detectors compatibles",
|
||||
"availableModels": "Models disponibles",
|
||||
"availableModels": "Models Frigate+ disponibles",
|
||||
"cameras": "Càmeres",
|
||||
"plusModelType": {
|
||||
"userModel": "Afinat",
|
||||
@@ -733,7 +735,15 @@
|
||||
"loadingAvailableModels": "Carregant models disponibles…",
|
||||
"loading": "Carregant informació del model…",
|
||||
"error": "No s'ha pogut carregar la informació del model",
|
||||
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector."
|
||||
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector.",
|
||||
"noModelLoaded": "Actualment no s'ha carregat cap model Frigate+.",
|
||||
"selectModel": "Selecciona un model",
|
||||
"noModelsAvailable": "No hi ha models disponibles",
|
||||
"filter": {
|
||||
"ariaLabel": "Filtra els models per tipus",
|
||||
"baseModels": "Models de base",
|
||||
"fineTunedModels": "Models ajustats"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"plusLink": "Llegeix més sobre Frigate+",
|
||||
@@ -755,7 +765,8 @@
|
||||
"currentModel": "Model actual",
|
||||
"otherModels": "Altres models",
|
||||
"configuration": "Configuració"
|
||||
}
|
||||
},
|
||||
"changeInDetectorsAndModel": "Canviar model"
|
||||
},
|
||||
"enrichments": {
|
||||
"semanticSearch": {
|
||||
@@ -1295,7 +1306,7 @@
|
||||
"title": "Habilita / Inhabilita les càmeres",
|
||||
"desc": "Inhabilita temporalment una càmera fins que es reiniciï la fragata. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
||||
"enableLabel": "Càmeres habilitades",
|
||||
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
||||
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no inhabilita els restreams go2rtc.</em><br /><br />Drag el handle per reordenar les càmeres tal com apareixen a la interfície d'usuari. L'ordre de les càmeres habilitades es reflectirà en tota la interfície d'usuari, incloent el tauler en viu i els desplegables de selecció de càmeres.",
|
||||
"disableLabel": "Càmeres inhabilitades",
|
||||
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
|
||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.",
|
||||
@@ -1304,7 +1315,10 @@
|
||||
"title": "Edita el nom de la pantalla",
|
||||
"description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.",
|
||||
"rename": "Canvia el nom"
|
||||
}
|
||||
},
|
||||
"reorderHandle": "Arrossega per reordenar",
|
||||
"saving": "S'està desant…",
|
||||
"saved": "Desat"
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Afegeix una càmera",
|
||||
@@ -1362,7 +1376,8 @@
|
||||
"dedicatedLpr": "LPR dedicat",
|
||||
"saveSuccess": "Tipus de càmera actualitzat per {{cameraName}}. Reinicia la fragata per aplicar els canvis.",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"description": "Afegiu, editeu i suprimiu les càmeres, controleu quines càmeres estan habilitades, i configureu les superposicions per perfil i tipus de càmera. Per a configurar fluxos, detecció, moviment i altres paràmetres específics de la càmera, trieu la secció específica a Configuració de la càmera."
|
||||
},
|
||||
"cameraReview": {
|
||||
"object_descriptions": {
|
||||
@@ -1661,7 +1676,9 @@
|
||||
"options": {
|
||||
"embeddings": "Incrustació",
|
||||
"vision": "Visió",
|
||||
"tools": "Eines"
|
||||
"tools": "Eines",
|
||||
"descriptions": "Descripcions",
|
||||
"chat": "Xat"
|
||||
}
|
||||
},
|
||||
"semanticSearchModel": {
|
||||
@@ -1718,7 +1735,10 @@
|
||||
"saveAllPartial_many": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
|
||||
"saveAllPartial_other": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
|
||||
"saveAllFailure": "Ha fallat en desar totes les seccions.",
|
||||
"applied": "La configuració s'ha aplicat correctament"
|
||||
"applied": "La configuració s'ha aplicat correctament",
|
||||
"saveAllSuccessRestartRequired_one": "S'ha desat la secció {{count}} correctament. Reinicia la fragata per aplicar els canvis.",
|
||||
"saveAllSuccessRestartRequired_many": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis.",
|
||||
"saveAllSuccessRestartRequired_other": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis."
|
||||
},
|
||||
"unsavedChanges": "Teniu canvis sense desar",
|
||||
"confirmReset": "Confirma el restabliment",
|
||||
@@ -1743,7 +1763,15 @@
|
||||
"othersField_many": "{{count}} altres",
|
||||
"othersField_other": "{{count}} altres",
|
||||
"profilePrefix": "Perfil {{profile}}: {{fields}}"
|
||||
}
|
||||
},
|
||||
"overriddenGlobalHeading_one": "Aquesta càmera substitueix el camp {{count}} de la configuració global:",
|
||||
"overriddenGlobalHeading_many": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
|
||||
"overriddenGlobalHeading_other": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
|
||||
"overriddenGlobalNoDeltas": "Aquesta càmera anul·la la configuració global, però no hi ha valors de camp diferents.",
|
||||
"overriddenBaseConfigHeading_one": "El perfil {{profile}} substitueix el camp {{count}} de la configuració base:",
|
||||
"overriddenBaseConfigHeading_many": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
|
||||
"overriddenBaseConfigHeading_other": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
|
||||
"overriddenBaseConfigNoDeltas": "El perfil {{profile}} substitueix aquesta secció, però no hi ha valors de camp diferents de la configuració base."
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfils",
|
||||
@@ -1827,8 +1855,17 @@
|
||||
"audioMp3": "Transcodifica a MP3",
|
||||
"audioExclude": "Exclou",
|
||||
"hardwareNone": "Sense acceleració de hardware",
|
||||
"hardwareAuto": "Acceleració de hardware automàtica"
|
||||
}
|
||||
"hardwareAuto": "Automàtic (recomanat)",
|
||||
"addVideoCodec": "Afegeix un còdec de vídeo",
|
||||
"addAudioCodec": "Afegeix un còdec d'àudio",
|
||||
"removeCodec": "Elimina el còdec",
|
||||
"hardwareVaapi": "VAAPI",
|
||||
"hardwareCuda": "CUDA",
|
||||
"hardwareV4l2m2m": "V4L2 M2M",
|
||||
"hardwareDxva2": "DXVA2",
|
||||
"hardwareVideotoolbox": "VideoToolbox"
|
||||
},
|
||||
"streamNumber": "Flux {{index}}"
|
||||
},
|
||||
"timestampPosition": {
|
||||
"tl": "A dalt a l'esquerra",
|
||||
@@ -1838,7 +1875,14 @@
|
||||
},
|
||||
"onvif": {
|
||||
"profileAuto": "Automàtic",
|
||||
"profileLoading": "S'estan carregant perfils..."
|
||||
"profileLoading": "S'estan carregant perfils...",
|
||||
"autotracking": {
|
||||
"zooming": {
|
||||
"disabled": "Desactivat",
|
||||
"absolute": "Absolut",
|
||||
"relative": "Relatiu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configMessages": {
|
||||
"review": {
|
||||
@@ -1886,5 +1930,104 @@
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "La mida 'petita' amb el model Jina V2 té un alt cost de RAM i d'inferència. Es recomana el model 'gran' amb una GPU discreta."
|
||||
}
|
||||
},
|
||||
"modelSize": {
|
||||
"large": "Gran",
|
||||
"small": "Petit"
|
||||
},
|
||||
"birdseye": {
|
||||
"trackingMode": {
|
||||
"objects": "Objectes",
|
||||
"motion": "Moviment",
|
||||
"continuous": "Continu"
|
||||
},
|
||||
"cameraOrder": {
|
||||
"label": "Ordre de la càmera",
|
||||
"description": "Arrossega les càmeres per establir el seu ordre en la disposició Birdseye.",
|
||||
"reorderHandle": "Arrossega per reordenar",
|
||||
"saving": "S'està desant…",
|
||||
"saved": "Desat"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"retainMode": {
|
||||
"all": "Tots",
|
||||
"motion": "Moviment",
|
||||
"active_objects": "Objectes Actius"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"timeFormat": {
|
||||
"browser": "Visor",
|
||||
"12hour": "12 hores",
|
||||
"24hour": "24 hores"
|
||||
},
|
||||
"TimeOrDateStyle": {
|
||||
"full": "Complet",
|
||||
"long": "Llarg",
|
||||
"medium": "Mitjà",
|
||||
"short": "Curt"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Métric",
|
||||
"imperial": "Imperial"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"imageSource": {
|
||||
"recordings": "Gravacions",
|
||||
"previews": "Previsualitzacions"
|
||||
}
|
||||
},
|
||||
"logger": {
|
||||
"logLevel": {
|
||||
"debug": "Depurar",
|
||||
"info": "Informació",
|
||||
"warning": "Avís",
|
||||
"error": "Error",
|
||||
"critical": "Crític"
|
||||
}
|
||||
},
|
||||
"retainMode": {
|
||||
"all": "Tots",
|
||||
"motion": "Moviment",
|
||||
"active_objects": "Objectes actius"
|
||||
},
|
||||
"previewQuality": {
|
||||
"very_high": "Molt alta",
|
||||
"high": "Alta",
|
||||
"medium": "Mitja",
|
||||
"low": "Baix",
|
||||
"very_low": "Molt baix"
|
||||
},
|
||||
"detectorsAndModel": {
|
||||
"restartRequired": "Reinici requerit (canvi en detector o model)",
|
||||
"title": "Detectors i model",
|
||||
"description": "Configuri el detector final que corre la detecció d'objectes i el model que usa. Els canvis es gravaràn junts i així el detector i el model estan sincronitzats.",
|
||||
"cardTitles": {
|
||||
"detector": "Detector Hardware",
|
||||
"model": "Model de detecció"
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
"custom": "Model personalitzat"
|
||||
},
|
||||
"mismatch": {
|
||||
"warning": "El model actual de Frigate+ \"{{model}}\" requereix el detector {{required}}. Selecciona un model compatible a baix o canvía e model personalitzat abans de gravar."
|
||||
},
|
||||
"plusModel": {
|
||||
"requiresDetector": "Requereix: {{detector}}",
|
||||
"noModelSelected": "Selecciona un model Frigate+"
|
||||
},
|
||||
"toast": {
|
||||
"saveSuccess": "Configuració de detectors i model guardats. Reinicia Frigate per aplicar els canvis.",
|
||||
"saveError": "Fallo en gravar la configuració de detector i model"
|
||||
},
|
||||
"unsavedChanges": "Canvis de detector i model no gravats"
|
||||
},
|
||||
"menuDot": {
|
||||
"overrideGlobal": "Aquesta secció substitueix la configuració global",
|
||||
"overrideProfile": "Aquesta secció està substituïda pel perfil {{profile}}",
|
||||
"unsaved": "Aquesta secció té canvis sense desar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"bg": "Български (bulgarisch)",
|
||||
"gl": "Galego (Galicisch)",
|
||||
"id": "Bahasa Indonesia (Indonesisch)",
|
||||
"hr": "Hrvatski (Kroatisch)"
|
||||
"hr": "Hrvatski (Kroatisch)",
|
||||
"bs": "Bosnisch"
|
||||
},
|
||||
"appearance": "Erscheinung",
|
||||
"theme": {
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audiofilter",
|
||||
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
|
||||
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
|
||||
"threshold": {
|
||||
"label": "Mindestvertrauensgrad für Audio",
|
||||
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
|
||||
}
|
||||
},
|
||||
"max_not_heard": {
|
||||
"label": "Ende Timeout",
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audiofilter",
|
||||
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
|
||||
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
|
||||
"threshold": {
|
||||
"label": "Mindestvertrauensgrad für Audio",
|
||||
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
|
||||
}
|
||||
},
|
||||
"max_not_heard": {
|
||||
"label": "Ende Timeout",
|
||||
|
||||
@@ -121,5 +121,9 @@
|
||||
"royal_mail": "Royal-Mail",
|
||||
"school_bus": "Schulbus",
|
||||
"skunk": "Stinktier",
|
||||
"kangaroo": "Känguruh"
|
||||
"kangaroo": "Känguruh",
|
||||
"baby": "Baby",
|
||||
"baby_stroller": "Kinderwagen",
|
||||
"rickshaw": "Rikscha",
|
||||
"rodent": "Nagetier"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,23 @@
|
||||
"show_camera_status": "Wie ist der aktuelle Status meiner Kameras?",
|
||||
"recap": "Was ist passiert, während ich weg war?",
|
||||
"watch_camera": "Pass auf die Haustür auf und sag mir Bescheid, wenn jemand kommt"
|
||||
},
|
||||
"new_chat": "Neuer Chat",
|
||||
"settings": {
|
||||
"title": "Chat Einstellung",
|
||||
"show_stats": {
|
||||
"title": "Statistiken anzeigen",
|
||||
"desc": "Generierungsrate und Kontextgröße für Chat-Antworten anzeigen.",
|
||||
"while_generating": "Während der Erstellung",
|
||||
"always": "Immer"
|
||||
},
|
||||
"auto_scroll": {
|
||||
"title": "Auto scrollen",
|
||||
"desc": "Verfolgen Sie neue Nachrichten, sobald sie eintreffen."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"context": "{{tokens}} tokens",
|
||||
"tokens_per_second": "{{rate}} t/s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@
|
||||
"title": "Neueste Erkennungen",
|
||||
"aria": "Wähle aktuelle Erkennungen",
|
||||
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung",
|
||||
"titleShort": "frisch"
|
||||
"titleShort": "frisch",
|
||||
"emptyNoLibrary": {
|
||||
"title": "Gesicht hinzufügen",
|
||||
"description": "Sie müssen mindestens ein Gesicht zur Bibliothek hinzufügen, damit die Gesichtserkennung funktioniert."
|
||||
}
|
||||
},
|
||||
"deleteFaceLibrary": {
|
||||
"title": "Lösche Name",
|
||||
|
||||
@@ -803,7 +803,15 @@
|
||||
"availableModels": "Verfügbare Modelle",
|
||||
"loadingAvailableModels": "Lade verfügbare Modelle…",
|
||||
"baseModel": "Basis Model",
|
||||
"title": "Model Informationen"
|
||||
"title": "Model Informationen",
|
||||
"noModelLoaded": "Derzeit ist kein „Frigate+“-Modell geladen.",
|
||||
"selectModel": "Wählen Sie ein Modell aus",
|
||||
"noModelsAvailable": "Keine Modelle verfügbar",
|
||||
"filter": {
|
||||
"ariaLabel": "Modelle nach Typ filtern",
|
||||
"baseModels": "Basismodelle",
|
||||
"fineTunedModels": "Optimierte Modelle"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Speichern der Konfigurationsänderungen fehlgeschlagen: {{errorMessage}}",
|
||||
@@ -1415,7 +1423,8 @@
|
||||
"normal": "Normal",
|
||||
"dedicatedLpr": "Spezielles LPR-System",
|
||||
"saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen."
|
||||
}
|
||||
},
|
||||
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, legen Sie fest, welche Kameras aktiviert sind, und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus."
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "Kamera-Einstellungen überprüfen",
|
||||
@@ -1489,7 +1498,13 @@
|
||||
"othersField_one": "{{count}} andere",
|
||||
"othersField_other": "{{count}} weitere",
|
||||
"profilePrefix": "{{profile}} Profile: {{fields}}"
|
||||
}
|
||||
},
|
||||
"overriddenGlobalHeading_one": "Diese Kamera überschreibt das Feld {{count}} aus der globalen Konfiguration:",
|
||||
"overriddenGlobalHeading_other": "Diese Kamera überschreibt alle Felder {{count}} aus der globalen Konfiguration:",
|
||||
"overriddenGlobalNoDeltas": "Diese Kamera überschreibt die globale Konfiguration, es gibt jedoch keine Abweichungen bei den Feldwerten.",
|
||||
"overriddenBaseConfigHeading_one": "Das Profil {{profile}} überschreibt das Feld {{count}} aus der Basiskonfiguration:",
|
||||
"overriddenBaseConfigHeading_other": "Das Profil {{profile}} überschreibt di Felder {{count}} aus der Basiskonfiguration:",
|
||||
"overriddenBaseConfigNoDeltas": "Das Profil {{profile}} überschreibt diesen Abschnitt, jedoch weichen keine Feldwerte von der Basiskonfiguration ab."
|
||||
},
|
||||
"timestampPosition": {
|
||||
"tl": "Oben links",
|
||||
@@ -1726,7 +1741,9 @@
|
||||
"options": {
|
||||
"embeddings": "Einbetten",
|
||||
"vision": "Vision",
|
||||
"tools": "Werkzeuge"
|
||||
"tools": "Werkzeuge",
|
||||
"descriptions": "Beschreibung",
|
||||
"chat": "Chat"
|
||||
}
|
||||
},
|
||||
"semanticSearchModel": {
|
||||
@@ -1884,7 +1901,14 @@
|
||||
},
|
||||
"onvif": {
|
||||
"profileAuto": "Auto",
|
||||
"profileLoading": "Profile werden geladen..."
|
||||
"profileLoading": "Profile werden geladen...",
|
||||
"autotracking": {
|
||||
"zooming": {
|
||||
"disabled": "deaktiviert",
|
||||
"absolute": "Absolut",
|
||||
"relative": "Verwandter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configMessages": {
|
||||
"review": {
|
||||
@@ -1932,5 +1956,60 @@
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "Die „kleine“ Variante des Jina V2-Modells verursacht hohe RAM- und Inferenzkosten. Es wird das „große“ Modell mit einer dedizierten GPU empfohlen."
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
"trackingMode": {
|
||||
"objects": "Objekte",
|
||||
"motion": "Bewegung",
|
||||
"continuous": "Fortlaufend"
|
||||
}
|
||||
},
|
||||
"retainMode": {
|
||||
"all": "Alle",
|
||||
"motion": "Bewegung",
|
||||
"active_objects": "Aktive Objekte"
|
||||
},
|
||||
"previewQuality": {
|
||||
"very_high": "sehr hoch",
|
||||
"high": "hoch",
|
||||
"medium": "Mittel",
|
||||
"low": "niedrig",
|
||||
"very_low": "sehr niedrig"
|
||||
},
|
||||
"ui": {
|
||||
"timeFormat": {
|
||||
"browser": "Browser",
|
||||
"12hour": "12 Stunden",
|
||||
"24hour": "24 Stunden"
|
||||
},
|
||||
"TimeOrDateStyle": {
|
||||
"full": "vollständig",
|
||||
"long": "lang",
|
||||
"medium": "mittel",
|
||||
"short": "kurz"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Metrik",
|
||||
"imperial": "Imperial"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"imageSource": {
|
||||
"recordings": "Aufnahmen",
|
||||
"previews": "Vorschau"
|
||||
}
|
||||
},
|
||||
"logger": {
|
||||
"logLevel": {
|
||||
"debug": "Debug",
|
||||
"info": "Info",
|
||||
"warning": "Warnung",
|
||||
"error": "Fehler",
|
||||
"critical": "Kritisch"
|
||||
}
|
||||
},
|
||||
"modelSize": {
|
||||
"small": "klein",
|
||||
"large": "groß"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"en": "English (English)",
|
||||
"es": "Español (Spanish)",
|
||||
"zhCN": "简体中文 (Simplified Chinese)",
|
||||
"zhHant": "繁體中文 (Traditional Chinese)",
|
||||
"hi": "हिन्दी (Hindi)",
|
||||
"fr": "Français (French)",
|
||||
"ar": "العربية (Arabic)",
|
||||
@@ -316,5 +317,8 @@
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"no_items": "No items",
|
||||
"validation_errors": "Validation Errors"
|
||||
"validation_errors": "Validation Errors",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Saved — leave blank to keep current"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"title": "Stream Offline",
|
||||
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled",
|
||||
"cameraOff": "Camera is off",
|
||||
"stats": {
|
||||
"streamType": {
|
||||
"title": "Stream Type:",
|
||||
|
||||
@@ -28,5 +28,8 @@
|
||||
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
|
||||
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"dimensionMustBeEven": "Must be an even number."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,5 +60,13 @@
|
||||
"stats": {
|
||||
"context": "{{tokens}} tokens",
|
||||
"tokens_per_second": "{{rate}} t/s"
|
||||
},
|
||||
"reasoning": {
|
||||
"active": "Reasoning…",
|
||||
"show": "Show reasoning",
|
||||
"hide": "Hide reasoning"
|
||||
},
|
||||
"thinking": {
|
||||
"toggle": "Toggle thinking"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"presets": "PTZ camera presets"
|
||||
},
|
||||
"camera": {
|
||||
"enable": "Enable Camera",
|
||||
"disable": "Disable Camera"
|
||||
"turnOn": "Turn Camera On",
|
||||
"turnOff": "Turn Camera Off"
|
||||
},
|
||||
"muteCameras": {
|
||||
"enable": "Mute All Cameras",
|
||||
@@ -153,7 +153,7 @@
|
||||
},
|
||||
"cameraSettings": {
|
||||
"title": "{{camera}} Settings",
|
||||
"cameraEnabled": "Camera Enabled",
|
||||
"camera": "Camera",
|
||||
"objectDetection": "Object Detection",
|
||||
"recording": "Recording",
|
||||
"snapshots": "Snapshots",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"cameraUi": "Camera UI",
|
||||
"cameraTimestampStyle": "Timestamp style",
|
||||
"cameraMqtt": "Camera MQTT",
|
||||
"cameraManagement": "Management",
|
||||
"cameraManagement": "Camera management",
|
||||
"cameraReview": "Review",
|
||||
"masksAndZones": "Masks / Zones",
|
||||
"motionTuner": "Motion tuner",
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"cameraManagement": {
|
||||
"title": "Manage Cameras",
|
||||
"description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
|
||||
"description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
|
||||
"addCamera": "Add New Camera",
|
||||
"deleteCamera": "Delete Camera",
|
||||
"deleteCameraDialog": {
|
||||
@@ -475,20 +475,29 @@
|
||||
"selectCamera": "Select a Camera",
|
||||
"backToSettings": "Back to Camera Settings",
|
||||
"streams": {
|
||||
"title": "Enable / Disable Cameras",
|
||||
"enableLabel": "Enabled cameras",
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
|
||||
"disableLabel": "Disabled cameras",
|
||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||
"title": "Camera State and Details",
|
||||
"label": "Camera state",
|
||||
"description": "Set the operating state for each camera.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.",
|
||||
"disabledSubheading": "Disabled in configuration",
|
||||
"status": {
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.",
|
||||
"disableSuccess": "Disabled {{cameraName}} and saved to configuration.",
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"friendlyName": {
|
||||
"edit": "Edit camera display name",
|
||||
"title": "Edit Display Name",
|
||||
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||
"rename": "Rename"
|
||||
"details": {
|
||||
"edit": "Edit camera details",
|
||||
"title": "Edit Camera Details",
|
||||
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
|
||||
"friendlyNameLabel": "Display Name",
|
||||
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||
"webuiUrlLabel": "Camera Web UI URL",
|
||||
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
|
||||
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
@@ -523,10 +532,10 @@
|
||||
"profiles": {
|
||||
"title": "Profile Camera Overrides",
|
||||
"selectLabel": "Select profile",
|
||||
"description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.",
|
||||
"description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.",
|
||||
"inherit": "Inherit",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"cameraType": {
|
||||
"title": "Camera Type",
|
||||
@@ -1539,6 +1548,9 @@
|
||||
"builtIn": "Built-in Models",
|
||||
"genaiProviders": "GenAI Providers"
|
||||
},
|
||||
"semanticSearchModelSize": {
|
||||
"notApplicable": "Not applicable for GenAI providers"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings"
|
||||
},
|
||||
@@ -1557,9 +1569,14 @@
|
||||
"searchPlaceholder": "Search...",
|
||||
"addCustomLabel": "Add custom label...",
|
||||
"genaiModel": {
|
||||
"placeholder": "Select model…",
|
||||
"search": "Search models…",
|
||||
"noModels": "No models available"
|
||||
"placeholder": "Select or enter a model…",
|
||||
"search": "Search or enter a model…",
|
||||
"noModels": "No models available",
|
||||
"available": "Available models",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"refresh": "Refresh models",
|
||||
"probeFailed": "Failed to probe models",
|
||||
"fetchedModels": "Successfully fetched model list"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
@@ -1782,7 +1799,9 @@
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
|
||||
@@ -154,7 +154,8 @@
|
||||
"gl": "Galego (Gallego)",
|
||||
"id": "Bahasa Indonesia (Indonesio)",
|
||||
"ur": "اردو (Urdu)",
|
||||
"hr": "Hrvatski (Croata)"
|
||||
"hr": "Hrvatski (Croata)",
|
||||
"bs": "Bosanski (Bosnio)"
|
||||
},
|
||||
"appearance": "Apariencia",
|
||||
"darkMode": {
|
||||
@@ -196,7 +197,10 @@
|
||||
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
|
||||
"faceLibrary": "Biblioteca de rostros",
|
||||
"classification": "Clasificación",
|
||||
"profiles": "Perfiles"
|
||||
"profiles": "Perfiles",
|
||||
"actions": "Acciones",
|
||||
"features": "Funciones",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
@@ -252,7 +256,19 @@
|
||||
"saving": "Guardando…",
|
||||
"exitFullscreen": "Salir de pantalla completa",
|
||||
"on": "ENCENDIDO",
|
||||
"continue": "Continuar"
|
||||
"continue": "Continuar",
|
||||
"add": "Añadir",
|
||||
"applying": "Aplicando…",
|
||||
"undo": "Deshacer",
|
||||
"copiedToClipboard": "Copiado al portapapeles",
|
||||
"modified": "Modificado",
|
||||
"overridden": "Sobrescrito",
|
||||
"resetToGlobal": "Restablecer a global",
|
||||
"resetToDefault": "Restablecer valores predeterminados",
|
||||
"saveAll": "Guardar todo",
|
||||
"savingAll": "Guardando todo…",
|
||||
"undoAll": "Deshacer todo",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"toast": {
|
||||
"save": {
|
||||
@@ -260,7 +276,8 @@
|
||||
"noMessage": "No se pudieron guardar los cambios de configuración",
|
||||
"title": "No se pudieron guardar los cambios de configuración: {{errorMessage}}"
|
||||
},
|
||||
"title": "Guardar"
|
||||
"title": "Guardar",
|
||||
"success": "Cambios de configuración guardados correctamente."
|
||||
},
|
||||
"copyUrlToClipboard": "URL copiada al portapapeles."
|
||||
},
|
||||
@@ -314,5 +331,7 @@
|
||||
"field": {
|
||||
"optional": "Opcional",
|
||||
"internalID": "La ID interna que usa Frigate en la configuración y en la base de datos"
|
||||
}
|
||||
},
|
||||
"no_items": "No hay elementos",
|
||||
"validation_errors": "Errores de validación"
|
||||
}
|
||||
|
||||
@@ -71,16 +71,77 @@
|
||||
"endTimeMustAfterStartTime": "La hora de finalización debe ser posterior a la hora de inicio"
|
||||
},
|
||||
"success": "Exportación iniciada con éxito. Ver el archivo en la página exportaciones.",
|
||||
"view": "Ver"
|
||||
"view": "Ver",
|
||||
"queued": "Exportación en cola. Consulta el progreso en la página de exportaciones.",
|
||||
"batchSuccess_one": "Se inició 1 exportación. Abriendo el caso ahora.",
|
||||
"batchSuccess_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
|
||||
"batchSuccess_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
|
||||
"batchPartial": "Se iniciaron {{successful}} de {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
|
||||
"batchFailed": "No se pudieron iniciar {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "1 exportación en cola. Abriendo el caso ahora.",
|
||||
"batchQueuedSuccess_many": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
|
||||
"batchQueuedSuccess_other": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
|
||||
"batchQueuedPartial": "{{successful}} de {{total}} exportaciones en cola. Cámaras fallidas: {{failedCameras}}",
|
||||
"batchQueueFailed": "No se pudieron poner en cola {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Guardar exportación",
|
||||
"previewExport": "Vista previa de la exportación"
|
||||
"previewExport": "Vista previa de la exportación",
|
||||
"queueingExport": "Poniendo exportación en cola...",
|
||||
"useThisRange": "Usar este intervalo"
|
||||
},
|
||||
"selectOrExport": "Seleccionar o exportar",
|
||||
"case": {
|
||||
"label": "Caso",
|
||||
"newCaseDescriptionPlaceholder": "Descripción de caso"
|
||||
"newCaseDescriptionPlaceholder": "Descripción de caso",
|
||||
"newCaseOption": "Crear nuevo caso",
|
||||
"newCaseNamePlaceholder": "Nombre del nuevo caso",
|
||||
"nonAdminHelp": "Se creará un nuevo caso para estas exportaciones.",
|
||||
"placeholder": "Selecciona un caso"
|
||||
},
|
||||
"queueing": "Poniendo la exportación en cola…",
|
||||
"tabs": {
|
||||
"export": "Cámara única",
|
||||
"multiCamera": "Multicámara"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Intervalo de tiempo",
|
||||
"selectFromTimeline": "Seleccionar desde la línea de tiempo",
|
||||
"cameraSelection": "Cámaras",
|
||||
"cameraSelectionHelp": "Las cámaras con objetos detectados en este intervalo de tiempo están preseleccionadas",
|
||||
"checkingActivity": "Comprobando actividad de las cámaras...",
|
||||
"noCameras": "No hay cámaras disponibles",
|
||||
"detectionCount_one": "1 objeto detectado",
|
||||
"detectionCount_many": "{{count}} objetos detectados",
|
||||
"detectionCount_other": "{{count}} objetos detectados",
|
||||
"nameLabel": "Nombre de la exportación",
|
||||
"namePlaceholder": "Nombre base opcional para estas exportaciones",
|
||||
"queueingButton": "Poniendo exportaciones en cola...",
|
||||
"exportButton_one": "Exportar 1 cámara",
|
||||
"exportButton_many": "Exportar {{count}} cámaras",
|
||||
"exportButton_other": "Exportar {{count}} cámaras"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Exportar 1 revisión",
|
||||
"title_many": "Exportar {{count}} revisiones",
|
||||
"title_other": "Exportar {{count}} revisiones",
|
||||
"description": "Exportar cada revisión seleccionada. Todas las exportaciones se agruparán en un único caso.",
|
||||
"descriptionNoCase": "Exportar cada revisión seleccionada.",
|
||||
"caseNamePlaceholder": "Exportación de revisión - {{date}}",
|
||||
"exportButton_one": "Exportar 1 revisión",
|
||||
"exportButton_many": "Exportar {{count}} revisiones",
|
||||
"exportButton_other": "Exportar {{count}} revisiones",
|
||||
"exportingButton": "Exportando...",
|
||||
"toast": {
|
||||
"started_one": "Se inició 1 exportación. Abriendo el caso ahora.",
|
||||
"started_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
|
||||
"started_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
|
||||
"startedNoCase_one": "Se inició 1 exportación.",
|
||||
"startedNoCase_many": "Se iniciaron {{count}} exportaciones.",
|
||||
"startedNoCase_other": "Se iniciaron {{count}} exportaciones.",
|
||||
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
|
||||
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
@@ -130,7 +191,12 @@
|
||||
"markAsUnreviewed": "Marcar como no revisado"
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara."
|
||||
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.",
|
||||
"label": "Compartir marca de tiempo",
|
||||
"title": "Compartir marca de tiempo",
|
||||
"custom": "Marca de tiempo personalizada",
|
||||
"button": "Compartir URL de la marca de tiempo",
|
||||
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"description": "Habilitado"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Eventos de audio",
|
||||
"label": "Detección de audio",
|
||||
"description": "Configuración para la detección de eventos basada en audio para esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar la detección de audio",
|
||||
@@ -28,14 +28,19 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtros de audio",
|
||||
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos."
|
||||
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos.",
|
||||
"threshold": {
|
||||
"label": "Confianza mínima de audio",
|
||||
"description": "Umbral mínimo de confianza para que se cuente el evento de audio."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"description": "Indica si la detección de audio estaba habilitada originalmente en el archivo de configuración estática.",
|
||||
"label": "Estado original del audio"
|
||||
},
|
||||
"num_threads": {
|
||||
"label": "Hilos de detección"
|
||||
"label": "Hilos de detección",
|
||||
"description": "Número de hilos que se utilizarán para el procesamiento de la detección de audio."
|
||||
}
|
||||
},
|
||||
"friendly_name": {
|
||||
@@ -50,29 +55,79 @@
|
||||
},
|
||||
"autotracking": {
|
||||
"zoom_factor": {
|
||||
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75."
|
||||
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75.",
|
||||
"label": "Factor de zoom"
|
||||
},
|
||||
"calibrate_on_startup": {
|
||||
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración."
|
||||
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración.",
|
||||
"label": "Calibrar al iniciar"
|
||||
},
|
||||
"description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.",
|
||||
"zooming": {
|
||||
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)."
|
||||
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos).",
|
||||
"label": "Modo de zoom"
|
||||
},
|
||||
"return_preset": {
|
||||
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento."
|
||||
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento.",
|
||||
"label": "Preajuste de retorno"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida."
|
||||
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida.",
|
||||
"label": "Tiempo de espera de retorno"
|
||||
},
|
||||
"label": "Seguimiento automático",
|
||||
"enabled": {
|
||||
"label": "Habilitar seguimiento automático",
|
||||
"description": "Habilita o deshabilita el seguimiento automático con cámara PTZ de objetos detectados."
|
||||
},
|
||||
"track": {
|
||||
"label": "Objetos rastreados",
|
||||
"description": "Lista de tipos de objetos que deben activar el seguimiento automático."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zonas requeridas",
|
||||
"description": "Los objetos deben entrar en una de estas zonas antes de que comience el seguimiento automático."
|
||||
},
|
||||
"movement_weights": {
|
||||
"label": "Pesos de movimiento",
|
||||
"description": "Valores de calibración generados automáticamente por la calibración de la cámara. No los modifiques manualmente."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de autoseguimiento",
|
||||
"description": "Campo interno para rastrear si el seguimiento automático estaba habilitado en la configuración."
|
||||
}
|
||||
},
|
||||
"tls_insecure": {
|
||||
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)."
|
||||
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras).",
|
||||
"label": "Deshabilitar verificación TLS"
|
||||
},
|
||||
"label": "ONVIF",
|
||||
"description": "Ajustes de conexión ONVIF y seguimiento automático PTZ para esta cámara.",
|
||||
"host": {
|
||||
"label": "Host ONVIF",
|
||||
"description": "Host (y esquema opcional) para el servicio ONVIF de esta cámara."
|
||||
},
|
||||
"port": {
|
||||
"label": "Puerto ONVIF",
|
||||
"description": "Número de puerto del servicio ONVIF."
|
||||
},
|
||||
"user": {
|
||||
"label": "Nombre de usuario ONVIF",
|
||||
"description": "Nombre de usuario para la autenticación ONVIF; algunos dispositivos requieren un usuario administrador para ONVIF."
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña ONVIF",
|
||||
"description": "Contraseña para la autenticación ONVIF."
|
||||
},
|
||||
"ignore_time_mismatch": {
|
||||
"label": "Ignorar discrepancia horaria",
|
||||
"description": "Ignora las diferencias de sincronización horaria entre la cámara y el servidor Frigate para la comunicación ONVIF."
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
"distances": {
|
||||
"label": "Distancias reales"
|
||||
"label": "Distancias reales",
|
||||
"description": "Distancias reales opcionales para cada lado del cuadrilátero de la zona, usadas para cálculos de velocidad o distancia. Debe tener exactamente 4 valores si se establece."
|
||||
},
|
||||
"coordinates": {
|
||||
"description": "Coordenadas del polígono que definen el área de la zona. Puede ser una cadena separada por comas o una lista de cadenas de coordenadas. Las coordenadas deben ser relativas (0-1) o absolutas (heredadas).",
|
||||
@@ -106,23 +161,41 @@
|
||||
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede expresarse en píxeles (entero) o como porcentaje (decimal entre 0,000001 y 0,99).",
|
||||
"label": "Área máxima del objeto"
|
||||
},
|
||||
"description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona."
|
||||
"description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona.",
|
||||
"label": "Filtros de zona",
|
||||
"min_area": {
|
||||
"label": "Área mínima de objeto",
|
||||
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos."
|
||||
"description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos.",
|
||||
"label": "Objetos activadores"
|
||||
},
|
||||
"description": "Las zonas le permiten definir un área específica del fotograma, de modo que pueda determinar si un objeto se encuentra o no dentro de un área determinada.",
|
||||
"speed_threshold": {
|
||||
"description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad."
|
||||
"description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad.",
|
||||
"label": "Velocidad mínima"
|
||||
},
|
||||
"friendly_name": {
|
||||
"description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona."
|
||||
"description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona.",
|
||||
"label": "Nombre de zona"
|
||||
},
|
||||
"inertia": {
|
||||
"description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias."
|
||||
"description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias.",
|
||||
"label": "Fotogramas de inercia"
|
||||
},
|
||||
"loitering_time": {
|
||||
"description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo."
|
||||
"description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo.",
|
||||
"label": "Segundos de permanencia"
|
||||
},
|
||||
"label": "Zonas",
|
||||
"enabled": {
|
||||
"label": "Habilitado",
|
||||
"description": "Habilita o deshabilita esta zona. Las zonas deshabilitadas se ignoran en tiempo de ejecución."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Mantiene el registro del estado original de la zona."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
@@ -142,148 +215,739 @@
|
||||
},
|
||||
"send_triggers": {
|
||||
"after_significant_updates": {
|
||||
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado."
|
||||
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado.",
|
||||
"label": "Activador temprano de GenAI"
|
||||
},
|
||||
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)."
|
||||
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.).",
|
||||
"label": "Activadores de GenAI",
|
||||
"tracked_object_end": {
|
||||
"label": "Enviar al finalizar",
|
||||
"description": "Envía una solicitud a GenAI cuando finaliza el objeto rastreado."
|
||||
}
|
||||
},
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI."
|
||||
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI.",
|
||||
"label": "Zonas requeridas"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Prompt de descripción",
|
||||
"description": "Plantilla de prompt predeterminada usada al generar descripciones con GenAI."
|
||||
},
|
||||
"object_prompts": {
|
||||
"label": "Prompts de objetos",
|
||||
"description": "Prompts por objeto para personalizar las salidas de GenAI para etiquetas concretas."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Objetos de GenAI",
|
||||
"description": "Lista de etiquetas de objetos que se enviarán a GenAI de forma predeterminada."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Guardar miniaturas",
|
||||
"description": "Guarda las miniaturas enviadas a GenAI para depuración y revisión."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de GenAI",
|
||||
"description": "Indica si GenAI estaba habilitado en la configuración estática original."
|
||||
}
|
||||
},
|
||||
"label": "Objetos",
|
||||
"description": "Valores predeterminados de seguimiento de objetos, incluidas las etiquetas que se rastrean y los filtros por objeto.",
|
||||
"track": {
|
||||
"label": "Objetos a rastrear",
|
||||
"description": "Lista de etiquetas de objetos a rastrear para esta cámara."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtros de objetos",
|
||||
"description": "Filtros aplicados a los objetos detectados para reducir falsos positivos (área, relación, confianza).",
|
||||
"min_area": {
|
||||
"label": "Área mínima de objeto",
|
||||
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
|
||||
},
|
||||
"max_area": {
|
||||
"label": "Área máxima de objeto",
|
||||
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Relación de aspecto mínima",
|
||||
"description": "Relación mínima anchura/altura necesaria para que el cuadro delimitador sea válido."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Relación de aspecto máxima",
|
||||
"description": "Relación máxima anchura/altura permitida para que el cuadro delimitador sea válido."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Umbral de confianza",
|
||||
"description": "Umbral medio de confianza de detección necesario para que el objeto se considere un positivo verdadero."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Confianza mínima",
|
||||
"description": "Confianza mínima de detección en un único fotograma necesaria para que el objeto se contabilice."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Máscara de filtro",
|
||||
"description": "Coordenadas del polígono que definen dónde se aplica este filtro dentro del fotograma."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Máscara sin procesar"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
"label": "Máscara de objeto",
|
||||
"description": "Polígono de máscara usado para evitar la detección de objetos en áreas especificadas."
|
||||
}
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT."
|
||||
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT.",
|
||||
"label": "Zonas requeridas"
|
||||
},
|
||||
"description": "Ajustes de publicación de imágenes MQTT.",
|
||||
"enabled": {
|
||||
"label": "Enviar imagen",
|
||||
"description": "Habilita la publicación de instantáneas de objetos en temas MQTT para esta cámara."
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "Añadir marca de tiempo",
|
||||
"description": "Superpone una marca de tiempo en las imágenes publicadas en MQTT."
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "Añadir cuadro delimitador",
|
||||
"description": "Dibuja cuadros delimitadores en las imágenes publicadas mediante MQTT."
|
||||
},
|
||||
"crop": {
|
||||
"label": "Recortar imagen",
|
||||
"description": "Recorta las imágenes publicadas en MQTT al cuadro delimitador del objeto detectado."
|
||||
},
|
||||
"height": {
|
||||
"label": "Altura de imagen",
|
||||
"description": "Altura (píxeles) a la que redimensionar las imágenes publicadas mediante MQTT."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Calidad JPEG",
|
||||
"description": "Calidad JPEG de las imágenes publicadas en MQTT (0-100)."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"email": {
|
||||
"label": "Email de notificacion"
|
||||
"label": "Email de notificacion",
|
||||
"description": "Dirección de correo electrónico usada para notificaciones push o requerida por ciertos proveedores de notificaciones."
|
||||
},
|
||||
"label": "Notificaciones",
|
||||
"description": "Ajustes para habilitar y controlar las notificaciones de esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar notificaciones",
|
||||
"description": "Habilita o deshabilita las notificaciones para esta cámara."
|
||||
},
|
||||
"cooldown": {
|
||||
"label": "Periodo de enfriamiento",
|
||||
"description": "Periodo de enfriamiento (segundos) entre notificaciones para evitar saturar a los destinatarios."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de notificaciones",
|
||||
"description": "Indica si las notificaciones estaban habilitadas en la configuración estática original."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.",
|
||||
"enabled": {
|
||||
"label": "Habilitar transcripción"
|
||||
"label": "Habilitar transcripción",
|
||||
"description": "Activar o desactivar la transcripción de eventos de audio activados manualmente."
|
||||
},
|
||||
"label": "Transcripción de audio",
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de la transcripción"
|
||||
},
|
||||
"live_enabled": {
|
||||
"label": "Transcripción en directo",
|
||||
"description": "Activar la transcripción en directo del audio a medida que se recibe."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"skip_motion_threshold": {
|
||||
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función."
|
||||
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función.",
|
||||
"label": "Omitir umbral de movimiento"
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento."
|
||||
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento.",
|
||||
"label": "Umbral de iluminación"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)."
|
||||
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255).",
|
||||
"label": "Umbral de movimiento"
|
||||
},
|
||||
"label": "Detección de movimiento",
|
||||
"description": "Ajustes predeterminados de detección de movimiento para esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar detección de movimiento",
|
||||
"description": "Habilita o deshabilita la detección de movimiento para esta cámara."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Mejorar contraste",
|
||||
"description": "Aplica una mejora de contraste a los fotogramas antes del análisis de movimiento para ayudar a la detección."
|
||||
},
|
||||
"contour_area": {
|
||||
"label": "Área de contorno",
|
||||
"description": "Área mínima de contorno en píxeles necesaria para que se cuente un contorno de movimiento."
|
||||
},
|
||||
"delta_alpha": {
|
||||
"label": "Delta alfa",
|
||||
"description": "Factor de mezcla alfa usado en la diferencia entre fotogramas para calcular el movimiento."
|
||||
},
|
||||
"frame_alpha": {
|
||||
"label": "Alfa del fotograma",
|
||||
"description": "Valor alfa usado al mezclar fotogramas para el preprocesamiento de movimiento."
|
||||
},
|
||||
"frame_height": {
|
||||
"label": "Altura del fotograma",
|
||||
"description": "Altura en píxeles a la que escalar los fotogramas al calcular el movimiento."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Coordenadas de máscara",
|
||||
"description": "Coordenadas x,y ordenadas que definen el polígono de máscara de movimiento usado para incluir/excluir áreas."
|
||||
},
|
||||
"mqtt_off_delay": {
|
||||
"label": "Retraso de apagado MQTT",
|
||||
"description": "Segundos a esperar tras el último movimiento antes de publicar un estado MQTT 'off'."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado de movimiento original",
|
||||
"description": "Indica si la detección de movimiento estaba habilitada en la configuración estática original."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Máscara sin procesar"
|
||||
}
|
||||
},
|
||||
"lpr": {
|
||||
"enhancement": {
|
||||
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución."
|
||||
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución.",
|
||||
"label": "Nivel de mejora"
|
||||
},
|
||||
"expire_time": {
|
||||
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)."
|
||||
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas).",
|
||||
"label": "Segundos hasta caducar"
|
||||
},
|
||||
"label": "Reconocimiento de matrículas",
|
||||
"description": "Ajustes de reconocimiento de matrículas, incluidos umbrales de detección, formato y matrículas conocidas.",
|
||||
"enabled": {
|
||||
"label": "Habilitar LPR",
|
||||
"description": "Habilita o deshabilita LPR en esta cámara."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Área mínima de matrícula",
|
||||
"description": "Área mínima de matrícula (píxeles) necesaria para intentar el reconocimiento."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"fps": {
|
||||
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)."
|
||||
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez).",
|
||||
"label": "FPS de detección"
|
||||
},
|
||||
"min_initialized": {
|
||||
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2."
|
||||
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2.",
|
||||
"label": "Fotogramas mínimos de inicialización"
|
||||
},
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión.",
|
||||
"label": "Altura de detección"
|
||||
},
|
||||
"width": {
|
||||
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión.",
|
||||
"label": "Anchura de detección"
|
||||
},
|
||||
"stationary": {
|
||||
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo."
|
||||
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo.",
|
||||
"label": "Configuración de objetos estacionarios",
|
||||
"interval": {
|
||||
"label": "Intervalo estacionario",
|
||||
"description": "Frecuencia (en fotogramas) con la que se ejecuta una comprobación de detección para confirmar un objeto estacionario."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Umbral estacionario",
|
||||
"description": "Número de fotogramas sin cambio de posición necesarios para marcar un objeto como estacionario."
|
||||
},
|
||||
"max_frames": {
|
||||
"label": "Fotogramas máximos",
|
||||
"description": "Limita durante cuánto tiempo se rastrean los objetos estacionarios antes de descartarlos.",
|
||||
"default": {
|
||||
"label": "Fotogramas máximos predeterminados",
|
||||
"description": "Número máximo predeterminado de fotogramas para rastrear un objeto estacionario antes de detenerse."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Fotogramas máximos por objeto",
|
||||
"description": "Sobrescrituras por objeto para el número máximo de fotogramas en los que rastrear objetos estacionarios."
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"label": "Habilitar clasificador visual",
|
||||
"description": "Usa un clasificador visual para detectar objetos realmente estacionarios incluso cuando los cuadros delimitadores oscilan."
|
||||
}
|
||||
},
|
||||
"label": "Detección de objetos",
|
||||
"description": "Ajustes del rol de detección/detect usado para ejecutar la detección de objetos e inicializar los rastreadores.",
|
||||
"enabled": {
|
||||
"label": "Habilitar detección de objetos",
|
||||
"description": "Habilita o deshabilita la detección de objetos para esta cámara."
|
||||
},
|
||||
"max_disappeared": {
|
||||
"label": "Fotogramas máximos desaparecido",
|
||||
"description": "Número de fotogramas sin detección antes de que un objeto rastreado se considere desaparecido."
|
||||
},
|
||||
"annotation_offset": {
|
||||
"label": "Desplazamiento de anotaciones",
|
||||
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"motion": {
|
||||
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones.",
|
||||
"label": "Retención de movimiento",
|
||||
"days": {
|
||||
"label": "Días de retención",
|
||||
"description": "Días durante los que conservar las grabaciones."
|
||||
}
|
||||
},
|
||||
"continuous": {
|
||||
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones.",
|
||||
"label": "Retención continua",
|
||||
"days": {
|
||||
"label": "Días de retención",
|
||||
"description": "Días durante los que conservar las grabaciones."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación.",
|
||||
"label": "Segundos de captura previa"
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación.",
|
||||
"label": "Segundos de captura posterior"
|
||||
},
|
||||
"label": "Retención de detección",
|
||||
"description": "Ajustes de retención de grabaciones para eventos de detección, incluidas las duraciones de captura previa/posterior.",
|
||||
"retain": {
|
||||
"label": "Retención de eventos",
|
||||
"description": "Ajustes de retención para grabaciones de eventos de detección.",
|
||||
"days": {
|
||||
"label": "Días de retención",
|
||||
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de retención",
|
||||
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación.",
|
||||
"label": "Segundos de captura previa"
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación.",
|
||||
"label": "Segundos de captura posterior"
|
||||
},
|
||||
"label": "Retención de alertas",
|
||||
"description": "Ajustes de retención de grabaciones para eventos de alerta, incluidas las duraciones de captura previa/posterior.",
|
||||
"retain": {
|
||||
"label": "Retención de eventos",
|
||||
"description": "Ajustes de retención para grabaciones de eventos de detección.",
|
||||
"days": {
|
||||
"label": "Días de retención",
|
||||
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de retención",
|
||||
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": "Grabación",
|
||||
"description": "Ajustes de grabación y retención para esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar grabación",
|
||||
"description": "Habilita o deshabilita la grabación para esta cámara."
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "Intervalo de limpieza de grabaciones",
|
||||
"description": "Minutos entre pasadas de limpieza que eliminan segmentos de grabación caducados."
|
||||
},
|
||||
"export": {
|
||||
"label": "Configuración de exportación",
|
||||
"description": "Ajustes usados al exportar grabaciones, como timelapse y aceleración por hardware.",
|
||||
"hwaccel_args": {
|
||||
"label": "Argumentos hwaccel de exportación",
|
||||
"description": "Argumentos de aceleración por hardware que se usarán en operaciones de exportación/transcodificación."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Exportaciones simultáneas máximas",
|
||||
"description": "Número máximo de trabajos de exportación que se procesarán al mismo tiempo."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Configuración de vista previa",
|
||||
"description": "Ajustes que controlan la calidad de las vistas previas de grabaciones mostradas en la interfaz.",
|
||||
"quality": {
|
||||
"label": "Calidad de vista previa",
|
||||
"description": "Nivel de calidad de vista previa (very_low, low, medium, high, very_high)."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado de grabación original",
|
||||
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"dashboard": {
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz."
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz.",
|
||||
"label": "Mostrar en la interfaz"
|
||||
},
|
||||
"label": "Interfaz de cámara",
|
||||
"description": "Orden de visualización y visibilidad de esta cámara en la interfaz. El orden afecta al panel predeterminado. Para un control más granular, usa grupos de cámaras.",
|
||||
"order": {
|
||||
"label": "Orden en la interfaz",
|
||||
"description": "Orden numérico usado para ordenar la cámara en la interfaz (panel predeterminado y listas); los números más altos aparecen más tarde."
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección."
|
||||
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección.",
|
||||
"label": "Altura en directo"
|
||||
},
|
||||
"description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo."
|
||||
"description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo.",
|
||||
"label": "Reproducción en directo",
|
||||
"streams": {
|
||||
"label": "Nombres de flujos en directo",
|
||||
"description": "Asignación de nombres de flujos configurados a nombres de restream/go2rtc usados para la reproducción en directo."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Calidad en directo",
|
||||
"description": "Calidad de codificación para el flujo jsmpeg (1 la más alta, 31 la más baja)."
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"description": "Configuraciones que controlan las alertas, las detecciones y los resúmenes de revisión de GenAI utilizados por la interfaz de usuario y el almacenamiento de esta cámara.",
|
||||
"alerts": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona."
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona.",
|
||||
"label": "Zonas requeridas"
|
||||
},
|
||||
"labels": {
|
||||
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)."
|
||||
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person).",
|
||||
"label": "Etiquetas de alerta"
|
||||
},
|
||||
"label": "Configuración de alertas",
|
||||
"description": "Ajustes sobre qué objetos rastreados generan alertas y cómo se conservan las alertas.",
|
||||
"enabled": {
|
||||
"label": "Habilitar alertas",
|
||||
"description": "Habilita o deshabilita la generación de alertas para esta cámara."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de alertas",
|
||||
"description": "Rastrea si las alertas estaban habilitadas originalmente en la configuración estática."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Tiempo de corte de alertas",
|
||||
"description": "Segundos que se esperarán tras dejar de haber actividad causante de alerta antes de cortar una alerta."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona."
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona.",
|
||||
"label": "Zonas requeridas"
|
||||
},
|
||||
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones."
|
||||
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones.",
|
||||
"label": "Configuración de detecciones",
|
||||
"enabled": {
|
||||
"label": "Habilitar detecciones",
|
||||
"description": "Habilita o deshabilita los eventos de detección para esta cámara."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Etiquetas de detección",
|
||||
"description": "Lista de etiquetas de objetos que cuentan como eventos de detección."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Tiempo de corte de detecciones",
|
||||
"description": "Segundos que se esperarán tras dejar de haber actividad causante de detección antes de cortar una detección."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de detecciones",
|
||||
"description": "Rastrea si las detecciones estaban habilitadas originalmente en la configuración estática."
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
"image_source": {
|
||||
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens."
|
||||
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens.",
|
||||
"label": "Origen de imagen de revisión"
|
||||
},
|
||||
"additional_concerns": {
|
||||
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara."
|
||||
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara.",
|
||||
"label": "Consideraciones adicionales"
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI."
|
||||
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI.",
|
||||
"label": "Prompt de contexto de actividad"
|
||||
},
|
||||
"description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.",
|
||||
"debug_save_thumbnails": {
|
||||
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión."
|
||||
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión.",
|
||||
"label": "Guardar miniaturas"
|
||||
},
|
||||
"label": "Configuración de GenAI",
|
||||
"enabled": {
|
||||
"label": "Habilitar descripciones de GenAI",
|
||||
"description": "Habilita o deshabilita las descripciones y resúmenes generados por GenAI para los elementos de revisión."
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Habilitar GenAI para alertas",
|
||||
"description": "Usa GenAI para generar descripciones de elementos de alerta."
|
||||
},
|
||||
"detections": {
|
||||
"label": "Habilitar GenAI para detecciones",
|
||||
"description": "Usa GenAI para generar descripciones de elementos de detección."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de GenAI",
|
||||
"description": "Rastrea si la revisión de GenAI estaba habilitada originalmente en la configuración estática."
|
||||
},
|
||||
"preferred_language": {
|
||||
"label": "Idioma preferido",
|
||||
"description": "Idioma preferido que se solicitará al proveedor de GenAI para las respuestas generadas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": "Revisión"
|
||||
},
|
||||
"birdseye": {
|
||||
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista."
|
||||
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista.",
|
||||
"label": "Vista general",
|
||||
"enabled": {
|
||||
"label": "Habilitar Birdseye",
|
||||
"description": "Habilita o deshabilita la función de vista Birdseye."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de seguimiento",
|
||||
"description": "Modo para incluir cámaras en Birdseye: 'objects', 'motion' o 'continuous'."
|
||||
},
|
||||
"order": {
|
||||
"label": "Posición",
|
||||
"description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"retry_interval": {
|
||||
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10."
|
||||
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10.",
|
||||
"label": "Tiempo de reintento de FFmpeg"
|
||||
},
|
||||
"path": {
|
||||
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")."
|
||||
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\").",
|
||||
"label": "Ruta de FFmpeg"
|
||||
},
|
||||
"output_args": {
|
||||
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación."
|
||||
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación.",
|
||||
"label": "Argumentos de salida",
|
||||
"detect": {
|
||||
"label": "Argumentos de salida de detección",
|
||||
"description": "Argumentos de salida predeterminados para los flujos con rol de detección."
|
||||
},
|
||||
"record": {
|
||||
"label": "Argumentos de salida de grabación",
|
||||
"description": "Argumentos de salida predeterminados para los flujos con rol de grabación."
|
||||
}
|
||||
},
|
||||
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol."
|
||||
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol.",
|
||||
"label": "FFmpeg",
|
||||
"global_args": {
|
||||
"label": "Argumentos globales de FFmpeg",
|
||||
"description": "Argumentos globales pasados a los procesos de FFmpeg."
|
||||
},
|
||||
"hwaccel_args": {
|
||||
"label": "Argumentos de aceleración por hardware",
|
||||
"description": "Argumentos de aceleración por hardware para FFmpeg. Se recomiendan preajustes específicos del proveedor."
|
||||
},
|
||||
"input_args": {
|
||||
"label": "Argumentos de entrada",
|
||||
"description": "Argumentos de entrada aplicados a los flujos de entrada de FFmpeg."
|
||||
},
|
||||
"apple_compatibility": {
|
||||
"label": "Compatibilidad con Apple",
|
||||
"description": "Habilita el etiquetado HEVC para mejorar la compatibilidad con reproductores de Apple al grabar H.265."
|
||||
},
|
||||
"gpu": {
|
||||
"label": "Índice de GPU",
|
||||
"description": "Índice de GPU predeterminado usado para la aceleración por hardware si está disponible."
|
||||
},
|
||||
"inputs": {
|
||||
"label": "Entradas de cámara",
|
||||
"description": "Lista de definiciones de flujos de entrada (rutas y roles) para esta cámara.",
|
||||
"path": {
|
||||
"label": "Ruta de entrada",
|
||||
"description": "URL o ruta del flujo de entrada de la cámara."
|
||||
},
|
||||
"roles": {
|
||||
"label": "Roles de entrada",
|
||||
"description": "Roles para este flujo de entrada."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Argumentos globales de FFmpeg",
|
||||
"description": "Argumentos globales de FFmpeg para este flujo de entrada."
|
||||
},
|
||||
"hwaccel_args": {
|
||||
"label": "Argumentos de aceleración por hardware",
|
||||
"description": "Argumentos de aceleración por hardware para este flujo de entrada."
|
||||
},
|
||||
"input_args": {
|
||||
"label": "Argumentos de entrada",
|
||||
"description": "Argumentos de entrada específicos para este flujo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"face_recognition": {
|
||||
"label": "Reconocimiento facial",
|
||||
"description": "Ajustes de detección y reconocimiento facial para esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar reconocimiento facial",
|
||||
"description": "Habilita o deshabilita el reconocimiento facial."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Área mínima de rostro",
|
||||
"description": "Área mínima (píxeles) del cuadro de un rostro detectado necesaria para intentar el reconocimiento."
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "Búsqueda semántica",
|
||||
"description": "Ajustes de búsqueda semántica, que crea y consulta embeddings de objetos para encontrar elementos similares.",
|
||||
"triggers": {
|
||||
"label": "Activadores",
|
||||
"description": "Acciones y criterios de coincidencia para activadores de búsqueda semántica específicos de la cámara.",
|
||||
"friendly_name": {
|
||||
"label": "Nombre descriptivo",
|
||||
"description": "Nombre descriptivo opcional mostrado en la interfaz para este activador."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Habilitar este activador",
|
||||
"description": "Habilita o deshabilita este activador de búsqueda semántica."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de activador",
|
||||
"description": "Tipo de activador: 'thumbnail' (coincidir con imagen) o 'description' (coincidir con texto)."
|
||||
},
|
||||
"data": {
|
||||
"label": "Contenido del activador",
|
||||
"description": "Frase de texto o ID de miniatura que se comparará con objetos rastreados."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Umbral del activador",
|
||||
"description": "Puntuación mínima de similitud (0-1) necesaria para activar este activador."
|
||||
},
|
||||
"actions": {
|
||||
"label": "Acciones del activador",
|
||||
"description": "Lista de acciones que se ejecutarán cuando el activador coincida (notification, sub_label, attribute)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"label": "Instantáneas",
|
||||
"description": "Ajustes de instantáneas generadas por la API de objetos rastreados para esta cámara.",
|
||||
"enabled": {
|
||||
"label": "Habilitar instantáneas",
|
||||
"description": "Habilita o deshabilita el guardado de instantáneas para esta cámara."
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "Superposición de marca de tiempo",
|
||||
"description": "Superpone una marca de tiempo en las instantáneas de la API."
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "Superposición de cuadro delimitador",
|
||||
"description": "Dibuja cuadros delimitadores para los objetos rastreados en las instantáneas de la API."
|
||||
},
|
||||
"crop": {
|
||||
"label": "Recortar instantánea",
|
||||
"description": "Recorta las instantáneas de la API al cuadro delimitador del objeto detectado."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zonas requeridas",
|
||||
"description": "Zonas en las que debe entrar un objeto para que se guarde una instantánea."
|
||||
},
|
||||
"height": {
|
||||
"label": "Altura de instantánea",
|
||||
"description": "Altura (píxeles) a la que redimensionar las instantáneas de la API; déjalo vacío para conservar el tamaño original."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Retención de instantáneas",
|
||||
"description": "Ajustes de retención de instantáneas, incluidos días predeterminados y sobrescrituras por objeto.",
|
||||
"default": {
|
||||
"label": "Retención predeterminada",
|
||||
"description": "Número predeterminado de días durante los que conservar instantáneas."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de retención",
|
||||
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Retención por objeto",
|
||||
"description": "Sobrescrituras por objeto para los días de retención de instantáneas."
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"label": "Calidad de instantánea",
|
||||
"description": "Calidad de codificación de las instantáneas guardadas (0-100)."
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Estilo de marca de tiempo",
|
||||
"description": "Opciones de estilo para marcas de tiempo integradas aplicadas a grabaciones e instantáneas.",
|
||||
"position": {
|
||||
"label": "Posición de marca de tiempo",
|
||||
"description": "Posición de la marca de tiempo en la imagen (tl/tr/bl/br)."
|
||||
},
|
||||
"format": {
|
||||
"label": "Formato de marca de tiempo",
|
||||
"description": "Cadena de formato de fecha y hora usada para las marcas de tiempo (códigos de formato datetime de Python)."
|
||||
},
|
||||
"color": {
|
||||
"label": "Color de marca de tiempo",
|
||||
"description": "Valores de color RGB para el texto de la marca de tiempo (todos los valores 0-255).",
|
||||
"red": {
|
||||
"label": "Rojo",
|
||||
"description": "Componente rojo (0-255) para el color de la marca de tiempo."
|
||||
},
|
||||
"green": {
|
||||
"label": "Verde",
|
||||
"description": "Componente verde (0-255) para el color de la marca de tiempo."
|
||||
},
|
||||
"blue": {
|
||||
"label": "Azul",
|
||||
"description": "Componente azul (0-255) para el color de la marca de tiempo."
|
||||
}
|
||||
},
|
||||
"thickness": {
|
||||
"label": "Grosor de marca de tiempo",
|
||||
"description": "Grosor de línea del texto de la marca de tiempo."
|
||||
},
|
||||
"effect": {
|
||||
"label": "Efecto de marca de tiempo",
|
||||
"description": "Efecto visual para el texto de la marca de tiempo (none, solid, shadow)."
|
||||
}
|
||||
},
|
||||
"best_image_timeout": {
|
||||
"label": "Tiempo de espera de mejor imagen",
|
||||
"description": "Tiempo que se esperará la imagen con la puntuación de confianza más alta."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de cámara",
|
||||
"description": "Tipo de cámara"
|
||||
},
|
||||
"webui_url": {
|
||||
"label": "URL de la cámara",
|
||||
"description": "URL para visitar la cámara directamente desde la página del sistema"
|
||||
},
|
||||
"profiles": {
|
||||
"label": "Perfiles",
|
||||
"description": "Perfiles de configuración con nombre y sobrescrituras parciales que pueden activarse en tiempo de ejecución."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estado original de cámara",
|
||||
"description": "Mantiene el registro del estado original de la cámara."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,5 +116,15 @@
|
||||
"animal": "Animal",
|
||||
"postnord": "PostNord",
|
||||
"usps": "USPS",
|
||||
"gls": "GLS"
|
||||
"gls": "GLS",
|
||||
"canada_post": "Canada Post",
|
||||
"royal_mail": "Royal Mail",
|
||||
"school_bus": "Autobús escolar",
|
||||
"skunk": "Mofeta",
|
||||
"kangaroo": "Canguro",
|
||||
"baby": "Bebé",
|
||||
"baby_stroller": "Cochecito de bebé",
|
||||
"rickshaw": "Rickshaw",
|
||||
"Rodent": "Roedor",
|
||||
"rodent": "Roedor"
|
||||
}
|
||||
|
||||
@@ -1 +1,69 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Chat - Frigate",
|
||||
"title": "Frigate Chat",
|
||||
"subtitle": "Tu asistente de IA para la gestión de cámaras y análisis",
|
||||
"placeholder": "Pregunta cualquier cosa...",
|
||||
"error": "Algo salió mal. Por favor, inténtalo de nuevo.",
|
||||
"processing": "Procesando...",
|
||||
"toolsUsed": "Usado: {{tools}}",
|
||||
"showTools": "Mostrar herramientas ({{count}})",
|
||||
"hideTools": "Ocultar herramientas",
|
||||
"call": "Llamar",
|
||||
"result": "Resultado",
|
||||
"arguments": "Argumentos:",
|
||||
"response": "Respuesta:",
|
||||
"attachment_chip_label": "{{label}} en {{camera}}",
|
||||
"attachment_chip_remove": "Eliminar adjunto",
|
||||
"open_in_explore": "Abrir en Explorar",
|
||||
"attach_event_aria": "Adjuntar evento {{eventId}}",
|
||||
"attachment_picker_paste_label": "O pega el ID del evento",
|
||||
"attachment_picker_attach": "Adjuntar",
|
||||
"attachment_picker_placeholder": "Adjuntar un evento",
|
||||
"quick_reply_find_similar": "Buscar avistamientos similares",
|
||||
"quick_reply_tell_me_more": "Cuéntame más sobre esto",
|
||||
"quick_reply_when_else": "¿Cuándo más se vio?",
|
||||
"quick_reply_find_similar_text": "Buscar avistamientos similares a este.",
|
||||
"quick_reply_tell_me_more_text": "Cuéntame más sobre este.",
|
||||
"quick_reply_when_else_text": "¿Cuándo más se vio esto?",
|
||||
"anchor": "Referencia",
|
||||
"similarity_score": "Similitud",
|
||||
"no_similar_objects_found": "No se encontraron objetos similares.",
|
||||
"semantic_search_required": "La búsqueda semántica debe estar activada para encontrar objetos similares.",
|
||||
"send": "Enviar",
|
||||
"suggested_requests": "Prueba preguntando:",
|
||||
"starting_requests": {
|
||||
"show_recent_events": "Mostrar eventos recientes",
|
||||
"show_camera_status": "Mostrar estado de la cámara",
|
||||
"recap": "¿Qué ha pasado mientras estaba fuera?",
|
||||
"watch_camera": "Vigilar una cámara en busca de actividad"
|
||||
},
|
||||
"starting_requests_prompts": {
|
||||
"show_recent_events": "Muéstrame los eventos recientes de la última hora",
|
||||
"show_camera_status": "¿Cuál es el estado actual de mis cámaras?",
|
||||
"recap": "¿Qué ha pasado mientras estaba fuera?",
|
||||
"watch_camera": "Vigila la puerta principal y avísame si aparece alguien"
|
||||
},
|
||||
"new_chat": "Nuevo chat",
|
||||
"settings": {
|
||||
"title": "Ajustes del chat",
|
||||
"show_stats": {
|
||||
"title": "Mostrar estadísticas",
|
||||
"desc": "Mostrar la velocidad de generación y el tamaño del contexto en las respuestas del chat.",
|
||||
"while_generating": "Durante la generación",
|
||||
"always": "Siempre"
|
||||
},
|
||||
"auto_scroll": {
|
||||
"title": "Desplazamiento automático",
|
||||
"desc": "Seguir los mensajes nuevos a medida que llegan."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"context": "{{tokens}} tokens",
|
||||
"tokens_per_second": "{{rate}} t/s"
|
||||
},
|
||||
"reasoning": {
|
||||
"active": "Razonando…",
|
||||
"show": "Mostrar razonamiento",
|
||||
"hide": "Ocultar razonamiento"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
"generateSuccess": "Imágenes de ejemplo generadas correctamente",
|
||||
"missingStatesWarning": {
|
||||
"title": "Faltan Ejemplos de Estado",
|
||||
"description": "Se recomienda seleccionar ejemplos para todos los estados para obtener mejores resultados. Puede continuar sin seleccionar todos los estados, pero el modelo no se entrenará hasta que todos los estados tengan imágenes. Después de continuar, use la vista \"Clasificaciones recientes\" para clasificar las imágenes de los estados faltantes y luego entrene el modelo."
|
||||
"description": "No todas las clases tienen ejemplos. Prueba a generar nuevos ejemplos para encontrar la clase que falta, o continúa y usa la vista de Clasificaciones recientes para añadir imágenes más tarde."
|
||||
},
|
||||
"allImagesRequired_one": "Por favor clasifique todas las imágenes. Queda {{count}} imagen.",
|
||||
"allImagesRequired_many": "Por favor clasifique todas las imágenes. Quedan {{count}} imágenes.",
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
},
|
||||
"camera": "Cámara",
|
||||
"recordings": {
|
||||
"documentTitle": "Grabaciones - Frigate"
|
||||
"documentTitle": "Grabaciones - Frigate",
|
||||
"invalidSharedLink": "No se puede abrir el enlace de la grabación con marca de tiempo debido a un error de análisis.",
|
||||
"invalidSharedCamera": "No se puede abrir el enlace de la grabación con marca de tiempo debido a una cámara desconocida o no autorizada."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Últimas 24 horas"
|
||||
@@ -66,5 +68,28 @@
|
||||
"select_all": "Todas",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Necesita revisión",
|
||||
"securityConcern": "Aviso de seguridad"
|
||||
"securityConcern": "Aviso de seguridad",
|
||||
"motionSearch": {
|
||||
"menuItem": "Búsqueda de movimiento",
|
||||
"openMenu": "Opciones de cámara"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "Ver vistas previas de movimiento",
|
||||
"title": "Vistas previas de movimiento: {{camera}}",
|
||||
"mobileSettingsTitle": "Ajustes de vistas previas de movimiento",
|
||||
"mobileSettingsDesc": "Ajusta la velocidad de reproducción y el atenuado, y elige una fecha para revisar clips solo de movimiento.",
|
||||
"dim": "Atenuar",
|
||||
"dimAria": "Ajustar intensidad de atenuado",
|
||||
"dimDesc": "Aumenta el atenuado para mejorar la visibilidad de las áreas con movimiento.",
|
||||
"speed": "Velocidad",
|
||||
"speedAria": "Seleccionar velocidad de reproducción de las vistas previas",
|
||||
"speedDesc": "Elige la velocidad a la que se reproducen los clips de vista previa.",
|
||||
"back": "Atrás",
|
||||
"empty": "No hay vistas previas disponibles",
|
||||
"noPreview": "Vista previa no disponible",
|
||||
"seekAria": "Mover el reproductor de {{camera}} a {{time}}",
|
||||
"filter": "Filtrar",
|
||||
"filterDesc": "Selecciona áreas para mostrar solo clips con movimiento en esas regiones.",
|
||||
"filterClear": "Limpiar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,10 @@
|
||||
},
|
||||
"more": {
|
||||
"aria": "Más"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Reproducción de depuración",
|
||||
"aria": "Ver este objeto rastreado en la reproducción de depuración"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
@@ -282,7 +286,10 @@
|
||||
"zones": "Zonas",
|
||||
"area": "Área",
|
||||
"score": "Puntuación",
|
||||
"ratio": "Ratio(proporción)"
|
||||
"ratio": "Ratio(proporción)",
|
||||
"computedScore": "Puntuación calculada",
|
||||
"topScore": "Puntuación más alta",
|
||||
"toggleAdvancedScores": "Alternar puntuaciones avanzadas"
|
||||
},
|
||||
"entered_zone": "{{label}} ha entrado en {{zones}}"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "No se pudo renombrar la exportación: {{errorMessage}}",
|
||||
"assignCaseFailed": "Fallo en la actualización de la asignación de caso: {{errorMessage}}"
|
||||
"assignCaseFailed": "Fallo en la actualización de la asignación de caso: {{errorMessage}}",
|
||||
"caseSaveFailed": "No se pudo guardar el caso: {{errorMessage}}",
|
||||
"caseDeleteFailed": "No se pudo eliminar el caso: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteExport.desc": "¿Estás seguro de que quieres eliminar {{exportName}}?",
|
||||
@@ -38,10 +40,89 @@
|
||||
"descriptionLabel": "Descripción"
|
||||
},
|
||||
"toolbar": {
|
||||
"addExport": "Añadir Exportación"
|
||||
"addExport": "Añadir Exportación",
|
||||
"newCase": "Nuevo caso",
|
||||
"editCase": "Editar caso",
|
||||
"deleteCase": "Eliminar caso"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Eliminar caso",
|
||||
"desc": "¿Estás seguro de que quieres eliminar {{caseName}}?"
|
||||
"desc": "¿Estás seguro de que quieres eliminar {{caseName}}?",
|
||||
"descKeepExports": "Las exportaciones seguirán disponibles como exportaciones sin categoría.",
|
||||
"descDeleteExports": "Todas las exportaciones de este caso se eliminarán de forma permanente.",
|
||||
"deleteExports": "Eliminar también las exportaciones"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Aún no hay exportaciones"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "Exportación de {{camera}}",
|
||||
"queued": "En cola",
|
||||
"running": "En ejecución",
|
||||
"preparing": "Preparando",
|
||||
"copying": "Copiando",
|
||||
"encoding": "Codificando",
|
||||
"encodingRetry": "Codificando (reintento)",
|
||||
"finalizing": "Finalizando"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "Sin descripción",
|
||||
"createdAt": "Creado {{value}}",
|
||||
"exportCount_one": "1 exportación",
|
||||
"exportCount_other": "{{count}} exportaciones",
|
||||
"cameraCount_one": "1 cámara",
|
||||
"cameraCount_other": "{{count}} cámaras",
|
||||
"showMore": "Mostrar más",
|
||||
"showLess": "Mostrar menos",
|
||||
"emptyTitle": "Este caso está vacío",
|
||||
"emptyDescription": "Añade exportaciones existentes sin categorizar para mantener el caso organizado.",
|
||||
"emptyDescriptionNoExports": "Todavía no hay exportaciones sin categorizar disponibles para añadir."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Crear caso",
|
||||
"editTitle": "Editar caso",
|
||||
"namePlaceholder": "Nombre del caso",
|
||||
"descriptionPlaceholder": "Añade notas o contexto para este caso"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Añadir exportación a {{caseName}}",
|
||||
"searchPlaceholder": "Buscar exportaciones sin categorizar",
|
||||
"empty": "Ninguna exportación sin categorizar coincide con esta búsqueda.",
|
||||
"addButton_one": "Añadir 1 exportación",
|
||||
"addButton_other": "Añadir {{count}} exportaciones",
|
||||
"adding": "Añadiendo..."
|
||||
},
|
||||
"selected_one": "{{count}} seleccionados",
|
||||
"selected_other": "{{count}} seleccionados",
|
||||
"bulkActions": {
|
||||
"addToCase": "Añadir al caso",
|
||||
"moveToCase": "Mover al caso",
|
||||
"removeFromCase": "Eliminar del caso",
|
||||
"delete": "Eliminar",
|
||||
"deleteNow": "Eliminar ahora"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Eliminar exportaciones",
|
||||
"desc_one": "¿Seguro que quieres eliminar {{count}} exportación?",
|
||||
"desc_other": "¿Seguro que quieres eliminar {{count}} exportaciones?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Eliminar del caso",
|
||||
"desc_one": "¿Eliminar {{count}} exportación de este caso?",
|
||||
"desc_other": "¿Eliminar {{count}} exportaciones de este caso?",
|
||||
"descKeepExports": "Las exportaciones se moverán a sin categorizar.",
|
||||
"descDeleteExports": "Las exportaciones se eliminarán permanentemente.",
|
||||
"deleteExports": "Eliminar exportaciones en su lugar"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Exportaciones eliminadas correctamente",
|
||||
"reassign": "Asignación de caso actualizada correctamente",
|
||||
"remove": "Exportaciones eliminadas del caso correctamente"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "No se pudieron eliminar las exportaciones: {{errorMessage}}",
|
||||
"reassignFailed": "No se pudo actualizar la asignación del caso: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
"title": "Reconocimientos Recientes",
|
||||
"aria": "Seleccionar reconocimientos recientes",
|
||||
"empty": "No hay intentos recientes de reconocimiento facial",
|
||||
"titleShort": "Reciente"
|
||||
"titleShort": "Reciente",
|
||||
"emptyNoLibrary": {
|
||||
"title": "Subir una cara",
|
||||
"description": "Debes añadir al menos una cara a la biblioteca para que el reconocimiento facial funcione."
|
||||
}
|
||||
},
|
||||
"selectItem": "Seleccionar {{item}}",
|
||||
"selectFace": "Seleccionar rostro",
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
},
|
||||
"recording": {
|
||||
"enable": "Habilitar grabación",
|
||||
"disable": "Deshabilitar grabación"
|
||||
"disable": "Deshabilitar grabación",
|
||||
"disabledInConfig": "La grabación debe activarse primero en Ajustes para esta cámara."
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "Habilitar capturas de pantalla",
|
||||
|
||||
@@ -1 +1,77 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Búsqueda por movimiento - Frigate",
|
||||
"title": "Búsqueda por movimiento",
|
||||
"description": "Dibuja un polígono para definir la región de interés y especifica un intervalo de tiempo para buscar cambios de movimiento dentro de esa región.",
|
||||
"selectCamera": "Búsqueda por movimiento se está cargando",
|
||||
"startSearch": "Iniciar búsqueda",
|
||||
"searchStarted": "Búsqueda iniciada",
|
||||
"searchCancelled": "Búsqueda cancelada",
|
||||
"cancelSearch": "Cancelar",
|
||||
"searching": "Búsqueda en progreso.",
|
||||
"searchComplete": "Búsqueda completada",
|
||||
"noResultsYet": "Ejecuta una búsqueda para encontrar cambios de movimiento en la región seleccionada",
|
||||
"noChangesFound": "No se detectaron cambios de píxeles en la región seleccionada",
|
||||
"changesFound_one": "Encontrado {{count}} cambio de movimiento",
|
||||
"changesFound_many": "Encontrados {{count}} cambios de movimiento",
|
||||
"changesFound_other": "Encontrados {{count}} cambios de movimiento",
|
||||
"framesProcessed": "{{count}} fotogramas procesados",
|
||||
"jumpToTime": "Saltar a este tiempo",
|
||||
"results": "Resultados",
|
||||
"showSegmentHeatmap": "Mapa de calor",
|
||||
"newSearch": "Nueva búsqueda",
|
||||
"clearResults": "Borrar resultados",
|
||||
"clearROI": "Borrar polígono",
|
||||
"polygonControls": {
|
||||
"points_one": "{{count}} punto",
|
||||
"points_many": "{{count}} puntos",
|
||||
"points_other": "{{count}} puntos",
|
||||
"undo": "Deshacer el último punto",
|
||||
"reset": "Restablecer polígono"
|
||||
},
|
||||
"motionHeatmapLabel": "Mapa de calor de movimiento",
|
||||
"dialog": {
|
||||
"title": "Búsqueda de movimiento",
|
||||
"cameraLabel": "Cámara",
|
||||
"previewAlt": "Vista previa de la cámara {{camera}}"
|
||||
},
|
||||
"timeRange": {
|
||||
"title": "Rango de búsqueda",
|
||||
"start": "Hora de inicio",
|
||||
"end": "Hora de finalización"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes de búsqueda",
|
||||
"parallelMode": "Modo paralelo",
|
||||
"parallelModeDesc": "Analiza varios segmentos de grabación al mismo tiempo (más rápido, pero consume significativamente más CPU)",
|
||||
"threshold": "Umbral de sensibilidad",
|
||||
"thresholdDesc": "Los valores más bajos detectan cambios más pequeños (1-255)",
|
||||
"minArea": "Área mínima de cambio",
|
||||
"minAreaDesc": "Porcentaje mínimo de la región de interés que debe cambiar para considerarse significativo",
|
||||
"frameSkip": "Salto de fotogramas",
|
||||
"frameSkipDesc": "Procesa cada N fotogramas. Establécelo según la tasa de FPS de tu cámara para procesar un fotograma por segundo (p. ej., 5 para una cámara de 5 FPS, 30 para una cámara de 30 FPS). Los valores más altos serán más rápidos, pero pueden omitir eventos de movimiento breves.",
|
||||
"maxResults": "Resultados máximos",
|
||||
"maxResultsDesc": "Detener después de esta cantidad de marcas de tiempo coincidentes"
|
||||
},
|
||||
"errors": {
|
||||
"noCamera": "Selecciona una cámara",
|
||||
"noROI": "Dibuja una región de interés",
|
||||
"noTimeRange": "Selecciona un rango de tiempo",
|
||||
"invalidTimeRange": "La hora de fin debe ser posterior a la hora de inicio",
|
||||
"searchFailed": "La búsqueda falló: {{message}}",
|
||||
"polygonTooSmall": "El polígono debe tener al menos 3 puntos",
|
||||
"unknown": "Error desconocido"
|
||||
},
|
||||
"changePercentage": "{{percentage}}% cambiado",
|
||||
"metrics": {
|
||||
"title": "Métricas de búsqueda",
|
||||
"segmentsScanned": "Segmentos analizados",
|
||||
"segmentsProcessed": "Procesado",
|
||||
"segmentsSkippedInactive": "Omitido (sin actividad)",
|
||||
"segmentsSkippedHeatmap": "Omitido (sin superposición de ROI)",
|
||||
"fallbackFullRange": "Análisis completo de respaldo",
|
||||
"framesDecoded": "Fotogramas decodificados",
|
||||
"wallTime": "Tiempo de búsqueda",
|
||||
"segmentErrors": "Errores de segmento",
|
||||
"seconds": "{{seconds}} s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,59 @@
|
||||
{}
|
||||
{
|
||||
"title": "Depuración de reproducción",
|
||||
"description": "Reproducir grabaciones de cámara para depuración. La lista de objetos muestra un resumen con retraso temporal de los objetos detectados y la pestaña Mensajes muestra un flujo de los mensajes internos de Frigate de la grabación reproducida.",
|
||||
"websocket_messages": "Mensajes",
|
||||
"dialog": {
|
||||
"title": "Iniciar depuración de reproducción",
|
||||
"description": "Crea una cámara de reproducción temporal que reproduzca en bucle imágenes históricas para depurar problemas de detección y seguimiento de objetos. La cámara de reproducción tendrá la misma configuración de detección que la cámara de origen. Elige un intervalo de tiempo para comenzar.",
|
||||
"camera": "Cámara de origen",
|
||||
"timeRange": "Intervalo de tiempo",
|
||||
"preset": {
|
||||
"1m": "Último 1 minuto",
|
||||
"5m": "Últimos 5 minutos",
|
||||
"timeline": "Desde la línea de tiempo",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"startButton": "Iniciar reproducción",
|
||||
"selectFromTimeline": "Seleccionar",
|
||||
"starting": "Iniciando reproducción...",
|
||||
"startLabel": "Iniciar",
|
||||
"endLabel": "Fin",
|
||||
"toast": {
|
||||
"error": "No se pudo iniciar la reproducción de depuración: {{error}}",
|
||||
"alreadyActive": "Ya hay una sesión de reproducción activa",
|
||||
"stopError": "No se pudo detener la reproducción de depuración: {{error}}",
|
||||
"goToReplay": "Ir a la reproducción"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "No hay ninguna sesión activa de reproducción de depuración",
|
||||
"noSessionDesc": "Inicia una reproducción de depuración desde la vista Historial haciendo clic en el botón Acciones de la barra de herramientas y seleccionando Reproducción de depuración.",
|
||||
"goToRecordings": "Ir al historial",
|
||||
"preparingClip": "Preparando clip…",
|
||||
"preparingClipDesc": "Frigate está uniendo las grabaciones del intervalo de tiempo seleccionado. Esto puede tardar un minuto en intervalos más largos.",
|
||||
"startingCamera": "Iniciando reproducción de depuración…",
|
||||
"startError": {
|
||||
"title": "No se pudo iniciar la reproducción de depuración",
|
||||
"back": "Volver al historial"
|
||||
},
|
||||
"sourceCamera": "Cámara de origen",
|
||||
"replayCamera": "Cámara de reproducción",
|
||||
"initializingReplay": "Inicializando reproducción de depuración…",
|
||||
"stoppingReplay": "Deteniendo repetición de depuración...",
|
||||
"stopReplay": "Detener repetición",
|
||||
"confirmStop": {
|
||||
"title": "¿Detener repetición de depuración?",
|
||||
"description": "Esto detendrá la sesión y eliminará todos los datos temporales. ¿Estás seguro?",
|
||||
"confirm": "Detener repetición",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"activity": "Actividad",
|
||||
"objects": "Lista de objetos",
|
||||
"audioDetections": "Detecciones de audio",
|
||||
"noActivity": "No se detectó actividad",
|
||||
"activeTracking": "Seguimiento activo",
|
||||
"noActiveTracking": "No hay seguimiento activo",
|
||||
"configuration": "Configuración",
|
||||
"configurationDesc": "Ajusta con precisión la detección de movimiento y los ajustes de seguimiento de objetos para la cámara de repetición de depuración. No se guardará ningún cambio en el archivo de configuración de Frigate."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"globalConfig": "Configuración Global - Frigate",
|
||||
"cameraConfig": "Configuración de Cámara - Frigate",
|
||||
"maintenance": "Mantenimiento - Frigate",
|
||||
"profiles": "Perfiles - Frigate"
|
||||
"profiles": "Perfiles - Frigate",
|
||||
"detectorsAndModel": "Detectores y modelo - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"cameras": "Configuración de Cámara",
|
||||
@@ -42,7 +43,7 @@
|
||||
"globalDetect": "Detección de Objetos",
|
||||
"globalRecording": "Grabación",
|
||||
"globalSnapshots": "Instantáneas",
|
||||
"globalFfmpeg": "FFmpeg",
|
||||
"globalFfmpeg": "arguments,Introduce",
|
||||
"globalMotion": "Detección de Movimiento",
|
||||
"globalObjects": "Objetos",
|
||||
"globalReview": "Revisión",
|
||||
@@ -50,7 +51,49 @@
|
||||
"globalLivePlayback": "Reproducción en Vivo",
|
||||
"globalTimestampStyle": "Estilo de Marca de Tiempo",
|
||||
"systemDatabase": "Base de Datos",
|
||||
"systemAuthentication": "Autenticación"
|
||||
"systemAuthentication": "Autenticación",
|
||||
"systemTls": "TLS",
|
||||
"systemNetworking": "Red",
|
||||
"systemProxy": "Proxy",
|
||||
"systemUi": "Interfaz",
|
||||
"systemLogging": "Registro",
|
||||
"systemEnvironmentVariables": "Variables de entorno",
|
||||
"systemTelemetry": "Telemetría",
|
||||
"systemBirdseye": "Birdseye",
|
||||
"systemFfmpeg": "FFmpeg",
|
||||
"systemDetectorHardware": "Hardware del detector",
|
||||
"systemDetectionModel": "Modelo de detección",
|
||||
"systemMqtt": "MQTT",
|
||||
"systemGo2rtcStreams": "Flujos go2rtc",
|
||||
"integrationSemanticSearch": "Búsqueda semántica",
|
||||
"integrationGenerativeAi": "IA generativa",
|
||||
"integrationFaceRecognition": "Reconocimiento facial",
|
||||
"integrationLpr": "Reconocimiento de matrículas",
|
||||
"integrationObjectClassification": "Clasificación de objetos",
|
||||
"integrationAudioTranscription": "Transcripción de audio",
|
||||
"cameraDetect": "Detección de objetos",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Grabación",
|
||||
"cameraSnapshots": "Instantáneas",
|
||||
"cameraMotion": "Detección de movimiento",
|
||||
"cameraObjects": "Objetos",
|
||||
"cameraConfigReview": "Revisión",
|
||||
"cameraAudioEvents": "Detección de audio",
|
||||
"cameraAudioTranscription": "Transcripción de audio",
|
||||
"cameraNotifications": "Notificaciones",
|
||||
"cameraLivePlayback": "Reproducción en directo",
|
||||
"cameraBirdseye": "Birdseye",
|
||||
"cameraFaceRecognition": "Reconocimiento facial",
|
||||
"cameraLpr": "Reconocimiento de matrículas",
|
||||
"cameraMqttConfig": "MQTT",
|
||||
"cameraOnvif": "ONVIF",
|
||||
"cameraUi": "Interfaz de cámara",
|
||||
"cameraTimestampStyle": "Estilo de marca de tiempo",
|
||||
"cameraMqtt": "MQTT de cámara",
|
||||
"maintenance": "Mantenimiento",
|
||||
"mediaSync": "Sincronización de medios",
|
||||
"regionGrid": "Cuadrícula de regiones",
|
||||
"systemDetectorsAndModel": "Detectores y modelo"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@@ -59,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"cameraSetting": {
|
||||
"camera": "Cámara",
|
||||
"camera": "Overrides,Sobrescrituras",
|
||||
"noCamera": "Sin cámara"
|
||||
},
|
||||
"general": {
|
||||
@@ -303,6 +346,10 @@
|
||||
"zone": "zona",
|
||||
"motion_mask": "máscara de movimiento",
|
||||
"object_mask": "máscara de objeto"
|
||||
},
|
||||
"revertOverride": {
|
||||
"title": "Revertir a la configuración base",
|
||||
"desc": "Esto eliminará la sobrescritura del perfil para {{type}} <em>{{name}}</em> y revertirá a la configuración base."
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -314,6 +361,12 @@
|
||||
"error": {
|
||||
"mustNotBeEmpty": "El nombre no puede estar vacío."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"error": {
|
||||
"mustNotBeEmpty": "El ID no puede estar vacío.",
|
||||
"alreadyExists": "Ya existe una máscara con este ID para esta cámara."
|
||||
}
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
@@ -370,7 +423,8 @@
|
||||
"success": "La zona ({{zoneName}}) ha sido guardada."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Indica si esta zona está activa y habilitada en la configuración. Si está deshabilitado, no puede ser habilitado por MQTT. Las zonas deshabilitadas se ignoran durante la ejecución."
|
||||
"description": "Indica si esta zona está activa y habilitada en la configuración. Si está deshabilitado, no puede ser habilitado por MQTT. Las zonas deshabilitadas se ignoran durante la ejecución.",
|
||||
"title": "Habilitado"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -411,7 +465,13 @@
|
||||
"documentTitle": "Editar Máscara de Movimiento - Frigate",
|
||||
"point_one": "{{count}} punto",
|
||||
"point_many": "{{count}} puntos",
|
||||
"point_other": "{{count}} puntos"
|
||||
"point_other": "{{count}} puntos",
|
||||
"defaultName": "Máscara de movimiento {{number}}",
|
||||
"name": {
|
||||
"title": "Nombre",
|
||||
"description": "Un nombre descriptivo opcional para esta máscara de movimiento.",
|
||||
"placeholder": "Introduce un nombre..."
|
||||
}
|
||||
},
|
||||
"objectMasks": {
|
||||
"label": "Máscaras de Objetos",
|
||||
@@ -437,11 +497,26 @@
|
||||
"point_one": "{{count}} punto",
|
||||
"point_many": "{{count}} puntos",
|
||||
"point_other": "{{count}} puntos",
|
||||
"clickDrawPolygon": "Haz clic para dibujar un polígono en la imagen."
|
||||
"clickDrawPolygon": "Haz clic para dibujar un polígono en la imagen.",
|
||||
"name": {
|
||||
"title": "Nombre",
|
||||
"description": "Un nombre descriptivo opcional para esta máscara de objeto.",
|
||||
"placeholder": "Introduce un nombre..."
|
||||
}
|
||||
},
|
||||
"restart_required": "Es necesario reiniciar (se han cambiado las máscaras/zonas)",
|
||||
"motionMaskLabel": "Máscara de movimiento {{number}}",
|
||||
"objectMaskLabel": "Máscara de objeto {{number}}"
|
||||
"objectMaskLabel": "Máscara de objeto {{number}}",
|
||||
"disabledInConfig": "El elemento está deshabilitado en el archivo de configuración",
|
||||
"addDisabledProfile": "Añádelo primero a la configuración base y luego sobrescríbelo en el perfil",
|
||||
"profileBase": "(base)",
|
||||
"profileOverride": "(sobrescritura)",
|
||||
"masks": {
|
||||
"enabled": {
|
||||
"title": "Habilitado",
|
||||
"description": "Indica si esta máscara está habilitada en el archivo de configuración. Si está deshabilitada, no se puede habilitar mediante MQTT. Las máscaras deshabilitadas se ignoran en tiempo de ejecución."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionDetectionTuner": {
|
||||
"title": "Sintonizador de Detección de Movimiento",
|
||||
@@ -714,7 +789,7 @@
|
||||
"snapshots": "Instantáneas",
|
||||
"cleanCopySnapshots": "<code>clean_copy</code> Instantáneas"
|
||||
},
|
||||
"desc": "Enviar a Frigate+ requiere que tanto las capturas instantáneas como las capturas <code>clean_copy</code> estén habilitadas en tu configuración.",
|
||||
"desc": "Enviar a Frigate+ requiere que las instantáneas estén habilitadas en tu configuración.",
|
||||
"cleanCopyWarning": "Algunas cámaras tienen las instantáneas deshabilitadas"
|
||||
},
|
||||
"modelInfo": {
|
||||
@@ -726,13 +801,21 @@
|
||||
"cameras": "Cámaras",
|
||||
"loading": "Cargando información del modelo…",
|
||||
"error": "No se pudo cargar la información del modelo",
|
||||
"availableModels": "Modelos disponibles",
|
||||
"availableModels": "Modelos de Frigate+ disponibles",
|
||||
"loadingAvailableModels": "Cargando modelos disponibles…",
|
||||
"modelSelect": "Tus modelos disponibles en Frigate+ se pueden seleccionar aquí. Ten en cuenta que solo se pueden seleccionar modelos compatibles con tu configuración actual de detectores.",
|
||||
"trainDate": "Fecha de entrenamiento",
|
||||
"plusModelType": {
|
||||
"baseModel": "Modelo Base",
|
||||
"userModel": "Ajustado Finamente"
|
||||
},
|
||||
"noModelLoaded": "Actualmente no hay ningún modelo de Frigate+ cargado.",
|
||||
"selectModel": "Selecciona un modelo",
|
||||
"noModelsAvailable": "No hay modelos disponibles",
|
||||
"filter": {
|
||||
"ariaLabel": "Filtrar modelos por tipo",
|
||||
"baseModels": "Modelos base",
|
||||
"fineTunedModels": "Modelos ajustados"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -741,7 +824,14 @@
|
||||
},
|
||||
"restart_required": "Es necesario reiniciar (se ha cambiado el modelo Frigate+)",
|
||||
"unsavedChanges": "Cambios en la configuración de Frigate+ no guardados",
|
||||
"description": "Frigate+ es un servicio de suscripción que proporciona acceso a funciones y capacidades adicionales para su instancia de Frigate, incluida la posibilidad de utilizar modelos de detección de objetos personalizados entrenados con sus propios datos. Puede gestionar la configuración de sus modelos de Frigate+ aquí."
|
||||
"description": "Frigate+ es un servicio de suscripción que proporciona acceso a funciones y capacidades adicionales para su instancia de Frigate, incluida la posibilidad de utilizar modelos de detección de objetos personalizados entrenados con sus propios datos. Puede gestionar la configuración de sus modelos de Frigate+ aquí.",
|
||||
"cardTitles": {
|
||||
"api": "API",
|
||||
"currentModel": "Modelo actual",
|
||||
"otherModels": "Otros modelos",
|
||||
"configuration": "Configuración"
|
||||
},
|
||||
"changeInDetectorsAndModel": "Cambiar modelo"
|
||||
},
|
||||
"enrichments": {
|
||||
"title": "Configuración de Enriquecimientos",
|
||||
@@ -767,11 +857,11 @@
|
||||
"modelSize": {
|
||||
"label": "Tamaño del Modelo",
|
||||
"small": {
|
||||
"title": "pequeño",
|
||||
"title": "size",
|
||||
"desc": "Usar la opción <em>small</em> emplea una versión cuantizada del modelo que consume menos memoria RAM y se ejecuta más rápido en la CPU, con una diferencia muy pequeña o casi imperceptible en la calidad de las representaciones (embeddings)."
|
||||
},
|
||||
"large": {
|
||||
"title": "grande",
|
||||
"title": "model",
|
||||
"desc": "Usar la opción <em>large</em> emplea el modelo completo de Jina y se ejecutará automáticamente en la GPU, si está disponible."
|
||||
},
|
||||
"desc": "Tamaño del modelo usado para la búsqueda semántica."
|
||||
@@ -1157,7 +1247,8 @@
|
||||
},
|
||||
"hikvision": {
|
||||
"substreamWarning": "La subtransmisión 1 está limitada a una resolución baja. Muchas cámaras Hikvision admiten subtransmisiones adicionales que deben habilitarse en la configuración de la cámara. Se recomienda comprobar y utilizar dichas transmisiones si están disponibles."
|
||||
}
|
||||
},
|
||||
"resolutionUnknown": "No se pudo detectar la resolución de este flujo. Debes establecer manualmente la resolución de detección en Ajustes o en tu configuración."
|
||||
}
|
||||
},
|
||||
"title": "Añadir cámara",
|
||||
@@ -1192,7 +1283,20 @@
|
||||
"streams": {
|
||||
"title": "Habilitar/deshabilitar cámaras",
|
||||
"desc": "Desactiva temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de Frigate. La detección, la grabación y la depuración no estarán disponibles.<br /> <em>Nota: Esto no desactiva las retransmisiones de go2rtc.</em>",
|
||||
"enableDesc": "Deshabilita temporalmente una cámara habilitada hasta que Frigate se reinicie. Deshabilitar una cámara detiene por completo el procesamiento de las transmisiones de esa cámara por parte de Frigate. La detección, la grabación y la depuración no estarán disponibles.<br /> <em>Nota: Esto no deshabilita las retransmisiones de go2rtc.</em>"
|
||||
"enableDesc": "Deshabilita temporalmente una cámara habilitada hasta que Frigate se reinicie. Deshabilitar una cámara detiene completamente el procesamiento de los flujos de esa cámara por parte de Frigate. La detección, la grabación y la depuración no estarán disponibles. Nota: Esto no deshabilita las retransmisiones de go2rtc.Arrastra el controlador para reordenar las cámaras tal y como aparecen en la interfaz. El orden de las cámaras habilitadas se reflejará en toda la interfaz, incluido el panel en directo y los menús desplegables de selección de cámaras.",
|
||||
"enableLabel": "Cámaras habilitadas",
|
||||
"disableLabel": "Cámaras deshabilitadas",
|
||||
"disableDesc": "Habilita una cámara que actualmente no está visible en la interfaz y está deshabilitada en la configuración. Es necesario reiniciar Frigate después de habilitarla.",
|
||||
"enableSuccess": "{{cameraName}} se ha habilitado en la configuración. Reinicia Frigate para aplicar los cambios.",
|
||||
"friendlyName": {
|
||||
"edit": "Editar nombre visible de la cámara",
|
||||
"title": "Editar nombre visible",
|
||||
"description": "Establece el nombre descriptivo que se mostrará para esta cámara en toda la interfaz de Frigate. Déjalo en blanco para usar el ID de la cámara.",
|
||||
"rename": "Renombrar"
|
||||
},
|
||||
"reorderHandle": "Arrastrar para reordenar",
|
||||
"saving": "Guardando…",
|
||||
"saved": "Guardado"
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Añadir cámara",
|
||||
@@ -1224,8 +1328,34 @@
|
||||
}
|
||||
},
|
||||
"deleteCameraDialog": {
|
||||
"description": "Eliminar una cámara borrará permanentemente todas las grabaciones, los objetos rastreados y la configuración de esa cámara. Es posible que sea necesario eliminar manualmente cualquier transmisión go2rtc asociada a esta cámara."
|
||||
}
|
||||
"description": "Eliminar una cámara borrará permanentemente todas las grabaciones, los objetos rastreados y la configuración de esa cámara. Es posible que sea necesario eliminar manualmente cualquier transmisión go2rtc asociada a esta cámara.",
|
||||
"title": "Eliminar cámara",
|
||||
"selectPlaceholder": "Elegir cámara...",
|
||||
"confirmTitle": "¿Estás seguro?",
|
||||
"confirmWarning": "Eliminar <strong>{{cameraName}}</strong> no se puede deshacer.",
|
||||
"deleteExports": "Eliminar también las exportaciones de esta cámara",
|
||||
"confirmButton": "Eliminar permanentemente",
|
||||
"success": "La cámara {{cameraName}} se ha eliminado correctamente",
|
||||
"error": "No se pudo eliminar la cámara {{cameraName}}"
|
||||
},
|
||||
"deleteCamera": "Eliminar cámara",
|
||||
"profiles": {
|
||||
"title": "Sobrescrituras de cámaras del perfil",
|
||||
"selectLabel": "Seleccionar perfil",
|
||||
"description": "Configura qué cámaras se habilitan o deshabilitan cuando se activa un perfil. Las cámaras configuradas como \"Heredar\" conservan su estado base habilitado.",
|
||||
"inherit": "Heredar",
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"cameraType": {
|
||||
"title": "Tipo de cámara",
|
||||
"label": "Tipo de cámara",
|
||||
"description": "Establece el tipo de cada cámara. Las cámaras LPR dedicadas son cámaras de un solo propósito con un zoom óptico potente para capturar matrículas de vehículos lejanos. La mayoría de cámaras deberían usar el tipo de cámara normal salvo que la cámara esté específicamente destinada a LPR y tenga una vista muy enfocada a matrículas.",
|
||||
"normal": "Normal",
|
||||
"dedicatedLpr": "LPR dedicada",
|
||||
"saveSuccess": "Se ha actualizado el tipo de cámara de {{cameraName}}. Reinicia Frigate para aplicar los cambios."
|
||||
},
|
||||
"description": "Añade, edita y elimina cámaras, controla qué cámaras están habilitadas y configura sobrescrituras por perfil y tipo de cámara. Para configurar flujos, detección, movimiento y otros ajustes específicos de cámara, selecciona la sección correspondiente dentro de Configuración de cámara."
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "Configuración de revisión de la cámara",
|
||||
@@ -1268,34 +1398,295 @@
|
||||
"overriddenGlobal": "Sobrescrito (Global)",
|
||||
"overriddenBaseConfigTooltip": "El perfil {{profile}} sobrescribe los ajustes de configuración de esta sección",
|
||||
"overriddenGlobalTooltip": "Esta cámara sobrescribe los ajustes de configuración global en esta sección",
|
||||
"overriddenBaseConfig": "Sobrescrito (Configuración Base)"
|
||||
"overriddenBaseConfig": "Sobrescrito (Configuración Base)",
|
||||
"overriddenInCameras": {
|
||||
"label_one": "Sobrescrito en {{count}} cámara",
|
||||
"label_many": "Sobrescrito en {{count}} cámaras",
|
||||
"label_other": "Sobrescrito en {{count}} cámaras",
|
||||
"tooltip_one": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
|
||||
"tooltip_many": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
|
||||
"tooltip_other": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
|
||||
"heading_one": "This global section has fields that are overridden in {{count}} camera.",
|
||||
"heading_many": "Esta sección global tiene campos que están sobrescritos en {{count}} cámaras.",
|
||||
"heading_other": "Esta sección global tiene campos que están sobrescritos en {{count}} cámaras.",
|
||||
"othersField_one": "{{count}} más",
|
||||
"othersField_many": "{{count}} más",
|
||||
"othersField_other": "{{count}} más",
|
||||
"profilePrefix": "Perfil {{profile}}: {{fields}}"
|
||||
},
|
||||
"overriddenGlobalHeading_one": "Esta cámara sobrescribe {{count}} campo de la configuración global:",
|
||||
"overriddenGlobalHeading_many": "Esta cámara sobrescribe {{count}} campos de la configuración global:",
|
||||
"overriddenGlobalHeading_other": "Esta cámara sobrescribe {{count}} campos de la configuración global:",
|
||||
"overriddenGlobalNoDeltas": "Esta cámara sobrescribe la configuración global, pero no hay diferencias en los valores de los campos.",
|
||||
"overriddenBaseConfigHeading_one": "El perfil {{profile}} sobrescribe {{count}} campo de la configuración base:",
|
||||
"overriddenBaseConfigHeading_many": "El perfil {{profile}} sobrescribe {{count}} campos de la configuración base:",
|
||||
"overriddenBaseConfigHeading_other": "El perfil {{profile}} sobrescribe {{count}} campos de la configuración base:",
|
||||
"overriddenBaseConfigNoDeltas": "El perfil {{profile}} sobrescribe esta sección, pero no hay diferencias en los valores de los campos respecto a la configuración base."
|
||||
},
|
||||
"onvif": {
|
||||
"profileLoading": "Cargando perfiles..."
|
||||
"profileLoading": "Cargando perfiles...",
|
||||
"profileAuto": "Auto",
|
||||
"autotracking": {
|
||||
"zooming": {
|
||||
"disabled": "Deshabilitado",
|
||||
"absolute": "Absoluto",
|
||||
"relative": "Relativo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"sync": {
|
||||
"verboseDesc": "Escribe una lista completa de archivos huérfanos en el disco para su revisión.",
|
||||
"verbose": "Detallado",
|
||||
"desc": "Frigate limpiará periódicamente los archivos multimedia según un cronograma regular, de acuerdo con su configuración de retención. Es normal ver algunos archivos huérfanos mientras Frigate se ejecuta. Utilice esta función para eliminar del disco los archivos multimedia huérfanos que ya no se referencian en la base de datos.",
|
||||
"forceDesc": "Omitir el umbral de seguridad y completar la sincronización incluso si se eliminara más del 50% de los archivos."
|
||||
"forceDesc": "Omitir el umbral de seguridad y completar la sincronización incluso si se eliminara más del 50% de los archivos.",
|
||||
"title": "Sincronización de medios",
|
||||
"started": "Sincronización de medios iniciada.",
|
||||
"alreadyRunning": "Ya hay una tarea de sincronización en ejecución",
|
||||
"error": "No se pudo iniciar la sincronización",
|
||||
"currentStatus": "Estado",
|
||||
"jobId": "ID de tarea",
|
||||
"startTime": "Hora de inicio",
|
||||
"endTime": "Hora de finalización",
|
||||
"statusLabel": "Estado",
|
||||
"results": "Resultados",
|
||||
"errorLabel": "Error",
|
||||
"mediaTypes": "Tipos de medios",
|
||||
"allMedia": "Todos los medios",
|
||||
"dryRun": "Simulación",
|
||||
"dryRunEnabled": "No se eliminará ningún archivo",
|
||||
"dryRunDisabled": "Se eliminarán archivos",
|
||||
"force": "Forzar",
|
||||
"running": "Sincronización en curso...",
|
||||
"start": "Iniciar sincronización",
|
||||
"inProgress": "La sincronización está en curso. Esta página está deshabilitada.",
|
||||
"status": {
|
||||
"queued": "En cola",
|
||||
"running": "En ejecución",
|
||||
"completed": "Completado",
|
||||
"failed": "Fallido",
|
||||
"notRunning": "No está en ejecución"
|
||||
},
|
||||
"resultsFields": {
|
||||
"filesChecked": "Archivos comprobados",
|
||||
"orphansFound": "Huérfanos encontrados",
|
||||
"orphansDeleted": "Huérfanos eliminados",
|
||||
"aborted": "Abortado. La eliminación superaría el umbral de seguridad.",
|
||||
"error": "Error",
|
||||
"totals": "Totales"
|
||||
},
|
||||
"event_snapshots": "Instantáneas de objetos rastreados",
|
||||
"event_thumbnails": "Miniaturas de objetos rastreados",
|
||||
"review_thumbnails": "Miniaturas de revisión",
|
||||
"previews": "Vistas previas",
|
||||
"exports": "Exportaciones",
|
||||
"recordings": "Grabaciones"
|
||||
},
|
||||
"regionGrid": {
|
||||
"clearConfirmDesc": "No se recomienda borrar la cuadrícula de la región a menos que haya cambiado recientemente el tamaño del modelo de su detector o la posición física de su cámara y esté experimentando problemas de seguimiento de objetos. La cuadrícula se reconstruirá automáticamente con el tiempo a medida que se realice el seguimiento de los objetos. Es necesario reiniciar Frigate para que los cambios surtan efecto.",
|
||||
"desc": "La cuadrícula de regiones es una optimización que aprende dónde suelen aparecer los objetos de diferentes tamaños en el campo de visión de cada cámara. Frigate utiliza estos datos para dimensionar de forma eficiente las regiones de detección. La cuadrícula se construye automáticamente a lo largo del tiempo a partir de los datos de los objetos rastreados."
|
||||
}
|
||||
"desc": "La cuadrícula de regiones es una optimización que aprende dónde suelen aparecer los objetos de diferentes tamaños en el campo de visión de cada cámara. Frigate utiliza estos datos para dimensionar de forma eficiente las regiones de detección. La cuadrícula se construye automáticamente a lo largo del tiempo a partir de los datos de los objetos rastreados.",
|
||||
"title": "Cuadrícula de regiones",
|
||||
"clear": "Borrar cuadrícula de regiones",
|
||||
"clearConfirmTitle": "Borrar cuadrícula de regiones",
|
||||
"clearSuccess": "Cuadrícula de regiones borrada correctamente",
|
||||
"clearError": "No se pudo borrar la cuadrícula de regiones",
|
||||
"restartRequired": "Es necesario reiniciar para que los cambios de la cuadrícula de regiones surtan efecto"
|
||||
},
|
||||
"title": "Mantenimiento"
|
||||
},
|
||||
"configForm": {
|
||||
"camera": {
|
||||
"noCameras": "No hay cámaras disponibles",
|
||||
"description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales."
|
||||
"description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales.",
|
||||
"title": "Ajustes de cámara"
|
||||
},
|
||||
"genaiModel": {
|
||||
"noModels": "No hay modelos disponibles"
|
||||
"noModels": "No hay modelos disponibles",
|
||||
"placeholder": "Seleccionar modelo…",
|
||||
"search": "Buscar modelos…"
|
||||
},
|
||||
"global": {
|
||||
"description": "Estos ajustes se aplican a todas las cámaras, a menos que se anulen en los ajustes específicos de cada cámara."
|
||||
}
|
||||
"description": "Estos ajustes se aplican a todas las cámaras, a menos que se anulen en los ajustes específicos de cada cámara.",
|
||||
"title": "Ajustes globales"
|
||||
},
|
||||
"sections": {
|
||||
"go2rtc": "streams",
|
||||
"detect": "Detección",
|
||||
"record": "Grabación",
|
||||
"snapshots": "Instantáneas",
|
||||
"motion": "Movimiento",
|
||||
"objects": "Objetos",
|
||||
"review": "Revisión",
|
||||
"audio": "Audio",
|
||||
"notifications": "Notificaciones",
|
||||
"live": "Vista en directo",
|
||||
"timestamp_style": "Marcas de tiempo",
|
||||
"mqtt": "MQTT",
|
||||
"database": "Base de datos",
|
||||
"telemetry": "Telemetría",
|
||||
"auth": "Autenticación",
|
||||
"tls": "TLS",
|
||||
"proxy": "Proxy",
|
||||
"ffmpeg": "FFmpeg",
|
||||
"detectors": "Detectores",
|
||||
"model": "Modelo",
|
||||
"semantic_search": "Búsqueda semántica",
|
||||
"genai": "GenAI",
|
||||
"face_recognition": "Reconocimiento facial",
|
||||
"lpr": "Reconocimiento de matrículas",
|
||||
"birdseye": "Birdseye",
|
||||
"masksAndZones": "Máscaras / zonas"
|
||||
},
|
||||
"advancedSettingsCount": "Ajustes avanzados ({{count}})",
|
||||
"advancedCount": "Avanzado ({{count}})",
|
||||
"showAdvanced": "Mostrar ajustes avanzados",
|
||||
"tabs": {
|
||||
"sharedDefaults": "Valores predeterminados compartidos",
|
||||
"system": "Sistema",
|
||||
"integrations": "Integraciones"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"keyLabel": "Clave",
|
||||
"valueLabel": "Valor",
|
||||
"keyPlaceholder": "Nueva clave",
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "p. ej., Coche de mi mujer",
|
||||
"platePlaceholder": "Número de matrícula o regex"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Usar zona horaria del navegador"
|
||||
},
|
||||
"roleMap": {
|
||||
"empty": "No hay asignaciones de roles",
|
||||
"roleLabel": "Rol",
|
||||
"groupsLabel": "Grupos",
|
||||
"addMapping": "Añadir asignación de rol",
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"ffmpegArgs": {
|
||||
"preset": "Preajuste",
|
||||
"manual": "Argumentos manuales",
|
||||
"inherit": "Heredar del ajuste de cámara",
|
||||
"none": "Ninguno",
|
||||
"useGlobalSetting": "Heredar del ajuste global",
|
||||
"selectPreset": "Seleccionar preajuste",
|
||||
"manualPlaceholder": "Introduce argumentos de FFmpeg",
|
||||
"presetLabels": {
|
||||
"preset-rpi-64-h264": "Raspberry Pi (H.264)",
|
||||
"preset-rpi-64-h265": "Raspberry Pi (H.265)",
|
||||
"preset-vaapi": "VAAPI (GPU Intel/AMD)",
|
||||
"preset-intel-qsv-h264": "Intel QuickSync (H.264)",
|
||||
"preset-intel-qsv-h265": "Intel QuickSync (H.265)",
|
||||
"preset-nvidia": "GPU NVIDIA",
|
||||
"preset-jetson-h264": "NVIDIA Jetson (H.264)",
|
||||
"preset-jetson-h265": "NVIDIA Jetson (H.265)",
|
||||
"preset-rkmpp": "Rockchip RKMPP",
|
||||
"preset-http-jpeg-generic": "HTTP JPEG (genérico)",
|
||||
"preset-http-mjpeg-generic": "HTTP MJPEG (genérico)",
|
||||
"preset-http-reolink": "HTTP - Cámaras Reolink",
|
||||
"preset-rtmp-generic": "RTMP (genérico)",
|
||||
"preset-rtsp-generic": "RTSP (genérico)",
|
||||
"preset-rtsp-restream": "RTSP - Retransmisión desde go2rtc",
|
||||
"preset-rtsp-restream-low-latency": "RTSP - Retransmisión desde go2rtc (baja latencia)",
|
||||
"preset-rtsp-udp": "RTSP - UDP",
|
||||
"preset-rtsp-blue-iris": "RTSP - Blue Iris",
|
||||
"preset-record-generic": "Grabación (genérica, sin audio)",
|
||||
"preset-record-generic-audio-copy": "Grabación (genérica + copiar audio)",
|
||||
"preset-record-generic-audio-aac": "Grabación (genérica + audio a AAC)",
|
||||
"preset-record-mjpeg": "Grabación - Cámaras MJPEG",
|
||||
"preset-record-jpeg": "Grabación - Cámaras JPEG",
|
||||
"preset-record-ubiquiti": "Grabación - Cámaras Ubiquiti"
|
||||
}
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Flujo {{index}}"
|
||||
},
|
||||
"restartRequiredField": "Reinicio necesario",
|
||||
"restartRequiredFooter": "Configuración modificada - reinicio necesario",
|
||||
"detect": {
|
||||
"title": "Ajustes de detección"
|
||||
},
|
||||
"detectors": {
|
||||
"title": "Ajustes de detector",
|
||||
"singleType": "Solo se permite un detector {{type}}.",
|
||||
"keyRequired": "El nombre del detector es obligatorio.",
|
||||
"keyDuplicate": "El nombre del detector ya existe.",
|
||||
"noSchema": "No hay esquemas de detector disponibles.",
|
||||
"none": "No hay instancias de detector configuradas.",
|
||||
"add": "Añadir detector",
|
||||
"addCustomKey": "Añadir clave personalizada"
|
||||
},
|
||||
"record": {
|
||||
"title": "Ajustes de grabación"
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Ajustes de instantáneas"
|
||||
},
|
||||
"motion": {
|
||||
"title": "Ajustes de movimiento"
|
||||
},
|
||||
"objects": {
|
||||
"title": "Ajustes de objetos"
|
||||
},
|
||||
"audioLabels": {
|
||||
"summary": "{{count}} etiquetas de audio seleccionadas",
|
||||
"empty": "No hay etiquetas de audio disponibles"
|
||||
},
|
||||
"objectLabels": {
|
||||
"summary": "{{count}} tipos de objeto seleccionados",
|
||||
"empty": "No hay etiquetas de objeto disponibles"
|
||||
},
|
||||
"reviewLabels": {
|
||||
"summary": "{{count}} etiquetas seleccionadas",
|
||||
"empty": "No hay etiquetas disponibles"
|
||||
},
|
||||
"filters": {
|
||||
"objectFieldLabel": "{{field}} para {{label}}"
|
||||
},
|
||||
"zoneNames": {
|
||||
"summary": "{{count}} seleccionados",
|
||||
"empty": "No hay zonas disponibles"
|
||||
},
|
||||
"inputRoles": {
|
||||
"summary": "{{count}} roles seleccionados",
|
||||
"empty": "No hay roles disponibles",
|
||||
"options": {
|
||||
"detect": "Detectar",
|
||||
"record": "Grabar",
|
||||
"audio": "Audio"
|
||||
}
|
||||
},
|
||||
"genaiRoles": {
|
||||
"options": {
|
||||
"embeddings": "Embedding",
|
||||
"descriptions": "Descripciones",
|
||||
"chat": "Chat"
|
||||
}
|
||||
},
|
||||
"semanticSearchModel": {
|
||||
"placeholder": "Seleccionar modelo…",
|
||||
"builtIn": "Modelos integrados",
|
||||
"genaiProviders": "Proveedores de GenAI"
|
||||
},
|
||||
"review": {
|
||||
"title": "Ajustes de revisión"
|
||||
},
|
||||
"audio": {
|
||||
"title": "Ajustes de audio"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Ajustes de notificaciones"
|
||||
},
|
||||
"live": {
|
||||
"title": "Ajustes de vista en directo"
|
||||
},
|
||||
"timestamp_style": {
|
||||
"title": "Ajustes de marcas de tiempo"
|
||||
},
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"addCustomLabel": "Añadir etiqueta personalizada..."
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Configuración global",
|
||||
@@ -1330,7 +1721,10 @@
|
||||
"saveAllPartial_one": "Se ha guardado {{successCount}} de {{totalCount}} sección. {{failCount}} ha fallado.",
|
||||
"saveAllPartial_many": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.",
|
||||
"saveAllPartial_other": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.",
|
||||
"saveAllFailure": "Error al guardar todas las secciones."
|
||||
"saveAllFailure": "Error al guardar todas las secciones.",
|
||||
"saveAllSuccessRestartRequired_one": "La sección {{count}} se ha guardado correctamente. Reinicia Frigate para aplicar los cambios.",
|
||||
"saveAllSuccessRestartRequired_many": "Las {{count}} secciones se han guardado correctamente. Reinicia Frigate para aplicar los cambios.",
|
||||
"saveAllSuccessRestartRequired_other": "Las {{count}} secciones se han guardado correctamente. Reinicia Frigate para aplicar los cambios."
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfiles",
|
||||
@@ -1364,26 +1758,84 @@
|
||||
"renameProfile": "Renombrar perfil",
|
||||
"renameSuccess": "Perfil renombrado a '{{profile}}'",
|
||||
"enabledDescription": "Los perfiles están habilitados. Cree un nuevo perfil a continuación, navegue a una sección de configuración de cámara para realizar sus cambios y guarde para que estos surtan efecto.",
|
||||
"disabledDescription": "Los perfiles le permiten definir conjuntos con nombre de anulaciones de configuración de la cámara (por ejemplo: armado, fuera, noche) que pueden activarse bajo demanda."
|
||||
"disabledDescription": "Los perfiles le permiten definir conjuntos con nombre de anulaciones de configuración de la cámara (por ejemplo: armado, fuera, noche) que pueden activarse bajo demanda.",
|
||||
"deleteProfile": "Eliminar perfil",
|
||||
"deleteProfileConfirm": "¿Eliminar el perfil \"{{profile}}\" de todas las cámaras? Esta acción no se puede deshacer.",
|
||||
"deleteSuccess": "Perfil '{{profile}}' eliminado",
|
||||
"createSuccess": "Perfil '{{profile}}' creado",
|
||||
"removeOverride": "Eliminar sobrescritura de perfil",
|
||||
"deleteSection": "Eliminar sobrescrituras de sección",
|
||||
"deleteSectionConfirm": "¿Eliminar las sobrescrituras de {{section}} del perfil {{profile}} en {{camera}}?",
|
||||
"deleteSectionSuccess": "Sobrescrituras de {{section}} eliminadas para {{profile}}",
|
||||
"enableSwitch": "Habilitar perfiles"
|
||||
},
|
||||
"go2rtcStreams": {
|
||||
"renameStreamDesc": "Introduce un nuevo nombre para esta transmisión. Cambiar el nombre de una transmisión puede provocar fallos en las cámaras u otras transmisiones que hagan referencia a ella por su nombre.",
|
||||
"addStreamDesc": "Introduce un nombre para la nueva transmisión. Este nombre se utilizará para hacer referencia a la transmisión en la configuración de su cámara.",
|
||||
"description": "Gestione las configuraciones de transmisión de go2rtc para la retransmisión de cámaras. Cada transmisión tiene un nombre y una o más URL de origen.",
|
||||
"deleteStreamConfirm": "¿Está seguro de que desea eliminar la transmisión \"{{streamName}}\"? Las cámaras que hagan referencia a esta transmisión podrían dejar de funcionar."
|
||||
"deleteStreamConfirm": "¿Está seguro de que desea eliminar la transmisión \"{{streamName}}\"? Las cámaras que hagan referencia a esta transmisión podrían dejar de funcionar.",
|
||||
"title": "Flujos go2rtc",
|
||||
"addStream": "Añadir flujo",
|
||||
"addUrl": "Añadir URL",
|
||||
"streamName": "Nombre del flujo",
|
||||
"streamNamePlaceholder": "p. ej., puerta_principal",
|
||||
"streamUrlPlaceholder": "p. ej., rtsp://usuario:contraseña@192.168.1.100/stream",
|
||||
"deleteStream": "Eliminar flujo",
|
||||
"noStreams": "No hay flujos go2rtc configurados. Añade un flujo para empezar.",
|
||||
"validation": {
|
||||
"nameRequired": "El nombre del flujo es obligatorio",
|
||||
"nameDuplicate": "Ya existe un flujo con este nombre",
|
||||
"nameInvalid": "El nombre del flujo solo puede contener letras, números, guiones bajos y guiones",
|
||||
"urlRequired": "Se requiere al menos una URL"
|
||||
},
|
||||
"renameStream": "Renombrar flujo",
|
||||
"newStreamName": "Nuevo nombre del flujo",
|
||||
"ffmpeg": {
|
||||
"useFfmpegModule": "Usar modo de compatibilidad (ffmpeg)",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
"hardware": "Aceleración por hardware",
|
||||
"videoCopy": "Copiar",
|
||||
"videoH264": "Transcodificar a H.264",
|
||||
"videoH265": "Transcodificar a H.265",
|
||||
"videoExclude": "Excluir",
|
||||
"audioCopy": "Copiar",
|
||||
"audioAac": "Transcodificar a AAC",
|
||||
"audioOpus": "Transcodificar a Opus",
|
||||
"audioPcmu": "Transcodificar a PCM μ-law",
|
||||
"audioPcma": "Transcodificar a PCM A-law",
|
||||
"audioPcm": "Transcodificar a PCM",
|
||||
"audioMp3": "Transcodificar a MP3",
|
||||
"audioExclude": "Excluir",
|
||||
"hardwareNone": "Sin aceleración por hardware",
|
||||
"hardwareAuto": "Automático (recomendado)",
|
||||
"hardwareVaapi": "VAAPI",
|
||||
"hardwareCuda": "CUDA",
|
||||
"hardwareV4l2m2m": "V4L2 M2M",
|
||||
"hardwareDxva2": "DXVA2",
|
||||
"hardwareVideotoolbox": "VideoToolbox",
|
||||
"addVideoCodec": "Añadir códec de vídeo",
|
||||
"addAudioCodec": "Añadir códec de audio",
|
||||
"removeCodec": "Eliminar códec"
|
||||
},
|
||||
"streamNumber": "Flujo {{index}}"
|
||||
},
|
||||
"configMessages": {
|
||||
"birdseye": {
|
||||
"objectsModeDetectDisabled": "Birdseye está configurado en modo 'objects', pero la detección de objetos está desactivada para esta cámara. La cámara no aparecerá en Birdseye."
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "El reconocimiento de matrículas no está habilitado a nivel global. Habilítelo en la configuración global para que funcione el reconocimiento de matrículas a nivel de cámara."
|
||||
"globalDisabled": "El reconocimiento de matrículas no está habilitado a nivel global. Habilítelo en la configuración global para que funcione el reconocimiento de matrículas a nivel de cámara.",
|
||||
"vehicleNotTracked": "El reconocimiento de matrículas requiere rastrear 'car' o 'motorcycle'. Habilita 'car' o 'motorcycle' en Objetos para esta cámara.",
|
||||
"modelSizeLarge": "El modelo 'large' está optimizado para matrículas de varias líneas. El modelo 'small' ofrece mejor rendimiento que 'large' y debería usarse salvo que tu región use formatos de matrícula de varias líneas."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "Ninguna transmisión tiene definido el rol de audio. Debe habilitar el rol de audio para que funcione la detección de audio."
|
||||
},
|
||||
"faceRecognition": {
|
||||
"personNotTracked": "El reconocimiento facial requiere que se realice el seguimiento del objeto 'person'. Asegúrese de que 'person' se encuentre en la lista de seguimiento de objetos."
|
||||
"personNotTracked": "El reconocimiento facial requiere que se realice el seguimiento del objeto 'person'. Asegúrese de que 'person' se encuentre en la lista de seguimiento de objetos.",
|
||||
"globalDisabled": "El enriquecimiento de reconocimiento facial debe estar habilitado para que las funciones de reconocimiento facial funcionen en esta cámara.",
|
||||
"modelSizeLarge": "El modelo 'large' requiere una GPU o NPU para ofrecer un rendimiento razonable. Usa 'small' en sistemas solo con CPU."
|
||||
},
|
||||
"audioTranscription": {
|
||||
"audioDetectionDisabled": "La detección de audio no está habilitada para esta cámara. La transcripción de audio requiere que la detección de audio esté activa."
|
||||
@@ -1392,17 +1844,165 @@
|
||||
"detectDisabled": "La detección de objetos está desactivada. Las instantáneas se generan a partir de los objetos rastreados y no se crearán."
|
||||
},
|
||||
"detectors": {
|
||||
"mixedTypes": "Todos los detectores deben ser del mismo tipo. Retire los detectores existentes para utilizar un tipo diferente."
|
||||
"mixedTypes": "Todos los detectores deben ser del mismo tipo. Retire los detectores existentes para utilizar un tipo diferente.",
|
||||
"mixedTypesSuggestion": "Todos los detectores deben usar el mismo tipo. Elimina los detectores existentes o selecciona {{type}}."
|
||||
},
|
||||
"review": {
|
||||
"detectDisabled": "La detección de objetos está desactivada. Los elementos de revisión requieren objetos detectados para categorizar las alertas y detecciones."
|
||||
"detectDisabled": "La detección de objetos está desactivada. Los elementos de revisión requieren objetos detectados para categorizar las alertas y detecciones.",
|
||||
"recordDisabled": "La grabación está deshabilitada; no se generarán elementos de revisión.",
|
||||
"allNonAlertDetections": "Toda la actividad que no sea de alerta se incluirá como detecciones.",
|
||||
"genaiImageSourceRecordingsRecordDisabled": "El origen de imagen está establecido en 'recordings', pero la grabación está deshabilitada. Frigate usará imágenes de vista previa como alternativa."
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "No se recomienda establecer los FPS de detección por encima de 5. Valores más altos pueden causar problemas de rendimiento y no aportarán ningún beneficio.",
|
||||
"disabled": "La detección de objetos está deshabilitada. Las instantáneas, los elementos de revisión y enriquecimientos como el reconocimiento facial, el reconocimiento de matrículas y la IA generativa no funcionarán."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "Debes configurar un proveedor GenAI con el rol 'descriptions' para que se generen descripciones."
|
||||
},
|
||||
"record": {
|
||||
"noRecordRole": "Ningún flujo tiene definido el rol de grabación. La grabación no funcionará."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "El tamaño 'small' con el modelo Jina V2 tiene un alto consumo de RAM y coste de inferencia. Se recomienda el modelo 'large' con una GPU dedicada."
|
||||
}
|
||||
},
|
||||
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",
|
||||
"resetToGlobalDescription": "Esto restablecerá la configuración de esta sección a los valores predeterminados globales. Esta acción no se puede deshacer.",
|
||||
"detectionModel": {
|
||||
"plusActive": {
|
||||
"description": "Esta instancia está ejecutando un modelo de Frigate+. Seleccione o cambie su modelo en la configuración de Frigate+."
|
||||
"description": "Esta instancia está ejecutando un modelo de Frigate+. Seleccione o cambie su modelo en la configuración de Frigate+.",
|
||||
"title": "Gestión de modelos de Frigate+",
|
||||
"label": "Origen del modelo actual",
|
||||
"goToFrigatePlus": "Ir a los ajustes de Frigate+",
|
||||
"showModelForm": "Configurar un modelo manualmente"
|
||||
}
|
||||
},
|
||||
"saveAllPreview": {
|
||||
"profile": {
|
||||
"label": "Override,Eliminar"
|
||||
},
|
||||
"title": "Cambios pendientes de guardar",
|
||||
"triggerLabel": "Revisar cambios pendientes",
|
||||
"empty": "No hay cambios pendientes.",
|
||||
"scope": {
|
||||
"label": "Ámbito",
|
||||
"global": "Global",
|
||||
"camera": "Cámara: {{cameraName}}"
|
||||
},
|
||||
"field": {
|
||||
"label": "Campo"
|
||||
},
|
||||
"value": {
|
||||
"label": "Nuevo valor",
|
||||
"reset": "Restablecer"
|
||||
}
|
||||
},
|
||||
"timestampPosition": {
|
||||
"tl": "Arriba a la izquierda",
|
||||
"tr": "Arriba a la derecha",
|
||||
"bl": "Abajo a la izquierda",
|
||||
"br": "Abajo a la derecha"
|
||||
},
|
||||
"unsavedChanges": "Tienes cambios sin guardar",
|
||||
"confirmReset": "Confirmar restablecimiento",
|
||||
"birdseye": {
|
||||
"trackingMode": {
|
||||
"objects": "Objetos",
|
||||
"motion": "Movimiento",
|
||||
"continuous": "Continuo"
|
||||
},
|
||||
"cameraOrder": {
|
||||
"label": "Orden de cámaras",
|
||||
"description": "Arrastra las cámaras para establecer su orden en el diseño de Birdseye.",
|
||||
"reorderHandle": "Arrastrar para reordenar",
|
||||
"saving": "Guardando…",
|
||||
"saved": "Guardado"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"retainMode": {
|
||||
"all": "Todo",
|
||||
"motion": "Movimiento",
|
||||
"active_objects": "Objetos activos"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"timeFormat": {
|
||||
"browser": "Navegador",
|
||||
"12hour": "12 horas",
|
||||
"24hour": "24 horas"
|
||||
},
|
||||
"TimeOrDateStyle": {
|
||||
"full": "Completo",
|
||||
"long": "Largo",
|
||||
"medium": "Medio",
|
||||
"short": "Corto"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"imageSource": {
|
||||
"recordings": "Grabaciones",
|
||||
"previews": "Vistas previas"
|
||||
}
|
||||
},
|
||||
"logger": {
|
||||
"logLevel": {
|
||||
"debug": "Depuración",
|
||||
"info": "Información",
|
||||
"warning": "Advertencia",
|
||||
"error": "Error",
|
||||
"critical": "Crítico"
|
||||
}
|
||||
},
|
||||
"modelSize": {
|
||||
"small": "Pequeño",
|
||||
"large": "Grande"
|
||||
},
|
||||
"retainMode": {
|
||||
"all": "Todo",
|
||||
"motion": "Movimiento",
|
||||
"active_objects": "Objetos activos"
|
||||
},
|
||||
"previewQuality": {
|
||||
"very_high": "Muy alto",
|
||||
"high": "Alto",
|
||||
"medium": "Medio",
|
||||
"low": "Bajo",
|
||||
"very_low": "Muy bajo"
|
||||
},
|
||||
"detectorsAndModel": {
|
||||
"title": "Detectores y modelo",
|
||||
"description": "Configura el backend del detector que ejecuta la detección de objetos y el modelo que utiliza. Los cambios se guardan juntos para que el detector y el modelo permanezcan sincronizados.",
|
||||
"cardTitles": {
|
||||
"detector": "Hardware del detector",
|
||||
"model": "Modelo de detección"
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
"custom": "Modelo personalizado"
|
||||
},
|
||||
"mismatch": {
|
||||
"warning": "El modelo actual de Frigate+ “{{model}}” requiere el detector {{required}}. Selecciona un modelo compatible a continuación o cambia a Modelo personalizado antes de guardar."
|
||||
},
|
||||
"plusModel": {
|
||||
"requiresDetector": "Requiere: {{detector}}",
|
||||
"noModelSelected": "Selecciona un modelo de Frigate+"
|
||||
},
|
||||
"toast": {
|
||||
"saveSuccess": "Los ajustes de detectores y modelo se han guardado. Reinicia Frigate para aplicar los cambios.",
|
||||
"saveError": "No se pudieron guardar los ajustes del detector y del modelo"
|
||||
},
|
||||
"unsavedChanges": "Cambios sin guardar en el detector y el modelo",
|
||||
"restartRequired": "Reinicio necesario (se ha cambiado el detector o el modelo)"
|
||||
},
|
||||
"menuDot": {
|
||||
"overrideGlobal": "Esta sección sobrescribe la configuración global",
|
||||
"overrideProfile": "Esta sección está sobrescrita por el perfil {{profile}}",
|
||||
"unsaved": "Esta sección tiene cambios sin guardar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
},
|
||||
"count_other": "{{count}} mensajes",
|
||||
"count_one": "{{count}} mensaje",
|
||||
"empty": "No se han capturado mensaje aún"
|
||||
"empty": "No se han capturado mensaje aún",
|
||||
"expanded": {
|
||||
"payload": "Carga útil"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Sistema",
|
||||
@@ -209,6 +212,9 @@
|
||||
"unusable": "No usable",
|
||||
"fair": "Normal",
|
||||
"stallsLastHour": "Bloqueos (última hora)"
|
||||
},
|
||||
"noCameras": {
|
||||
"title": "No se han encontrado cámaras"
|
||||
}
|
||||
},
|
||||
"lastRefreshed": "Última actualización: ",
|
||||
|
||||
@@ -140,7 +140,8 @@
|
||||
"gl": "Galego (galeegi keel)",
|
||||
"id": "Bahasa Indonesia (indoneesia keel)",
|
||||
"ur": "اردو (urdu keel)",
|
||||
"hr": "Hrvatski (horvaadi keel)"
|
||||
"hr": "Hrvatski (horvaadi keel)",
|
||||
"bs": "Bosanski (bosnia keel)"
|
||||
},
|
||||
"system": "Süsteem",
|
||||
"systemMetrics": "Süsteemi meetrika",
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"royal_mail": "Royal Mail",
|
||||
"school_bus": "Koolibuss",
|
||||
"skunk": "Vinukloom (skunk)",
|
||||
"kangaroo": "Känguru"
|
||||
"kangaroo": "Känguru",
|
||||
"baby": "Väikelaps",
|
||||
"baby_stroller": "Lapsevanker",
|
||||
"rickshaw": "Rikša",
|
||||
"Rodent": "Näriline",
|
||||
"rodent": "Näriline"
|
||||
}
|
||||
|
||||
@@ -121,5 +121,9 @@
|
||||
"royal_mail": "Poste du Royaume Uni",
|
||||
"school_bus": "Bus scolaire",
|
||||
"skunk": "Mouffette",
|
||||
"kangaroo": "Kangourou"
|
||||
"kangaroo": "Kangourou",
|
||||
"baby": "Bébé",
|
||||
"baby_stroller": "Poussette",
|
||||
"rickshaw": "Pousse-pousse",
|
||||
"Rodent": "Rongeur"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user