diff --git a/.cspell/frigate-dictionary.txt b/.cspell/frigate-dictionary.txt index f2bcf417af..6f2b2cc528 100644 --- a/.cspell/frigate-dictionary.txt +++ b/.cspell/frigate-dictionary.txt @@ -8,6 +8,7 @@ amdgpu analyzeduration Annke apexcharts +Aqara arange argmax argmin @@ -64,6 +65,7 @@ dsize dtype ECONNRESET edgetpu +Eufy facenet fastapi faststart @@ -82,6 +84,7 @@ frontdoor fstype fullchain fullscreen +gatekeep genai generativeai genpts @@ -162,6 +165,7 @@ mpegts mqtt mse msenc +muxing namedtuples nbytes nchw @@ -197,6 +201,8 @@ OWASP paddleocr paho passwordless +PCMA +PCMU popleft posthog postprocess @@ -222,13 +228,16 @@ radeontop rawvideo rcond RDONLY +realmonitor rebranded +recvonly referer reindex Reolink restream restreamed restreaming +RJSF rkmpp rknn rkrga @@ -238,8 +247,11 @@ rocminfo rootfs rtmp RTSP +rtsps +rtspx ruamel scroller +sendonly setproctitle setpts shms @@ -250,6 +262,7 @@ SNDMORE socs sqliteq sqlitevecq +Srtp ssdlite statm stimeout diff --git a/.github/DISCUSSION_TEMPLATE/beta-support.yml b/.github/DISCUSSION_TEMPLATE/beta-support.yml index e342127a02..478d3e541d 100644 --- a/.github/DISCUSSION_TEMPLATE/beta-support.yml +++ b/.github/DISCUSSION_TEMPLATE/beta-support.yml @@ -10,7 +10,11 @@ body: Before submitting, read the [beta documentation][docs]. - [docs]: https://deploy-preview-19787--frigate-docs.netlify.app/ + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + + [docs]: https://docs-dev.frigate.video/ + [discussions]: https://github.com/blakeblackshear/frigate/discussions + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -22,8 +26,8 @@ body: id: version attributes: label: Beta Version - description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.17.0-beta1) - placeholder: "0.17.0-beta1" + description: Visible on the System Metrics page in the Web UI. Please include the full version including the build identifier (eg. 0.18.0-beta1, 0.18.0-8b72c7a, etc.) + placeholder: "0.18.0-beta1" validations: required: true - type: dropdown @@ -71,11 +75,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxomox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/camera-support.yml b/.github/DISCUSSION_TEMPLATE/camera-support.yml index 521d65ded5..213eaab614 100644 --- a/.github/DISCUSSION_TEMPLATE/camera-support.yml +++ b/.github/DISCUSSION_TEMPLATE/camera-support.yml @@ -8,9 +8,12 @@ body: Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community. + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -87,11 +90,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxomox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/config-support.yml b/.github/DISCUSSION_TEMPLATE/config-support.yml index 575f7f640e..2aa1dfc25e 100644 --- a/.github/DISCUSSION_TEMPLATE/config-support.yml +++ b/.github/DISCUSSION_TEMPLATE/config-support.yml @@ -8,9 +8,12 @@ body: Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community. + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -73,11 +76,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxomox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/detector-support.yml b/.github/DISCUSSION_TEMPLATE/detector-support.yml index fb994500f4..9b6423d79e 100644 --- a/.github/DISCUSSION_TEMPLATE/detector-support.yml +++ b/.github/DISCUSSION_TEMPLATE/detector-support.yml @@ -8,9 +8,12 @@ body: Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community. + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -53,11 +56,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxomox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/general-support.yml b/.github/DISCUSSION_TEMPLATE/general-support.yml index 0b9f225b6b..7ccf4299b1 100644 --- a/.github/DISCUSSION_TEMPLATE/general-support.yml +++ b/.github/DISCUSSION_TEMPLATE/general-support.yml @@ -8,9 +8,12 @@ body: Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community. + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -73,11 +76,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxmox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/hardware-acceleration-support.yml b/.github/DISCUSSION_TEMPLATE/hardware-acceleration-support.yml index 861156696a..ad35e166d8 100644 --- a/.github/DISCUSSION_TEMPLATE/hardware-acceleration-support.yml +++ b/.github/DISCUSSION_TEMPLATE/hardware-acceleration-support.yml @@ -8,9 +8,12 @@ body: Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community. + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: @@ -69,11 +72,12 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI - Proxmox via Docker - - Proxmox via TTeck Script + - Proxmox via installation script + - Proxomox via VM - Windows WSL2 validations: required: true diff --git a/.github/DISCUSSION_TEMPLATE/question.yml b/.github/DISCUSSION_TEMPLATE/question.yml index 6a4789c9c5..f4eee5caed 100644 --- a/.github/DISCUSSION_TEMPLATE/question.yml +++ b/.github/DISCUSSION_TEMPLATE/question.yml @@ -10,9 +10,12 @@ body: **If you are looking for support, start a new discussion and use a support category.** + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: textarea id: description attributes: diff --git a/.github/DISCUSSION_TEMPLATE/report-a-bug.yml b/.github/DISCUSSION_TEMPLATE/report-a-bug.yml index de870ac0f7..ec28ceb66b 100644 --- a/.github/DISCUSSION_TEMPLATE/report-a-bug.yml +++ b/.github/DISCUSSION_TEMPLATE/report-a-bug.yml @@ -6,17 +6,20 @@ body: value: | Use this form to submit a reproducible bug in Frigate or Frigate's UI. - **⚠️ If you are running a beta version (0.17.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.** + **⚠️ If you are running a beta version (0.18.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.** Before submitting your bug report, please ask the AI with the "Ask AI" button on the [official documentation site][ai] about your issue, [search the discussions][discussions], look at recent open and closed [pull requests][prs], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your bug has already been fixed by the developers or reported by the community. **If you are unsure if your issue is actually a bug or not, please submit a support request first.** + By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response. + [discussions]: https://www.github.com/blakeblackshear/frigate/discussions [prs]: https://www.github.com/blakeblackshear/frigate/pulls [docs]: https://docs.frigate.video [faq]: https://github.com/blakeblackshear/frigate/discussions/12724 [ai]: https://docs.frigate.video + [ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md - type: checkboxes attributes: label: Checklist @@ -116,9 +119,13 @@ body: attributes: label: Install method options: - - Home Assistant Add-on + - Home Assistant App - Docker Compose - Docker CLI + - Proxmox via Docker + - Proxmox via installation script + - Proxomox via VM + - Windows WSL2 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 57f76d3086..a9b84979d9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,6 +7,13 @@ assignees: '' --- + + **Describe what you are trying to accomplish and why in non technical terms** I want to be able to ... so that I can ... diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index f053abe3f2..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,385 +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
{t("camera_not_found")}
; - } - ``` - -- **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 -
Camera not found
# ❌ Use t("camera_not_found") - -# Missing error handling -data = await api.get_data() # ❌ No exception handling - -# Bare exceptions in regular code -try: - value = await sensor.read() -except Exception: # ❌ Too broad - logger.error("Failed") -``` - -### ✅ Use These Instead - -```python -# Async operations -import aiohttp -async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - data = await response.json() - -await asyncio.sleep(5) # ✅ Non-blocking - -# Translatable strings in React -const { t } = useTranslation(); -
{t("camera_not_found")}
# ✅ Translatable - -# Proper error handling -try: - data = await api.get_data() -except ApiException as err: - logger.error("API error: %s", err) - raise - -# Specific exceptions -try: - value = await sensor.read() -except SensorException as err: # ✅ Specific - logger.exception("Failed to read sensor") -``` - -## Project-Specific Conventions - -### Configuration Files - -- Main config: `config/config.yml` - -### Directory Structure - -- Backend code: `frigate/` -- Frontend code: `web/` -- Docker files: `docker/` -- Documentation: `docs/` -- Database migrations: `migrations/` - -### Code Style Conformance - -Always conform new and refactored code to the existing coding style in the project: - -- Follow established patterns in similar files -- Match indentation and formatting of surrounding code -- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript) -- Maintain the same level of verbosity in comments and docstrings - -## Additional Resources - -- Documentation: https://docs.frigate.video -- Main Repository: https://github.com/blakeblackshear/frigate -- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3204244a6c..841f97fb79 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,17 +1,18 @@ +_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) and the [AI policy](https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md) before submitting a PR. Every PR must be read and submitted by a person, and PRs that appear to be unreviewed AI output will be closed without review._ + ## Proposed change + - ## Type of change - [ ] Dependency upgrade @@ -25,6 +26,45 @@ - This PR fixes or closes issue: fixes # - This PR is related to issue: +- Link to discussion with maintainers (**required** for any large or "planned" features): + +## For new features + + + +- [ ] There is an existing feature request or discussion with community interest for this change. + - Link: + +## AI disclosure + + + +- [ ] No AI tools were used in this PR. +- [ ] AI tools were used in this PR. Details below: + +**AI tool(s) used** (e.g., Claude, Copilot, ChatGPT, Cursor): + +**How AI was used** (e.g., code generation, code review, debugging, documentation): + +**Extent of AI involvement** (e.g., generated entire implementation, assisted with specific functions, suggested fixes): + +**Human oversight**: Describe what manual review, testing, and validation you performed on the AI-generated portions. ## Checklist @@ -35,5 +75,6 @@ - [ ] The code change is tested and works locally. - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. +- [ ] I can explain every line of code in this PR if asked. - [ ] UI changes including text have used i18n keys and have been added to the `en` locale. - [ ] The code has been formatted using Ruff (`ruff format frigate`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54df536d64..41080be5d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push amd64 standard build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: docker/main/Dockerfile @@ -56,7 +56,7 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push arm64 standard build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: docker/main/Dockerfile @@ -67,7 +67,7 @@ jobs: ${{ steps.setup.outputs.image-name }}-standard-arm64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 - name: Build and push RPi build - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true @@ -96,7 +96,7 @@ jobs: BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu SLIM_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu TRT_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true @@ -124,7 +124,7 @@ jobs: - name: Build and push TensorRT (x86 GPU) env: COMPUTE_LEVEL: "50 60 70 80 90" - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true @@ -137,7 +137,7 @@ jobs: - name: AMD/ROCm general build env: HSA_OVERRIDE: 0 - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true @@ -163,7 +163,7 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Rockchip build - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true @@ -188,7 +188,7 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Synaptics build - uses: docker/bake-action@v6 + uses: docker/bake-action@v7 with: source: . push: true diff --git a/.github/workflows/pr_template_check.yml b/.github/workflows/pr_template_check.yml new file mode 100644 index 0000000000..c82b202ef5 --- /dev/null +++ b/.github/workflows/pr_template_check.yml @@ -0,0 +1,120 @@ +name: PR template check + +on: + pull_request_target: + types: [opened, edited] + +permissions: + pull-requests: write + +jobs: + check_template: + name: Validate PR description + runs-on: ubuntu-latest + steps: + - name: Check PR description against template + uses: actions/github-script@v9 + with: + script: | + const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate']; + const author = context.payload.pull_request.user.login; + + if (maintainers.includes(author)) { + console.log(`Skipping template check for maintainer: ${author}`); + return; + } + + const body = context.payload.pull_request.body || ''; + const errors = []; + + // Check that key template sections exist + const requiredSections = [ + '## Proposed change', + '## Type of change', + '## AI disclosure', + '## Checklist', + ]; + + for (const section of requiredSections) { + if (!body.includes(section)) { + errors.push(`Missing section: **${section}**`); + } + } + + // Check that "Proposed change" has content beyond the default HTML comment + const proposedChangeMatch = body.match( + /## Proposed change\s*(?:\s*)?([\s\S]*?)(?=\n## )/ + ); + const proposedContent = proposedChangeMatch + ? proposedChangeMatch[1].trim() + : ''; + if (!proposedContent) { + errors.push( + 'The **Proposed change** section is empty. Please describe what this PR does.' + ); + } + + // Check that at least one "Type of change" checkbox is checked + const typeSection = body.match( + /## Type of change\s*([\s\S]*?)(?=\n## )/ + ); + if (typeSection && !/- \[x\]/i.test(typeSection[1])) { + errors.push( + 'No **Type of change** selected. Please check at least one option.' + ); + } + + // Check that at least one AI disclosure checkbox is checked + const aiSection = body.match( + /## AI disclosure\s*([\s\S]*?)(?=\n## )/ + ); + if (aiSection && !/- \[x\]/i.test(aiSection[1])) { + errors.push( + 'No **AI disclosure** option selected. Please indicate whether AI tools were used.' + ); + } + + // Check that at least one checklist item is checked + const checklistSection = body.match( + /## Checklist\s*([\s\S]*?)$/ + ); + if (checklistSection && !/- \[x\]/i.test(checklistSection[1])) { + errors.push( + 'No **Checklist** items checked. Please review and check the items that apply.' + ); + } + + if (errors.length === 0) { + console.log('PR description passes template validation.'); + return; + } + + const prNumber = context.payload.pull_request.number; + const message = [ + '## PR template validation failed', + '', + 'This PR was automatically closed because the description does not follow the [pull request template](https://github.com/blakeblackshear/frigate/blob/dev/.github/pull_request_template.md).', + '', + '**Issues found:**', + ...errors.map((e) => `- ${e}`), + '', + 'Please update your PR description to include all required sections from the template, then reopen this PR.', + '', + '> If you used an AI tool to generate this PR, please see our [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) for details.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed', + }); + + core.setFailed('PR description does not follow the template.'); diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index c4d8aa7a03..d2e279966e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -27,6 +27,9 @@ jobs: - name: Lint run: npm run lint working-directory: ./web + - name: Check i18n keys + run: npm run i18n:extract:ci + working-directory: ./web web_test: name: Web - Test @@ -47,6 +50,37 @@ jobs: # run: npm run test # working-directory: ./web + web_e2e: + name: Web - E2E Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 20.x + - run: npm install + working-directory: ./web + - name: Install Playwright Chromium + run: npx playwright install chromium --with-deps + working-directory: ./web + - name: Build web for E2E + run: npm run e2e:build + working-directory: ./web + - name: Run E2E tests + run: npm run e2e + working-directory: ./web + - name: Upload test artifacts + uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report + path: | + web/test-results/ + web/playwright-report/ + retention-days: 7 + python_checks: runs-on: ubuntu-latest name: Python Checks @@ -91,5 +125,7 @@ jobs: run: devcontainer up --workspace-folder . - name: Run mypy in devcontainer run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate" + - name: Check API spec is up to date + run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" - name: Run unit tests in devcontainer run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 011f70afd3..39512f24a3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,9 +18,9 @@ jobs: close-issue-message: "" days-before-stale: 30 days-before-close: 3 - exempt-draft-pr: true - exempt-issue-labels: "pinned,security" - exempt-pr-labels: "pinned,security,dependencies" + exempt-draft-pr: false + exempt-issue-labels: "planned,security" + exempt-pr-labels: "planned,security,dependencies" operations-per-run: 120 - name: Print outputs env: diff --git a/.gitignore b/.gitignore index 660a378b01..70ec5ae76f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__ .mypy_cache *.swp debug +.claude/* +.mcp.json .vscode/* !.vscode/launch.json config/* @@ -10,6 +12,7 @@ config/* models *.mp4 *.db +*.db-* *.csv frigate/version.py web/build @@ -19,4 +22,9 @@ web/.env core !/web/**/*.ts .idea/* -.ipynb_checkpoints \ No newline at end of file +.ipynb_checkpoints + +# Auto-generated Docker Compose Generator config files +docs/src/components/DockerComposeGenerator/config/devices.ts +docs/src/components/DockerComposeGenerator/config/hardware.ts +docs/src/components/DockerComposeGenerator/config/ports.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 5c858267d8..2d7b6c8fb5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,23 @@ "type": "debugpy", "request": "launch", "module": "frigate" + }, + { + "type": "editor-browser", + "request": "launch", + "name": "Vite: Launch in integrated browser", + "url": "http://localhost:5173" + }, + { + "type": "editor-browser", + "request": "launch", + "name": "Nginx: Launch in integrated browser", + "url": "http://localhost:5000" + }, + { + "type": "editor-browser", + "request": "attach", + "name": "Attach to integrated browser" } ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..41d4b6460b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,450 @@ +# 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 +- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences) + +### 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
{t("camera_not_found")}
; + } + ``` + +- **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 + +# Regenerate the OpenAPI spec after adding, changing, or removing an API +# endpoint or its auth dependency — outputs docs/static/frigate-api.yaml, +# annotated with each endpoint's auth requirement (admin / any / camera / +# public). NEVER edit that file by hand. CI runs the --check variant and fails +# if it is out of date. (from repo root) +python3 generate_api_auth_spec.py +python3 generate_api_auth_spec.py --check +``` + +### 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 +``` + +After adding, changing, or removing an endpoint (or its auth dependency), regenerate the OpenAPI spec with `python3 generate_api_auth_spec.py` so `docs/static/frigate-api.yaml` stays in sync and the endpoint's auth requirement is documented. CI enforces this via the `--check` variant; never edit that file by hand. + +### 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 +
Camera not found
# ❌ 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(); +
{t("camera_not_found")}
# ✅ 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 diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 0000000000..de6dc602f1 --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,126 @@ +# Frigate AI Policy + +## TL;DR + +- **Use AI tools if they help you.** We do too. This is about what you post, not which tools you use to write it. +- **A person has to read it and send it.** Don't wire a bot or an agent up to post on your behalf. +- **Write your posts yourself.** Your own words, the template filled in, and you answering maintainers rather than your assistant. +- **Don't paste an AI's guess at the cause as though it were a diagnosis.** Tell us what you actually observed. +- **Read your code before you submit it.** Disclose that AI was used, and be ready to explain every line. +- **If we misjudge something you wrote, just say so.** We'll take you at your word. + +The rest of this document explains each of these, and why. + +## Scope + +AI tools are a reality of modern development and we're not opposed to their use. You are responsible for anything you submit, however it was produced, and we are responsible for anything we merge and release. We hold a high bar for both. + +This policy applies everywhere this project is discussed: issues, discussions, pull requests, code reviews, and commit comments. + +## Why this exists + +Frigate is built and supported by a small group of maintainers and a community of volunteers who read every post and review every pull request. Nobody here is paid to do it, and time spent reading a post is time not spent fixing bugs or building features. + +We're not opposed to AI tools. We use them too. But content generated by an AI and submitted without review costs a real person real time, and usually gives them less to work with than a few honest sentences would have. That is the problem this policy addresses. + +## A person has to be in the loop + +Every issue, discussion, comment, and pull request here must be read and submitted by a person. Using an AI tool to help you write is fine. Wiring one up to post on your behalf is not. + +Specifically, do not: + +- Connect a bot or agent to GitHub that opens issues, discussions, or pull requests without you reading them first +- Post output from a tool you have not read +- Use tooling to file bulk or drive-by contributions across the repository + +We will close anything we believe was posted without a person reading it, and we may mark it as spam. Posts that skip the templates are the most common sign of this. + +## Issues, discussions, and comments + +We do not mind if you use AI tools to help you write. Do not have tools post unreviewed content on your behalf. We may hide any comment we believe to be unreviewed AI output. + +Keep posts to what is needed to communicate your point. A long, confidently written, AI-padded post is harder to help with than a short direct one, not easier, and it is usually obvious. + +**Describe your actual problem in your own words.** Tell us what you did, what you expected, and what actually happened. That is the information we need, and only you have it. + +**Do not paste an AI's guess at the cause as though it were a diagnosis.** It is frequently wrong in ways that send everyone down the wrong path, and it buries the details that would have led to the real answer. We would rather see what you observed than what a model inferred. + +**Fill in the template completely.** The templates ask for logs, config, version, and hardware because those are the things needed to help you. An AI cannot supply them for you, and a post missing them cannot be acted on. + +**Answer maintainers yourself.** If we ask you a question, we are asking _you_, not your AI assistant. These are the spaces where we build trust and understanding with the community, and that only works if we're talking to each other. Using AI to fix your grammar or clarity is fine, but the substance has to be yours. + +This applies to pull request descriptions and review replies as much as it does to bug reports and discussions. + +### Quoting AI output + +If you want to include something an AI told you, it must be: + +- In a quote block, using `>` +- Disclosed as AI output, saying which tool it came from +- Accompanied by your own comment explaining why you think it is relevant + +Keep the excerpt short. Do not paste long transcripts. + +### Non-native English speakers + +AI is genuinely useful for participating in a project that operates in English, and we would rather hear from you through a translation tool than not hear from you at all. Using AI to improve the grammar or clarity of something you wrote yourself is fine. + +If you are translating your posts, make sure the translation says what you meant. Including your original text in a `
` block helps us verify the translation if something reads oddly, and keeps the thread readable. + +## Code contributions + +We need to understand your relationship with the code you're submitting. The more AI was involved, the more important it is that you've genuinely reviewed, tested, and understood what it produced. + +Because of the long-term maintenance burden every merged change creates, we require a human in the loop who understands the work the AI produced. Pull requests that appear to be unreviewed AI output will be closed without review. + +### Requirements when AI is used + +If AI is used to generate any portion of the code, contributors must adhere to the following requirements: + +1. **Explicitly disclose the manner in which AI was employed.** The PR template asks for this. Be honest, this won't automatically disqualify your PR. We'd rather have an honest disclosure than find out later. Trust matters more than method. +2. **Perform a comprehensive manual review prior to submitting the pull request.** Don't submit code you haven't read carefully and tested locally. +3. **Be prepared to explain every line of code you submitted when asked about it by a maintainer.** If you can't explain why something works the way it does, you're not ready to submit it. +4. **Check for an existing pull request addressing the same change.** If one exists, comment there and work with its author instead of opening a duplicate. +5. **It is strictly prohibited to use AI to write your posts for you** (bug reports, feature requests, pull request descriptions, GitHub discussions, responding to humans, etc.). We need to hear from _you_, not your AI assistant. These are the spaces where we build trust and understanding with contributors, and that only works if we're talking to each other. + +### Established contributors + +Contributors with a long history of thoughtful, quality contributions to Frigate have earned trust through that track record. The level of scrutiny we apply to AI usage naturally reflects that trust. This isn't a formal exemption, it's just how trust works. If you've been around, we know how you think and how you work. If you're new, we're still getting to know you, and clear disclosure helps build that relationship. + +### What this means in practice + +We're not trying to gatekeep how you write code. Use whatever tools make you productive. But there's a difference between using AI as a tool to implement something you understand and handing a feature request to an AI and submitting whatever comes back. The former is fine. The latter creates maintenance risk for the project. + +Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term, often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability. + +One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated, where the author can't explain the design, debug issues independently, or engage substantively in design discussions, doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term. + +## Our use of AI + +The Frigate documentation site has an "Ask AI" search that answers questions from the docs, and we may use AI tooling to help with triage and project management. Like any automated tooling, it is not always right. + +If an AI tool leaves a comment on your contribution, treat it the way you would any other comment. If you think it is wrong, say so, and a brief explanation is enough. Maintainers always have the final say. + +## Enforcement + +Contributions and posts that do not follow this policy will be closed. Depending on the situation, maintainers may also: + +- Hide or delete comments that appear to be unreviewed AI output +- Mark automated content as spam +- Close an issue, discussion, or pull request without further review +- Lock a conversation +- Temporarily or permanently block an account from participating in the project + +Repeated violations may result in being blocked from contributing to Frigate. + +### When we get it wrong + +There is no reliable way to detect this, and we're not going to pretend otherwise. Whether something reads as unreviewed AI output is a judgment call, usually made quickly, by a volunteer with limited time and no way to know for certain. These calls are subjective and we won't always get them right. + +If it happens to you, just say so. A short reply telling us you wrote it yourself is enough, and we'll take you at your word and pick the conversation back up. We would much rather occasionally reopen something we misjudged than treat everyone who posts here as a suspect. + +We'd ask for some understanding in return. These calls get made quickly because the volume is real, and time spent second-guessing them is time not spent helping the person in the next thread. + +## Attribution + +Portions of this policy are adapted from the [Open Home Foundation AI Policy](https://developers.home-assistant.io/docs/ai_policy/). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c94423d687 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,135 @@ +# Contributing to Frigate + +Thank you for your interest in contributing to Frigate. This document covers the expectations and guidelines for contributions. Please read it before submitting a pull request. + +All participation in this project, including pull requests, issues, and discussions, is covered by our [AI policy](AI_POLICY.md). + +## Before you start + +### Bugfixes + +If you've found a bug and want to fix it, go for it. Link to the relevant issue in your PR if one exists, or describe the bug in the PR description. + +### New features + +A pull request is more than just code — it's a request for the maintainers to review, integrate, and support the change long-term. We're selective about what we take on, and prioritize changes that align with the project's direction and can be responsibly maintained in the long term. + +**Large or highly-requested features** raise the bar even higher. Popularity signals demand, but it doesn't pre-approve any particular implementation. The bigger the change, the higher the long-term cost, and the more important it is that we're aligned on scope and approach before any code is written. A large PR that lands without prior discussion is unlikely to be merged as-is, no matter how well it's implemented. + +Before writing code for a new feature: + +1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one. +2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it. + +## AI usage policy + +AI tools are a reality of modern development and we're not opposed to their use. But we need to understand your relationship with the code you're submitting, and we need to hear from you rather than from your AI assistant. + +**Read the [AI policy](AI_POLICY.md) before you open a pull request.** It is short, and it applies to everything you post here. The parts that most often catch people out: + +- A person has to be in the loop. Don't wire a bot or agent up to open pull requests, issues, or discussions on your behalf. +- Disclose how AI was used. The PR template asks for this. Be honest, it won't automatically disqualify your PR. +- Review and test everything you submit, and be prepared to explain every line when asked. +- Don't use AI to write your PR description or your replies to maintainers. + +Pull requests that appear to be unreviewed AI output will be closed without review. + +## Pull request guidelines + +### Before submitting + +- **Search for existing PRs** to avoid duplicating effort. +- **Test your changes locally.** Your PR cannot be merged unless tests pass. +- **Format your code.** Run `ruff format frigate` for Python and `npm run prettier:write` from the `web/` directory for frontend changes. +- **Run the linter.** Run `ruff check frigate` for Python and `npm run lint` from `web/` for frontend. +- **One concern per PR.** Don't combine unrelated changes. A bugfix and a new feature should be separate PRs. + +### What we look for in review + +- **Does it work?** Tested locally, tests pass, no regressions. +- **Is it maintainable?** Clear code, appropriate complexity, good separation of concerns. +- **Does it fit?** Consistent with Frigate's architecture and design philosophy. +- **Is it scoped well?** Solves the stated problem without unnecessary additions. + +### After submitting + +- Be responsive to review feedback. We may ask for changes. +- Expect honest, direct feedback. We try to be respectful but we also try to be efficient. +- If your PR goes stale, rebase it on the latest `dev` branch. + +## Coding standards + +### Python (backend) + +- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses) +- **Formatting**: Ruff (configured in `pyproject.toml`) +- **Linting**: Ruff +- **Testing**: `python3 -u -m unittest` +- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting +- **Async**: All external I/O must be async. No blocking calls in async functions. +- **Error handling**: Use specific exception types. Keep try blocks minimal. +- **Language**: American English for all code, comments, and documentation + +### TypeScript/React (frontend) + +- **Linting**: ESLint (`npm run lint` from `web/`) +- **Formatting**: Prettier (`npm run prettier:write` from `web/`) +- **Type safety**: TypeScript strict mode. Avoid `any`. +- **i18n**: All user-facing strings must use `react-i18next`. Never hardcode display text in components. Add English strings to the appropriate files in `web/public/locales/en/`. +- **Components**: Use Radix UI/shadcn primitives and TailwindCSS with the `cn()` utility. + +### Development commands + +```bash +# Python +python3 -u -m unittest # Run all tests +python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test +ruff format frigate # Format +ruff check frigate # Lint + +# Frontend (from web/ directory) +npm run build # Build +npm run lint # Lint +npm run lint:fix # Lint + fix +npm run prettier:write # Format +``` + +## Project structure + +``` +frigate/ # Python backend + api/ # FastAPI route handlers + config/ # Configuration parsing and validation + detectors/ # Object detection backends + events/ # Event management and storage + test/ # Backend tests + util/ # Shared utilities +web/ # React/TypeScript frontend + src/ + api/ # API client functions + components/ # Reusable components + hooks/ # Custom React hooks + pages/ # Route components + types/ # TypeScript type definitions + views/ # Complex view components +docker/ # Docker build files +docs/ # Documentation site +migrations/ # Database migrations +``` + +## Translations + +Frigate uses [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) for managing language translations. If you'd like to help translate Frigate into your language: + +1. Visit the [Frigate project on Weblate](https://hosted.weblate.org/projects/frigate-nvr/). +2. Create an account or log in. +3. Browse the available languages and select the one you'd like to contribute to, or request a new language. +4. Translate strings directly in the Weblate interface — no code changes or pull requests needed. + +Translation contributions through Weblate are automatically synced to the repository. Please do not submit pull requests for translation changes — use Weblate instead so that translations are properly tracked and coordinated. + +## Resources + +- [Documentation](https://docs.frigate.video) +- [Discussions, Support, and Bug Reports](https://github.com/blakeblackshear/frigate/discussions) +- [Feature Requests](https://github.com/blakeblackshear/frigate/issues) diff --git a/Makefile b/Makefile index 42adb6bacc..3800399ea1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.17.2 +VERSION = 0.18.0 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty @@ -49,7 +49,8 @@ push: push-boards --push run: local - docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest + docker run --rm --publish=5000:5000 --publish=8971:8971 \ + --volume=${PWD}/config:/config frigate:latest run_tests: local docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ diff --git a/audio-labelmap.txt b/audio-labelmap.txt index 4a38b5f639..a303c3b7a8 100644 --- a/audio-labelmap.txt +++ b/audio-labelmap.txt @@ -24,7 +24,7 @@ yell sigh singing choir -sodeling +yodeling chant mantra child_singing diff --git a/docker-compose.yml b/docker-compose.yml index db63297d5e..1563057bb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: dockerfile: docker/main/Dockerfile # Use target devcontainer-trt for TensorRT dev target: devcontainer + cache_from: + - ghcr.io/blakeblackshear/frigate:cache-amd64 ## Uncomment this block for nvidia gpu support # deploy: # resources: diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 055a1458f8..bf17146e30 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -52,10 +52,18 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \ --mount=type=cache,target=/root/.ccache \ /deps/build_sqlite_vec.sh +# Build intel-media-driver from source against bookworm's system libva so it +# works with Debian 12's glibc/libstdc++ (pre-built noble/trixie packages +# require glibc 2.38 which is not available on bookworm). +FROM base AS intel-media-driver +ARG DEBIAN_FRONTEND +RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \ + /deps/build_intel_media_driver.sh + FROM scratch AS go2rtc ARG TARGETARCH WORKDIR /rootfs/usr/local/go2rtc/bin -ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.10/go2rtc_linux_${TARGETARCH}" go2rtc +ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc FROM wget AS tempio ARG TARGETARCH @@ -73,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_ FROM base_host AS ov-converter ARG DEBIAN_FRONTEND -# Install OpenVino Runtime and Dev library +# Install OpenVINO for model conversion COPY docker/main/requirements-ov.txt /requirements-ov.txt RUN apt-get -qq update \ - && apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ + && apt-get -qq install -y wget python3 python3-distutils \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && python3 get-pip.py "pip" \ @@ -200,6 +208,7 @@ RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install FROM scratch AS deps-rootfs COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/ COPY --from=sqlite-vec /usr/local/lib/ /usr/local/lib/ +COPY --from=intel-media-driver /rootfs/ / COPY --from=go2rtc /rootfs/ / COPY --from=libusb-build /usr/local/lib /usr/local/lib COPY --from=tempio /rootfs/ / @@ -256,8 +265,8 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \ /deps/install_deps.sh -ENV DEFAULT_FFMPEG_VERSION="7.0" -ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:5.0" +ENV DEFAULT_FFMPEG_VERSION="8.0" +ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0" RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ @@ -266,6 +275,12 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \ pip3 install -U /deps/wheels/*.whl +# Install Axera Engine +RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl + +ENV PATH="${PATH}:/usr/bin/axcl" +ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl" + # Install MemryX runtime (requires libgomp (OpenMP) in the final docker image) RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \ bash -c "bash /deps/install_memryx.sh" diff --git a/docker/main/build_intel_media_driver.sh b/docker/main/build_intel_media_driver.sh new file mode 100755 index 0000000000..acc9caf09d --- /dev/null +++ b/docker/main/build_intel_media_driver.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euxo pipefail + +# Intel media driver is x86_64-only. Create empty rootfs on other arches so +# the downstream COPY --from has a valid source. +if [ "$(uname -m)" != "x86_64" ]; then + mkdir -p /rootfs + exit 0 +fi + +MEDIA_DRIVER_VERSION="intel-media-25.2.6" +GMMLIB_VERSION="intel-gmmlib-22.7.2" + +apt-get -qq update +apt-get -qq install -y wget gnupg ca-certificates cmake g++ make pkg-config + +# Use Intel's jammy repo for newer libva-dev (2.22) which provides the +# VVC/VVC-decode headers required by media-driver 25.x +wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg +echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" > /etc/apt/sources.list.d/intel-gpu-jammy.list +apt-get -qq update +apt-get -qq install -y libva-dev + +# Build gmmlib (required by media-driver) +wget -qO gmmlib.tar.gz "https://github.com/intel/gmmlib/archive/refs/tags/${GMMLIB_VERSION}.tar.gz" +mkdir /tmp/gmmlib +tar -xf gmmlib.tar.gz -C /tmp/gmmlib --strip-components 1 +cmake -S /tmp/gmmlib -B /tmp/gmmlib/build -DCMAKE_BUILD_TYPE=Release +make -C /tmp/gmmlib/build -j"$(nproc)" +make -C /tmp/gmmlib/build install + +# Build intel-media-driver +wget -qO media-driver.tar.gz "https://github.com/intel/media-driver/archive/refs/tags/${MEDIA_DRIVER_VERSION}.tar.gz" +mkdir /tmp/media-driver +tar -xf media-driver.tar.gz -C /tmp/media-driver --strip-components 1 +cmake -S /tmp/media-driver -B /tmp/media-driver/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_KERNELS=ON \ + -DENABLE_NONFREE_KERNELS=ON \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INSTALL_LIBDIR=/usr/lib/x86_64-linux-gnu \ + -DCMAKE_C_FLAGS="-Wno-error" \ + -DCMAKE_CXX_FLAGS="-Wno-error" +make -C /tmp/media-driver/build -j"$(nproc)" + +# Install driver to rootfs for COPY --from +make -C /tmp/media-driver/build install DESTDIR=/rootfs diff --git a/docker/main/build_nginx.sh b/docker/main/build_nginx.sh index 6066826651..708a4cb45c 100755 --- a/docker/main/build_nginx.sh +++ b/docker/main/build_nginx.sh @@ -73,6 +73,7 @@ cd /tmp/nginx --with-file-aio \ --with-http_sub_module \ --with-http_ssl_module \ + --with-http_v2_module \ --with-http_auth_request_module \ --with-http_realip_module \ --with-threads \ diff --git a/docker/main/build_ov_model.py b/docker/main/build_ov_model.py index 2888d87a85..f585078f3d 100644 --- a/docker/main/build_ov_model.py +++ b/docker/main/build_ov_model.py @@ -1,11 +1,106 @@ -import openvino as ov -from openvino.tools import mo +"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR. -ov_model = mo.convert_model( - "/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb", - compress_to_fp16=True, - transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json", - tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config", - reverse_input_channels=True, +Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow +frontend translates the Object Detection API pre and post processors literally, +producing per-class NonMaxSuppression, NonZero ops and map loops with data +dependent shapes that the GPU plugin handles very badly. Both are cut out the +way ssd_v2_support.json used to do it: the preprocessor is an identity at the +native 300x300 input, and the postprocessor becomes a single fused +DetectionOutput. The result is the [1, 1, 100, 7] tensor that Frigate's +OpenVINO detector expects, with the input flipped to BGR to match the legacy +reverse_input_channels behavior. +""" + +import numpy as np +import openvino as ov +from openvino import opset8 as ops +from openvino.preprocess import PrePostProcessor + +MODEL_DIR = "/models/ssdlite_mobilenet_v2_coco_2018_05_09" +OUTPUT_PATH = "/models/ssdlite_mobilenet_v2.xml" +INPUT_SHAPE = [1, 300, 300, 3] + +# faster_rcnn_box_coder divides the deltas by pipeline.config's y/x/height/width +# scales of 10/10/5/5, which DetectionOutput expresses as per-prior variances. +BOX_VARIANCES = np.float32([0.1, 0.1, 0.2, 0.2]) + +model = ov.convert_model( + f"{MODEL_DIR}/frozen_inference_graph.pb", + input=[("image_tensor:0", INPUT_SHAPE)], ) -ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml") + +nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()} +parameter = model.get_parameters()[0] + +preprocessor = nodes["Preprocessor/map/TensorArrayStack/TensorArrayGatherV3"] +box_deltas = nodes["Postprocessor/Reshape_1"].output(0) +class_scores = nodes["Postprocessor/convert_scores"].output(0) +anchors_output = nodes["Postprocessor/Reshape"].output(0) + +# The anchors only depend on the static input shape, so fold them into a +# constant and drop the generator subgraph with the rest of the postprocessor. +probe = ov.Core().compile_model( + ov.Model([anchors_output, preprocessor.output(0)], [parameter], "probe"), "CPU" +) +probe_input = np.random.default_rng(0).integers(0, 255, INPUT_SHAPE, dtype=np.uint8) +anchors, resized = (out.copy() for out in probe([probe_input]).values()) + +assert np.allclose(resized, probe_input, atol=1e-3), ( + "preprocessor is not an identity at 300x300, it cannot be bypassed" +) + +image = ops.convert(parameter, "f32") + +for consumer in list(preprocessor.output(0).get_target_inputs()): + consumer.replace_source_output(image.output(0)) + +# (ymin, xmin, ymax, xmax) -> (xmin, ymin, xmax, ymax) +priors = anchors[:, [1, 0, 3, 2]].astype(np.float32).reshape(-1) +variances = np.tile(BOX_VARIANCES, len(anchors)) +proposals = ops.constant(np.stack([priors, variances])[np.newaxis]) + +# (ty, tx, th, tw) -> (dx, dy, dw, dh) for the CENTER_SIZE decode +box_logits = ops.reshape(ops.gather(box_deltas, [1, 0, 3, 2], 1), [1, -1], False) +class_preds = ops.reshape(class_scores, [1, -1], False) + +detections = ops.detection_output( + box_logits, + class_preds, + proposals, + { + "background_label_id": 0, + "top_k": 100, + "keep_top_k": [100], + "nms_threshold": 0.6, + "confidence_threshold": 0.3, + "code_type": "caffe.PriorBoxParameter.CENTER_SIZE", + "share_location": True, + "variance_encoded_in_target": False, + "normalized": True, + "clip_before_nms": False, + "clip_after_nms": True, + "decrease_label_id": False, + }, +) +detections.output(0).get_tensor().set_names({"detection_out"}) + +model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2") + +ppp = PrePostProcessor(model) +ppp.input().tensor().set_layout(ov.Layout("NHWC")) +ppp.input().preprocess().reverse_channels() +model = ppp.build() + +# Fail the build rather than silently ship the dynamically shaped graph again. +op_types = [op.get_type_name() for op in model.get_ordered_ops()] +assert op_types.count("DetectionOutput") == 1, "postprocessor was not fused" + +for dynamic_op in ("NonMaxSuppression", "NonZero", "Loop", "TensorIterator"): + assert dynamic_op not in op_types, f"{dynamic_op} left in the graph" + +output_shape = model.outputs[0].get_partial_shape() +assert output_shape.is_static and list(output_shape) == [1, 1, 100, 7], ( + f"unexpected detector output shape {output_shape}" +) + +ov.save_model(model, OUTPUT_PATH, compress_to_fp16=True) diff --git a/docker/main/build_sqlite_vec.sh b/docker/main/build_sqlite_vec.sh index b41f3383d9..8036c6f522 100755 --- a/docker/main/build_sqlite_vec.sh +++ b/docker/main/build_sqlite_vec.sh @@ -2,7 +2,7 @@ set -euxo pipefail -SQLITE_VEC_VERSION="0.1.3" +SQLITE_VEC_VERSION="0.1.9" source /etc/os-release diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index 330caff9f5..e197ce1b68 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -55,6 +55,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz" tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe rm -rf ffmpeg.tar.xz + mkdir -p /usr/lib/ffmpeg/8.0 + wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz" + tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe + rm -rf ffmpeg.tar.xz fi # ffmpeg -> arm64 @@ -67,6 +71,10 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz" tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe rm -f ffmpeg.tar.xz + mkdir -p /usr/lib/ffmpeg/8.0 + wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz" + tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe + rm -f ffmpeg.tar.xz fi # arch specific packages @@ -87,46 +95,60 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then # intel packages use zst compression so we need to update dpkg apt-get install -y dpkg - # use intel apt intel packages + # use intel apt repo for libmfx1 (legacy QSV, pre-Gen12) wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list apt-get -qq update - apt-get -qq install --no-install-recommends --no-install-suggests -y \ - intel-media-va-driver-non-free libmfx1 libmfxgen1 libvpl2 + # intel-media-va-driver-non-free is built from source in the + # intel-media-driver Dockerfile stage for Battlemage (Xe2) support + apt-get -qq install --no-install-recommends --no-install-suggests -y \ + libmfx1 + rm -f /usr/share/keyrings/intel-graphics.gpg + rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list + + # upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support + echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list + apt-get -qq update + apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1 + apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2 + rm -f /etc/apt/sources.list.d/trixie.list + apt-get -qq update apt-get -qq install -y ocl-icd-libopencl1 # install libtbb12 for NPU support apt-get -qq install -y libtbb12 - rm -f /usr/share/keyrings/intel-graphics.gpg - rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list - - # install legacy and standard intel icd and level-zero-gpu + # install legacy and standard intel compute packages # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info # needed core package - wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/libigdgmm12_22.5.5_amd64.deb - dpkg -i libigdgmm12_22.5.5_amd64.deb - rm libigdgmm12_22.5.5_amd64.deb + wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb + dpkg -i libigdgmm12_22.9.0_amd64.deb + rm libigdgmm12_22.9.0_amd64.deb - # legacy packages + # legacy compute-runtime packages wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb - # standard packages - wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-opencl-icd_24.52.32224.5_amd64.deb - wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-level-zero-gpu_1.6.32224.5_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-opencl-2_2.5.6+18417_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-core-2_2.5.6+18417_amd64.deb + # standard compute-runtime packages + wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb + wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb # npu packages - wget https://github.com/oneapi-src/level-zero/releases/download/v1.21.9/level-zero_1.21.9+u22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-driver-compiler-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-fw-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-level-zero-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb + wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb + wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb + wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb + wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb dpkg -i *.deb rm *.deb + apt-get -qq install -f -y + + # Battlemage uses the xe kernel driver, but the VA-API driver is still iHD. + # The oneVPL runtime may look for a driver named after the kernel module. + ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so fi if [[ "${TARGETARCH}" == "arm64" ]]; then diff --git a/docker/main/requirements-dev.txt b/docker/main/requirements-dev.txt index ac9d357583..df5818fe0d 100644 --- a/docker/main/requirements-dev.txt +++ b/docker/main/requirements-dev.txt @@ -1,4 +1,4 @@ -ruff +ruff == 0.15.20 # types types-peewee == 3.17.* diff --git a/docker/main/requirements-ov.txt b/docker/main/requirements-ov.txt index 6fd1ca55d9..2df7890dd5 100644 --- a/docker/main/requirements-ov.txt +++ b/docker/main/requirements-ov.txt @@ -1,3 +1,2 @@ numpy -tensorflow -openvino-dev>=2024.0.0 \ No newline at end of file +openvino >= 2026.2.0 diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index f81fefea47..5ed9664fc2 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -11,7 +11,7 @@ joserfc == 1.2.* cryptography == 44.0.* pathvalidate == 3.3.* markupsafe == 3.0.* -python-multipart == 0.0.20 +python-multipart == 0.0.26 # Classification Model Training tensorflow == 2.19.* ; platform_machine == 'aarch64' tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64' @@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.* opencv-contrib-python == 4.11.0.* scipy == 1.16.* # OpenVino & ONNX -openvino == 2025.3.* +openvino == 2025.4.* onnxruntime == 1.22.* # Embeddings transformers == 4.45.* @@ -79,7 +79,5 @@ sherpa-onnx==1.12.* faster-whisper==1.1.* librosa==0.11.* soundfile==0.13.* -# DeGirum detector -degirum == 0.16.* # Memory profiling memray == 1.15.* diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run index 4ce1c133f5..b834c09bbf 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run @@ -10,7 +10,8 @@ echo "[INFO] Starting certsync..." lefile="/etc/letsencrypt/live/frigate/fullchain.pem" -tls_enabled=`python3 /usr/local/nginx/get_listen_settings.py | jq -r .tls.enabled` +tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` +listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port` while true do @@ -34,7 +35,7 @@ do ;; esac - liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:8971 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'` + liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'` case "$liveprint" in *Fingerprint*) @@ -55,4 +56,4 @@ do done -exit 0 \ No newline at end of file +exit 0 diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index 8bd9b5250f..a3c7b32484 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -80,14 +80,14 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain. fi # build templates for optional FRIGATE_BASE_PATH environment variable -python3 /usr/local/nginx/get_base_path.py | \ +python3 /usr/local/nginx/get_nginx_settings.py | \ tempio -template /usr/local/nginx/templates/base_path.gotmpl \ - -out /usr/local/nginx/conf/base_path.conf + -out /usr/local/nginx/conf/base_path.conf -# build templates for optional TLS support -python3 /usr/local/nginx/get_listen_settings.py | \ - tempio -template /usr/local/nginx/templates/listen.gotmpl \ - -out /usr/local/nginx/conf/listen.conf +# build templates for additional network settings +python3 /usr/local/nginx/get_nginx_settings.py | \ + tempio -template /usr/local/nginx/templates/listen.gotmpl \ + -out /usr/local/nginx/conf/listen.conf # Replace the bash process with the NGINX process, redirecting stderr to stdout exec 2>&1 diff --git a/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py index 0f492cc5c5..9f4d08f2e3 100644 --- a/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py +++ b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py @@ -5,11 +5,7 @@ from typing import Any from ruamel.yaml import YAML sys.path.insert(0, "/opt/frigate") -from frigate.const import ( - DEFAULT_FFMPEG_VERSION, - INCLUDED_FFMPEG_VERSIONS, -) -from frigate.util.config import find_config_file +from frigate.util.config import find_config_file, resolve_ffmpeg_path sys.path.remove("/opt/frigate") @@ -29,9 +25,4 @@ except FileNotFoundError: config: dict[str, Any] = {} path = config.get("ffmpeg", {}).get("path", "default") -if path == "default": - print(f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg") -elif path in INCLUDED_FFMPEG_VERSIONS: - print(f"/usr/lib/ffmpeg/{path}/bin/ffmpeg") -else: - print(f"{path}/bin/ffmpeg") +print(resolve_ffmpeg_path(path, "ffmpeg")) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index dfd8b722ca..70cb744f13 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -9,14 +9,13 @@ from typing import Any from ruamel.yaml import YAML sys.path.insert(0, "/opt/frigate") +from frigate.config.env import substitute_frigate_vars from frigate.const import ( BIRDSEYE_PIPE, - DEFAULT_FFMPEG_VERSION, - INCLUDED_FFMPEG_VERSIONS, LIBAVFORMAT_VERSION_MAJOR, ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode -from frigate.util.config import find_config_file +from frigate.util.config import find_config_file, resolve_ffmpeg_path from frigate.util.services import ( is_go2rtc_arbitrary_exec_allowed, is_restricted_go2rtc_source, @@ -82,23 +81,18 @@ if go2rtc_config["webrtc"].get("candidates") is None: go2rtc_config["webrtc"]["candidates"] = default_candidates if go2rtc_config.get("rtsp", {}).get("username") is not None: - go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( - **FRIGATE_ENV_VARS + go2rtc_config["rtsp"]["username"] = substitute_frigate_vars( + go2rtc_config["rtsp"]["username"] ) if go2rtc_config.get("rtsp", {}).get("password") is not None: - go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( - **FRIGATE_ENV_VARS + go2rtc_config["rtsp"]["password"] = substitute_frigate_vars( + go2rtc_config["rtsp"]["password"] ) # ensure ffmpeg path is set correctly path = config.get("ffmpeg", {}).get("path", "default") -if path == "default": - ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" -elif path in INCLUDED_FFMPEG_VERSIONS: - ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg" -else: - ffmpeg_path = f"{path}/bin/ffmpeg" +ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg") if go2rtc_config.get("ffmpeg") is None: go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path} diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index f6b0928eb9..cad314ae35 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -63,6 +63,9 @@ http { server { include listen.conf; + # enable HTTP/2 for TLS connections to eliminate browser 6-connection limit + http2 on; + # vod settings vod_base_url ''; vod_segments_base_url ''; @@ -147,7 +150,9 @@ http { include auth_request.conf; types { video/mp4 mp4; - image/jpeg jpg; + image/jpeg jpg jpeg; + image/png png; + image/webp webp; } expires 7d; @@ -224,16 +229,6 @@ http { include proxy.conf; } - # frontend uses this to fetch the version - location /api/go2rtc/api { - include auth_request.conf; - limit_except GET { - deny all; - } - proxy_pass http://go2rtc/api; - include proxy.conf; - } - # integration uses this to add webrtc candidate location /api/go2rtc/webrtc { include auth_request.conf; @@ -281,6 +276,13 @@ http { include proxy.conf; } + location /api/logout { + auth_request off; + rewrite ^/api(/.*)$ $1 break; + proxy_pass http://frigate_api; + include proxy.conf; + } + # Allow unauthenticated access to the first_time_login endpoint # so the login page can load help text before authentication. location /api/auth/first_time_login { diff --git a/docker/main/rootfs/usr/local/nginx/get_base_path.py b/docker/main/rootfs/usr/local/nginx/get_base_path.py deleted file mode 100644 index 2e78a7de9e..0000000000 --- a/docker/main/rootfs/usr/local/nginx/get_base_path.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Prints the base path as json to stdout.""" - -import json -import os -from typing import Any - -base_path = os.environ.get("FRIGATE_BASE_PATH", "") - -result: dict[str, Any] = {"base_path": base_path} - -print(json.dumps(result)) diff --git a/docker/main/rootfs/usr/local/nginx/get_listen_settings.py b/docker/main/rootfs/usr/local/nginx/get_listen_settings.py deleted file mode 100644 index d879db56e4..0000000000 --- a/docker/main/rootfs/usr/local/nginx/get_listen_settings.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Prints the tls config as json to stdout.""" - -import json -import sys -from typing import Any - -from ruamel.yaml import YAML - -sys.path.insert(0, "/opt/frigate") -from frigate.util.config import find_config_file - -sys.path.remove("/opt/frigate") - -yaml = YAML() - -config_file = find_config_file() - -try: - with open(config_file) as f: - raw_config = f.read() - - if config_file.endswith((".yaml", ".yml")): - config: dict[str, Any] = yaml.load(raw_config) - elif config_file.endswith(".json"): - config: dict[str, Any] = json.loads(raw_config) -except FileNotFoundError: - config: dict[str, Any] = {} - -tls_config: dict[str, any] = config.get("tls", {"enabled": True}) -networking_config = config.get("networking", {}) -ipv6_config = networking_config.get("ipv6", {"enabled": False}) - -output = {"tls": tls_config, "ipv6": ipv6_config} - -print(json.dumps(output)) diff --git a/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py new file mode 100644 index 0000000000..79cda36860 --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py @@ -0,0 +1,62 @@ +"""Prints the nginx settings as json to stdout.""" + +import json +import os +import sys +from typing import Any + +from ruamel.yaml import YAML + +sys.path.insert(0, "/opt/frigate") +from frigate.util.config import find_config_file + +sys.path.remove("/opt/frigate") + +yaml = YAML() + +config_file = find_config_file() + +try: + with open(config_file) as f: + raw_config = f.read() + + if config_file.endswith((".yaml", ".yml")): + config: dict[str, Any] = yaml.load(raw_config) + elif config_file.endswith(".json"): + config: dict[str, Any] = json.loads(raw_config) +except FileNotFoundError: + config: dict[str, Any] = {} + +tls_config: dict[str, Any] = config.get("tls", {}) +tls_config.setdefault("enabled", True) + +networking_config: dict[str, Any] = config.get("networking", {}) +ipv6_config: dict[str, Any] = networking_config.get("ipv6", {}) +ipv6_config.setdefault("enabled", False) + +listen_config: dict[str, Any] = networking_config.get("listen", {}) +listen_config.setdefault("internal", 5000) +listen_config.setdefault("external", 8971) + +# handle case where internal port is a string with ip:port +internal_port = listen_config["internal"] +if type(internal_port) is str: + internal_port = int(internal_port.split(":")[-1]) +listen_config["internal_port"] = internal_port + +# handle case where external port is a string with ip:port +external_port = listen_config["external"] +if type(external_port) is str: + external_port = int(external_port.split(":")[-1]) +listen_config["external_port"] = external_port + +base_path = os.environ.get("FRIGATE_BASE_PATH", "") + +result: dict[str, Any] = { + "tls": tls_config, + "ipv6": ipv6_config, + "listen": listen_config, + "base_path": base_path, +} + +print(json.dumps(result)) diff --git a/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl index ace4443ee5..ca945ba1fe 100644 --- a/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl +++ b/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl @@ -7,7 +7,7 @@ location ^~ {{ .base_path }}/ { # remove base_url from the path before passing upstream rewrite ^{{ .base_path }}/(.*) /$1 break; - proxy_pass $scheme://127.0.0.1:8971; + proxy_pass $scheme://127.0.0.1:{{ .listen.external_port }}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl index 066f872cb9..628784b609 100644 --- a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl +++ b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl @@ -1,45 +1,36 @@ - # Internal (IPv4 always; IPv6 optional) -listen 5000; -{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:5000;{{ end }}{{ end }} - +listen {{ .listen.internal }}; +{{ if .ipv6.enabled }}listen [::]:{{ .listen.internal_port }};{{ end }} # intended for external traffic, protected by auth -{{ if .tls }} - {{ if .tls.enabled }} - # external HTTPS (IPv4 always; IPv6 optional) - listen 8971 ssl; - {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971 ssl;{{ end }}{{ end }} +{{ if .tls.enabled }} + # external HTTPS (IPv4 always; IPv6 optional) + listen {{ .listen.external }} ssl; + {{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }} - ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; + ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; - # generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP - # https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 - ssl_session_timeout 1d; - ssl_session_cache shared:MozSSL:10m; # about 40000 sessions - ssl_session_tickets off; + # generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP + # https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; - # modern configuration - ssl_protocols TLSv1.3; - ssl_prefer_server_ciphers off; + # modern configuration + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; - # HSTS (ngx_http_headers_module is required) (63072000 seconds) - add_header Strict-Transport-Security "max-age=63072000" always; + # HSTS (ngx_http_headers_module is required) (63072000 seconds) + add_header Strict-Transport-Security "max-age=63072000" always; - # ACME challenge location - location /.well-known/acme-challenge/ { - default_type "text/plain"; - root /etc/letsencrypt/www; - } - {{ else }} - # external HTTP (IPv4 always; IPv6 optional) - listen 8971; - {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }} - {{ end }} + # ACME challenge location + location /.well-known/acme-challenge/ { + default_type "text/plain"; + root /etc/letsencrypt/www; + } {{ else }} - # (No tls section) default to HTTP (IPv4 always; IPv6 optional) - listen 8971; - {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }} + # (No tls) default to HTTP (IPv4 always; IPv6 optional) + listen {{ .listen.external }}; + {{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }};{{ end }} {{ end }} - diff --git a/docker/rockchip/conv2rknn.py b/docker/rockchip/conv2rknn.py index 4880d98684..700f35689f 100644 --- a/docker/rockchip/conv2rknn.py +++ b/docker/rockchip/conv2rknn.py @@ -11,10 +11,10 @@ except FileNotFoundError: pass try: - with open("/config/conv2rknn.yaml", "r") as config_file: + with open("/config/conv2rknn.yaml") as config_file: configuration = yaml.safe_load(config_file) except FileNotFoundError: - raise Exception("Please place a config file at /config/conv2rknn.yaml") + raise Exception("Please place a config file at /config/conv2rknn.yaml") from None if configuration["config"] != None: rknn_config = configuration["config"] @@ -31,7 +31,7 @@ if "soc" not in configuration: with open("/proc/device-tree/compatible") as file: soc = file.read().split(",")[-1].strip("\x00") except FileNotFoundError: - raise Exception("Make sure to run docker in privileged mode.") + raise Exception("Make sure to run docker in privileged mode.") from None configuration["soc"] = [ soc, diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 9edcd60585..653ed1e4ee 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -13,7 +13,7 @@ ARG ROCM RUN apt update -qq && \ apt install -y wget gpg && \ - wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \ + wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \ apt install -y ./rocm.deb && \ apt update && \ apt install -qq -y rocm @@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf FROM deps AS deps-prelim COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources -RUN apt-get update && \ +# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching +# -dev package must also come from trixie or apt refuses to satisfy it. +RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \ + apt-get update && \ apt-get install -y libnuma1 && \ apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \ - # Install C++ standard library headers for HIPRTC kernel compilation fallback - apt-get install -qq -y libstdc++-12-dev && \ + apt-get install -qq -y -t trixie libstdc++-14-dev && \ + rm -f /etc/apt/sources.list.d/trixie.list && \ rm -rf /var/lib/apt/lists/* WORKDIR /opt/frigate @@ -56,13 +59,17 @@ FROM scratch AS rocm-dist ARG ROCM +# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime +COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/ COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/ -# Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3) +# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4) COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/ -# Copy rocBLAS library files for gfx10xx and gfx11xx only +COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/ +# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/ +COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-dist/ / ####################################################################### @@ -71,6 +78,10 @@ ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1 ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1 ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1 ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1 +ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx +ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx +ENV AMD_COMGR_CACHE=1 +ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx COPY --from=rocm-dist / / diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index b6a202f93f..f60b550c3f 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1 @@ -onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.1.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl \ No newline at end of file +onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl \ No newline at end of file diff --git a/docker/rocm/rocm.hcl b/docker/rocm/rocm.hcl index 6595066c50..224118818e 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -1,5 +1,5 @@ variable "ROCM" { - default = "7.1.1" + default = "7.2.3" } variable "HSA_OVERRIDE_GFX_VERSION" { default = "" diff --git a/docker/tensorrt/requirements-amd64.txt b/docker/tensorrt/requirements-amd64.txt index 63c68b5832..597680c00c 100644 --- a/docker/tensorrt/requirements-amd64.txt +++ b/docker/tensorrt/requirements-amd64.txt @@ -1,18 +1,18 @@ -# NVidia TensorRT Support (amd64 only) +# Nvidia ONNX Runtime GPU Support --extra-index-url 'https://pypi.nvidia.com' cython==3.0.*; platform_machine == 'x86_64' -nvidia_cuda_cupti_cu12==12.5.82; platform_machine == 'x86_64' -nvidia-cublas-cu12==12.5.3.*; platform_machine == 'x86_64' -nvidia-cudnn-cu12==9.3.0.*; platform_machine == 'x86_64' -nvidia-cufft-cu12==11.2.3.*; platform_machine == 'x86_64' -nvidia-curand-cu12==10.3.6.*; platform_machine == 'x86_64' -nvidia_cuda_nvcc_cu12==12.5.82; platform_machine == 'x86_64' -nvidia-cuda-nvrtc-cu12==12.5.82; platform_machine == 'x86_64' -nvidia_cuda_runtime_cu12==12.5.82; platform_machine == 'x86_64' -nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64' -nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64' -nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64' -nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64' +nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64' +nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64' +nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64' +nvidia-cufft-cu12==11.3.3.83; platform_machine == 'x86_64' +nvidia-curand-cu12==10.3.9.90; platform_machine == 'x86_64' +nvidia-cuda-nvcc-cu12==12.8.93; platform_machine == 'x86_64' +nvidia-cuda-nvrtc-cu12==12.8.93; platform_machine == 'x86_64' +nvidia-cuda-runtime-cu12==12.8.90; platform_machine == 'x86_64' +nvidia-cusolver-cu12==11.7.3.90; platform_machine == 'x86_64' +nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64' +nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64' +nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64' -onnxruntime-gpu==1.22.*; platform_machine == 'x86_64' +onnxruntime-gpu==1.24.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64' diff --git a/docs/.gitignore b/docs/.gitignore index b2d6de3062..6e46bafc0f 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -7,6 +7,7 @@ # Generated files .docusaurus .cache-loader +docs/integrations/api/ # Misc .DS_Store diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml new file mode 100644 index 0000000000..9d9aa40a1d --- /dev/null +++ b/docs/data/object_detectors_models.yaml @@ -0,0 +1,1271 @@ +edgeTPU: + title: EdgeTPU + models: + - key: mobiledet + label: Mobiledet + recommended: true + download: A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`. + ui: Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. + yaml: |- + detectors: + coral: + type: edgetpu + device: usb + - key: yolov9 + label: YOLOv9 + recommended: false + download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes." + ui: |- + Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings: + + | Field | Value | + | ---------------------------------------- | ----------------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite` | + | **Label map for custom object detector** | `/config/labels-coco17.txt` | + | **Object detection model input width** | `320` (should match the imgsize of the model) | + | **Object detection model input height** | `320` (should match the imgsize of the model) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nhwc` (Frigate's default value) | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + coral: + type: edgetpu + device: usb + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize of the model, typically 320 + height: 320 # <--- should match the imgsize of the model, typically 320 + path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite + labelmap_path: /config/labels-coco17.txt +hailo8l: + title: Hailo-8/Hailo-8L + models: + - key: yolo + label: YOLO + recommended: true + download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings: + + | Field | Value | + | ---------------------------------------- | ----------------------- | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` | + | **Object Detection Model Type** | `yolo-generic` | + + The detector automatically selects the default model based on your hardware. Optionally, specify a local model path or URL to override. + yaml: |- + detectors: + hailo: + type: hailo8l + device: PCIe + + model: + width: 320 + height: 320 + input_tensor: nhwc + input_pixel_format: rgb + input_dtype: int + model_type: yolo-generic + labelmap_path: /labelmap/coco-80.txt + + # The detector automatically selects the default model based on your hardware: + # - For Hailo-8 hardware: YOLOv6n (default: yolov6n.hef) + # - For Hailo-8L hardware: YOLOv6n (default: yolov6n.hef) + # + # Optionally, you can specify a local model path to override the default. + # If a local path is provided and the file exists, it will be used instead of downloading. + # Example: + # path: /config/model_cache/hailo/yolov6n.hef + # + # You can also override using a custom URL: + # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef + # just make sure to give it the write configuration based on the model + - key: ssd + label: SSD MobileNet v1 + recommended: false + download: For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings: + + | Field | Value | + | --------------------------------------- | ------ | + | **Object detection model input width** | `300` | + | **Object detection model input height** | `300` | + | **Model Input Pixel Color Format** | `rgb` | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `ssd` | + + Specify the local model path or URL for SSD MobileNet v1. + yaml: |- + detectors: + hailo: + type: hailo8l + device: PCIe + + model: + width: 300 + height: 300 + input_tensor: nhwc + input_pixel_format: rgb + model_type: ssd + # Specify the local model path (if available) or URL for SSD MobileNet v1. + # Example with a local path: + # path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef + # + # Or override using a custom URL: + # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/ssd_mobilenet_v1.hef +openvino: + title: OpenVINO + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: |- + YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`). + + ```sh + docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF' + FROM python:3.11 AS build + RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ + WORKDIR /yolov9 + ADD https://github.com/WongKinYiu/yolov9.git . + RUN uv pip install --system -r requirements.txt + RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript + ARG MODEL_SIZE + ARG IMG_SIZE + ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt + RUN sed -i "s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g" models/experimental.py + RUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx + FROM scratch + ARG MODEL_SIZE + ARG IMG_SIZE + COPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + ov: + type: openvino + device: GPU # or NPU + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: ssd + label: SSDLite MobileNet v2 + recommended: false + download: An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------ | + | **Custom object detector model path** | `/openvino-model/ssdlite_mobilenet_v2.xml` | + | **Label map for custom object detector** | `/openvino-model/coco_91cl_bkgr.txt` | + | **Object detection model input width** | `300` | + | **Object detection model input height** | `300` | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `ssd` (Frigate's default value) | + yaml: |- + detectors: + ov: + type: openvino + device: GPU # Or NPU + + model: + width: 300 + height: 300 + input_tensor: nhwc + input_pixel_format: bgr + path: /openvino-model/ssdlite_mobilenet_v2.xml + labelmap_path: /openvino-model/coco_91cl_bkgr.txt + - key: yolo-legacy + label: YOLO (v3, v4, v7) + recommended: false + download: |- + To export as ONNX: + + ```sh + git clone https://github.com/NateMeyer/tensorrt_demos + cd tensorrt_demos/yolo + ./download_yolo.sh + python3 yolo_to_onnx.py -m yolov7-320 + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + ov: + type: openvino + device: GPU # or NPU + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: yolonas + label: YOLO-NAS + recommended: false + download: |- + You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). + + :::warning + + The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html + + ::: + + The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------------- | + | **Custom object detector model path** | `/config/yolo_nas_s.onnx` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match whatever was set in notebook) | + | **Object detection model input height** | `320` (should match whatever was set in notebook) | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolonas` | + yaml: |- + detectors: + ov: + type: openvino + device: GPU + + model: + model_type: yolonas + width: 320 # <--- should match whatever was set in notebook + height: 320 # <--- should match whatever was set in notebook + input_tensor: nchw + input_pixel_format: bgr + path: /config/yolo_nas_s.onnx + labelmap_path: /labelmap/coco-80.txt + - key: yolox + label: YOLOX + recommended: false + download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime). + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ------------------------------------- | -------------------------------- | + | **Custom object detector model path** | `/config/yolox.onnx` (use the filename you generated above) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nhwc` (Frigate's default value) | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolox` | + yaml: |- + detectors: + ov: + type: openvino + device: GPU + + model: + model_type: yolox + path: /config/model_cache/yolox.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: rfdetr + label: RF-DETR + recommended: false + download: |- + RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size. + + ```sh + docker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF' + FROM python:3.12 AS build + RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ + WORKDIR /rfdetr + RUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript + ARG MODEL_SIZE + RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)" + FROM scratch + ARG MODEL_SIZE + COPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | --------------------------------------- | --------------------------------- | + | **Custom object detector model path** | `/config/model_cache/rfdetr.onnx` (use the filename you generated above) | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `rfdetr` | + yaml: |- + detectors: + ov: + type: openvino + device: GPU + + model: + model_type: rfdetr + width: 320 + height: 320 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/rfdetr.onnx # use the filename you generated above + - key: dfine + label: D-FINE / DEIMv2 + recommended: false + download: |- + #### D-FINE + + D-FINE can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=s` in the first line to `s`, `m`, or `l` size. + + ```sh + docker build . --build-arg MODEL_SIZE=s --output . -f- <<'EOF' + FROM python:3.11 AS build + RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ + WORKDIR /dfine + RUN git clone https://github.com/Peterande/D-FINE.git . + RUN uv pip install --system -r requirements.txt + RUN uv pip install --system onnx onnxruntime onnxsim onnxscript + # Create output directory and download checkpoint + RUN mkdir -p output + ARG MODEL_SIZE + RUN wget https://github.com/Peterande/storage/releases/download/dfinev1.0/dfine_${MODEL_SIZE}_obj2coco.pth -O output/dfine_${MODEL_SIZE}_obj2coco.pth + # Modify line 58 of export_onnx.py to change batch size to 1 + RUN sed -i '58s/data = torch.rand(.*)/data = torch.rand(1, 3, 640, 640)/' tools/deployment/export_onnx.py + RUN python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_${MODEL_SIZE}_obj2coco.yml -r output/dfine_${MODEL_SIZE}_obj2coco.pth + FROM scratch + ARG MODEL_SIZE + COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL_SIZE}.onnx + EOF + ``` + + #### DEIMv2 + + [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families: + + - **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n` + - **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x` + + Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`). + + ```sh + docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF' + FROM python:3.11-slim AS build + RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ + WORKDIR /deimv2 + RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git . + # Install CPU-only PyTorch first to avoid pulling CUDA variant + RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu + RUN uv pip install --no-cache --system -r requirements.txt + RUN uv pip install --no-cache --system onnx safetensors huggingface_hub + RUN mkdir -p output + ARG BACKBONE + ARG MODEL_SIZE + # Download from Hugging Face and convert safetensors to pth + RUN python3 -c "\ + from huggingface_hub import hf_hub_download; \ + from safetensors.torch import load_file; \ + import torch; \ + backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \ + size = '${MODEL_SIZE}'.upper(); \ + st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \ + torch.save({'model': st}, 'output/deimv2.pth')" + RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py + # HuggingFace safetensors omits frozen constants that the model constructor initializes + RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py + RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth + FROM scratch + ARG BACKBONE + ARG MODEL_SIZE + COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ---------------------------------- | + | **Custom object detector model path** | `/config/model_cache/dfine-s.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `640` | + | **Object detection model input height** | `640` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `dfine` | + yaml: |- + detectors: + ov: + type: openvino + device: CPU + + model: + model_type: dfine + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/dfine-s.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt +appleSilicon: + title: Apple Silicon + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: |- + YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`). + + ```sh + docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF' + FROM python:3.11 AS build + RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ + WORKDIR /yolov9 + ADD https://github.com/WongKinYiu/yolov9.git . + RUN uv pip install --system -r requirements.txt + RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript + ARG MODEL_SIZE + ARG IMG_SIZE + ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt + RUN sed -i "s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g" models/experimental.py + RUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx + FROM scratch + ARG MODEL_SIZE + ARG IMG_SIZE + COPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + apple-silicon: + type: zmq + endpoint: tcp://host.docker.internal:5555 + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: yolo-legacy + label: YOLO (v3, v4, v7) + recommended: false + download: |- + To export as ONNX: + + ```sh + git clone https://github.com/NateMeyer/tensorrt_demos + cd tensorrt_demos/yolo + ./download_yolo.sh + python3 yolo_to_onnx.py -m yolov7-320 + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + apple-silicon: + type: zmq + endpoint: tcp://host.docker.internal:5555 + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt +onnx: + title: ONNX + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: |- + YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`). + + ```sh + docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF' + FROM python:3.11 AS build + RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ + WORKDIR /yolov9 + ADD https://github.com/WongKinYiu/yolov9.git . + RUN uv pip install --system -r requirements.txt + RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript + ARG MODEL_SIZE + ARG IMG_SIZE + ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt + RUN sed -i "s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g" models/experimental.py + RUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx + FROM scratch + ARG MODEL_SIZE + ARG IMG_SIZE + COPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: rfdetr + label: RF-DETR + recommended: false + download: |- + RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size. + + ```sh + docker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF' + FROM python:3.12 AS build + RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ + WORKDIR /rfdetr + RUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript + ARG MODEL_SIZE + RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)" + FROM scratch + ARG MODEL_SIZE + COPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | --------------------------------------- | --------------------------------- | + | **Custom object detector model path** | `/config/model_cache/rfdetr.onnx` (use the filename you generated above) | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `rfdetr` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: rfdetr + width: 320 + height: 320 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/rfdetr.onnx # use the filename you generated above + - key: yolonas + label: YOLO-NAS + recommended: false + download: |- + You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). + + :::warning + + The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html + + ::: + + The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------------- | + | **Custom object detector model path** | `/config/yolo_nas_s.onnx` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match whatever was set in notebook) | + | **Object detection model input height** | `320` (should match whatever was set in notebook) | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolonas` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: yolonas + width: 320 # <--- should match whatever was set in notebook + height: 320 # <--- should match whatever was set in notebook + input_pixel_format: bgr + input_tensor: nchw + path: /config/yolo_nas_s.onnx + labelmap_path: /labelmap/coco-80.txt + - key: yolox + label: YOLOX + recommended: false + download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime). + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolox_tiny.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `416` (should match the imgsize set during model export) | + | **Object detection model input height** | `416` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float_denorm` | + | **Object Detection Model Type** | `yolox` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: yolox + width: 416 # <--- should match the imgsize set during model export + height: 416 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float_denorm + path: /config/model_cache/yolox_tiny.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: dfine + label: D-FINE / DEIMv2 + recommended: false + download: |- + #### Downloading D-FINE Model + + D-FINE can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=s` in the first line to `s`, `m`, or `l` size. + + ```sh + docker build . --build-arg MODEL_SIZE=s --output . -f- <<'EOF' + FROM python:3.11 AS build + RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ + WORKDIR /dfine + RUN git clone https://github.com/Peterande/D-FINE.git . + RUN uv pip install --system -r requirements.txt + RUN uv pip install --system onnx onnxruntime onnxsim onnxscript + # Create output directory and download checkpoint + RUN mkdir -p output + ARG MODEL_SIZE + RUN wget https://github.com/Peterande/storage/releases/download/dfinev1.0/dfine_${MODEL_SIZE}_obj2coco.pth -O output/dfine_${MODEL_SIZE}_obj2coco.pth + # Modify line 58 of export_onnx.py to change batch size to 1 + RUN sed -i '58s/data = torch.rand(.*)/data = torch.rand(1, 3, 640, 640)/' tools/deployment/export_onnx.py + RUN python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_${MODEL_SIZE}_obj2coco.yml -r output/dfine_${MODEL_SIZE}_obj2coco.pth + FROM scratch + ARG MODEL_SIZE + COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL_SIZE}.onnx + EOF + ``` + + #### Downloading DEIMv2 Model + + [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families: + + - **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n` + - **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x` + + Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`). + + ```sh + docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF' + FROM python:3.11-slim AS build + RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ + WORKDIR /deimv2 + RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git . + # Install CPU-only PyTorch first to avoid pulling CUDA variant + RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu + RUN uv pip install --no-cache --system -r requirements.txt + RUN uv pip install --no-cache --system onnx safetensors huggingface_hub + RUN mkdir -p output + ARG BACKBONE + ARG MODEL_SIZE + # Download from Hugging Face and convert safetensors to pth + RUN python3 -c "\ + from huggingface_hub import hf_hub_download; \ + from safetensors.torch import load_file; \ + import torch; \ + backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \ + size = '${MODEL_SIZE}'.upper(); \ + st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \ + torch.save({'model': st}, 'output/deimv2.pth')" + RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py + # HuggingFace safetensors omits frozen constants that the model constructor initializes + RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py + RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth + FROM scratch + ARG BACKBONE + ARG MODEL_SIZE + COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx + EOF + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/dfine_m_obj2coco.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `640` | + | **Object detection model input height** | `640` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `dfine` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: dfine + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/dfine_m_obj2coco.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + - key: yolo-legacy + label: YOLO (v3, v4, v7) + recommended: false + download: |- + To export as ONNX: + + ```sh + git clone https://github.com/NateMeyer/tensorrt_demos + cd tensorrt_demos/yolo + ./download_yolo.sh + python3 yolo_to_onnx.py -m yolov7-320 + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------------- | + | **Custom object detector model path** | `/config/model_cache/yolo.onnx` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (should match the imgsize set during model export) | + | **Object detection model input height** | `320` (should match the imgsize set during model export) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + onnx: + type: onnx + + model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.onnx # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt +cpu: + title: CPU + models: + - key: ssd + label: MobileNet v2 + recommended: true + download: A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended). + + | Field | Value | + | ----------------- | ----- | + | **Detector type** | `cpu` | + | **Num threads** | `3` | + yaml: |- + detectors: + cpu1: + type: cpu + num_threads: 3 +deepstack: + title: DeepStack / CodeProject.AI + models: + - key: yolo + label: YOLO + recommended: true + download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://:/v1/vision/detection`). + + | Field | Value | + | ------------- | ---------------------------------------------------------------------- | + | **API URL** | `http://:/v1/vision/detection` | + | **API Timeout** | `0.1` (seconds) | + yaml: |- + detectors: + deepstack: + api_url: http://:/v1/vision/detection + type: deepstack + api_timeout: 0.1 # seconds +memryx: + title: MemryX + models: + - key: yolonas + label: YOLO-NAS + recommended: true + download: |- + The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded automatically and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage). + + **Note:** The default model for the MemryX detector is YOLO-NAS 320x320. + + The input size for **YOLO-NAS** can be set to either **320x320** (default) or **640x640**. + + - The default size of **320x320** is optimized for lower CPU usage and faster inference times. + + MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------------- | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (can be set to `640` for higher resolution) | + | **Object detection model input height** | `320` (can be set to `640` for higher resolution) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolonas` | + yaml: |- + detectors: + memx0: + type: memryx + device: PCIe:0 + + model: + model_type: yolonas + width: 320 # (Can be set to 640 for higher resolution) + height: 320 # (Can be set to 640 for higher resolution) + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolonas.zip + # The .zip file must contain: + # ├── yolonas.dfp (a file ending with .dfp) + # └── yolonas_post.onnx (optional; only if the model includes a cropped post-processing network) + - key: yolov9 + label: YOLOv9 + recommended: false + download: |- + The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage). + + MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------------- | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (can be set to `640` for higher resolution) | + | **Object detection model input height** | `320` (can be set to `640` for higher resolution) | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + memx0: + type: memryx + device: PCIe:0 + + model: + model_type: yolo-generic + width: 320 # (Can be set to 640 for higher resolution) + height: 320 # (Can be set to 640 for higher resolution) + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolov9.zip + # The .zip file must contain: + # ├── yolov9.dfp (a file ending with .dfp) + - key: yolox + label: YOLOX + recommended: false + download: |- + The model is sourced from the [OpenCV Model Zoo](https://github.com/opencv/opencv_zoo) and precompiled to DFP. + + MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ----------------------- | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `640` | + | **Object detection model input height** | `640` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float_denorm` | + | **Object Detection Model Type** | `yolox` | + yaml: |- + detectors: + memx0: + type: memryx + device: PCIe:0 + + model: + model_type: yolox + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float_denorm + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolox.zip + # The .zip file must contain: + # ├── yolox.dfp (a file ending with .dfp) + - key: ssd + label: SSDLite MobileNet v2 + recommended: false + download: |- + The model is sourced from the [OpenMMLab Model Zoo](https://mmdeploy-oss.openmmlab.com/model/mmdet-det/ssdlite-e8679f.onnx) and has been converted to DFP. + + MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ----------------------- | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `float` | + | **Object Detection Model Type** | `ssd` | + yaml: |- + detectors: + memx0: + type: memryx + device: PCIe:0 + + model: + model_type: ssd + width: 320 + height: 320 + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/ssdlite_mobilenet.zip + # The .zip file must contain: + # ├── ssdlite_mobilenet.dfp (a file ending with .dfp) + # └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network) +tensorrt: + title: TensorRT + models: + - key: yolo-legacy + label: YOLO (v3, v4, v7) + recommended: true + download: |- + The model used for TensorRT must be preprocessed on the same hardware platform that it will run on, so Frigate generates the `.trt` model file on-device at startup. Processed models are stored in the `/config/model_cache` folder. + + By default no models are generated. Set the `YOLO_MODELS` environment variable in Docker to one or more comma-separated model names (from the available `yolov3`/`yolov4`/`yolov7` models) and each one will be generated on startup if the corresponding `{model}.trt` file is not already present in `model_cache` (delete it to force regeneration). On Jetson devices with DLAs (Xavier or Orin), append `-dla` to a model name to generate a DLA model. If your GPU does not support FP16 operations, pass `USE_FP16=False` to disable it. + + An example `docker-compose.yml` fragment that converts the `yolov7-320` and `yolov7x-640` models: + + ```yml + frigate: + environment: + - YOLO_MODELS=yolov7-320,yolov7x-640 + - USE_FP16=false + ``` + ui: |- + Navigate to **Settings > System > Detectors and model** and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ------------------------------------------------------------ | + | **Custom object detector model path** | `/config/model_cache/tensorrt/yolov7-320.trt` (use the filename you generated above) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` (MUST match the chosen model, e.g., yolov7-320 -> 320) | + | **Object detection model input height** | `320` (MUST match the chosen model, e.g., yolov7-320 -> 320) | + | **Model Input Pixel Color Format** | `rgb` | + | **Model Input Tensor Shape** | `nchw` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `ssd` (Frigate's default value) | + yaml: |- + detectors: + tensorrt: + type: tensorrt + device: 0 #This is the default, select the first GPU + + model: + path: /config/model_cache/tensorrt/yolov7-320.trt # use the filename you generated above + labelmap_path: /labelmap/coco-80.txt + input_tensor: nchw + input_pixel_format: rgb + width: 320 # MUST match the chosen model i.e yolov7-320 -> 320, yolov4-416 -> 416 + height: 320 # MUST match the chosen model i.e yolov7-320 -> 320 yolov4-416 -> 416 +synaptics: + title: Synaptics + models: + - key: ssd + label: SSD MobileNet + recommended: true + download: A synap model is provided in the container at `/synaptics/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80). + ui: |- + Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ---------------------------- | + | **Custom object detector model path** | `/synaptics/mobilenet.synap` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `224` | + | **Object detection model input height** | `224` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `ssd` (Frigate's default value) | + yaml: |- + detectors: # required + synap_npu: # required + type: synaptics # required + + model: # required + path: /synaptics/mobilenet.synap # required + width: 224 # required + height: 224 # required + input_tensor: nhwc # default value (optional. If you change the model, it is required) + labelmap_path: /labelmap/coco-80.txt # required +rknn: + title: RKNN + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: |- + If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. + + You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models. + ui: |- + Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | -------------------------------------------------- | + | **Custom object detector model path** | `frigate-fp16-yolov9-t` (or other yolov9 variants) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + model: # required + # name of model (will be automatically downloaded) or path to your own .rknn model file + # possible values are: + # - frigate-fp16-yolov9-t + # - frigate-fp16-yolov9-s + # - frigate-fp16-yolov9-m + # - frigate-fp16-yolov9-c + # - frigate-fp16-yolov9-e + # your yolo_model.rknn + path: frigate-fp16-yolov9-t + model_type: yolo-generic + width: 320 + height: 320 + input_tensor: nhwc + labelmap_path: /labelmap/coco-80.txt + - key: yolonas + label: YOLO-NAS + recommended: false + download: |- + If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. + + You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models. + + **Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html + ui: |- + Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ----------------------------------------------------------------------- | + | **Custom object detector model path** | `deci-fp16-yolonas_s` (or `deci-fp16-yolonas_m`, `deci-fp16-yolonas_l`) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolonas` | + yaml: |- + model: # required + # name of model (will be automatically downloaded) or path to your own .rknn model file + # possible values are: + # - deci-fp16-yolonas_s + # - deci-fp16-yolonas_m + # - deci-fp16-yolonas_l + # your yolonas_model.rknn + path: deci-fp16-yolonas_s + model_type: yolonas + width: 320 + height: 320 + input_pixel_format: bgr + input_tensor: nhwc + labelmap_path: /labelmap/coco-80.txt + - key: yolox + label: YOLOx + recommended: false + download: |- + If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. + + You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models. + ui: |- + Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ---------------------------------------------- | + | **Custom object detector model path** | `rock-i8-yolox_nano` (or other yolox variants) | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `416` | + | **Object detection model input height** | `416` | + | **Model Input Pixel Color Format** | `rgb` (Frigate's default value) | + | **Model Input Tensor Shape** | `nhwc` | + | **Model Input D Type** | `int` (Frigate's default value) | + | **Object Detection Model Type** | `yolox` | + yaml: |- + model: # required + # name of model (will be automatically downloaded) or path to your own .rknn model file + # possible values are: + # - rock-i8-yolox_nano + # - rock-i8-yolox_tiny + # - rock-fp16-yolox_nano + # - rock-fp16-yolox_tiny + # your yolox_model.rknn + path: rock-i8-yolox_nano + model_type: yolox + width: 416 + height: 416 + input_tensor: nhwc + labelmap_path: /labelmap/coco-80.txt +axengine: + title: AXEngine + models: + - key: yolov9 + label: YOLOv9 + recommended: true + download: A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline. + ui: |- + Navigate to **Settings > System > Detectors and model** and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ----------------------- | + | **Custom object detector model path** | `frigate-yolov9-tiny` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `320` | + | **Object detection model input height** | `320` | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nhwc` (Frigate's default value) | + | **Model Input D Type** | `int` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + axengine: + type: axengine + + model: + path: frigate-yolov9-tiny + model_type: yolo-generic + width: 320 + height: 320 + input_dtype: int + input_pixel_format: bgr + labelmap_path: /labelmap/coco-80.txt diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/advanced/reference.md similarity index 83% rename from docs/docs/configuration/reference.md rename to docs/docs/configuration/advanced/reference.md index edcea57a14..802f3d7856 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -11,6 +11,8 @@ It is not recommended to copy this full configuration file. Only specify values ::: +Sections marked `# NOTE: Can be overridden at the camera level` can be set globally and then adjusted per camera. See [Global and Camera-Level Configuration](../config_overrides.md) for how that works. + ```yaml mqtt: # Optional: Enable mqtt server (default: shown below) @@ -75,11 +77,19 @@ tls: # Optional: Enable TLS for port 8971 (default: shown below) enabled: True -# Optional: IPv6 configuration +# Optional: Networking configuration networking: # Optional: Enable IPv6 on 5000, and 8971 if tls is configured (default: shown below) ipv6: enabled: False + # Optional: Override ports Frigate uses for listening (defaults: shown below) + # An IP address may also be provided to bind to a specific interface, e.g. ip:port + # NOTE: This setting is for advanced users and may break some integrations. The majority + # of users should change ports in the docker compose file + # or use the docker run `--publish` option to select a different port. + listen: + internal: 5000 + external: 8971 # Optional: Proxy configuration proxy: @@ -139,6 +149,13 @@ auth: # NOTE: changing this value will not automatically update password hashes, you # will need to change each user password for it to apply hash_iterations: 600000 + # Optional: Map roles to the list of cameras each role can access (default: none) + # NOTE: An empty list grants the role access to all cameras. Roles defined here can be + # referenced by proxy header role mapping or assigned to native users. + roles: + my_custom_role: + - front_door + - back_yard # Optional: model modifications # NOTE: The default values are for the EdgeTPU detector. @@ -156,10 +173,14 @@ model: # Valid values are rgb, bgr, or yuv. (default: shown below) input_pixel_format: rgb # Required: Object detection model input tensor format - # Valid values are nhwc or nchw (default: shown below) + # Valid values are nhwc, nchw, hwnc, or hwcn (default: shown below) input_tensor: nhwc - # Required: Object detection model type, currently only used with the OpenVINO detector - # Valid values are ssd, yolox, yolonas (default: shown below) + # Optional: Data type of the model input tensor + # Valid values are float, float_denorm, or int (default: shown below) + input_dtype: int + # Required: Object detection model architecture, used by detectors that support more + # than one model type (openvino, onnx, rknn, memryx, axengine, synaptics, and others) + # Valid values are ssd, yolox, yolonas, yolo-generic, rfdetr, dfine (default: shown below) model_type: ssd # Required: Label name modifications. These are merged into the standard labelmap. labelmap: @@ -188,11 +209,12 @@ audio: # - 500 - medium sensitivity # - 1000 - low sensitivity min_volume: 500 + # Optional: Number of threads to use for audio detection (default: shown below) + num_threads: 2 # Optional: Types of audio to listen for (default: shown below) listen: - bark - fire_alarm - - scream - speech - yell # Optional: Filters to configure detection. @@ -249,7 +271,7 @@ birdseye: # More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets ffmpeg: # Optional: ffmpeg binary path (default: shown below) - # can also be set to `7.0` or `5.0` to specify one of the included versions + # can also be set to `8.0` or `5.0` to specify one of the included versions # or can be set to any path that holds `bin/ffmpeg` & `bin/ffprobe` path: "default" # Optional: global ffmpeg args (default: shown below) @@ -320,7 +342,7 @@ detect: # especially when using separate streams for detect and record. # Use this setting to make the timeline bounding boxes more closely align # with the recording. The value can be positive or negative. - # TIP: Imagine there is an tracked object clip with a person walking from left to right. + # TIP: Imagine there is a tracked object clip with a person walking from left to right. # If the tracked object lifecycle bounding box is consistently to the left of the person # then the value should be decreased. Similarly, if a person is walking from # left to right and the bounding box is consistently ahead of the person @@ -339,7 +361,15 @@ objects: # Optional: mask to prevent all object types from being detected in certain areas (default: no mask) # Checks based on the bottom center of the bounding box of the object. # NOTE: This mask is COMBINED with the object type specific mask below - mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278 + mask: + # Object filter mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Object filter mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" # Optional: filters to reduce false positives for specific object types filters: person: @@ -359,7 +389,15 @@ objects: threshold: 0.7 # Optional: mask to prevent this object type from being detected in certain areas (default: no mask) # Checks based on the bottom center of the bounding box of the object - mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278 + mask: + # Object filter mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Object filter mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" # Optional: Configuration for AI generated tracked object descriptions genai: # Optional: Enable AI object description generation (default: shown below) @@ -433,8 +471,8 @@ review: detections: False # Optional: Activity Context Prompt to give context to the GenAI what activity is and is not suspicious. # It is important to be direct and detailed. See documentation for the default prompt structure. - activity_context_prompt: """Define what is and is not suspicious -""" + activity_context_prompt: | + Define what is and is not suspicious # Optional: Image source for GenAI (default: preview) # Options: "preview" (uses cached preview frames at ~180p) or "recordings" (extracts frames from recordings at 480p) # Using "recordings" provides better image quality but uses more tokens per image. @@ -445,6 +483,8 @@ review: - Animals in the garden # Optional: Preferred response language (default: English) preferred_language: English + # Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below) + debug_save_thumbnails: False # Optional: Motion configuration # NOTE: Can be overridden at the camera level @@ -458,12 +498,16 @@ motion: # Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. # The value should be between 1 and 255. threshold: 30 - # Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection - # needs to recalibrate. (default: shown below) + # Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection needs + # to recalibrate and motion checks stop for that frame. Recordings are unaffected. (default: shown below) # Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion. - # Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching - # a doorbell camera. + # Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera. lightning_threshold: 0.8 + # Optional: Fraction of the frame that must change in a single update before motion boxes are completely + # ignored. Values range between 0.0 and 1.0. When exceeded, no motion boxes are reported and **no motion + # recording** is created for that frame. Leave unset (null) to disable this feature. Use with care on PTZ + # cameras or other situations where you require guaranteed frame capture. + skip_motion_threshold: None # Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below) # Increasing this value will prevent smaller areas of motion from being detected. Decreasing will # make motion detection more sensitive to smaller moving objects. @@ -472,6 +516,8 @@ motion: # - 30 - medium sensitivity # - 50 - low sensitivity contour_area: 10 + # Optional: Alpha blending factor used in frame differencing for motion calculation (default: shown below) + delta_alpha: 0.2 # Optional: Alpha value passed to cv2.accumulateWeighted when averaging frames to determine the background (default: shown below) # Higher values mean the current frame impacts the average a lot, and a new object will be averaged into the background faster. # Low values will cause things like moving shadows to be detected as motion for longer. @@ -483,7 +529,15 @@ motion: frame_height: 100 # Optional: motion mask # NOTE: see docs for more detailed info on creating masks - mask: 0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000 + mask: + # Motion mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Motion mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000" # Optional: improve contrast (default: shown below) # Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive # for daytime. @@ -512,8 +566,6 @@ record: # Optional: Number of minutes to wait between cleanup runs (default: shown below) # This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o expire_interval: 60 - # Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below). - sync_recordings: False # Optional: Continuous retention settings continuous: # Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below) @@ -536,6 +588,10 @@ record: # The -r (framerate) dictates how smooth the output video is. # So the args would be -vf setpts=0.02*PTS -r 30 in that case. timelapse_args: "-vf setpts=0.04*PTS -r 30" + # Optional: Global hardware acceleration settings for timelapse exports. (default: inherit) + hwaccel_args: auto + # Optional: Maximum number of export jobs to process at the same time (default: shown below) + max_concurrent: 3 # Optional: Recording Preview Settings preview: # Optional: Quality of recording preview (default: shown below). @@ -582,13 +638,12 @@ record: # never stored, so setting the mode to "all" here won't bring them back. mode: motion -# Optional: Configuration for the jpg snapshots written to the clips directory for each tracked object +# Optional: Configuration for the snapshots written to the clips directory for each tracked object +# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots. # NOTE: Can be overridden at the camera level snapshots: - # Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below) + # Optional: Enable writing snapshot images to /media/frigate/clips (default: shown below) enabled: False - # Optional: save a clean copy of the snapshot image (default: shown below) - clean_copy: True # Optional: print a timestamp on the snapshots (default: shown below) timestamp: False # Optional: draw bounding box on the snapshots (default: shown below) @@ -606,8 +661,8 @@ snapshots: # Optional: Per object retention days objects: person: 15 - # Optional: quality of the encoded jpeg, 0-100 (default: shown below) - quality: 70 + # Optional: quality of the encoded snapshot image, 0-100 (default: shown below) + quality: 60 # Optional: Configuration for semantic search capability semantic_search: @@ -679,28 +734,42 @@ lpr: enhancement: 0 # Optional: Save plate images to /media/frigate/clips/lpr for debugging purposes (default: shown below) debug_save_plates: False - # Optional: List of regex replacement rules to normalize detected plates (default: shown below) - replace_rules: {} + # Optional: List of regex replacement rules to normalize detected plates before matching (default: none) + replace_rules: + # Required: regex pattern to match in the detected plate + - pattern: "O" + # Required: string to replace the matched pattern with + replacement: "0" -# Optional: Configuration for AI / LLM provider +# Optional: Configuration for AI / LLM providers # WARNING: Depending on the provider, this will send thumbnails over the internet # to Google or OpenAI's LLMs to generate descriptions. GenAI features can be configured at # the camera level to enhance privacy for indoor cameras. +# NOTE: genai is a map of named providers. Each key is a name you choose for the provider, +# and each role (chat, descriptions, embeddings) may be assigned to exactly one provider. genai: - # Required: Provider must be one of ollama, gemini, or openai - provider: ollama - # Required if provider is ollama. May also be used for an OpenAI API compatible backend with the openai provider. - base_url: http://localhost::11434 - # Required if gemini or openai - api_key: "{FRIGATE_GENAI_API_KEY}" - # Required: The model to use with the provider. - model: gemini-1.5-flash - # Optional additional args to pass to the GenAI Provider (default: None) - provider_options: - keep_alive: -1 - # Optional: Options to pass during inference calls (default: {}) - runtime_options: - temperature: 0.7 + # Required: name of the provider (chosen by you, used to reference it elsewhere) + my_provider: + # Required: Provider must be one of ollama, openai, azure_openai, gemini, or llamacpp + provider: ollama + # Required if provider is ollama. May also be used for an OpenAI API compatible backend with the openai provider. + base_url: http://localhost::11434 + # Required if gemini or openai + api_key: "{FRIGATE_GENAI_API_KEY}" + # Required: The model to use with the provider. + model: gemini-1.5-flash + # Optional: Roles this provider handles (default: shown below) + # Each role (chat, descriptions, embeddings) must be assigned to exactly one provider. + roles: + - chat + - descriptions + - embeddings + # Optional additional args to pass to the GenAI Provider (default: None) + provider_options: + keep_alive: -1 + # Optional: Options to pass during inference calls (default: {}) + runtime_options: + temperature: 0.7 # Optional: Configuration for audio transcription # NOTE: only the enabled option can be overridden at the camera level @@ -747,14 +816,15 @@ classification: cameras: camera_name: # Required: Crop of image frame on this camera to run classification on - crop: [0, 180, 220, 400] + # [x1, y1, x2, y2] as decimals between 0 and 1, relative to the detect resolution + crop: [0.0, 0.25, 0.3, 0.85] # Optional: If classification should be run when motion is detected in the crop (default: shown below) motion: False # Optional: Interval to run classification on in seconds (default: shown below) interval: None # Optional: Restream configuration -# Uses https://github.com/AlexxIT/go2rtc (v1.9.10) +# Uses https://github.com/AlexxIT/go2rtc (v1.9.14) # NOTE: The default go2rtc API port (1984) must be used, # changing this port for the integrated go2rtc instance is not supported. go2rtc: @@ -805,8 +875,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" @@ -840,6 +910,11 @@ cameras: # Optional: camera specific output args (default: inherit) # output_args: + # Optional: camera specific hwaccel args for timelapse export (default: inherit) + # record: + # export: + # hwaccel_args: + # Optional: timeout for highest scoring image before allowing it # to be replaced by a newer image. (default: shown below) best_image_timeout: 60 @@ -855,6 +930,9 @@ cameras: front_steps: # Optional: A friendly name or descriptive text for the zones friendly_name: "" + # Optional: Whether this zone is active (default: shown below) + # Disabled zones are completely ignored at runtime - no object tracking or debug drawing + enabled: True # Required: List of x,y coordinates to define the polygon of the zone. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 @@ -865,6 +943,9 @@ cameras: inertia: 3 # Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below) loitering_time: 0 + # Optional: Minimum speed required for an object to be considered present in the zone (default: none) + # In real-world units if distances are set. Used for speed-based zone triggers. + speed_threshold: 2.5 # Optional: List of objects that can trigger this zone (default: all tracked objects) objects: - person @@ -900,15 +981,20 @@ cameras: # Optional: Adjust sort order of cameras in the UI. Larger numbers come later (default: shown below) # By default the cameras are sorted alphabetically. order: 0 - # Optional: Whether or not to show the camera in the Frigate UI (default: shown below) + # Optional: Whether or not to show the camera on the default All Cameras live dashboard. + # The camera is still available everywhere else, including camera groups and settings + # (default: shown below) dashboard: True + # Optional: Whether this camera is visible in review (the review page and its camera + # filter, motion review, and the history view) (default: shown below) + review: True # Optional: connect to ONVIF camera # to enable PTZ controls. onvif: # Required: host of the camera being connected to. # NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0". - # NOTE: ONVIF user, and password can be specified with environment variables or docker secrets + # NOTE: ONVIF host, user, and password can be specified with environment variables or docker secrets # that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}' host: 0.0.0.0 # Optional: ONVIF port for device (default: shown below). @@ -923,6 +1009,10 @@ cameras: # Optional: Ignores time synchronization mismatches between the camera and the server during authentication. # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. ignore_time_mismatch: False + # Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below) + # If not set, the first profile with valid PTZ configuration is selected automatically. + # Use this when your camera has multiple ONVIF profiles and you need to select a specific one. + profile: None # Optional: PTZ camera object autotracking. Keeps a moving object in # the center of the frame by automatically moving the PTZ camera. autotracking: @@ -986,6 +1076,49 @@ cameras: actions: - notification + # Optional: Named config profiles with partial overrides that can be activated at runtime. + # NOTE: Profile names must be defined in the top-level 'profiles' section. + profiles: + # Required: name of the profile (must match a top-level profile definition) + away: + # Optional: Enable or disable the camera when this profile is active (default: not set, inherits base) + enabled: true + # Optional: Override audio settings + audio: + enabled: true + # Optional: Override birdseye settings + # birdseye: + # Optional: Override detect settings + detect: + enabled: true + # Optional: Override face_recognition settings + # face_recognition: + # Optional: Override lpr settings + # lpr: + # Optional: Override motion settings + # motion: + # Optional: Override notification settings + notifications: + enabled: true + # Optional: Override objects settings + objects: + track: + - person + - car + # Optional: Override record settings + record: + enabled: true + # Optional: Override review settings + review: + alerts: + labels: + - person + - car + # Optional: Override snapshot settings + # snapshots: + # Optional: Override or add zones (merged with base zones) + # zones: + # Optional ui: # Optional: Set a timezone to use in the UI (default: use browser local time) @@ -993,22 +1126,6 @@ ui: # Optional: Set the time format used. # Options are browser, 12hour, or 24hour (default: shown below) time_format: browser - # Optional: Set the date style for a specified length. - # Options are: full, long, medium, short - # Examples: - # short: 2/11/23 - # medium: Feb 11, 2023 - # full: Saturday, February 11, 2023 - # (default: shown below). - date_style: short - # Optional: Set the time style for a specified length. - # Options are: full, long, medium, short - # Examples: - # short: 8:14 PM - # medium: 8:15:22 PM - # full: 8:15:22 PM Mountain Standard Time - # (default: shown below). - time_style: medium # Optional: Set the unit system to either "imperial" or "metric" (default: metric) # Used in the UI and in MQTT topics unit_system: metric @@ -1052,4 +1169,14 @@ camera_groups: icon: LuCar # Required: index of this group order: 0 + +# Optional: Profile definitions for named config overrides +# NOTE: Profile names defined here can be referenced in camera profiles sections +profiles: + # Required: name of the profile (machine name used internally) + home: + # Required: display name shown in the UI + friendly_name: Home + away: + friendly_name: Away ``` diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced/system.md similarity index 56% rename from docs/docs/configuration/advanced.md rename to docs/docs/configuration/advanced/system.md index c04cec97cc..75415c7053 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced/system.md @@ -1,15 +1,31 @@ --- -id: advanced -title: Advanced Options -sidebar_label: Advanced Options +id: system +title: System --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + ### Logging #### Frigate `logger` Change the default log level for troubleshooting purposes. + + + +Navigate to . + +| Field | Description | +| ------------------------- | ------------------------------------------------------- | +| **Logging level** | The default log level for all modules (default: `info`) | +| **Per-process log level** | Override the log level for specific modules | + + + + ```yaml logger: # Optional: default log level (default: shown below) @@ -19,6 +35,9 @@ logger: frigate.mqtt: error ``` + + + Available log levels are: `debug`, `info`, `warning`, `error`, `critical` Examples of available modules are: @@ -48,7 +67,26 @@ This section can be used to set environment variables for those unable to modify Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax. -Example: +:::note + +The `go2rtc` section is an exception. go2rtc runs as a separate process, so its stream definitions can only be substituted with variables that exist in the container's environment (set via Docker `-e`, the `environment:` section of `docker-compose.yml`, or Docker secrets). Variables defined in the `environment_vars` block above are not available to go2rtc streams. Home Assistant app users, who cannot set container environment variables, must instead put credentials directly in their go2rtc stream URLs. + +::: + + + + +Navigate to to add or edit environment variables. + +| Field | Description | +| ----------------- | --------------------------------------------------------- | +| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) | +| **Value** | The value for the variable | + +Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax. + + + ```yaml environment_vars: @@ -61,10 +99,27 @@ mqtt: password: "{FRIGATE_MQTT_PASSWORD}" ``` + + + #### TensorFlow Thread Configuration If you encounter thread creation errors during classification model training, you can limit TensorFlow's thread usage: + + + +Navigate to and add the following variables: + +| Variable | Description | +| --------------------------------- | ---------------------------------------------- | +| `TF_INTRA_OP_PARALLELISM_THREADS` | Threads within operations (`0` = use default) | +| `TF_INTER_OP_PARALLELISM_THREADS` | Threads between operations (`0` = use default) | +| `TF_DATASET_THREAD_POOL_SIZE` | Data pipeline threads (`0` = use default) | + + + + ```yaml environment_vars: TF_INTRA_OP_PARALLELISM_THREADS: "2" # Threads within operations (0 = use default) @@ -72,19 +127,35 @@ environment_vars: TF_DATASET_THREAD_POOL_SIZE: "2" # Data pipeline threads (0 = use default) ``` + + + ### `database` Tracked object and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant. -If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database in the config if necessary. +If you are storing your database on a network share (SMB, NFS, etc), you may get a `database is locked` error message on startup. You can customize the location of the database if necessary. This may need to be in a custom location if network storage is used for the media folder. + + + +Navigate to . + +- Set **Database path** to the custom path for the Frigate database file (default: `/config/frigate.db`) + + + + ```yaml database: path: /path/to/frigate.db ``` + + + ### `model` If using a custom model, the width and height will need to be specified. @@ -103,6 +174,22 @@ Custom models may also require different input tensor formats. The colorspace co | "nhwc" | | "nchw" | + + + +Navigate to and open the **Custom Model** tab to configure the model path, dimensions, and input format. + +| Field | Description | +| --------------------------------------------- | ------------------------------------ | +| **Custom object detector model path** | Path to the custom model file | +| **Object detection model input width** | Model input width (default: 320) | +| **Object detection model input height** | Model input height (default: 320) | +| **Advanced > Model Input Tensor Shape** | Input tensor shape: `nhwc` or `nchw` | +| **Advanced > Model Input Pixel Color Format** | Pixel format: `rgb`, `bgr`, or `yuv` | + + + + ```yaml # Optional: model config model: @@ -113,11 +200,14 @@ model: input_pixel_format: "bgr" ``` + + + #### `labelmap` :::warning -If the labelmap is customized then the labels used for alerts will need to be adjusted as well. See [alert labels](../configuration/review.md#restricting-alerts-to-specific-labels) for more info. +If the labelmap is customized then the labels used for alerts will need to be adjusted as well. See [alert labels](../review.md#restricting-alerts-to-specific-labels) for more info. ::: @@ -149,7 +239,69 @@ Some labels have special handling and modifications can disable functionality. ## Network Configuration -Changes to Frigate's internal network configuration can be made by bind mounting nginx.conf into the container. For example: +Frigate exposes a few networking options. IPv6 and the listen ports are set in the `networking` configuration (or from the Settings UI); more advanced changes require [customizing the bundled Nginx configuration](#customizing-the-nginx-configuration). + +### Enabling IPv6 + +By default Frigate listens on IPv4 only. To also listen on IPv6 (on port `5000`, and on `8971` when TLS is configured), enable it in the `networking` configuration. + + + + +Navigate to and enable **IPv6**. + + + + +```yaml +networking: + ipv6: + enabled: true +``` + + + + +### Listen on different ports + +You can change the ports Nginx uses for listening. The internal port (unauthenticated) and external port (authenticated) can be changed independently. You can also specify an IP address using the format `ip:port` if you wish to bind the port to a specific interface. This may be useful for example to prevent exposing the internal port outside the container. + + + + +Navigate to to configure the listen ports. + +| Field | Description | +| ----------------- | --------------------------------------------------------- | +| **Internal port** | The unauthenticated listen address/port (default: `5000`) | +| **External port** | The authenticated listen address/port (default: `8971`) | + + + + +```yaml +networking: + listen: + internal: 127.0.0.1:5000 + external: 8971 +``` + + + + +:::warning + +This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations. + +The internal and external ports must be different port numbers, and Frigate will refuse to start otherwise. Requests arriving on the internal port are treated as authenticated admins, so pointing both at the same port would remove authentication from the external one. + +Nginx binds these ports when it starts, so port changes only take effect after Frigate restarts. + +::: + +### Customizing the Nginx configuration + +More advanced changes to Frigate's internal network configuration can be made by bind mounting your own `nginx.conf` into the container. For example: ```yaml services: @@ -161,36 +313,6 @@ services: - /path/to/your/nginx.conf:/usr/local/nginx/conf/nginx.conf ``` -### Enabling IPv6 - -IPv6 is disabled by default, to enable IPv6 listen.gotmpl needs to be bind mounted with IPv6 enabled. For example: - -``` -{{ if not .enabled }} -# intended for external traffic, protected by auth -listen 8971; -{{ else }} -# intended for external traffic, protected by auth -listen 8971 ssl; - -# intended for internal traffic, not protected by auth -listen 5000; -``` - -becomes - -``` -{{ if not .enabled }} -# intended for external traffic, protected by auth -listen [::]:8971 ipv6only=off; -{{ else }} -# intended for external traffic, protected by auth -listen [::]:8971 ipv6only=off ssl; - -# intended for internal traffic, not protected by auth -listen [::]:5000 ipv6only=off; -``` - ## Base path By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing. @@ -217,7 +339,7 @@ For example: ``` services: frigate: - image: blakeblackshear/frigate:latest + image: ghcr.io/blakeblackshear/frigate:stable environment: - FRIGATE_BASE_PATH=/frigate ``` @@ -242,7 +364,7 @@ To do this: ### Custom go2rtc version -Frigate currently includes go2rtc v1.9.10, there may be certain cases where you want to run a different version of go2rtc. +Frigate currently includes go2rtc v1.9.14, there may be certain cases where you want to run a different version of go2rtc. To do this: diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index 9576679147..39ec2e9ff9 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -3,6 +3,10 @@ id: audio_detectors title: Audio Detectors --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + Frigate provides a builtin audio detector which runs on the CPU. Compared to object detection in images, audio detection is a relatively lightweight operation so the only option is to run the detection on a CPU. ## Configuration @@ -11,7 +15,17 @@ Audio events work by detecting a type of audio and creating an event, the event ### Enabling Audio Events -Audio events can be enabled for all cameras or only for specific cameras. +Audio events can be enabled globally or for specific cameras. + + + + +**Global:** Navigate to and set **Enable audio detection** to on. + +**Per-camera:** Navigate to and set **Enable audio detection** to on for the desired camera. + + + ```yaml @@ -26,6 +40,9 @@ cameras: enabled: True # <- enable audio events for the front_camera ``` + + + If you are using multiple streams then you must set the `audio` role on the stream that is going to be used for audio detection, this can be any stream but the stream must have audio included. :::note @@ -34,6 +51,14 @@ The ffmpeg process for capturing audio will be a separate connection to the came ::: + + + +Navigate to and add an input with the `audio` role pointing to a stream that includes audio. + + + + ```yaml cameras: front_camera: @@ -48,9 +73,12 @@ cameras: - detect ``` + + + ### Configuring Minimum Volume -The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that Frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. The Debug view in the Frigate UI has an Audio tab for cameras that have the `audio` role assigned where a graph and the current levels are is displayed. The `min_volume` parameter should be set to the minimum the `RMS` level required to run audio detection. +The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that Frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. The [Debug view](/usage/live#the-single-camera-view) in the Frigate UI has an Audio tab for cameras that have the `audio` role assigned where a graph and the current levels are displayed. The `min_volume` parameter should be set to the minimum the `RMS` level required to run audio detection. :::tip @@ -60,7 +88,18 @@ Volume is considered motion for recordings, this means when the `record -> retai ### Configuring Audio Events -The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `scream`, `speech`, and `yell` are enabled but these can be customized. +The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `speech`, and `yell` are enabled but these can be customized. + + + + +Navigate to . + +- Set **Enable audio detection** to on +- Set **Listen types** to include the audio types you want to detect + + + ```yaml audio: @@ -68,20 +107,106 @@ audio: listen: - bark - fire_alarm - - scream - speech - yell ``` + + + +### Common Audio Labels + +The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly. + +Some labels cover several related sounds: `yell` is triggered by shouting, yelling, children shouting, and screaming; `crying` covers baby cries, sobbing, and whimpering; and `speech` covers ordinary talking and conversation. + +**Safety and security** + +| Label | Detects | +| ---------------- | ---------------------------------- | +| `yell` | Shouting, yelling, screaming | +| `fire_alarm` | Fire and smoke alarm sirens | +| `smoke_detector` | Smoke detector beeps | +| `alarm` | General alarm sounds | +| `car_alarm` | Car alarms | +| `siren` | Emergency vehicle and civil sirens | +| `glass` | Glass clinking | +| `shatter` | Breaking glass | +| `breaking` | Something breaking | +| `gunshot` | Gunshots | +| `explosion` | Explosions | + +**People and activity** + +| Label | Detects | +| ----------- | ------------------------ | +| `speech` | Talking and conversation | +| `laughter` | Laughing | +| `crying` | Baby crying and sobbing | +| `cough` | Coughing | +| `footsteps` | Footsteps and walking | +| `knock` | Knocking on a door | +| `doorbell` | Doorbell | +| `ding-dong` | Doorbell chime | + +**Pets and animals** + +| Label | Detects | +| ---------- | ---------------- | +| `bark` | Dog barking | +| `dog` | Other dog sounds | +| `howl` | Howling | +| `growling` | Growling | +| `meow` | Cat meowing | +| `cat` | Other cat sounds | +| `hiss` | Hissing | + +**Vehicles and driveway** + +| Label | Detects | +| ----------------- | -------------------- | +| `car` | Passing cars | +| `honk` | Car horns | +| `truck` | Trucks | +| `reversing_beeps` | Vehicle backup beeps | +| `motorcycle` | Motorcycles | +| `engine_starting` | Engines starting | + +:::tip + +Frequently-heard labels like `speech` can generate a lot of events, and each event could save a snapshot and recording based on your configuration, so start with a focused set and expand from there. The defaults (`bark`, `fire_alarm`, `speech`, `yell`) plus a few of the safety labels above cover most needs. See the [full audio labelmap](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) or the Frigate UI for every available type. + +::: + ### Audio Transcription -Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background. +Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service. Automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background. + +:::info + +Audio transcription requires a one-time internet connection to download the Whisper or Sherpa-ONNX model on first use. Once cached, transcription runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service. #### Configuration -To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features. +To enable transcription, configure it globally and optionally disable for specific cameras. Audio detection must also be enabled as described above. + + + + +**Global:** Navigate to . + +- Set **Enable audio transcription** to on +- Set **Transcription device** to the desired device +- Set **Model size** to the desired size + +**Per-camera:** Navigate to to enable or disable transcription for a specific camera. + + + ```yaml audio_transcription: @@ -100,6 +225,9 @@ cameras: enabled: False ``` + + + :::note Audio detection must be enabled and configured as described above in order to use audio transcription features. @@ -128,7 +256,7 @@ The only field that is valid at the camera level is `enabled`. #### Live transcription -The single camera Live view in the Frigate UI supports live transcription of audio for streams defined with the `audio` role. Use the Enable/Disable Live Audio Transcription button/switch to toggle transcription processing. When speech is heard, the UI will display a black box over the top of the camera stream with text. The MQTT topic `frigate//audio/transcription` will also be updated in real-time with transcribed text. +The single camera Live view in the Frigate UI supports live transcription of audio for streams defined with the `audio` role. Use the Enable/Disable Live Audio Transcription button/switch to toggle transcription processing, or toggle it outside of the UI with the [`frigate//audio_transcription/set`](/integrations/mqtt#frigatecamera_nameaudio_transcriptionset) MQTT topic or the HTTP API. When speech is heard, the UI will display a black box over the top of the camera stream with text. The MQTT topic `frigate//audio/transcription` will also be updated in real-time with transcribed text. Results can be error-prone due to a number of factors, including: @@ -144,9 +272,9 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o #### Transcription and translation of `speech` audio events -Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane. +Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane. -In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for in your config. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). +In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). The transcribed/translated speech will appear in the description box in the Tracked Object Details pane. If Semantic Search is enabled, embeddings are generated for the transcription text and are fully searchable using the description search type. @@ -162,16 +290,16 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m 1. Why doesn't Frigate automatically transcribe all `speech` events? - Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. That’s a significant amount of complexity for a feature that, in most real-world environments, would mostly just churn through low-value noise. + Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. That's a significant amount of complexity for a feature that, in most real-world environments, would mostly just churn through low-value noise. Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware. - If you hear speech that’s actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control. + If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button (the microphone icon) in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control. Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available. 2. Why don't you save live transcription text and use that for `speech` events? - There’s no guarantee that a `speech` event is even created from the exact audio that went through the transcription model. Live transcription and `speech` event creation are **separate, asynchronous processes**. Even when both are correctly configured, trying to align the **precise start and end time of a speech event** with whatever audio the model happened to be processing at that moment is unreliable. + There's no guarantee that a `speech` event is even created from the exact audio that went through the transcription model. Live transcription and `speech` event creation are **separate, asynchronous processes**. Even when both are correctly configured, trying to align the **precise start and end time of a speech event** with whatever audio the model happened to be processing at that moment is unreliable. - Automatically persisting that data would often result in **misaligned, partial, or irrelevant transcripts**, while still incurring all of the CPU, storage, and privacy costs of transcription. That’s why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event. + Automatically persisting that data would often result in **misaligned, partial, or irrelevant transcripts**, while still incurring all of the CPU, storage, and privacy costs of transcription. That's why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event. diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 694c4badaf..94d46dba07 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -3,6 +3,10 @@ id: authentication title: Authentication --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + # Authentication Frigate stores user information in its database. Password hashes are generated using industry standard PBKDF2-SHA256 with 600,000 iterations. Upon successful login, a JWT token is issued with an expiration date and set as a cookie. The cookie is refreshed as needed automatically. This JWT token can also be passed in the Authorization header as a bearer token. @@ -22,13 +26,26 @@ On startup, an admin user and password are generated and printed in the logs. It ## Resetting admin password -In the event that you are locked out of your instance, you can tell Frigate to reset the admin password and print it in the logs on next startup using the `reset_admin_password` setting in your config file. +In the event that you are locked out of your instance, you can tell Frigate to reset the admin password and print it in the logs on next startup. + + + + +Navigate to . + +- Set **Reset admin password** to on to reset the admin password and print it in the logs on next startup + + + ```yaml auth: reset_admin_password: true ``` + + + ## Password guidance Constructing secure passwords and managing them properly is important. Frigate requires a minimum length of 12 characters. For guidance on password standards see [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). To learn what makes a password truly secure, read this [article](https://medium.com/peerio/how-to-build-a-billion-dollar-password-3d92568d9277). @@ -47,7 +64,20 @@ Restarting Frigate will reset the rate limits. If you are running Frigate behind a proxy, you will want to set `trusted_proxies` or these rate limits will apply to the upstream proxy IP address. This means that a brute force attack will rate limit login attempts from other devices and could temporarily lock you out of your instance. In order to ensure rate limits only apply to the actual IP address where the requests are coming from, you will need to list the upstream networks that you want to trust. These trusted proxies are checked against the `X-Forwarded-For` header when looking for the IP address where the request originated. -If you are running a reverse proxy in the same Docker Compose file as Frigate, here is an example of how your auth config might look: +If you are running a reverse proxy in the same Docker Compose file as Frigate, configure rate limiting and trusted proxies as follows: + + + + +Navigate to . + +| Field | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **Failed login limits** | Rate limit string for login failures (e.g., `1/second;5/minute;20/hour`) | +| **Trusted proxies** | List of upstream network CIDRs to trust for `X-Forwarded-For` (e.g., `172.18.0.0/16` for internal Docker Compose network) | + + + ```yaml auth: @@ -56,9 +86,12 @@ auth: - 172.18.0.0/16 # <---- this is the subnet for the internal Docker Compose network ``` + + + ## Session Length -The default session length for user authentication in Frigate is 24 hours. This setting determines how long a user's authenticated session remains active before a token refresh is required — otherwise, the user will need to log in again. +The default session length for user authentication in Frigate is 24 hours. This setting determines how long a user's authenticated session remains active before a token refresh is required. Otherwise, the user will need to log in again. While the default provides a balance of security and convenience, you can customize this duration to suit your specific security requirements and user experience preferences. The session length is configured in seconds. @@ -67,11 +100,24 @@ The default value of `86400` will expire the authentication session after 24 hou - `0`: Setting the session length to 0 will require a user to log in every time they access the application or after a very short, immediate timeout. - `604800`: Setting the session length to 604800 will require a user to log in if the token is not refreshed for 7 days. + + + +Navigate to . + +- Set **Session length** to the duration in seconds before the authentication session expires (default: 86400 / 24 hours) + + + + ```yaml auth: session_length: 86400 ``` + + + ## JWT Token Secret The JWT token secret needs to be kept secure. Anyone with this secret can generate valid JWT tokens to authenticate with Frigate. This should be a cryptographically random string of at least 64 characters. @@ -95,11 +141,22 @@ Changing the secret will invalidate current tokens. ## Proxy configuration -Frigate can be configured to leverage features of common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth. +Frigate can be configured to leverage features of common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth. Frigate does not implement OIDC, SAML, or LDAP natively; as an NVR focused on recording and object detection, it relies on robust, battle-tested proxies to handle those protocols and passes the authenticated user and role through via headers (see below). If you are leveraging the authentication of an upstream proxy, you likely want to disable Frigate's authentication as there is no correspondence between users in Frigate's database and users authenticated via the proxy. Optionally, if communication between the reverse proxy and Frigate is over an untrusted network, you should set an `auth_secret` in the `proxy` config and configure the proxy to send the secret value as a header named `X-Proxy-Secret`. Assuming this is an untrusted network, you will also want to [configure a real TLS certificate](tls.md) to ensure the traffic can't simply be sniffed to steal the secret. -Here is an example of how to disable Frigate's authentication and also ensure the requests come only from your known proxy. +To disable Frigate's authentication and ensure requests come only from your known proxy: + + + + +1. Navigate to . + - Set **Enable authentication** to off +2. Navigate to . + - Set **Proxy secret** to `` + + + ```yaml auth: @@ -109,6 +166,9 @@ proxy: auth_secret: ``` + + + You can use the following code to generate a random secret. ```shell @@ -119,6 +179,20 @@ python3 -c 'import secrets; print(secrets.token_hex(64))' If you have disabled Frigate's authentication and your proxy supports passing a header with authenticated usernames and/or roles, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` and `X-Forwarded-Groups` values. Header names are not case sensitive. Multiple values can be included in the role header. Frigate expects that the character separating the roles is a comma, but this can be specified using the `separator` config entry. + + + +Navigate to and configure the header mapping and separator settings. + +| Field | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Separator character** | Character separating multiple roles in the role header (default: comma). Authentik uses a pipe `\|`. | +| **Header mapping > User header** | Header name for the authenticated username (e.g., `x-forwarded-user`) | +| **Header mapping > Role header** | Header name for the authenticated role/groups (e.g., `x-forwarded-groups`) | + + + + ```yaml proxy: ... @@ -128,19 +202,37 @@ proxy: role: x-forwarded-groups ``` + + + Frigate supports `admin`, `viewer`, and custom roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. A default role can be provided. Any value in the mapped `role` header will override the default. + + + +Navigate to and set the default role. + +| Field | Description | +| ---------------- | ------------------------------------------------------------- | +| **Default role** | Fallback role when no role header is present (e.g., `viewer`) | + + + + ```yaml proxy: ... default_role: viewer ``` + + + ## Role mapping -In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate’s internal roles (`admin`, `viewer`, or custom). +In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate's internal roles (`admin`, `viewer`, or custom). This is configurable via YAML in the configuration file: ```yaml proxy: @@ -170,12 +262,25 @@ In this example: - Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). +:::note + +If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy: + +```yaml +logger: + default: info + logs: + frigate.api.auth: debug +``` + +::: + #### Port Considerations **Authenticated Port (8971)** - Header mapping is **fully supported**. -- The `remote-role` header determines the user’s privileges: +- The `remote-role` header determines the user's privileges: - **admin** → Full access (user management, configuration changes). - **viewer** → Read-only access. - **Custom roles** → Read-only access limited to the cameras defined in `auth.roles[role]`. @@ -232,6 +337,14 @@ The viewer role provides read-only access to all cameras in the UI and API. Cust ### Role Configuration Example + + + +Navigate to to define custom roles and assign which cameras each role can access. + + + + ```yaml {11-16} cameras: front_door: @@ -251,13 +364,16 @@ auth: - side_yard ``` + + + If you want to provide access to all cameras to a specific user, just use the **viewer** role. ### Managing User Roles 1. Log in as an **admin** user via port `8971` (preferred), or unauthenticated via port `5000`. 2. Navigate to **Settings**. -3. In the **Users** section, edit a user’s role by selecting from available roles (admin, viewer, or custom). +3. In the **Users** section, edit a user's role by selecting from available roles (admin, viewer, or custom). 4. In the **Roles** section, add/edit/delete custom roles (select cameras via switches). Deleting a role auto-reassigns users to "viewer". ### Role Enforcement @@ -277,7 +393,7 @@ To use role-based access control, you must connect to Frigate via the **authenti 1. Log in as an **admin** user via port `8971`. 2. Navigate to **Settings > Users**. -3. Edit a user’s role by selecting **admin** or **viewer**. +3. Edit a user's role by selecting **admin** or **viewer**. ## API Authentication Guide diff --git a/docs/docs/configuration/autotracking.md b/docs/docs/configuration/autotracking.md index 86179a2641..f49026e11c 100644 --- a/docs/docs/configuration/autotracking.md +++ b/docs/docs/configuration/autotracking.md @@ -3,6 +3,11 @@ id: autotracking title: Camera Autotracking --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + An ONVIF-capable, PTZ (pan-tilt-zoom) camera that supports relative movement within the field of view (FOV) can be configured to automatically track moving objects and keep them in the center of the frame. ![Autotracking example with zooming](/img/frigate-autotracking-example.gif) @@ -29,12 +34,44 @@ A growing list of cameras and brands that have been reported by users to work wi First, set up a PTZ preset in your camera's firmware and give it a name. If you're unsure how to do this, consult the documentation for your camera manufacturer's firmware. Some tutorials for common brands: [Amcrest](https://www.youtube.com/watch?v=lJlE9-krmrM), [Reolink](https://www.youtube.com/watch?v=VAnxHUY5i5w), [Dahua](https://www.youtube.com/watch?v=7sNbc5U-k54). -Edit your Frigate configuration file and enter the ONVIF parameters for your camera. Specify the object types to track, a required zone the object must enter to begin autotracking, and the camera preset name you configured in your camera's firmware to return to when tracking has ended. Optionally, specify a delay in seconds before Frigate returns the camera to the preset. +Configure the ONVIF connection and autotracking parameters for your camera. Specify the object types to track, a required zone the object must enter to begin autotracking, and the camera preset name you configured in your camera's firmware to return to when tracking has ended. Optionally, specify a delay in seconds before Frigate returns the camera to the preset. An [ONVIF connection](cameras.md) is required for autotracking to function. Also, a [motion mask](masks.md) over your camera's timestamp and any overlay text is recommended to ensure they are completely excluded from scene change calculations when the camera is moving. Note that `autotracking` is disabled by default but can be enabled in the configuration or by MQTT. + + + +Navigate to for the desired camera. + +**ONVIF Connection** + +| Field | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ONVIF host** | Host of the camera being connected to. HTTP is assumed by default; prefix with `https://` for HTTPS. | +| **ONVIF port** | ONVIF port for device (default: 8000) | +| **ONVIF username** | Username for login. Some devices require admin to access ONVIF. | +| **ONVIF password** | Password for login | +| **Disable TLS verify** | Skip TLS verification and disable digest auth for ONVIF (default: false) | +| **ONVIF profile** | ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically. | + +**Autotracking** + +| Field | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| **Enable Autotracking** | Enable or disable object autotracking (default: false) | +| **Calibrate on start** | Calibrate the camera on startup by measuring PTZ motor speed (default: false) | +| **Zoom mode** | Zoom mode during autotracking: `disabled`, `absolute`, or `relative` (default: disabled) | +| **Zoom Factor** | Controls zoom behavior on tracked objects, between 0.1 and 0.75. Lower keeps more scene visible; higher zooms in more (default: 0.3) | +| **Tracked objects** | List of object types to track (default: person) | +| **Required Zones** | Zones an object must enter to begin autotracking | +| **Return Preset** | Name of ONVIF preset in camera firmware to return to when tracking ends (default: home) | +| **Return timeout** | Seconds to delay before returning to preset (default: 10) | + + + + ```yaml cameras: ptzcamera: @@ -52,6 +89,10 @@ cameras: password: admin # Optional: Skip TLS verification from the ONVIF server (default: shown below) tls_insecure: False + # Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below) + # If not set, the first profile with valid PTZ configuration is selected automatically. + # Use this when your camera has multiple ONVIF profiles and you need to select a specific one. + profile: None # Optional: PTZ camera object autotracking. Keeps a moving object in # the center of the frame by automatically moving the PTZ camera. autotracking: @@ -88,13 +129,16 @@ cameras: movement_weights: [] ``` + + + ## Calibration PTZ motors operate at different speeds. Performing a calibration will direct Frigate to measure this speed over a variety of movements and use those measurements to better predict the amount of movement necessary to keep autotracked objects in the center of the frame. Calibration is optional, but will greatly assist Frigate in autotracking objects that move across the camera's field of view more quickly. -To begin calibration, set the `calibrate_on_startup` for your camera to `True` and restart Frigate. Frigate will then make a series of small and large movements with your camera. Don't move the PTZ manually while calibration is in progress. Once complete, camera motion will stop and your config file will be automatically updated with a `movement_weights` parameter to be used in movement calculations. You should not modify this parameter manually. +To begin calibration, set `calibrate_on_startup` for your camera to `True` and restart Frigate. Frigate will then make a series of small and large movements with your camera. Don't move the PTZ manually while calibration is in progress. Once complete, camera motion will stop and your config file will be automatically updated with a `movement_weights` parameter to be used in movement calculations. You should not modify this parameter manually. After calibration has ended, your PTZ will be moved to the preset specified by `return_preset`. @@ -118,13 +162,13 @@ Every PTZ camera is different, so autotracking may not perform ideally in every The object tracker in Frigate estimates the motion of the PTZ so that tracked objects are preserved when the camera moves. In most cases 5 fps is sufficient, but if you plan to track faster moving objects, you may want to increase this slightly. Higher frame rates (> 10fps) will only slow down Frigate and the motion estimator and may lead to dropped frames, especially if you are using experimental zooming. -A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. You can watch Frigate's debug viewer for your camera to see a thicker colored box around the object currently being autotracked. +A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. You can watch Frigate's [debug viewer](/usage/live#the-single-camera-view) for your camera to see a thicker colored box around the object currently being autotracked. ![Autotracking Debug View](/img/autotracking-debug.gif) A full-frame zone in `required_zones` is not recommended, especially if you've calibrated your camera and there are `movement_weights` defined in the configuration file. Frigate will continue to autotrack an object that has entered one of the `required_zones`, even if it moves outside of that zone. -Some users have found it helpful to adjust the zone `inertia` value. See the [configuration reference](index.md). +Some users have found it helpful to adjust the zone `inertia` value. See the [configuration reference](advanced/reference.md). ## Zooming @@ -144,30 +188,96 @@ In security and surveillance, it's common to use "spotter" cameras in combinatio ## Troubleshooting and FAQ -### The autotracker loses track of my object. Why? +### Camera Compatibility + + + +See the community-maintained list of [ONVIF PTZ camera recommendations](cameras.md#onvif-ptz-camera-recommendations) for cameras and brands reported to work (and not work) with autotracking. This is not an exhaustive list that is frequently updated, so other cameras not listed may also work well. Frigate's autotracking was developed with a Dahua SD1A404XB-GNR (now sold as the EmpireTech PTZ1A4M-4X-S2), and Dahua / EmpireTech PTZs are the most consistently reported as working well. + +When comparing models: + +- Verify ONVIF support first. See [Checking ONVIF camera support](#checking-onvif-camera-support) above. +- Favor a camera with a fast PTZ motor. Cameras with slow motors may fail [calibration](#calibration) and will struggle to keep up with objects that move across the field of view quickly. + + + + + +No. Reolink cameras (including the TrackMix series) lack the ONVIF FOV RelativeMove firmware support that Frigate's autotracker requires, so autotracking will not work with any current Reolink PTZ. Their video streams and basic PTZ controls still work in Frigate. If you want object tracking on a Reolink PTZ, you will need to use the tracking feature built into the camera's firmware, which is proprietary and operates independently of Frigate. + + + + + +There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware, such as some Annke models. In rare cases the vendor may provide fixed firmware on request; for example, Annke has supplied firmware that resolves this for the CZ504 (see the [camera recommendations list](cameras.md#onvif-ptz-camera-recommendations)). + + + + + +Some cameras have firmware that reports that FOV RelativeMove, the ONVIF command that Frigate uses for autotracking, is supported. However, if the camera does not pan or tilt when an object comes into the required zone, your camera's firmware does not actually support FOV RelativeMove. One such camera is the Uniview IPC672LR-AX4DUPK. It actually moves its zoom motor instead of panning and tilting and does not follow the ONVIF standard whatsoever. + + + +### Calibration Issues + + + +This is often caused by the same reason as the "MOVING" status error above - the `MoveStatus` ONVIF parameter is not changing due to a bug in your camera's firmware. Also, see the note above: Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal. But if you don't see log entries every few seconds for calibration progress, your camera is not compatible with autotracking. + + + + + +Calibration measures the amount of time it takes for Frigate to make a series of movements with your PTZ. This error message is recorded in the log if these values are too high for Frigate to support calibrated autotracking. This is often the case when your camera's motor or network connection is too slow or your camera's firmware doesn't report the motor status in a timely manner. + +Some things to try: + +- If your camera's firmware has a PTZ or motor speed setting, set it to the fastest available speed and calibrate again. +- Run without calibration: remove the `movement_weights` line from your config, set `calibrate_on_startup` to `False`, and restart. + +If calibration consistently fails, this often means your camera's motor is too slow and autotracking will behave unpredictably or won't be able to keep up with moving objects. + + + + + +Frigate uses the `movement_weights` measured during calibration to predict how far the camera needs to move to keep an object centered, so inaccurate values produce movements that don't seem to make sense: overshooting, moving the opposite direction, or zooming in on an object's last known position and losing it entirely. This is almost always a calibration issue. + +- Remove the `movement_weights` entry from your config and restart Frigate to run without calibration. If tracking improves, try recalibrating. +- Recalibrate several times. The `movement_weights` values should be close to each other after each run. If they vary significantly between runs, your camera may not be reporting its motor status reliably, and you may get better results without calibration. +- If you are using zooming, a high `zoom_factor` can cause the camera to zoom in too far and lose the object. Try a lower value. + +Remember to recalibrate whenever you change your `return_preset`, change your camera's detect `fps`, or enable zooming after calibrating with it disabled. + + + +### Tracking Behavior + + There are many reasons this could be the case. If you are using experimental zooming, your `zoom_factor` value might be too high, the object might be traveling too quickly, the scene might be too dark, there are not enough details in the scene (for example, a PTZ looking down on a driveway or other monotone background without a sufficient number of hard edges or corners), or the scene is otherwise less than optimal for Frigate to maintain tracking. Your camera's shutter speed may also be set too low so that blurring occurs with motion. Check your camera's firmware to see if you can increase the shutter speed. -Watching Frigate's debug view can help to determine a possible cause. The autotracked object will have a thicker colored box around it. +Watching Frigate's debug view can help to determine a possible cause. The autotracked object will have a thicker colored box around it. If the camera consistently zooms in on the object and then loses it, see [Autotracking is erratic, moves the camera in the wrong direction, or zooms past my object. Why?](#autotracking-is-erratic-or-moves-the-camera-in-the-wrong-direction) above. -### I'm seeing an error in the logs that my camera "is still in ONVIF 'MOVING' status." What does this mean? + -There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware. - -### I tried calibrating my camera, but the logs show that it is stuck at 0% and Frigate is not starting up. - -This is often caused by the same reason as above - the `MoveStatus` ONVIF parameter is not changing due to a bug in your camera's firmware. Also, see the note above: Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal. But if you don't see log entries every few seconds for calibration progress, your camera is not compatible with autotracking. - -### I'm seeing this error in the logs: "Autotracker: motion estimator couldn't get transformations". What does this mean? + To maintain object tracking during PTZ moves, Frigate tracks the motion of your camera based on the details of the frame. If you are seeing this message, it could mean that your `zoom_factor` may be set too high, the scene around your detected object does not have enough details (like hard edges or color variations), or your camera's shutter speed is too slow and motion blur is occurring. Try reducing `zoom_factor`, finding a way to alter the scene around your object, or changing your camera's shutter speed. -### Calibration seems to have completed, but the camera is not actually moving to track my object. Why? + -Some cameras have firmware that reports that FOV RelativeMove, the ONVIF command that Frigate uses for autotracking, is supported. However, if the camera does not pan or tilt when an object comes into the required zone, your camera's firmware does not actually support FOV RelativeMove. One such camera is the Uniview IPC672LR-AX4DUPK. It actually moves its zoom motor instead of panning and tilting and does not follow the ONVIF standard whatsoever. + -### Frigate reports an error saying that calibration has failed. Why? +When the PTZ moves, the entire frame changes at once. Frigate's motion detection treats sudden scene-wide changes (like a lightning flash, an infrared mode switch, or a camera move) specially and pauses detection momentarily until the scene stabilizes. This is expected and normal, and detection resumes shortly after the camera stops moving. If detection does not resume once the camera is stationary, use the [debug view](/usage/live#the-single-camera-view) to see what is happening. -Calibration measures the amount of time it takes for Frigate to make a series of movements with your PTZ. This error message is recorded in the log if these values are too high for Frigate to support calibrated autotracking. This is often the case when your camera's motor or network connection is too slow or your camera's firmware doesn't report the motor status in a timely manner. You can try running without calibration (just remove the `movement_weights` line from your config and restart), but if calibration fails, this often means that autotracking will behave unpredictably. + + + + +Yes. Autotracking can be toggled per camera at runtime over MQTT with the [`frigate//ptz_autotracker/set`](../integrations/mqtt.md#frigatecamera_nameptz_autotrackerset) topic, and the [Home Assistant integration](../integrations/home-assistant.md) exposes a switch for it. This pairs well with the "spotter" camera automations described in [Usage applications](#usage-applications) above, for example only enabling autotracking at night or when nobody is home. + + diff --git a/docs/docs/configuration/bird_classification.md b/docs/docs/configuration/bird_classification.md index 3987292905..1c521314e0 100644 --- a/docs/docs/configuration/bird_classification.md +++ b/docs/docs/configuration/bird_classification.md @@ -3,8 +3,18 @@ id: bird_classification title: Bird Classification --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + Bird classification identifies known birds using a quantized Tensorflow model. When a known bird is recognized, its common name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications. +:::info + +Bird classification requires a one-time internet connection to download the classification model and label map from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + ## Minimum System Requirements Bird classification runs a lightweight tflite model on the CPU, there are no significantly different system requirements than running Frigate itself. @@ -15,7 +25,18 @@ The classification model used is the MobileNet INat Bird Classification, [availa ## Configuration -Bird classification is disabled by default, it must be enabled in your config file before it can be used. Bird classification is a global configuration setting. +Bird classification is disabled by default and must be enabled before it can be used. Bird classification is a global configuration setting. + + + + +Navigate to . + +- Set **Bird classification config > Bird classification** to on +- Set **Bird classification config > Minimum score** to the desired confidence score (default: 0.9) + + + ```yaml classification: @@ -23,6 +44,9 @@ classification: enabled: true ``` + + + ## Advanced Configuration Fine-tune bird classification with these optional parameters: diff --git a/docs/docs/configuration/birdseye.md b/docs/docs/configuration/birdseye.md index f48299aec6..26a2386d89 100644 --- a/docs/docs/configuration/birdseye.md +++ b/docs/docs/configuration/birdseye.md @@ -1,11 +1,21 @@ # Birdseye +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + In addition to Frigate's Live camera dashboard, Birdseye allows a portable heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about. -Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the "+" icon on the Live page, and choose "Birdseye" as one of the cameras. +Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the pencil icon in the sidebar on the Live page, and choose "Birdseye" as one of the cameras. Birdseye can also be used in Home Assistant dashboards, cast to media devices, etc. +:::note + +Each camera tile in Birdseye is composed from the frames of the stream assigned the `detect` role, so a camera's image quality in Birdseye matches its detect stream resolution rather than a higher-resolution recording stream. If a camera looks low quality in Birdseye, increasing the detect width and height (or assigning the `detect` role to a higher-resolution stream) is what affects it. See [setting up camera inputs](./cameras.md#setting-up-camera-inputs) for how roles are assigned. + +::: + ## Birdseye Behavior ### Birdseye Modes @@ -22,7 +32,22 @@ A custom icon can be added to the birdseye background by providing a 180x180 ima ### Birdseye view override at camera level -If you want to include a camera in Birdseye view only for specific circumstances, or just don't include it at all, the Birdseye setting can be set at the camera level. +To include a camera in Birdseye view only for specific circumstances, or exclude it entirely, configure Birdseye at the camera level. + + + + +**Global settings:** Navigate to to configure the default Birdseye behavior for all cameras. + +**Per-camera overrides:** Navigate to to override the mode or disable Birdseye for a specific camera. + +| Field | Description | +| ------------------- | ------------------------------------------------------------- | +| **Enable Birdseye** | Whether this camera appears in Birdseye view | +| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` | + + + ```yaml {8-10,12-14} # Include all cameras by default in Birdseye view @@ -41,9 +66,24 @@ cameras: enabled: False ``` + + + ### Birdseye Inactivity -By default birdseye shows all cameras that have had the configured activity in the last 30 seconds, this can be configured: +By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured. + + + + +Navigate to . + +| Field | Description | +| ------------------------ | --------------------------------------------------------------------------- | +| **Inactivity threshold** | Seconds of inactivity before a camera is hidden from Birdseye (default: 30) | + + + ```yaml birdseye: @@ -52,12 +92,28 @@ birdseye: inactivity_threshold: 15 ``` + + + ## Birdseye Layout ### Birdseye Dimensions The resolution and aspect ratio of birdseye can be configured. Resolution will increase the quality but does not affect the layout. Changing the aspect ratio of birdseye does affect how cameras are laid out. + + + +Navigate to . + +| Field | Description | +| ---------- | ----------------------------------------------- | +| **Width** | Birdseye output width in pixels (default: 1280) | +| **Height** | Birdseye output height in pixels (default: 720) | + + + + ```yaml birdseye: enabled: True @@ -65,10 +121,20 @@ birdseye: height: 720 ``` + + + ### Sorting cameras in the Birdseye view -It is possible to override the order of cameras that are being shown in the Birdseye view. -The order needs to be set at the camera level. +It is possible to override the order of cameras that are being shown in the Birdseye view. The order is set at the camera level (when using YAML). + + + + +Navigate to and in the **Camera order** field, use the drag handle next to each camera name to control the display order. + + + ```yaml # Include all cameras by default in Birdseye view @@ -87,13 +153,26 @@ cameras: order: 2 ``` + + + _Note_: Cameras are sorted by default using their name to ensure a constant view inside Birdseye. ### Birdseye Cameras It is possible to limit the number of cameras shown on birdseye at one time. When this is enabled, birdseye will show the cameras with most recent activity. There is a cooldown to ensure that cameras do not switch too frequently. -For example, this can be configured to only show the most recently active camera. + + + +Navigate to . + +| Field | Description | +| ------------------------ | ----------------------------------------------------------------------------------- | +| **Layout > Max cameras** | Maximum number of cameras shown at once (e.g., `1` for only the most active camera) | + + + ```yaml {3-4} birdseye: @@ -102,13 +181,31 @@ birdseye: max_cameras: 1 ``` + + + ### Birdseye Scaling By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case. + + + +Navigate to . + +| Field | Description | +| --------------------------- | -------------------------------------------------------- | +| **Layout > Scaling factor** | Camera scaling factor between 1.0 and 5.0 (default: 2.0) | + + + + ```yaml {3-4} birdseye: enabled: True layout: scaling_factor: 3.0 ``` + + + diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md index c18b87f2e8..c83942e495 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -3,6 +3,8 @@ id: camera_specific title: Camera Specific Configurations --- +import NavPath from "@site/src/components/NavPath"; + :::note This page makes use of presets of FFmpeg args. For more information on presets, see the [FFmpeg Presets](/configuration/ffmpeg_presets) page. @@ -148,19 +150,34 @@ WEB Digest Algorithm - MD5 Reolink has many different camera models with inconsistently supported features and behavior. The below table shows a summary of various features and recommendations. -| Camera Resolution | Camera Generation | Recommended Stream Type | Additional Notes | -| ----------------- | ------------------------- | --------------------------------- | ----------------------------------------------------------------------- | -| 5MP or lower | All | http-flv | Stream is h264 | -| 6MP or higher | Latest (ex: Duo3, CX-8##) | http-flv with ffmpeg 8.0, or rtsp | This uses the new http-flv-enhanced over H265 which requires ffmpeg 8.0 | -| 6MP or higher | Older (ex: RLC-8##) | rtsp | | +| Camera Resolution | Camera Generation | Recommended Stream Type | Additional Notes | +| ----------------- | ------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------- | +| 5MP or lower | All | http-flv | Stream is h264 | +| 6MP or higher | Latest (ex: Duo3, CX-8##) | http-flv with ffmpeg 8.0, or rtsp | This uses the new http-flv-enhanced over H265 which requires ffmpeg 8.0 (Frigate's default) | +| 6MP or higher | Older (ex: RLC-8##) | rtsp | | -Frigate works much better with newer reolink cameras that are setup with the below options: +Frigate works much better with newer Reolink cameras that are setup with the below options: If available, recommended settings are: - `On, fluency first` this sets the camera to CBR (constant bit rate) - `Interframe Space 1x` this sets the iframe interval to the same as the frame rate +#### Setup via the Add Camera Wizard + +The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) is the recommended way to add a standard Reolink camera. Before starting, make sure [HTTP is enabled](https://support.reolink.com/articles/360003452893-How-to-Access-Reolink-Cameras-NVRs-Home-Hub-Locally-via-Web-Browsers/) in the camera's advanced network settings. The wizard uses the camera's HTTP API to determine its resolution and choose the recommended stream type from the table above. + +1. Click **Add Camera** in . +2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand. +3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras. +4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it. + +If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step. + +The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below. + +#### Manual configuration + According to [this discussion](https://github.com/blakeblackshear/frigate/issues/3235#issuecomment-1135876973), the http video streams seem to be the most reliable for Reolink. Cameras connected via a Reolink NVR can be connected with the http stream, use `channel[0..15]` in the stream url for the additional channels. @@ -175,7 +192,7 @@ Reolink's latest cameras support two way audio via go2rtc and other applications NOTE: The RTSP stream can not be prefixed with `ffmpeg:`, as go2rtc needs to handle the stream to support two way audio. -Ensure HTTP is enabled in the camera's advanced network settings. To use two way talk with Frigate, see the [Live view documentation](/configuration/live#two-way-talk). +Ensure [HTTP is enabled](https://support.reolink.com/articles/360003452893-How-to-Access-Reolink-Cameras-NVRs-Home-Hub-Locally-via-Web-Browsers/) in the camera's advanced network settings. To use two way talk with Frigate, see the [Live view documentation](/configuration/live#two-way-talk). ::: @@ -187,7 +204,7 @@ go2rtc: - "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus" your_reolink_camera_sub: - "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password" - # example for connectin to a Reolink camera that supports two way talk + # example for connecting to a Reolink camera that supports two way talk your_reolink_camera_twt: - "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus" - "rtsp://username:password@reolink_ip/Preview_01_sub" @@ -225,13 +242,14 @@ cameras: roles: - detect ``` +
### Unifi Protect Cameras -:::note +:::note -Unifi G5s cameras and newer need a Unifi Protect server to enable rtsps stream, it's not posible to enable it in standalone mode. +Unifi G5s cameras and newer need a Unifi Protect server to enable rtsps stream, it's not possible to enable it in standalone mode. ::: @@ -246,7 +264,7 @@ go2rtc: - rtspx://192.168.1.1:7441/abcdefghijk ``` -[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-rtsp) +[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#source-rtsp) In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect. @@ -269,7 +287,6 @@ Some community members have found better performance on Wyze cameras by using an To use a USB camera (webcam) with Frigate, the recommendation is to use go2rtc's [FFmpeg Device](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg-device) support: - Preparation outside of Frigate: - - Get USB camera path. Run `v4l2-ctl --list-devices` to get a listing of locally-connected cameras available. (You may need to install `v4l-utils` in a way appropriate for your Linux distribution). In the sample configuration below, we use `video=0` to correlate with a detected device path of `/dev/video0` - Get USB camera formats & resolutions. Run `ffmpeg -f v4l2 -list_formats all -i /dev/video0` to get an idea of what formats and resolutions the USB Camera supports. In the sample configuration below, we use a width of 1024 and height of 576 in the stream and detection settings based on what was reported back. - If using Frigate in a container (e.g. Docker on TrueNAS), ensure you have USB Passthrough support enabled, along with a specific Host Device (`/dev/video0`) + Container Device (`/dev/video0`) listed. diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index eed430b520..640b78841f 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -3,6 +3,78 @@ id: cameras title: Camera Configuration --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +## Adding a camera with the Add Camera Wizard + +The Add Camera Wizard is the recommended way to add a camera. Click **Add Camera** in . The wizard connects to your camera, tests each stream, and writes the camera's configuration for you, including the [go2rtc](go2rtc.md) restream and the live view stream mapping, so a standard setup needs no hand-written YAML. + +### Step 1: Name and connection + +Enter a name for the camera along with its host or IP address and credentials, then choose how the wizard should find the camera's streams: + +- **Probe camera** queries the camera over ONVIF (the ONVIF port is usually 80 or 8080) and asks it for its stream URLs. Some cameras use a separate ONVIF/service account rather than the device admin user, and some require **Use digest authentication** to be enabled. +- **Manual selection** builds a stream URL from a template for the camera brand you pick (Dahua/Amcrest/EmpireTech, Hikvision/Uniview/Annke, Ubiquiti, Reolink, Axis, TP-Link, or Foscam). Choose **Other** to enter a custom RTSP URL directly. Non-RTSP stream types must be [configured manually](#setting-up-camera-inputs). + +The name you enter is lowercased and spaces become underscores. If the result still isn't a valid config key, the wizard generates a safe name and stores what you typed as `friendly_name`. + +### Step 2: Probe or snapshot + +In probe mode, the wizard reports what the camera returned (manufacturer, model, firmware, profile count, and whether PTZ, presets, and [autotracking](autotracking.md) are supported) along with the RTSP URLs it discovered. Test each candidate to see its resolution, frame rate, and codecs together with a snapshot, then select the one you want to use. + +In manual mode, the wizard tests the templated URL and shows the same metadata and snapshot. + +If no RTSP URLs are found, the credentials may be wrong or the camera may not support ONVIF. Go back and use manual selection instead. + +### Step 3: Stream configuration + +Assign [roles](#setting-up-camera-inputs) to the stream, and use **Add Another Stream** to add the camera's other streams, for example a substream for `detect` alongside the main stream for `record`. At least one stream must have the `detect` role before you can continue. + +**Reduce connections to camera** routes that input through the go2rtc restream so Frigate and the live view share a single connection to the camera instead of each opening their own. See [restream](restream.md) for more detail. + +### Step 4: Validation and testing + +Connect each stream to get a live preview, an estimated bandwidth figure, and a list of validation results. The wizard checks for the most common misconfigurations, including: + +- A detect resolution that is too high (increased resource usage) or too low for reliable detection, or one it could not probe at all +- A stream marked `record` whose audio codec is not AAC, or that has no audio at all +- A stream marked `audio` that carries no audio stream +- Using a restreamed input for the `record` role +- Brand-specific issues, such as an RTSP stream on a Reolink camera that should use http-flv, or a Dahua/Hikvision substream selected for `detect` + +**Use stream compatibility mode** passes the stream through go2rtc's ffmpeg module. Enable it if a stream fails to load after several attempts. Note that this also prevents [two way talk](/configuration/live#two-way-talk) from being detected for that stream. + +**Save New Camera** writes the configuration and starts the camera right away. No restart is required. + +Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs. + +## Deleting a camera + +Click **Delete Camera** in , choose the camera, and confirm. Deleting a camera requires the `admin` role and cannot be undone. + +:::warning + +Deleting a camera permanently removes its recordings, tracked objects, and configuration. If you only want to stop processing a camera, set its state to **Off** or **Disabled** in instead. See [camera state](/configuration/live#camera-state). + +::: + +Deleting a camera removes: + +- The camera's section of your config file, along with its entries in any [role](authentication.md#user-roles) camera list. A custom role left with no cameras is removed as well. +- Every database record for the camera: tracked objects, review items, recordings, previews, timeline entries, the saved region grid, and [triggers](semantic_search.md#triggers). +- Every media file for the camera: recordings, snapshots, thumbnails, and preview clips. + +[Exports](/usage/exports) are kept by default, so saved footage survives the deletion of the camera it came from. Turn on **Also delete exports for this camera** in the confirmation step to remove those too. + +The camera's processes are stopped and the change takes effect immediately, so no restart is required. If the resulting config cannot be parsed, Frigate restores the previous config and reports an error instead of leaving Frigate in a broken state. + +Two things are not cleaned up for you: + +- **go2rtc streams.** Frigate makes a best effort to stop a running [go2rtc](go2rtc.md) stream named after the camera, but stream entries in your config file remain and are recreated on the next restart. Remove them in or in your config file. +- **Camera groups.** A deleted camera stays listed in any [camera group](#setting-up-camera-groups) that referenced it. The group skips the missing camera, so this is harmless, but you can edit the group to drop the stale entry. + ## Setting Up Camera Inputs Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa. @@ -17,6 +89,27 @@ Each role can only be assigned to one input per camera. The options for roles ar | `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) | | `audio` | Feed for audio based detection. [docs](audio_detectors.md) | + + + +Navigate to . + +| Field | Description | +| ----------------- | ------------------------------------------------------------------- | +| **Camera inputs** | List of input stream definitions (paths and roles) for this camera. | + +For each input you can choose its source: select **Restream (go2rtc)** to pick an existing [go2rtc stream](restream.md) from a dropdown (Frigate uses the `rtsp://127.0.0.1:8554/` path and `preset-rtsp-restream` input args for that input automatically), or **Manual input path** to type the stream URL directly. + +Navigate to . + +| Field | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------ | +| **Detect width** | Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution. | +| **Detect height** | Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution. | + + + + ```yaml mqtt: host: mqtt.server.com @@ -36,7 +129,18 @@ cameras: height: 720 # <- optional, by default Frigate tries to automatically detect resolution ``` -Additional cameras are simply added to the config under the `cameras` entry. + + + +Additional cameras are simply added under the camera configuration section. + + + + +Navigate to and use the [Add Camera Wizard](#adding-a-camera-with-the-add-camera-wizard) to configure each additional camera. + + + ```yaml mqtt: ... @@ -46,6 +150,9 @@ cameras: side: ... ``` + + + :::note If you only define one stream in your `inputs` and do not assign a `detect` role to it, Frigate will automatically assign it the `detect` role. Frigate will always decode a stream to support motion detection, Birdseye, the API image endpoints, and other features, even if you have disabled object detection with `enabled: False` in your config's `detect` section. @@ -64,7 +171,19 @@ Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to com ::: -Add the onvif section to your camera in your configuration file: +Configure the ONVIF connection for your camera to enable PTZ controls. + + + + +1. Navigate to and select your camera. + - Set **ONVIF host** to your camera's IP address, e.g.: `10.0.10.10` + - Set **ONVIF port** to your camera's ONVIF port, e.g.: `8000` + - Set **ONVIF username** to your camera's ONVIF username, e.g.: `admin` + - Set **ONVIF password** to your camera's ONVIF password, e.g.: `password` + + + ```yaml {4-8} cameras: @@ -77,6 +196,9 @@ cameras: password: password ``` + + + If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI. :::note @@ -91,6 +213,13 @@ If your ONVIF camera does not require authentication credentials, you may still ::: +If a camera connects but fails to authenticate, two optional fields can help: + +- `tls_insecure`: Skips TLS certificate verification and sends the ONVIF password as plaintext (`PasswordText`) instead of a hashed digest (`PasswordDigest`). Some cameras reject the digest token and only accept plaintext. This weakens connection security, so only enable it on a trusted local network. +- `ignore_time_mismatch`: ONVIF authentication tokens include a timestamp, and a camera will reject the token if its clock differs too much from Frigate's. Enabling this makes Frigate compensate for the time offset so authentication can still succeed. Running NTP on both the camera and the Frigate host is the recommended fix; only use this in a "safe" environment, as it slightly weakens token validation. + +If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera. + An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs. ## ONVIF PTZ camera recommendations @@ -120,7 +249,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or | Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | | | Reolink | ✅ | ❌ | | | Speco O8P32X | ✅ | ❌ | | -| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. | +| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatible. | | Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 | | Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands | | Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. | @@ -128,13 +257,15 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or ## Setting up camera groups -:::tip +Camera groups let you organize cameras together with a shared name and icon, making it easier to review and filter them. A default group for all cameras is always available. -It is recommended to set up camera groups using the UI. + + -::: +On the Live dashboard, press the **pencil icon** in the main navigation to add a new camera group. Configure the group name, select which cameras to include, choose an icon, and set the display order. -Cameras can be grouped together and assigned a name and icon, this allows them to be reviewed and filtered together. There will always be the default group for all cameras. + + ```yaml camera_groups: @@ -146,6 +277,9 @@ camera_groups: order: 0 ``` + + + ## Two-Way Audio See the guide [here](/configuration/live/#two-way-talk) diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md new file mode 100644 index 0000000000..76458e4d36 --- /dev/null +++ b/docs/docs/configuration/config.md @@ -0,0 +1,383 @@ +--- +id: config +title: Frigate Configuration +--- + +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Frigate can be configured through the **Settings UI** or by editing the YAML configuration file directly. The Settings UI is the recommended approach. It provides validation and a guided experience for all configuration options. + +## Using the Settings UI + +The Settings UI groups every configuration option into sections that are listed in the left-hand menu. Each section presents a guided form with validation, so you don't need to remember the structure of the YAML or look up option names by hand. + +### Global vs. camera-level configuration + +Settings are organized into two scopes: + +- **Global configuration**: values under apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on. +- **Camera configuration**: values under apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing. + +When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only: the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.` takes precedence over the same value set at the top level. See [Global and Camera-Level Configuration](./config_overrides.md) for the full details, including how lists and maps are handled and which settings must be enabled globally first. + +To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section: + +- On a camera section, the button is labeled **Reset to Global** and restores the camera to the global value. +- On a global section, the button is labeled **Reset to Default** and restores Frigate's built-in default. + +Resetting asks for confirmation and cannot be undone once applied. + +### Saving changes and the Save All button + +Edits are not applied until you save them. As soon as you change a value, the UI tracks it as a pending change: + +- The edited section shows a **Modified** badge, and the changed fields are highlighted. +- A **You have unsaved changes** notice appears above the section's **Save** and **Undo** buttons. **Save** commits just that section; **Undo** discards its pending edits. + +Because pending changes can span multiple sections (and multiple cameras), the header provides a **Save All** button that writes every pending change at once. Next to it, **Review pending changes** opens a summary that lists each pending edit with its scope (Global or a specific camera), the affected field, and the new value, so you can confirm exactly what will be written before committing. **Undo All** discards every pending change across all sections. + +### Restart-required indicators + +Most settings take effect immediately, but some require Frigate to restart before they apply. Fields that require a restart are marked with a small restart icon and a **Restart required** tooltip next to the field label. + +When you save a change that touches one of these fields, Frigate confirms the save and reminds you that a restart is needed (for example, _"Settings saved successfully. Restart Frigate to apply your changes."_). The notification includes a one-click **Restart Frigate** action so you can apply the change right away, or you can continue editing and restart later. + +### The colored dots in the camera configuration menu + +When you are working under , small colored dots can appear next to a section's name in the menu. They give you an at-a-glance summary of that section's state for the selected camera: + +- **Blue dot**: this section **overrides the global configuration**. One or more values in the section have been set specifically for this camera and differ from the global defaults. +- **Profile-colored dot**: when you are viewing a [camera profile](./profiles.md), a dot in that profile's assigned color indicates the section is **overridden by that profile**. Each profile is given its own distinct color so you can tell at a glance which sections it changes. +- **Amber dot**: this section has **unsaved changes**. It appears alongside the **Modified** badge whenever you have pending edits in the section that haven't been saved yet. + +Hover over any dot to see a tooltip describing what it means. Open a section to see exactly which fields are overridden: the section header indicates how many fields differ from the global (or base) configuration. + +## Configuration File Location + +For users who prefer to edit the YAML configuration file directly, it is recommended to start with a minimal configuration and add to it as described in [the getting started guide](../guides/getting_started.md). + +- **Home Assistant App:** `/addon_configs//config.yml` (see [directory list](#accessing-app-config-dir)) +- **All other installations:** Map to `/config/config.yml` inside the container + +It can be named `config.yml` or `config.yaml`, but if both files exist `config.yml` will be preferred and `config.yaml` will be ignored. + +A minimal starting configuration: + +```yaml +mqtt: + enabled: False + +cameras: + dummy_camera: # <--- this will be changed to your actual camera later + enabled: False + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:554/rtsp + roles: + - detect +``` + +## Accessing the Home Assistant App configuration directory {#accessing-app-config-dir} + +When running Frigate through the HA App, the Frigate `/config` directory is mapped to `/addon_configs/` in the host, where `` is specific to the variant of the Frigate App you are running. + +| App Variant | Configuration directory | +| -------------------------- | ----------------------------------------- | +| Frigate | `/addon_configs/ccab4aaf_frigate` | +| Frigate (Full Access) | `/addon_configs/ccab4aaf_frigate-fa` | +| Frigate Beta | `/addon_configs/ccab4aaf_frigate-beta` | +| Frigate Beta (Full Access) | `/addon_configs/ccab4aaf_frigate-fa-beta` | + +**Whenever you see `/config` in the documentation, it refers to this directory.** + +If for example you are running the standard App variant and use the [VS Code App](https://github.com/hassio-addons/addon-vscode) to browse your files, you can click _File_ > _Open folder..._ and navigate to `/addon_configs/ccab4aaf_frigate` to access the Frigate `/config` directory and edit the `config.yaml` file. You can also use the built-in config editor in the Frigate UI. + +## VS Code Configuration Schema + +VS Code supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VS Code and Frigate as an App, you should use `ccab4aaf-frigate` instead. Make sure to expose the internal unauthenticated port `5000` when accessing the config from VS Code on another machine. + +## Environment Variable Substitution + +Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). For example, the following values can be replaced at runtime by using environment variables: + +```yaml +mqtt: + host: "{FRIGATE_MQTT_HOST}" + user: "{FRIGATE_MQTT_USER}" + password: "{FRIGATE_MQTT_PASSWORD}" +``` + +```yaml +- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast +``` + +```yaml +onvif: + host: "192.168.1.12" + port: 8000 + user: "{FRIGATE_RTSP_USER}" + password: "{FRIGATE_RTSP_PASSWORD}" +``` + +```yaml +go2rtc: + rtsp: + username: "{FRIGATE_GO2RTC_RTSP_USERNAME}" + password: "{FRIGATE_GO2RTC_RTSP_PASSWORD}" +``` + +```yaml +genai: + my_provider: + api_key: "{FRIGATE_GENAI_API_KEY}" +``` + +## Common configuration examples + +Here are some common starter configuration examples. These can be configured through the Settings UI or via YAML. Refer to the [reference config](./advanced/reference.md) for detailed information about all config values. + +### Raspberry Pi Home Assistant App with USB Coral + +- Single camera with 720p, 5fps stream for detect +- MQTT connected to the Home Assistant Mosquitto App +- Hardware acceleration for decoding video +- USB Coral detector +- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not +- Continue to keep all video if it qualified as an alert or detection for 30 days +- Save snapshots for 30 days +- Motion mask for the camera timestamp + + + + +1. Navigate to and configure the MQTT connection to your Home Assistant Mosquitto broker +2. Navigate to and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` +3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` +4. Navigate to 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` +6. Navigate to and add your camera with the appropriate RTSP stream URL +7. Navigate to to add a motion mask for the camera timestamp + + + + +```yaml +mqtt: + host: core-mosquitto + user: mqtt-user + password: xxxxxxxxxx + +ffmpeg: + hwaccel_args: preset-rpi-64-h264 + +detectors: + coral: + type: edgetpu + device: usb + +record: + enabled: True + motion: + days: 7 + alerts: + retain: + days: 30 + mode: motion + detections: + retain: + days: 30 + mode: motion + +snapshots: + enabled: True + retain: + default: 30 + +cameras: + name_of_your_camera: + detect: + width: 1280 + height: 720 + fps: 5 + ffmpeg: + inputs: + - path: rtsp://10.0.10.10:554/rtsp + roles: + - detect + motion: + mask: + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" +``` + + + + +### Standalone Intel Mini PC with USB Coral + +- Single camera with 720p, 5fps stream for detect +- MQTT disabled (not integrated with Home Assistant) +- VAAPI hardware acceleration for decoding video +- USB Coral detector +- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not +- Continue to keep all video if it qualified as an alert or detection for 30 days +- Save snapshots for 30 days +- Motion mask for the camera timestamp + + + + +1. Navigate to and set **Enable MQTT** to off +2. Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)` +3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` +4. Navigate to 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` +6. Navigate to and add your camera with the appropriate RTSP stream URL +7. Navigate to to add a motion mask for the camera timestamp + + + + +```yaml +mqtt: + enabled: False + +ffmpeg: + hwaccel_args: preset-vaapi + +detectors: + coral: + type: edgetpu + device: usb + +record: + enabled: True + motion: + days: 7 + alerts: + retain: + days: 30 + mode: motion + detections: + retain: + days: 30 + mode: motion + +snapshots: + enabled: True + retain: + default: 30 + +cameras: + name_of_your_camera: + detect: + width: 1280 + height: 720 + fps: 5 + ffmpeg: + inputs: + - path: rtsp://10.0.10.10:554/rtsp + roles: + - detect + motion: + mask: + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" +``` + + + + +### Home Assistant integrated Intel Mini PC with OpenVINO + +- Single camera with 720p, 5fps stream for detect +- MQTT connected to same MQTT server as Home Assistant +- VAAPI hardware acceleration for decoding video +- OpenVINO detector +- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not +- Continue to keep all video if it qualified as an alert or detection for 30 days +- Save snapshots for 30 days +- Motion mask for the camera timestamp + + + + +1. Navigate to and configure the connection to your MQTT broker +2. Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)` +3. Navigate to and add a detector with **Type** `openvino` and **Device** `AUTO` +4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings +5. Navigate to 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` +7. Navigate to and add your camera with the appropriate RTSP stream URL +8. Navigate to to add a motion mask for the camera timestamp + + + + +```yaml +mqtt: + host: 192.168.X.X # <---- same mqtt broker that home assistant uses + user: mqtt-user + password: xxxxxxxxxx + +ffmpeg: + hwaccel_args: preset-vaapi + +detectors: + ov: + type: openvino + device: AUTO + +model: + width: 300 + height: 300 + input_tensor: nhwc + input_pixel_format: bgr + path: /openvino-model/ssdlite_mobilenet_v2.xml + labelmap_path: /openvino-model/coco_91cl_bkgr.txt + +record: + enabled: True + motion: + days: 7 + alerts: + retain: + days: 30 + mode: motion + detections: + retain: + days: 30 + mode: motion + +snapshots: + enabled: True + retain: + default: 30 + +cameras: + name_of_your_camera: + detect: + width: 1280 + height: 720 + fps: 5 + ffmpeg: + inputs: + - path: rtsp://10.0.10.10:554/rtsp + roles: + - detect + motion: + mask: + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" +``` + + + diff --git a/docs/docs/configuration/config_overrides.md b/docs/docs/configuration/config_overrides.md new file mode 100644 index 0000000000..61aa545f44 --- /dev/null +++ b/docs/docs/configuration/config_overrides.md @@ -0,0 +1,244 @@ +--- +id: config_overrides +title: Global and Camera-Level Configuration +--- + +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Most of Frigate's configuration can be set once for all cameras and then adjusted for individual cameras. The global value acts as the default for every camera, and any camera can override it. + +This page explains how that inheritance works. For a tour of the Settings UI itself, see [Frigate Configuration](./config.md). + +## The basics + +Set a value globally and every camera uses it. Set the same value on a camera and that camera uses its own value instead. + + + + +1. Navigate to and set **Detect FPS** to `5`. Every camera now detects at 5 fps. +2. Navigate to , select the `driveway` camera, and set **Detect FPS** to `10`. + +The `driveway` camera now detects at 10 fps. Every other camera still uses the global value of 5. + + + + +```yaml +detect: + fps: 5 # every camera detects at 5 fps + +cameras: + front_door: + ffmpeg: ... + driveway: + ffmpeg: ... + detect: + fps: 10 # except this one +``` + +`front_door` inherits `fps: 5`, and `driveway` uses `10`. + + + + +## Overrides apply per value, not per section + +Overriding one value in a section does not detach the rest of that section. Everything you don't set on the camera still comes from the global configuration. + + + + +If you set a camera's **Motion threshold** but leave **Contour area** alone, only the threshold is overridden. The contour area continues to follow , and changing it there still affects that camera. + +Open a section to see which values are overridden: the section header indicates how many fields differ from the global configuration. + + + + +```yaml +motion: + threshold: 30 + contour_area: 10 + +cameras: + driveway: + motion: + threshold: 40 +``` + +The `driveway` camera ends up with `threshold: 40` and `contour_area: 10`. Only the value you wrote was overridden. + + + + +## Returning a camera to the global value + + + + +A camera section that has its own values shows an **Overridden** badge. To remove the override and go back to inheriting, use the **Reset to Global** button at the bottom of the section. + + + + +Frigate treats a camera value as an override because it is written in the config file, not because it differs from the global value. Repeating the global value under a camera still creates an override: + +```yaml +snapshots: + enabled: true + +cameras: + driveway: + snapshots: + enabled: true # this is an override, even though it matches +``` + +If you later change the global `snapshots.enabled` to `false`, `driveway` keeps saving snapshots, because it has its own value. To make a camera follow the global value again, delete the key from the camera rather than setting it to match. + + + + +## Lists replace, maps merge + +This is the distinction that surprises people most. + +**Lists are replaced entirely.** A camera's list does not add to the global list, it takes its place. + + + + +The camera page shows the objects the camera is currently tracking, starting from the global list. Changing that selection under replaces the list for that camera, so make sure every object you want tracked is selected, not just the ones you are adding. + + + + +```yaml +objects: + track: + - person + - car + +cameras: + backyard: + objects: + track: + - dog # backyard tracks ONLY dog, not person or car +``` + +To track `dog` in addition to the global objects, list all of them on the camera. + + + + +An empty list is a valid override, and is the normal way to opt a camera out of something: + +```yaml +review: + alerts: + labels: + - person + +cameras: + street: + review: + alerts: + labels: [] # this camera never creates alerts +``` + +**Maps are merged key by key.** A camera can add an entry without redeclaring the others. + + + + +Adding a filter for one object under does not remove the filters inherited from . The camera keeps both. + + + + +```yaml +objects: + filters: + person: + min_area: 5000 + +cameras: + driveway: + objects: + filters: + car: + min_area: 10000 +``` + +The `driveway` camera ends up with both the `car` filter it defined and the `person` filter from the global configuration. + + + + +## Which settings can be overridden + +Most, but not all. The [full reference config](./advanced/reference.md) is the authoritative source: sections that support camera-level overrides are marked with the comment `# NOTE: Can be overridden at the camera level`. In the UI, a setting can be overridden if it appears under both and . + +A few things worth knowing beyond that: + +- Some sections are **global only** and have no camera-level equivalent, including `go2rtc`, `genai` providers, `classification`, `telemetry`, `camera_groups`, and `ui`. +- Some sections exist **only at the camera level**, such as `zones` and `onvif`. +- Some sections are **partially overridable**, meaning a camera accepts only a few of the keys available globally. `face_recognition`, `lpr`, and `audio_transcription` work this way, and the reference config notes which keys apply. + +## Enrichments that must be enabled globally first + +License plate recognition and face recognition are special: the global setting is not just a default, it is a switch that must be on before any camera can use the feature. Enabling one on a camera while it is disabled globally is a configuration error, and Frigate will refuse to start: + +``` +Camera driveway has lpr enabled but lpr is disabled at the global level of the config. You must enable lpr at the global level. +``` + +Enable the feature globally, then turn it off on the cameras that don't need it. + + + + +1. Navigate to and enable **LPR**. +2. Navigate to , select each camera that should not run LPR, and disable the **Enable LPR** toggle. + + + + +```yaml +lpr: + enabled: true + +cameras: + driveway: + ffmpeg: ... # inherits lpr, enabled + backyard: + ffmpeg: ... + lpr: + enabled: false # opted out +``` + + + + +:::note + +This applies only to `lpr` and `face_recognition`, because the global setting controls whether the supporting background process starts at all. Other features do not work this way. Audio transcription, for example, can be enabled on a single camera without being enabled globally. + +::: + +## Profiles + +[Profiles](./profiles.md) add a further layer on top of everything described above. A profile is a named set of camera overrides that you can switch on and off while Frigate is running, for example to change detection and recording behavior when you leave the house. + +Profiles are applied on top of a camera's already-resolved configuration, so a profile value wins over both the camera and the global value while that profile is active. Profiles cover a subset of the camera sections and do not modify your config file. + +## Summary + +- A camera inherits every value you don't set on it. +- Overriding one value does not detach the rest of the section. +- Writing a value on a camera overrides it, even if it matches the global value. Remove it to inherit again. +- Lists replace the global list. Maps merge into it. +- An empty list is an override, not an omission. +- `lpr` and `face_recognition` must be enabled globally before a camera can use them. diff --git a/docs/docs/configuration/custom_classification/object_classification.md b/docs/docs/configuration/custom_classification/object_classification.md index caf05d8f3c..4f65934e13 100644 --- a/docs/docs/configuration/custom_classification/object_classification.md +++ b/docs/docs/configuration/custom_classification/object_classification.md @@ -3,13 +3,23 @@ id: object_classification title: Object Classification --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + Object classification allows you to train a custom MobileNetV2 classification model to run on tracked objects (persons, cars, animals, etc.) to identify a finer category or attribute for that object. Classification results are visible in the Tracked Object Details pane in Explore, through the `frigate/tracked_object_details` MQTT topic, in Home Assistant sensors via the official Frigate integration, or through the event endpoints in the HTTP API. +:::info + +Training a custom object classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + ## Minimum System Requirements Object classification models are lightweight and run very fast on CPU. -Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer. +Training the model does briefly use a high amount of system resources for about 1-3 minutes per training run. On lower-power devices, training may take longer. A CPU with AVX + AVX2 instructions is required for training and inference. @@ -27,7 +37,7 @@ For object classification: ### Classification Type - **Sub label**: - - Applied to the object’s `sub_label` field. + - Applied to the object's `sub_label` field. - Ideal for a single, more specific identity or type. - Example: `cat` → `Leo`, `Charlie`, `None`. @@ -55,7 +65,7 @@ This two-step verification prevents false positives by requiring consistent pred ### Sub label -- **Known pet vs unknown**: For `dog` objects, set sub label to your pet’s name (e.g., `buddy`) or `none` for others. +- **Known pet vs unknown**: For `dog` objects, set sub label to your pet's name (e.g., `buddy`) or `none` for others. - **Mail truck vs normal car**: For `car`, classify as `mail_truck` vs `car` to filter important arrivals. - **Delivery vs non-delivery person**: For `person`, classify `delivery` vs `visitor` based on uniform/props. @@ -68,7 +78,27 @@ This two-step verification prevents false positives by requiring consistent pred ## Configuration -Object classification is configured as a custom classification model. Each model has its own name and settings. You must list which object labels should be classified. +Object classification is configured as a custom classification model. Each model has its own name and settings. Specify which object labels should be classified. + + + + +Navigate to the **Classification** page from the main navigation sidebar, then click **Add Classification**. + +In the **Create New Classification** dialog: + +| Field | Description | +| ----------------------- | ------------------------------------------------------------- | +| **Name** | A name for your classification model (e.g., `dog`) | +| **Type** | Select **Object** for object classification | +| **Object Label** | The object label to classify (e.g., `dog`, `person`, `car`) | +| **Classification Type** | Whether to assign results as a **Sub Label** or **Attribute** | +| **Classes** | The class names the model will learn to distinguish between | + +The `threshold` (default: `0.8`) can be adjusted in the YAML configuration. + + + ```yaml classification: @@ -82,6 +112,9 @@ classification: An optional config, `save_attempts`, can be set as a key under the model name. This defines the number of classification attempts to save in the Recent Classifications tab. For object classification models, the default is 200. + + + ## Training the model Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of two steps: @@ -102,18 +135,47 @@ If examples for some of your classes do not appear in the grid, you can continue ### Improving the Model +:::tip Diversity matters far more than volume + +Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data. The model learns what _that exact moment_ looked like rather than what actually defines the class. **This is why Frigate does not implement bulk training in the UI.** + +For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374). + +::: + +- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time. +- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90-100% or those captured under new lighting, weather, or distance conditions. +- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting. +- **The wizard is just the starting point**: You don't need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases. - **Problem framing**: Keep classes visually distinct and relevant to the chosen object types. -- **Data collection**: Use the model’s Recent Classification tab to gather balanced examples across times of day, weather, and distances. -- **Preprocessing**: Ensure examples reflect object crops similar to Frigate’s boxes; keep the subject centered. -- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels. +- **Preprocessing**: Ensure examples reflect object crops similar to Frigate's boxes; keep the subject centered. +- **Crop size**: Aim for crops of at least 100×100 pixels (a 10,000 pixel area). Crops smaller than ~80×80 get stretched 3-7× by the model's 224×224 input resize and tend to collapse into a generic "blob" region of feature space where identity becomes unreliable. If most of your detections are small because the camera is far from the subject, consider repositioning the camera for closer crops. +- **Class balance**: Aim to keep your largest class within ~3× the count of your smallest. Beyond that, the model becomes biased toward the dominant class and tends to default borderline predictions to it (the "everything looks like Buddy" failure mode). - **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation. +:::tip `none` works differently from named classes + +Named classes work best with visually uniform examples. Every Buddy photo should look like Buddy. The `none` class needs the opposite: visual diversity across sizes, framings, and qualities, because at inference it has to absorb everything that isn't one of your named classes. Don't apply the same "only keep large, well-framed images" rule to `none` that you would to a named class. Mix in small crops, partial views, and false positives deliberately - otherwise the model has no signal for "small/ambiguous thing = not one of my known classes" and will force those crops into a named class by default. + +::: + ## Debugging Classification Models To troubleshoot issues with object classification models, enable debug logging to see detailed information about classification attempts, scores, and consensus calculations. Enable debug logs for classification models by adding `frigate.data_processing.real_time.custom_classification: debug` to your `logger` configuration. These logs are verbose, so only keep this enabled when necessary. Restart Frigate after this change. + + + +Navigate to . + +- Set **Logging level** to `debug` +- Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging + + + + ```yaml logger: default: info @@ -122,6 +184,9 @@ logger: frigate.data_processing.real_time.custom_classification: debug ``` + + + The debug logs will show: - Classification probabilities for each attempt diff --git a/docs/docs/configuration/custom_classification/state_classification.md b/docs/docs/configuration/custom_classification/state_classification.md index c41d05439f..b24ce17922 100644 --- a/docs/docs/configuration/custom_classification/state_classification.md +++ b/docs/docs/configuration/custom_classification/state_classification.md @@ -3,13 +3,23 @@ id: state_classification title: State Classification --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + State classification allows you to train a custom MobileNetV2 classification model on a fixed region of your camera frame(s) to determine a current state. The model can be configured to run on a schedule and/or when motion is detected in that region. Classification results are available through the `frigate//classification/` MQTT topic and in Home Assistant sensors via the official Frigate integration. +:::info + +Training a custom state classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + ## Minimum System Requirements State classification models are lightweight and run very fast on CPU. -Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer. +Training the model does briefly use a high amount of system resources for about 1-3 minutes per training run. On lower-power devices, training may take longer. A CPU with AVX + AVX2 instructions is required for training and inference. @@ -33,7 +43,25 @@ For state classification: ## Configuration -State classification is configured as a custom classification model. Each model has its own name and settings. You must provide at least one camera crop under `state_config.cameras`. +State classification is configured as a custom classification model. Each model has its own name and settings. Provide at least one camera crop under `state_config.cameras`. + + + + +Navigate to the **Classification** page from the main navigation sidebar, select the **States** tab, then click **Add Classification**. + +In the **Create New Classification** dialog: + +| Field | Description | +| ----------- | ------------------------------------------------------------------------------------ | +| **Name** | A name for your state classification model (e.g., `front_door`) | +| **Type** | Select **State** for state classification | +| **Classes** | The state names the model will learn to distinguish between (e.g., `open`, `closed`) | + +After creating the model, the wizard will guide you through selecting the camera crop area and assigning training examples. The `threshold` (default: `0.8`), `motion`, and `interval` settings can be adjusted in the YAML configuration. + + + ```yaml classification: @@ -45,11 +73,18 @@ classification: interval: 10 # also run every N seconds (optional) cameras: front: - crop: [0, 180, 220, 400] + # [x1, y1, x2, y2] as decimals between 0 and 1, relative to the + # camera's detect resolution + crop: [0.0, 0.25, 0.3, 0.85] ``` +Crop coordinates are normalized: each value is a fraction of the camera's `detect` width or height, not a pixel value. Drawing the crop in the UI wizard writes these values for you. + An optional config, `save_attempts`, can be set as a key under the model name. This defines the number of classification attempts to save in the Recent Classifications tab. For state classification models, the default is 100. + + + ## Training the model Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps: @@ -70,10 +105,21 @@ Once some images are assigned, training will begin automatically. ### Improving the Model +:::tip Diversity matters far more than volume + +Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data. The model learns what _that exact moment_ looked like rather than what actually defines the state. This often leads to models that work perfectly under the original conditions but become unstable when day turns to night, weather changes, or seasonal lighting shifts. **This is why Frigate does not implement bulk training in the UI.** + +For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374). + +::: + +- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time. - **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary. - **Data collection**: Use the model's Recent Classifications tab to gather balanced examples across times of day and weather. - **When to train**: Focus on cases where the model is entirely incorrect or flips between states when it should not. There's no need to train additional images when the model is already working consistently. -- **Selecting training images**: Images scoring below 100% due to new conditions (e.g., first snow of the year, seasonal changes) or variations (e.g., objects temporarily in view, insects at night) are good candidates for training, as they represent scenarios different from the default state. Training these lower-scoring images that differ from existing training data helps prevent overfitting. Avoid training large quantities of images that look very similar, especially if they already score 100% as this can lead to overfitting. +- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90-100% or those captured under new conditions (e.g., first snow of the year, seasonal changes, objects temporarily in view, insects at night). These represent scenarios different from the default state and help prevent overfitting. +- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting. +- **The wizard is just the starting point**: You don't need to find and label every state upfront. Missing states will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases. ## Debugging Classification Models @@ -81,6 +127,17 @@ To troubleshoot issues with state classification models, enable debug logging to Enable debug logs for classification models by adding `frigate.data_processing.real_time.custom_classification: debug` to your `logger` configuration. These logs are verbose, so only keep this enabled when necessary. Restart Frigate after this change. + + + +Navigate to . + +- Set **Logging level** to `debug` +- Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging + + + + ```yaml logger: default: info @@ -89,6 +146,9 @@ logger: frigate.data_processing.real_time.custom_classification: debug ``` + + + The debug logs will show: - Classification probabilities for each attempt diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 74fd810710..333c3ff1e7 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -3,8 +3,19 @@ id: face_recognition title: Face Recognition --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications. +:::info + +Face recognition requires a one-time internet connection to download detection and embedding models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + ## Model Requirements ### Face Detection @@ -40,56 +51,115 @@ The `large` model is optimized for accuracy, an integrated or discrete GPU / NPU ## Configuration -Face recognition is disabled by default, face recognition must be enabled in the UI or in your config file before it can be used. Face recognition is a global configuration setting. +Face recognition is disabled by default and must be enabled before it can be used. Face recognition is a global configuration setting. + + + + +Navigate to . + +- Set **Enable face recognition** to on + + + ```yaml face_recognition: enabled: true ``` + + + Like the other real-time processors in Frigate, face recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements. ## Advanced Configuration -Fine-tune face recognition with these optional parameters at the global level of your config. The only optional parameters that can be set at the camera level are `enabled` and `min_area`. +Fine-tune face recognition with these optional parameters. The only optional parameters that can be set at the camera level are `enabled` and `min_area`. ### Detection -- `detection_threshold`: Face detection confidence score required before recognition runs: + + + +Navigate to . + +- **Detection threshold**: Face detection confidence score required before recognition runs. This field only applies to the standalone face detection model; `min_score` should be used to filter for models that have face detection built in. - Default: `0.7` - - Note: This is field only applies to the standalone face detection model, `min_score` should be used to filter for models that have face detection built in. -- `min_area`: Defines the minimum size (in pixels) a face must be before recognition runs. - - Default: `500` pixels. - - Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces. +- **Minimum face area**: Minimum size (in pixels) a face must be before recognition runs. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces. + - Default: `750` pixels + + + + +```yaml +face_recognition: + enabled: true + detection_threshold: 0.7 + min_area: 750 +``` + + + ### Recognition -- `model_size`: Which model size to use, options are `small` or `large` -- `unknown_score`: Min score to mark a person as a potential match, matches at or below this will be marked as unknown. - - Default: `0.8`. -- `recognition_threshold`: Recognition confidence score required to add the face to the object as a sub label. - - Default: `0.9`. -- `min_faces`: Min face recognitions for the sub label to be applied to the person object. + + + +Navigate to . + +- **Model size**: Which model size to use, options are `small` or `large`. +- **Unknown score threshold**: Min score to mark a person as a potential match; matches at or below this will be marked as unknown. + - Default: `0.8` +- **Recognition threshold**: Recognition confidence score required to add the face to the object as a sub label. + - Default: `0.9` +- **Minimum faces**: Min face recognitions for the sub label to be applied to the person object. - Default: `1` -- `save_attempts`: Number of images of recognized faces to save for training. - - Default: `200`. -- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this. - - Default: `True`. -- `device`: Target a specific device to run the face recognition model on (multi-GPU installation). - - Default: `None`. - - Note: This setting is only applicable when using the `large` model. See [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/) +- **Save attempts**: Number of images of recognized faces to save for training. + - Default: `200` +- **Blur confidence filter**: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this. + - Default: `True` +- **Device**: Target a specific device to run the face recognition model on (multi-GPU installation). This setting is only applicable when using the `large` model. See [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/). + - Default: `None` + + + + +```yaml +face_recognition: + enabled: true + model_size: small + unknown_score: 0.8 + recognition_threshold: 0.9 + min_faces: 1 + save_attempts: 200 + blur_confidence_filter: true + device: None +``` + + + ## Usage Follow these steps to begin: -1. **Enable face recognition** in your configuration file and restart Frigate. +1. **Enable face recognition** in your configuration and restart Frigate. 2. **Upload one face** using the **Add Face** button's wizard in the Face Library section of the Frigate UI. Read below for the best practices on expanding your training set. 3. When Frigate detects and attempts to recognize a face, it will appear in the **Train** tab of the Face Library, along with its associated recognition confidence. 4. From the **Train** tab, you can **assign the face** to a new or existing person to improve recognition accuracy for the future. ## Creating a Robust Training Set +:::tip + +**The short version:** Start with a few clear, front-facing photos of each person. As faces are detected in the Recent Recognitions tab, train clear images that scored lower, adding variety (different angles, lighting, and expressions) slowly. Diversity matters far more than volume, and low-quality images hurt recognition more than they help. + +For a step-by-step narrative of these best practices (and the same principles applied to state and object classification), see the [Frigate Tips: Best Practices for Training](https://github.com/blakeblackshear/frigate/discussions/21374) discussion. + +::: + The number of images needed for a sufficient training set for face recognition varies depending on several factors: - Diversity of the dataset: A dataset with diverse images, including variations in lighting, pose, and facial expressions, will require fewer images per person than a less diverse dataset. @@ -110,7 +180,7 @@ When choosing images to include in the face training set it is recommended to al - If it is difficult to make out details in a persons face it will not be helpful in training. - Avoid images with extreme under/over-exposure. - Avoid blurry / pixelated images. -- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images. +- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images. - Using images of people wearing hats / sunglasses may confuse the model. - Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting. @@ -120,9 +190,27 @@ When choosing images to include in the face training set it is recommended to al The Recent Recognitions tab in the face library displays recent face recognition attempts. Detected face images are grouped according to the person they were identified as potentially matching. -Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. While each image can be used to train the system for a specific person, not all images are suitable for training. +Each face image is labeled with a name (or `Unknown`) along with the confidence score of that recognition attempt. Images are grouped by the person they were matched against, not by who they actually are, so a group labeled with a person's name can contain a crop that is really someone else but happened to score as a partial match. The name and score shown on each individual crop describe that single attempt. -Refer to the guidelines below for best practices on selecting images for training. +While each image can be used to train the system for a specific person, not all images are suitable for training. Refer to the guidelines below for best practices on selecting images for training. + +### How Frigate Decides Who a Person Is + +Recognition does not happen one frame at a time. While a `person` is in view, Frigate runs face recognition on many frames, not just a single frame. The final `sub_label` is decided from all of those attempts together, weighted by the area of each face (larger, closer faces count more), not from any single frame. + +This has a few practical consequences: + +- A handful of wrong guesses on blurry or distant frames usually do not change the result. If Frigate sees a person as "Tom, Tom, Sam, Tom, Tom," it will still conclude the person was Tom. +- The goal is not for every individual face crop to be correct. The goal is for each person to be recognized correctly overall, across all the faces captured while they were present. +- A single very high confidence match will not by itself assign a sub label. Recognition must be consistent. See [I see scores above the threshold in the Recent Recognitions tab, but a sub label wasn't assigned?](#i-see-scores-above-the-threshold-in-the-recent-recognitions-tab-but-a-sub-label-wasnt-assigned) below. + +### Which Faces Are Worth Training? + +Whether a face is worth training has little to do with what it was recognized as. A crop is a good training candidate when all of these are true: + +- It did not already score high and correctly. Faces that are already recognized confidently add little and increase the risk of over-fitting. +- It is clear enough to be useful: not blurry, not heavily off-axis, not infrared (gray-scale). If it is hard for you to make out the face, it will not help the model. +- It adds something new: a different angle, lighting, expression, or distance than what you already have. ### Step 1 - Building a Strong Foundation @@ -138,39 +226,81 @@ Once front-facing images are performing well, start choosing slightly off-angle ## FAQ -### How do I debug Face Recognition issues? +### Getting Recognition Working + + Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above. -1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library. +1. Enable debug logs to see exactly what Frigate is doing. + - Enable debug logs for face recognition by adding `frigate.data_processing.real_time.face: debug` to your `logger` configuration. Restart Frigate after this change. + + ```yaml + logger: + default: info + logs: + # highlight-next-line + frigate.data_processing.real_time.face: debug + ``` + + - These logs report where the pipeline stopped for each `person` object, such as no face being found within the person's bounding box, the detected face being smaller than `min_area`, or a face being recognized but scoring too low. + - If you see no face-related messages at all, also add `frigate.embeddings.maintainer: debug` to confirm that the face processor was created at startup and that `person` updates are reaching it. + +2. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library. If you are using a Frigate+ or `face` detecting model: - - Watch the debug view (Settings --> Debug) to ensure that `face` is being detected along with `person`. + - Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`. - You may need to adjust the `min_score` for the `face` object if faces are not being detected. If you are **not** using a Frigate+ or `face` detecting model: - Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects. - You may need to lower your `detection_threshold` if faces are not being detected. -2. Any detected faces will then be _recognized_. +3. Any detected faces will then be _recognized_. - Make sure you have trained at least one face per the recommendations above. - Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration). -### Detection does not work well with blurry images? + -Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work. + + +Face recognition does not run on the recording stream, this would be suboptimal for many reasons: + +1. The latency of accessing the recordings means the notifications would not include the names of recognized people because recognition would not complete until after. +2. The embedding models used run on a set image size, so larger images will be scaled down to match this anyway. +3. Motion clarity is much more important than extra pixels, over-compression and motion blur are much more detrimental to results than resolution. + + + +### Improving Accuracy and Training + + + +Accuracy is definitely going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work. Some users have also noted that setting the stream in camera firmware to a constant bit rate (CBR) leads to better image clarity than with a variable bit rate (VBR). -### Why can't I bulk upload photos? + + + + +The embedding models are trained on color images, so gray-scale and infrared (IR) faces sit in a different feature distribution and are more easily confused with other people. Prefer color images, and avoid mixing gray-scale samples in early while you are building a foundation. If someone only ever appears at night, gray-scale training is acceptable, but keep those samples limited and as clear as possible, and add them only once color recognition is stable for your other people. + + + + It is important to methodically add photos to the library, bulk importing photos (especially from a general photo library) will lead to over-fitting in that particular scenario and hurt recognition performance. -### Why can't I bulk reprocess faces? + + + Face embedding models work by breaking apart faces into different features. This means that when reprocessing an image, only images from a similar angle will have its score affected. -### Why do unknown people score similarly to known people? + + + This can happen for a few different reasons, but this is usually an indicator that the training set needs to be improved. This is often related to over-fitting: @@ -180,33 +310,54 @@ This can happen for a few different reasons, but this is usually an indicator th Review your face collections and remove most of the unclear or low-quality images. Then, use the **Reprocess** button on each face in the **Train** tab to evaluate how the changes affect recognition scores. -Avoid training on images that already score highly, as this can lead to over-fitting. Instead, focus on relatively clear images that score lower - ideally with different lighting, angles, and conditions—to help the model generalize more effectively. +Avoid training on images that already score highly, as this can lead to over-fitting. Instead, focus on relatively clear images that score lower (ideally with different lighting, angles, and conditions) to help the model generalize more effectively. -### Frigate misidentified a face. Can I tell it that a face is "not" a specific person? + + + + +Only if it is a good image. Reassigning a face does add it to that person's training set, but two things are true at once: + +- Reassigning a single misclassified frame has a small effect. The image is weighted against every other sample for that person, so correcting 1 frame out of 20 will not move recognition much. Occasional wrong guesses on poor frames are normal and do not need to be fixed. +- Reassigning a poor image (blurry, off-angle, low-resolution, gray-scale) can hurt more than the misidentification did, because low-quality samples degrade recognition for that whole person. + +So the decision is about image quality, not about the wrong label. If the crop is clear, well-lit, and reasonably front-facing, and it scored low or was wrong, assigning it to the correct person is useful. If you can barely make out the face yourself, ignore it; do not train it just to correct the label. + +If a person is repeatedly misidentified, do not keep reassigning the same frame. Instead, remove low-quality or misleading images and add a few high-quality samples to the correct person. See [Why do unknown people score similarly to known people?](#why-do-unknown-people-score-similarly-to-known-people) above. + + + + No, face recognition does not support negative training (i.e., explicitly telling it who someone is _not_). Instead, the best approach is to improve the training data by using a more diverse and representative set of images for each person. For more guidance, refer to the section above on improving recognition accuracy. -### I see scores above the threshold in the Recent Recognitions tab, but a sub label wasn't assigned? +This also applies to a stranger who is repeatedly matched to a known person (for example, a delivery driver recognized as you). Do not create a profile for them and do not reassign their faces to yourself, as this pollutes your training set and makes recognition worse. Leave the detection as unknown and improve the known person's training set instead. Face recognition learns who someone is, not who they are not. -The Frigate considers the recognition scores across all recognition attempts for each person object. The scores are continually weighted based on the area of the face, and a sub label will only be assigned to person if a person is confidently recognized consistently. This avoids cases where a single high confidence recognition would throw off the results. + -### Can I use other face recognition software like DoubleTake at the same time as the built in face recognition? + + +Frigate considers the recognition scores across all recognition attempts for each person object. The scores are continually weighted based on the area of the face, and a sub label will only be assigned to person if a person is confidently recognized consistently. This avoids cases where a single high confidence recognition would throw off the results. + + + +### Compatibility and Maintenance + + No, using another face recognition service will interfere with Frigate's built in face recognition. When using double-take the sub_label feature must be disabled if the built in face recognition is also desired. -### Does face recognition run on the recording stream? + -Face recognition does not run on the recording stream, this would be suboptimal for many reasons: - -1. The latency of accessing the recordings means the notifications would not include the names of recognized people because recognition would not complete until after. -2. The embedding models used run on a set image size, so larger images will be scaled down to match this anyway. -3. Motion clarity is much more important than extra pixels, over-compression and motion blur are much more detrimental to results than resolution. - -### I get an unknown error when taking a photo directly with my iPhone + By default iOS devices will use HEIC (High Efficiency Image Container) for images, but this format is not supported for uploads. Choosing `large` as the format instead of `original` will use JPG which will work correctly. -### How can I delete the face database and start over? + + + Frigate does not store anything in its database related to face recognition. You can simply delete all of your faces through the Frigate UI or remove the contents of the `/media/frigate/clips/faces` directory. + + diff --git a/docs/docs/configuration/ffmpeg_presets.md b/docs/docs/configuration/ffmpeg_presets.md index 8bba62e363..5c1d0fc3b1 100644 --- a/docs/docs/configuration/ffmpeg_presets.md +++ b/docs/docs/configuration/ffmpeg_presets.md @@ -3,47 +3,75 @@ id: ffmpeg_presets title: FFmpeg presets --- -Some presets of FFmpeg args are provided by default to make the configuration easier. All presets can be seen in [this file](https://github.com/blakeblackshear/frigate/blob/master/frigate/ffmpeg_presets.py). +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; -### Hwaccel Presets +Frigate ships with a set of FFmpeg presets to keep your configuration short and readable. Each preset expands to a longer list of FFmpeg arguments at runtime. You can see exactly what every preset expands to in [this file](https://github.com/blakeblackshear/frigate/blob/master/frigate/ffmpeg_presets.py). -It is highly recommended to use hwaccel presets in the config. These presets not only replace the longer args, but they also give Frigate hints of what hardware is available and allows Frigate to make other optimizations using the GPU such as when encoding the birdseye restream or when scaling a stream that has a size different than the native stream size. +In the config file you reference a preset by its name (for example, `preset-vaapi`). In the UI, the same preset is shown with a friendly label (for example, **VAAPI (Intel/AMD GPU)**). Both refer to the same thing: the tables below list the config name alongside the label you'll see in the UI. -See [the hwaccel docs](/configuration/hardware_acceleration_video.md) for more info on how to setup hwaccel for your GPU / iGPU. +### Hwaccel (Hardware Acceleration) Presets {#hwaccel-presets} -| Preset | Usage | Other Notes | -| --------------------- | ------------------------------ | ----------------------------------------------------- | -| preset-rpi-64-h264 | 64 bit Rpi with h264 stream | | -| preset-rpi-64-h265 | 64 bit Rpi with h265 stream | | -| preset-vaapi | Intel & AMD VAAPI | Check hwaccel docs to ensure correct driver is chosen | -| preset-intel-qsv-h264 | Intel QSV with h264 stream | If issues occur recommend using vaapi preset instead | -| preset-intel-qsv-h265 | Intel QSV with h265 stream | If issues occur recommend using vaapi preset instead | -| preset-nvidia | Nvidia GPU | | -| preset-jetson-h264 | Nvidia Jetson with h264 stream | | -| preset-jetson-h265 | Nvidia Jetson with h265 stream | | -| preset-rkmpp | Rockchip MPP | Use image with \*-rk suffix and privileged mode | +Hardware acceleration arguments tell FFmpeg to decode your camera's video stream on a GPU or integrated graphics chip instead of the CPU, which dramatically lowers CPU usage. Using a preset is highly recommended. Beyond replacing a long list of arguments, each preset also tells Frigate what hardware is available so it can offload additional work to the GPU, for example, encoding the Birdseye restream or scaling a stream whose resolution differs from the camera's native size. + +See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware. + +| Preset (YAML config) | UI Label | Usage | Notes | +| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- | +| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | | +| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | | +| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected | +| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead | +| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead | +| preset-nvidia | NVIDIA GPU | NVIDIA GPU | | +| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | | +| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | | +| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode | + + + + +1. Navigate to and set **Hardware acceleration arguments** to the appropriate preset for your hardware. +2. To override for a specific camera, navigate to and set **Hardware acceleration arguments** for that camera. + + + + +```yaml +ffmpeg: + hwaccel_args: preset-vaapi + +cameras: + front_door: + ffmpeg: + hwaccel_args: preset-nvidia +``` + + + ### Input Args Presets -Input args presets help make the config more readable and handle use cases for different types of streams to ensure maximum compatibility. +Input arguments are passed to FFmpeg before your camera source and control how Frigate connects to and reads the stream: the transport protocol, timeouts, reconnection behavior, and how the stream is probed. The right input args ensure a reliable connection and maximum compatibility for each type of stream. -See [the camera specific docs](/configuration/camera_specific.md) for more info on non-standard cameras and recommendations for using them in Frigate. +See [the camera-specific docs](/configuration/camera_specific.md) for more on non-standard cameras and recommendations for using them in Frigate. -| Preset | Usage | Other Notes | -| -------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | -| preset-http-jpeg-generic | HTTP Live Jpeg | Recommend restreaming live jpeg instead | -| preset-http-mjpeg-generic | HTTP Mjpeg Stream | Recommend restreaming mjpeg stream instead | -| preset-http-reolink | Reolink HTTP-FLV Stream | Only for reolink http, not when restreaming as rtsp | -| preset-rtmp-generic | RTMP Stream | | -| preset-rtsp-generic | RTSP Stream | This is the default when nothing is specified | -| preset-rtsp-restream | RTSP Stream from restream | Use for rtsp restream as source for frigate | -| preset-rtsp-restream-low-latency | RTSP Stream from restream | Use for rtsp restream as source for frigate to lower latency, may cause issues with some cameras | -| preset-rtsp-udp | RTSP Stream via UDP | Use when camera is UDP only | -| preset-rtsp-blue-iris | Blue Iris RTSP Stream | Use when consuming a stream from Blue Iris | +| Preset (config) | UI Label | Usage | Notes | +| -------------------------------- | ----------------------------------------- | --------------------------- | ------------------------------------------------------------------------------- | +| preset-http-jpeg-generic | HTTP JPEG (Generic) | HTTP live JPEG | Restreaming the live JPEG is recommended instead | +| preset-http-mjpeg-generic | HTTP MJPEG (Generic) | HTTP MJPEG stream | Restreaming the MJPEG stream is recommended instead | +| preset-http-reolink | HTTP - Reolink Cameras | Reolink HTTP-FLV stream | Only for Reolink HTTP, not when restreaming as RTSP | +| preset-rtmp-generic | RTMP (Generic) | RTMP stream | | +| preset-rtsp-generic | RTSP (Generic) | RTSP stream | The default when no input args are specified | +| preset-rtsp-restream | RTSP - Restream from go2rtc | RTSP stream from a restream | Use when a go2rtc restream is the source for Frigate | +| preset-rtsp-restream-low-latency | RTSP - Restream from go2rtc (Low Latency) | RTSP stream from a restream | Lowers latency for a go2rtc restream source; may cause issues with some cameras | +| preset-rtsp-udp | RTSP - UDP | RTSP stream over UDP | Use when the camera only supports UDP | +| preset-rtsp-blue-iris | RTSP - Blue Iris | Blue Iris RTSP stream | Use when consuming a stream from Blue Iris | :::warning -It is important to be mindful of input args when using restream because you can have a mix of protocols. `http` and `rtmp` presets cannot be used with `rtsp` streams. For example, when using a reolink cam with the rtsp restream as a source for record the preset-http-reolink will cause a crash. In this case presets will need to be set at the stream level. See the example below. +Be mindful of input arguments when restreaming, because you can end up with a mix of protocols. The `http` and `rtmp` presets cannot be used with `rtsp` streams. For example, using a Reolink camera with an RTSP restream as the recording source while `preset-http-reolink` is applied will cause a crash. In cases like this, set the preset at the stream level instead. See the example below. ::: @@ -68,13 +96,13 @@ cameras: ### Output Args Presets -Output args presets help make the config more readable and handle use cases for different types of streams to ensure consistent recordings. +Output arguments are passed to FFmpeg after your camera source and control how recordings are written: which codecs are used and whether audio and video are copied as-is or re-encoded. The right output args ensure consistent, playable recordings for each type of stream. -| Preset | Usage | Other Notes | -| -------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| preset-record-generic | Record WITHOUT audio | If your camera doesn’t have audio, or if you don’t want to record audio, use this option | -| preset-record-generic-audio-copy | Record WITH original audio | Use this to enable audio in recordings | -| preset-record-generic-audio-aac | Record WITH transcoded aac audio | This is the default when no option is specified. Use it to transcode audio to AAC. If the source is already in AAC format, use preset-record-generic-audio-copy instead to avoid unnecessary re-encoding | -| preset-record-mjpeg | Record an mjpeg stream | Recommend restreaming mjpeg stream instead | -| preset-record-jpeg | Record live jpeg | Recommend restreaming live jpeg instead | -| preset-record-ubiquiti | Record ubiquiti stream with audio | Recordings with ubiquiti non-standard audio | +| Preset (config) | UI Label | Usage | Notes | +| -------------------------------- | ------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| preset-record-generic | Record (Generic, no audio) | Record without audio | Use this if your camera has no audio, or if you don't want to record audio | +| preset-record-generic-audio-copy | Record (Generic + Copy Audio) | Record with the original audio | Use this to keep the camera's audio in recordings without re-encoding | +| preset-record-generic-audio-aac | Record (Generic + Audio to AAC) | Record with audio transcoded to AAC | The default when no output args are specified. Transcodes audio to AAC. If the source is already AAC, use `preset-record-generic-audio-copy` to avoid re-encoding | +| preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead | +| preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead | +| preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format | diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index cde503e8b1..a3cabcbace 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -3,41 +3,79 @@ id: genai_config title: Configuring Generative AI --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + ## Configuration -A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. +A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 5 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below. -To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. +`genai` is a map of named providers. Each key under `genai` is a name you choose, and its value is that provider's settings: -## Ollama + + + +1. Navigate to . + - Click **Add** and enter a **Provider name**. Any name of letters, numbers, hyphens, and underscores is accepted, but it cannot be changed from the UI after the provider is created. + - Set **Provider** to the service you are using (e.g., `ollama`) + - Set **Base URL**, **API key**, and **Model** as required by that provider + - Set **Roles** to the roles this provider should handle. + + + + +```yaml +genai: + my_provider: # any name you like + provider: ollama + base_url: http://localhost:11434 + model: qwen3-vl:4b + roles: + - descriptions + - embeddings + - chat +``` + + + + +The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`). + +Each provider handles one or more **roles**: `chat`, `descriptions`, and `embeddings`. A provider handles all three by default, and each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option. + +If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. + +## Local Providers + +Local providers run on your own hardware and keep all data processing private. These require a GPU or dedicated hardware for best performance. :::warning -Using Ollama on CPU is not recommended, high inference times make using Generative AI impractical. +Running Generative AI models on CPU is not recommended, as high inference times make using Generative AI impractical. ::: -[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. +### Recommended Local Models -Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available. +#### Vision models -Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests). +You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles: -### Model Types: Instruct vs Thinking +| Model | Notes | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. | +| `qwen3.6`/`qwen3.8` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. | +| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. | -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. +#### Embedding models -- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case. -- **Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models. +The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why. -Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, Frigate will always use instruct-style prompts and specifically disables thinking-mode behaviors to ensure concise, useful responses. - -**Recommendation:** -Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider’s documentation or model library for guidance on the correct model variant to use. - -### Supported Models - -You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). Note that Frigate will not automatically download the model you specify in your config, Ollama will try to download the model but it may take longer than the timeout, it is recommended to pull the model beforehand by running `ollama pull your_model` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag. +| Model | Notes | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. | :::info @@ -45,49 +83,218 @@ Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger s ::: +:::note + +You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 24 GB to run the 33B models. + +::: + +### Model Types: Instruct vs Thinking + +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. + +Frigate manages reasoning per task automatically: + +- **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. + +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 + +[llama.cpp](https://github.com/ggml-org/llama.cpp) is a C++ implementation of LLaMA that provides a high-performance inference server. + +It is highly recommended to host the llama.cpp server on a machine with a discrete graphics card, or on an Apple silicon Mac for best performance. + +#### Supported Models + +You must use a vision capable model with Frigate. The llama.cpp server supports various vision models in GGUF format. + +#### Configuration + +All llama.cpp native options can be passed through `provider_options`, including `temperature`, `top_k`, `top_p`, `min_p`, `repeat_penalty`, `repeat_last_n`, `seed`, `grammar`, and more. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for a complete list of available parameters. + + + + +1. Navigate to . + - Set **Provider** to `llamacpp` + - Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`) + - Set **Model** to the name of your model + - Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server + + + + +```yaml +genai: + my_provider: + provider: llamacpp + base_url: http://localhost:8080 + model: your-model-name + provider_options: + context_size: 16000 # Optional, overrides the context size reported by the server. +``` + + + + +Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own. + +### Ollama + +[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. + +Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available. + +Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests). + :::tip If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama. ::: -The following models are recommended: +Note that Frigate will not automatically download the model you specify in your config. Ollama will try to download the model but it may take longer than the timeout, so it is recommended to pull the model beforehand by running `ollama pull your_model` on your Ollama server/Docker container. The model specified in Frigate's config must match the downloaded model tag. -| Model | Notes | -| ------------- | -------------------------------------------------------------------- | -| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement | -| `Intern3.5VL` | Relatively fast with good vision comprehension | -| `gemma3` | Strong frame-to-frame understanding, slower inference times | -| `qwen2.5-vl` | Fast but capable model with good vision comprehension | +#### Configuration -:::note + + -You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 32 GB to run the 33B models. +1. Navigate to . + - Set **Provider** to `ollama` + - Set **Base URL** to your Ollama server address (e.g., `http://localhost:11434`) + - Set **Model** to the model tag (e.g., `qwen3-vl:4b`) + - Under **Provider Options**, set `keep_alive` (e.g., `-1`) and `options.num_ctx` to match your desired context size -::: - -#### Ollama Cloud models - -Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud). - -### Configuration + + ```yaml genai: - provider: ollama - base_url: http://localhost:11434 - model: qwen3-vl:4b + my_provider: + provider: ollama + base_url: http://localhost:11434 + model: qwen3-vl:4b + provider_options: # other Ollama client options can be defined + keep_alive: -1 + options: + num_ctx: 8192 # make sure the context matches other services that are using ollama ``` -## Google Gemini + + + +### OpenAI-Compatible + +Frigate supports any provider that implements the OpenAI API standard. This includes self-hosted solutions like [vLLM](https://docs.vllm.ai/), [LocalAI](https://localai.io/), and other OpenAI-compatible servers. + +:::tip + +For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`: + +```yaml +genai: + my_provider: + provider: openai + base_url: http://your-llama-server + model: your-model-name + provider_options: + context_size: 8192 # Specify the configured context size +``` + +This ensures Frigate uses the correct context window size when generating prompts. + +::: + +#### Configuration + + + + +1. Navigate to . + - Set **Provider** to `openai` + - Set **Base URL** to your server address (e.g., `http://your-server:port`) + - Set **API key** if required by your server + - Set **Model** to the model name + + + + +```yaml +genai: + my_provider: + provider: openai + base_url: http://your-server:port + api_key: your-api-key # May not be required for local servers + model: your-model-name +``` + + + + +To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL. + +## Cloud Providers + +Cloud providers run on remote infrastructure and require an API key for authentication. These services handle all model inference on their servers. + +:::info + +Cloud Generative AI providers require an active internet connection to send images and prompts for processing. Local providers like llama.cpp and Ollama (with local models) do not require internet. See [Network Requirements](/frigate/network_requirements#generative-ai) for details. + +::: + +### Ollama Cloud + +Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud). + +#### Configuration + + + + +1. Navigate to . + - Set **Provider** to `ollama` + - Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference + - Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`) + - Set **Model** to the cloud model name + + + + +```yaml +genai: + my_provider: + provider: ollama + base_url: http://localhost:11434 + model: cloud-model-name +``` + +or when using Ollama Cloud directly + +```yaml +genai: + my_provider: + provider: ollama + base_url: https://ollama.com + model: cloud-model-name + api_key: your-api-key +``` + + + + +### Google Gemini Google Gemini has a [free tier](https://ai.google.dev/pricing) for the API, however the limits may not be sufficient for standard Frigate usage. Choose a plan appropriate for your installation. -### Supported Models +#### Supported Models You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). -### Get API Key +#### Get API Key To start using Gemini, you must first get an API key from [Google AI Studio](https://aistudio.google.com). @@ -96,52 +303,83 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt 3. Click "Create API key in new project" 4. Copy the API key for use in your config -### Configuration +#### Configuration + + + + +1. Navigate to . + - Set **Provider** to `gemini` + - Set **API key** to your Gemini API key (or use an environment variable such as `{FRIGATE_GEMINI_API_KEY}`) + - Set **Model** to the desired model (e.g., `gemini-2.5-flash`) + + + ```yaml genai: - provider: gemini - api_key: "{FRIGATE_GEMINI_API_KEY}" - model: gemini-2.5-flash + my_provider: + provider: gemini + api_key: "{FRIGATE_GEMINI_API_KEY}" + model: gemini-2.5-flash ``` + + + :::note To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example: -```yaml {4,5} +```yaml {5,6} genai: - provider: gemini - ... - provider_options: - base_url: https://... + my_provider: + provider: gemini + ... + provider_options: + base_url: https://... ``` Other HTTP options are available, see the [python-genai documentation](https://github.com/googleapis/python-genai). ::: -## OpenAI +### OpenAI -OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route. +OpenAI does not have a free tier for their API. -### Supported Models +#### Supported Models You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models). -### Get API Key +#### Get API Key To start using OpenAI, you must first [create an API key](https://platform.openai.com/api-keys) and [configure billing](https://platform.openai.com/settings/organization/billing/overview). -### Configuration +#### Configuration + + + + +1. Navigate to . + - Set **Provider** to `openai` + - Set **API key** to your OpenAI API key (or use an environment variable such as `{FRIGATE_OPENAI_API_KEY}`) + - Set **Model** to the desired model (e.g., `gpt-4o`) + + + ```yaml genai: - provider: openai - api_key: "{FRIGATE_OPENAI_API_KEY}" - model: gpt-4o + my_provider: + provider: openai + api_key: "{FRIGATE_OPENAI_API_KEY}" + model: gpt-4o ``` + + + :::note To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL. @@ -152,37 +390,133 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`: -```yaml {5,6} +```yaml {6,7} genai: - provider: openai - base_url: http://your-llama-server - model: your-model-name - provider_options: - context_size: 8192 # Specify the configured context size + my_provider: + provider: openai + base_url: http://your-llama-server + model: your-model-name + provider_options: + context_size: 8192 # Specify the configured context size ``` This ensures Frigate uses the correct context window size when generating prompts. ::: -## Azure OpenAI +### Azure OpenAI Microsoft offers several vision models through Azure OpenAI. A subscription is required. -### Supported Models +#### Supported Models You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models). -### Create Resource and Get API Key +#### Create Resource and Get API Key To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key, model name, and resource URL, which must include the `api-version` parameter (see the example below). -### Configuration +#### Configuration + + + + +1. Navigate to . + - Set **Provider** to `azure_openai` + - Set **Base URL** to your Azure resource URL including the `api-version` parameter (e.g., `https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview`) + - Set **Model** to your deployed model name (e.g., `gpt-5-mini`) + - Set **API key** to your Azure OpenAI API key (or use an environment variable such as `{FRIGATE_OPENAI_API_KEY}`) + + + ```yaml genai: - provider: azure_openai - base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview - model: gpt-5-mini - api_key: "{FRIGATE_OPENAI_API_KEY}" + my_provider: + provider: azure_openai + base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview + model: gpt-5-mini + api_key: "{FRIGATE_OPENAI_API_KEY}" ``` + + + + +## FAQ + + + +Frigate's Generative AI features are configured and enabled separately. [Review descriptions and summaries](/configuration/genai/genai_review) live under `review.genai`, and [object descriptions](/configuration/genai/genai_objects) live under `objects.genai`. Configuring a provider on this page does not enable either feature, and enabling one does not enable the other. Decide which of the two is not working, then work through the steps below. + +1. Confirm a provider is available and holds the `descriptions` role. + - Review descriptions, review summaries, and object descriptions all use the provider that has the `descriptions` role assigned in (`genai..roles`). + - A provider is contacted the first time one of its roles is actually used. A provider holding the `embeddings` role for semantic search is initialized during startup, while a `descriptions` provider is not initialized until the first description is requested, which may be well after boot. + - In , use **Refresh models** next to the model field. It queries the provider for its model list and is a quick way to verify that the base URL, API key, and network path between Frigate and your provider are correct. + +2. Confirm the feature you expect is actually enabled. + - Object descriptions are disabled by default. Turn on (`objects.genai.enabled`), either globally or per camera. This is the most common reason custom prompts appear to be ignored while review summaries are still being generated. + - Review descriptions are disabled by default. Turn on (`review.genai.enabled`). Once enabled, alerts are described by default but detections are not, so a detection-only review item will never get a summary unless **Enable GenAI for detections** (`review.genai.detections`) is also on. + +3. If object descriptions are never requested, check the filters that skip generation. + - (`objects.genai.objects`) limits generation to specific labels, and **Required zones** (`objects.genai.required_zones`) requires the object to have entered one of those zones. If either is set and does not match, Frigate skips the request silently. + - Thumbnails are only collected while an object is moving. Objects that go stationary early contribute fewer frames. + - **Use snapshots** (`objects.genai.use_snapshot`) requires snapshots to be enabled for the camera. If the snapshot cannot be read, Frigate logs `Cannot load snapshot for , file not found` and no description is generated. + - **Send on end** (`objects.genai.send_triggers.tracked_object_end`) is on by default. If you have turned it off in favor of **Early GenAI trigger** (`objects.genai.send_triggers.after_significant_updates`), descriptions are only requested once that number of updates is reached. + +4. Enable debug logs to see exactly what Frigate is doing. Restart Frigate after this change. The next step also requires a restart, so turn both on at the same time to avoid restarting twice. + + ```yaml + logger: + default: info + logs: + # highlight-start + frigate.genai: debug + frigate.data_processing.post.object_descriptions: debug + frigate.data_processing.post.review_descriptions: debug + # highlight-end + ``` + +5. Save the exact images and prompts that were sent to your provider. + - Turn on **Save thumbnails** for the feature you are debugging (`review.genai.debug_save_thumbnails` or `objects.genai.debug_save_thumbnails`). Both features write to `/media/frigate/clips/genai-requests/`, and these files are admin-only. + - Review descriptions write `genai-requests//` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response. + - Review summary reports write `genai-requests/-/prompt.txt` and `response.txt`. No images are involved, since a report summarizes existing review descriptions. + - Object descriptions write `genai-requests//` containing the numbered thumbnails. The prompt for object descriptions is not written to a file, it is only visible in the debug logs from step 4. + - Look at the saved images before blaming the model. If the object is small, blurry, or out of frame, no prompt will fix the result. For object descriptions, consider turning on **Use snapshots** (`objects.genai.use_snapshot`) to send a higher quality image. For review items, consider setting **Review image source** (`review.genai.image_source`) to `recordings` for 480p frames instead of the lower resolution preview frames. + + + + +For review descriptions, navigate to and set **GenAI config > Save thumbnails** to on. + +For object descriptions, navigate to , expand **GenAI object config**, and set **Save thumbnails** to on. + + + + +```yaml +review: + genai: + enabled: true + # highlight-next-line + debug_save_thumbnails: true + +objects: + genai: + enabled: true + # highlight-next-line + debug_save_thumbnails: true +``` + + + + +6. Verify the prompt is what you think it is. + - Object description prompts are the ones you control directly. A camera-level (`objects.genai.prompt`) overrides the global one, and an entry in **Object prompts** (`objects.genai.object_prompts`) for a label overrides both for that label. Only `{label}`, `{sub_label}`, and `{camera}` are substituted. + - Review description prompts are built by Frigate and request a structured JSON response, so they are not fully replaceable. The parts you control are (`review.genai.activity_context_prompt`) and **Additional concerns** (`review.genai.additional_concerns`). Keep the activity context prompt general, since overly specific rules will sway the model's threat level scoring. + +7. If descriptions are generated but the results are poor or inconsistent, look at the model and the context window. + - Empty fields, missing `shortSummary` values, or `Failed to parse review description` errors usually mean the model is not following the requested JSON schema. Smaller models struggle with structured output. Try a larger parameter size or one of the [recommended models](#recommended-local-models). + - Frigate calculates how many frames to send from the context size the provider reports. If your server reports a different value than it is actually running with, frames will be truncated or the request will fail. Pin the value by adding `context_size` under (`genai..provider_options`), and for Ollama also confirm `options.num_ctx` there matches the context you have configured. + - Check **Review Description Speed** and **Object Description Speed** in . If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect. + + diff --git a/docs/docs/configuration/genai/objects.md b/docs/docs/configuration/genai/objects.md index e3ae31393d..2e281dc0ec 100644 --- a/docs/docs/configuration/genai/objects.md +++ b/docs/docs/configuration/genai/objects.md @@ -3,6 +3,10 @@ id: genai_objects title: Object Descriptions --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail. Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response. @@ -11,13 +15,13 @@ By default, descriptions will be generated for all tracked objects and all zones Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction. -Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate//object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset). +Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate//object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt#frigatecamera_nameobject_descriptionsset). ## Usage and Best Practices -Frigate's thumbnail search excels at identifying specific details about tracked objects – for example, using an "image caption" approach to find a "person wearing a yellow vest," "a white dog running across the lawn," or "a red car on a residential street." To enhance this further, Frigate’s default prompts are designed to ask your AI provider about the intent behind the object's actions, rather than just describing its appearance. +Frigate's thumbnail search excels at identifying specific details about tracked objects -- for example, using an "image caption" approach to find a "person wearing a yellow vest," "a white dog running across the lawn," or "a red car on a residential street." To enhance this further, Frigate's default prompts are designed to ask your AI provider about the intent behind the object's actions, rather than just describing its appearance. -While generating simple descriptions of detected objects is useful, understanding intent provides a deeper layer of insight. Instead of just recognizing "what" is in a scene, Frigate’s default prompts aim to infer "why" it might be there or "what" it could do next. Descriptions tell you what’s happening, but intent gives context. For instance, a person walking toward a door might seem like a visitor, but if they’re moving quickly after hours, you can infer a potential break-in attempt. Detecting a person loitering near a door at night can trigger an alert sooner than simply noting "a person standing by the door," helping you respond based on the situation’s context. +While generating simple descriptions of detected objects is useful, understanding intent provides a deeper layer of insight. Instead of just recognizing "what" is in a scene, Frigate's default prompts aim to infer "why" it might be there or "what" it could do next. Descriptions tell you what's happening, but intent gives context. For instance, a person walking toward a door might seem like a visitor, but if they're moving quickly after hours, you can infer a potential break-in attempt. Detecting a person loitering near a door at night can trigger an alert sooner than simply noting "a person standing by the door," helping you respond based on the situation's context. ## Custom Prompts @@ -33,13 +37,25 @@ Prompts can use variable replacements `{label}`, `{sub_label}`, and `{camera}` t ::: -You are also able to define custom prompts in your configuration. +You can define custom prompts at the global level and per-object type. To configure custom prompts: + + + + +1. Navigate to . + - Expand the **GenAI object config** section + - Set **Caption prompt** to your custom prompt text + - Under **Object prompts**, add entries keyed by object type (e.g., `person`, `car`) with custom prompts for each + + + ```yaml genai: - provider: ollama - base_url: http://localhost:11434 - model: qwen3-vl:8b-instruct + my_provider: + provider: ollama + base_url: http://localhost:11434 + model: qwen3-vl:8b-instruct objects: genai: @@ -49,7 +65,25 @@ objects: car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company." ``` -Prompts can also be overridden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. + + + +Prompts can also be overridden at the camera level to provide a more detailed prompt to the model about your specific camera. To configure camera-level overrides: + + + + +1. Navigate to for the desired camera. + - Expand the **GenAI object config** section + - Set **Enable GenAI** to on + - Set **Use snapshots** to on if desired + - Set **Caption prompt** to a camera-specific prompt + - Under **Object prompts**, add entries keyed by object type with camera-specific prompts + - Set **GenAI objects** to the list of object types that should receive descriptions (e.g., `person`, `cat`) + - Set **Required zones** to limit descriptions to objects in specific zones (e.g., `steps`) + + + ```yaml cameras: @@ -69,6 +103,9 @@ cameras: - steps ``` + + + ### Experiment with prompts Many providers also have a public facing chat interface for their models. Download a couple of different thumbnails or snapshots from Frigate and try new things in the playground to get descriptions to your liking before updating the prompt in Frigate. @@ -76,3 +113,7 @@ Many providers also have a public facing chat interface for their models. Downlo - OpenAI - [ChatGPT](https://chatgpt.com) - Gemini - [Google AI Studio](https://aistudio.google.com) - Ollama - [Open WebUI](https://docs.openwebui.com/) + +## Troubleshooting + +If descriptions are not being generated, or the generated descriptions are not what you expect, see [How do I debug GenAI issues?](/configuration/genai/genai_config#how-do-i-debug-genai-issues). diff --git a/docs/docs/configuration/genai/review_summaries.md b/docs/docs/configuration/genai/review_summaries.md index c0d677a013..eabf094a15 100644 --- a/docs/docs/configuration/genai/review_summaries.md +++ b/docs/docs/configuration/genai/review_summaries.md @@ -3,11 +3,15 @@ id: genai_review title: Review Summaries --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + Generative AI can be used to automatically generate structured summaries of review items. These summaries will show up in Frigate's native notifications as well as in the UI. Generative AI can also be used to take a collection of summaries over a period of time and provide a report, which may be useful to get a quick report of everything that happened while out for some amount of time. Requests for a summary are requested automatically to your AI provider for alert review items when the activity has ended, they can also be optionally enabled for detections as well. -Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt/#frigatecamera_namereviewdescriptionsset). +Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt#frigatecamera_namereview_descriptionsset). ## Review Summary Usage and Best Practices @@ -28,6 +32,30 @@ This will show in multiple places in the UI to give additional context about eac Each installation and even camera can have different parameters for what is considered suspicious activity. Frigate allows the `activity_context_prompt` to be defined globally and at the camera level, which allows you to define more specifically what should be considered normal activity. It is important that this is not overly specific as it can sway the output of the response. +To configure the activity context prompt: + + + + +Navigate to . + +- Set **GenAI config > Activity context prompt** to your custom activity context text + + + + +```yaml +review: + genai: + activity_context_prompt: | + ### Normal Activity Indicators (Level 0) + - Known/verified people in any zone at any time + ... +``` + + + +
Default Activity Context Prompt @@ -74,7 +102,18 @@ review: ### Image Source -By default, review summaries use preview images (cached preview frames) which have a lower resolution but use fewer tokens per image. For better image quality and more detailed analysis, you can configure Frigate to extract frames directly from recordings at a higher resolution: +By default, review summaries use preview images (cached preview frames) which have a lower resolution but use fewer tokens per image. For better image quality and more detailed analysis, configure Frigate to extract frames directly from recordings at a higher resolution. + + + + +Navigate to . + +- Set **GenAI config > Enable GenAI descriptions** to on +- Set **GenAI config > Review image source** to `recordings` (default is `preview`) + + + ```yaml review: @@ -84,6 +123,9 @@ review: image_source: recordings # Options: "preview" (default) or "recordings" ``` + + + When using `recordings`, frames are extracted at 480px height while maintaining the camera's original aspect ratio, providing better detail for the LLM while being mindful of context window size. This is particularly useful for scenarios where fine details matter, such as identifying license plates, reading text, or analyzing distant objects. The number of frames sent to the LLM is dynamically calculated based on: @@ -103,7 +145,17 @@ If recordings are not available for a given time period, the system will automat ### Additional Concerns -Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. These concerns can be configured so that the review summaries will make note of them if the activity requires additional review. For example: +Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. Configure these concerns so that review summaries will make note of them if the activity requires additional review. + + + + +Navigate to . + +- Set **GenAI config > Additional concerns** to a list of your concerns (e.g., `animals in the garden`) + + + ```yaml {4,5} review: @@ -113,9 +165,22 @@ review: - animals in the garden ``` + + + ### Preferred Language -By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option: +By default, review summaries are generated in English. Configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option. + + + + +Navigate to . + +- Set **GenAI config > Preferred language** to the desired language (e.g., `Spanish`) + + + ```yaml {4} review: @@ -124,6 +189,9 @@ review: preferred_language: Spanish ``` + + + ## Review Reports Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation). @@ -133,3 +201,7 @@ Along with individual review item summaries, Generative AI can also produce a si Review reports can be requested via the [API](/integrations/api/generate-review-summary-review-summarize-start-start-ts-end-end-ts-post) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps. For Home Assistant users, there is a built-in service (`frigate.review_summarize`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs. + +## Troubleshooting + +If summaries are not being generated, or the generated summaries are not what you expect, see [How do I debug GenAI issues?](/configuration/genai/genai_config#how-do-i-debug-genai-issues). diff --git a/docs/docs/configuration/go2rtc.md b/docs/docs/configuration/go2rtc.md new file mode 100644 index 0000000000..3672fdf575 --- /dev/null +++ b/docs/docs/configuration/go2rtc.md @@ -0,0 +1,72 @@ +--- +id: go2rtc +title: go2rtc +--- + +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Frigate uses the bundled go2rtc to power a number of key features: + +- WebRTC or MSE for live viewing with audio, higher resolutions and frame rates than the jsmpeg stream which is limited to the detect stream and does not support audio +- Live stream support for cameras in Home Assistant Integration +- RTSP relay for use with other consumers to reduce the number of connections to your camera streams + +:::tip[Most users no longer need to configure go2rtc by hand] + +The [**camera setup wizard**](cameras.md#adding-a-camera-with-the-add-camera-wizard) is the recommended way to add cameras. Click **Add Camera** in , and the wizard probes your camera and writes its configuration for you, including the go2rtc restream and the live stream mapping, so go2rtc is set up automatically. + +This guide is mainly useful if you are **upgrading from an older version and have existing cameras that don't yet use go2rtc**, or if you want to fine-tune a stream by hand (for example, to transcode a codec your browser can't play). The [go2rtc troubleshooting guide](/troubleshooting/go2rtc) applies regardless of how your cameras were added. + +::: + +## Adding a go2rtc stream manually + +If you added your cameras with the wizard, go2rtc is already configured. You can skip straight to [troubleshooting](/troubleshooting/go2rtc). The steps below are for upgrading users with existing cameras that aren't using go2rtc yet, or for anyone who prefers to configure a stream by hand. + +Configure go2rtc to connect to your camera by adding the stream you want to use for live view. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#module-streams), not just rtsp. + +:::tip + +For the best experience, set the stream name under `go2rtc` to match the name of your camera so that Frigate will automatically map it and be able to use better live view options for the camera. + +See [the live view docs](/configuration/live#setting-streams-for-live-ui) for more information. + +::: + + + + +Navigate to and click **Add stream**. Give the stream a name (use the camera's name so Frigate can auto-map it - for example, if your camera's name is `back`, use `back` as the go2rtc stream name), then paste the camera's stream URL into the **Source** field. Save the section. + + + + +```yaml +go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 +``` + + + + +After adding this to the config, restart Frigate and try to watch the live stream for a single camera by clicking on it from the dashboard. It should look much clearer and more fluent than the original jsmpeg stream. + +### Next steps + +1. If the stream you added to go2rtc is also used by Frigate for the `record` or `detect` role, you can migrate your config to pull from the RTSP restream to reduce the number of connections to your camera as shown [here](/configuration/restream#reduce-connections-to-camera). +2. You can [set up WebRTC](/configuration/live#webrtc-extra-configuration) if your camera supports two-way talk. Note that WebRTC only supports specific audio formats and may require opening ports on your router. +3. If your camera supports two-way talk, you must configure your stream with `#backchannel=0` to prevent go2rtc from blocking other applications from accessing the camera's audio output. See [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream) in the restream documentation. + +## Troubleshooting + +If your stream won't play, has no audio, uses excessive CPU, or otherwise misbehaves, see the dedicated [go2rtc troubleshooting guide](/troubleshooting/go2rtc). It walks through how to isolate where the problem is and covers the most common issues: unsupported codecs, H.265/HEVC, audio, WebRTC and two-way talk, hardware-accelerated transcoding with FFmpeg 8, and camera-specific quirks. + +## Homekit Configuration + +To export camera streams to HomeKit, Frigate must be configured in docker to use `host` networking mode. HomeKit settings are stored in `/config/go2rtc_homekit.yml` rather than in your Frigate config, and are edited through the go2rtc config editor at `http://:1984/editor.html`. Pairings are saved back to that file automatically. + +See the [HomeKit integration docs](/integrations/homekit) for the full setup, including the video and audio requirements HomeKit places on the stream. diff --git a/docs/docs/configuration/hardware_acceleration_video.md b/docs/docs/configuration/hardware_acceleration_video.md index 318e1b23e5..360ca1a50c 100644 --- a/docs/docs/configuration/hardware_acceleration_video.md +++ b/docs/docs/configuration/hardware_acceleration_video.md @@ -4,6 +4,9 @@ title: Video Decoding --- import CommunityBadge from '@site/src/components/CommunityBadge'; +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; # Video Decoding @@ -14,8 +17,6 @@ Some types of hardware acceleration are detected and used automatically, but you - Check the logs: A message will either say that hardware acceleration was automatically detected, or there will be a warning that no hardware acceleration was automatically detected - If hardware acceleration is specified in the config, verification can be done by ensuring the logs are free from errors. There is no CPU fallback for hardware acceleration. -:::info - Frigate supports presets for optimal hardware accelerated video decoding: **AMD** @@ -46,29 +47,24 @@ Frigate supports presets for optimal hardware accelerated video decoding: Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro -::: - ## Intel-based CPUs Frigate can utilize most Intel integrated GPUs and Arc GPUs to accelerate video decoding. -:::info - **Recommended hwaccel Preset** -| CPU Generation | Intel Driver | Recommended Preset | Notes | -| -------------- | ------------ | ------------------- | ------------------------------------------- | -| gen1 - gen5 | i965 | preset-vaapi | qsv is not supported, may not support H.265 | -| gen6 - gen7 | iHD | preset-vaapi | qsv is not supported | -| gen8 - gen12 | iHD | preset-vaapi | preset-intel-qsv-\* can also be used | -| gen13+ | iHD / Xe | preset-intel-qsv-\* | | -| Intel Arc GPU | iHD / Xe | preset-intel-qsv-\* | | - -::: +| CPU Generation | Intel Driver | Recommended Preset | Notes | +| ------------------ | ------------ | ------------------- | ------------------------------------------- | +| gen1 - gen5 | i965 | preset-vaapi | qsv is not supported, may not support H.265 | +| gen6 - gen7 | iHD | preset-vaapi | qsv is not supported | +| gen8 - gen12 | iHD | preset-vaapi | preset-intel-qsv-\* can also be used | +| gen13+ | iHD / Xe | preset-intel-qsv-\* | | +| Intel Arc A-series | iHD / Xe | preset-intel-qsv-\* | | +| Intel Arc B-series | iHD / Xe | preset-intel-qsv-\* | Requires host kernel 6.12+ | :::note -The default driver is `iHD`. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `config.yml` for HA App users](advanced.md#environment_vars). +The default driver is `iHD`. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `config.yml` for HA App users](advanced/system.md#environment_vars). See [The Intel Docs](https://www.intel.com/content/www/us/en/support/articles/000005505/processors.html) to figure out what generation your CPU is. @@ -78,111 +74,86 @@ See [The Intel Docs](https://www.intel.com/content/www/us/en/support/articles/00 VAAPI supports automatic profile selection so it will work automatically with both H.264 and H.265 streams. + + + +Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-vaapi ``` + + + ### Via Quicksync #### H.264 streams + + + +Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-intel-qsv-h264 ``` + + + #### H.265 streams + + + +Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-intel-qsv-h265 ``` -### Configuring Intel GPU Stats in Docker + + -Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options: +### Configuring Intel GPU Stats -1. Run the container as privileged. -2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.) +Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc//fdinfo/`. This requires: -#### Run as privileged +- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver. +- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`. -This method works, but it gives more permissions to the container than are actually needed. +No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required. -##### Docker Compose - Privileged +#### Stats for SR-IOV or specific devices -```yaml -services: - frigate: - ... - image: ghcr.io/blakeblackshear/frigate:stable - # highlight-next-line - privileged: true -``` - -##### Docker Run CLI - Privileged - -```bash {4} -docker run -d \ - --name frigate \ - ... - --privileged \ - ghcr.io/blakeblackshear/frigate:stable -``` - -#### CAP_PERFMON - -Only recent versions of Docker support the `CAP_PERFMON` capability. You can test to see if yours supports it by running: `docker run --cap-add=CAP_PERFMON hello-world` - -##### Docker Compose - CAP_PERFMON - -```yaml {5,6} -services: - frigate: - ... - image: ghcr.io/blakeblackshear/frigate:stable - cap_add: - - CAP_PERFMON -``` - -##### Docker Run CLI - CAP_PERFMON - -```bash {4} -docker run -d \ - --name frigate \ - ... - --cap-add=CAP_PERFMON \ - ghcr.io/blakeblackshear/frigate:stable -``` - -#### perf_event_paranoid - -_Note: This setting must be changed for the entire system._ - -For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do. - -Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'` - -#### Stats for SR-IOV or other devices - -When using virtualized GPUs via SR-IOV, you need to specify the device path to use to gather stats from `intel_gpu_top`. This example may work for some systems using SR-IOV: +If the host has more than one Intel GPU (e.g. an iGPU plus a discrete GPU, or SR-IOV virtual functions), pin stats collection to a specific device by setting `intel_gpu_device` to either its PCI bus address or a DRM card/render-node path: ```yaml telemetry: stats: - intel_gpu_device: "sriov" + intel_gpu_device: "0000:00:02.0" ``` -For other virtualized GPUs, try specifying the direct path to the device instead: - ```yaml telemetry: stats: - intel_gpu_device: "drm:/dev/dri/card0" + intel_gpu_device: "/dev/dri/card1" ``` -If you are passing in a device path, make sure you've passed the device through to the container. +When passing a device path, make sure the device is also passed through to the container. ## AMD-based CPUs @@ -190,20 +161,31 @@ Frigate can utilize modern AMD integrated GPUs and AMD GPUs to accelerate video ### Configuring Radeon Driver -You need to change the driver to `radeonsi` by adding the following environment variable `LIBVA_DRIVER_NAME=radeonsi` to your docker-compose file or [in the `config.yml` for HA App users](advanced.md#environment_vars). +You need to change the driver to `radeonsi` by adding the following environment variable `LIBVA_DRIVER_NAME=radeonsi` to your docker-compose file or [in the `config.yml` for HA App users](advanced/system.md#environment_vars). ### Via VAAPI VAAPI supports automatic profile selection so it will work automatically with both H.264 and H.265 streams. + + + +Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-vaapi ``` + + + ## NVIDIA GPUs -While older GPUs may work, it is recommended to use modern, supported GPUs. NVIDIA provides a [matrix of supported GPUs and features](https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new). If your card is on the list and supports CUVID/NVDEC, it will most likely work with Frigate for decoding. However, you must also use [a driver version that will work with FFmpeg](https://github.com/FFmpeg/nv-codec-headers/blob/master/README). Older driver versions may be missing symbols and fail to work, and older cards are not supported by newer driver versions. The only way around this is to [provide your own FFmpeg](/configuration/advanced#custom-ffmpeg-build) that will work with your driver version, but this is unsupported and may not work well if at all. +While older GPUs may work, it is recommended to use modern, supported GPUs. NVIDIA provides a [matrix of supported GPUs and features](https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new). If your card is on the list and supports CUVID/NVDEC, it will most likely work with Frigate for decoding. However, you must also use [a driver version that will work with FFmpeg](https://github.com/FFmpeg/nv-codec-headers/blob/master/README). Older driver versions may be missing symbols and fail to work, and older cards are not supported by newer driver versions. The only way around this is to [provide your own FFmpeg](/configuration/advanced/system#custom-ffmpeg-build) that will work with your driver version, but this is unsupported and may not work well if at all. A more complete list of cards and their compatible drivers is available in the [driver release readme](https://download.nvidia.com/XFree86/Linux-x86_64/525.85.05/README/supportedchips.html). @@ -244,11 +226,22 @@ docker run -d \ Using `preset-nvidia` ffmpeg will automatically select the necessary profile for the incoming video, and will log an error if the profile is not supported by your GPU. + + + +Navigate to and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-nvidia ``` + + + If everything is working correctly, you should see a significant improvement in performance. Verify that hardware decoding is working by running `nvidia-smi`, which should show `ffmpeg` processes: @@ -296,6 +289,14 @@ These instructions were originally based on the [Jellyfin documentation](https:/ Ensure you increase the allocated RAM for your GPU to at least 128 (`raspi-config` > Performance Options > GPU Memory). If you are using the HA App, you may need to use the full access variant and turn off _Protection mode_ for hardware acceleration. + + + +Navigate to and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to . + + + + ```yaml # if you want to decode a h264 stream ffmpeg: @@ -306,6 +307,9 @@ ffmpeg: hwaccel_args: preset-rpi-64-h265 ``` + + + :::note If running Frigate through Docker, you either need to run in privileged mode or @@ -405,11 +409,22 @@ A list of supported codecs (you can use `ffmpeg -decoders | grep nvmpi` in the c For example, for H264 video, you'll select `preset-jetson-h264`. + + + +Navigate to and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to . + + + + ```yaml ffmpeg: hwaccel_args: preset-jetson-h264 ``` + + + If everything is working correctly, you should see a significant reduction in ffmpeg CPU load and power consumption. Verify that hardware decoding is working by running `jtop` (`sudo pip3 install -U jetson-stats`), which should show that NVDEC/NVDEC1 are in use. @@ -424,13 +439,24 @@ Make sure to follow the [Rockchip specific installation instructions](/frigate/i ### Configuration -Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing: +Set the FFmpeg hwaccel preset to enable hardware video processing. + + + + +Navigate to and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to . + + + ```yaml ffmpeg: hwaccel_args: preset-rkmpp ``` + + + :::note Make sure that your SoC supports hardware acceleration for your input stream. For example, if your camera streams with h265 encoding and a 4k resolution, your SoC must be able to de- and encode h265 with a 4k resolution or higher. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. @@ -451,7 +477,7 @@ Error marking filters as finished Restarting ffmpeg... ``` -you should try to uprade to FFmpeg 7. This can be done using this config option: +you should try to upgrade to FFmpeg 7. This can be done using this config option: ```yaml ffmpeg: @@ -472,7 +498,7 @@ cameras: ## Synaptics -Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC. +Hardware accelerated video de-/encoding is supported on Synaptics SL-series SoC. ### Prerequisites @@ -480,7 +506,15 @@ Make sure to follow the [Synaptics specific installation instructions](/frigate/ ### Configuration -Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing: +Set the FFmpeg hwaccel args to enable hardware video processing. + + + + +Navigate to and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to . + + + ```yaml {2} ffmpeg: @@ -490,6 +524,9 @@ output_args: record: preset-record-generic-audio-aac ``` + + + :::warning Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md deleted file mode 100644 index a7495b28bb..0000000000 --- a/docs/docs/configuration/index.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -id: index -title: Frigate Configuration ---- - -For Home Assistant App installations, the config file should be at `/addon_configs//config.yml`, where `` is specific to the variant of the Frigate App you are running. See the list of directories [here](#accessing-app-config-dir). - -For all other installation types, the config file should be mapped to `/config/config.yml` inside the container. - -It can be named `config.yml` or `config.yaml`, but if both files exist `config.yml` will be preferred and `config.yaml` will be ignored. - -It is recommended to start with a minimal configuration and add to it as described in [this guide](../guides/getting_started.md) and use the built in configuration editor in Frigate's UI which supports validation. - -```yaml -mqtt: - enabled: False - -cameras: - dummy_camera: # <--- this will be changed to your actual camera later - enabled: False - ffmpeg: - inputs: - - path: rtsp://127.0.0.1:554/rtsp - roles: - - detect -``` - -## Accessing the Home Assistant App configuration directory {#accessing-app-config-dir} - -When running Frigate through the HA App, the Frigate `/config` directory is mapped to `/addon_configs/` in the host, where `` is specific to the variant of the Frigate App you are running. - -| App Variant | Configuration directory | -| -------------------------- | ----------------------------------------- | -| Frigate | `/addon_configs/ccab4aaf_frigate` | -| Frigate (Full Access) | `/addon_configs/ccab4aaf_frigate-fa` | -| Frigate Beta | `/addon_configs/ccab4aaf_frigate-beta` | -| Frigate Beta (Full Access) | `/addon_configs/ccab4aaf_frigate-fa-beta` | - -**Whenever you see `/config` in the documentation, it refers to this directory.** - -If for example you are running the standard App variant and use the [VS Code App](https://github.com/hassio-addons/addon-vscode) to browse your files, you can click _File_ > _Open folder..._ and navigate to `/addon_configs/ccab4aaf_frigate` to access the Frigate `/config` directory and edit the `config.yaml` file. You can also use the built-in file editor in the Frigate UI to edit the configuration file. - -## VS Code Configuration Schema - -VS Code supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VS Code and Frigate as an App, you should use `ccab4aaf-frigate` instead. Make sure to expose the internal unauthenticated port `5000` when accessing the config from VS Code on another machine. - -## Environment Variable Substitution - -Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./reference.md). For example, the following values can be replaced at runtime by using environment variables: - -```yaml -mqtt: - host: "{FRIGATE_MQTT_HOST}" - user: "{FRIGATE_MQTT_USER}" - password: "{FRIGATE_MQTT_PASSWORD}" -``` - -```yaml -- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast -``` - -```yaml -onvif: - host: "192.168.1.12" - port: 8000 - user: "{FRIGATE_RTSP_USER}" - password: "{FRIGATE_RTSP_PASSWORD}" -``` - -```yaml -go2rtc: - rtsp: - username: "{FRIGATE_GO2RTC_RTSP_USERNAME}" - password: "{FRIGATE_GO2RTC_RTSP_PASSWORD}" -``` - -```yaml -genai: - api_key: "{FRIGATE_GENAI_API_KEY}" -``` - -## Common configuration examples - -Here are some common starter configuration examples. Refer to the [reference config](./reference.md) for detailed information about all the config values. - -### Raspberry Pi Home Assistant App with USB Coral - -- Single camera with 720p, 5fps stream for detect -- MQTT connected to the Home Assistant Mosquitto App -- Hardware acceleration for decoding video -- USB Coral detector -- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not -- Continue to keep all video if it qualified as an alert or detection for 30 days -- Save snapshots for 30 days -- Motion mask for the camera timestamp - -```yaml -mqtt: - host: core-mosquitto - user: mqtt-user - password: xxxxxxxxxx - -ffmpeg: - hwaccel_args: preset-rpi-64-h264 - -detectors: - coral: - type: edgetpu - device: usb - -record: - enabled: True - motion: - days: 7 - alerts: - retain: - days: 30 - mode: motion - detections: - retain: - days: 30 - mode: motion - -snapshots: - enabled: True - retain: - default: 30 - -cameras: - name_of_your_camera: - detect: - width: 1280 - height: 720 - fps: 5 - ffmpeg: - inputs: - - path: rtsp://10.0.10.10:554/rtsp - roles: - - detect - motion: - mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 -``` - -### Standalone Intel Mini PC with USB Coral - -- Single camera with 720p, 5fps stream for detect -- MQTT disabled (not integrated with home assistant) -- VAAPI hardware acceleration for decoding video -- USB Coral detector -- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not -- Continue to keep all video if it qualified as an alert or detection for 30 days -- Save snapshots for 30 days -- Motion mask for the camera timestamp - -```yaml -mqtt: - enabled: False - -ffmpeg: - hwaccel_args: preset-vaapi - -detectors: - coral: - type: edgetpu - device: usb - -record: - enabled: True - motion: - days: 7 - alerts: - retain: - days: 30 - mode: motion - detections: - retain: - days: 30 - mode: motion - -snapshots: - enabled: True - retain: - default: 30 - -cameras: - name_of_your_camera: - detect: - width: 1280 - height: 720 - fps: 5 - ffmpeg: - inputs: - - path: rtsp://10.0.10.10:554/rtsp - roles: - - detect - motion: - mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 -``` - -### Home Assistant integrated Intel Mini PC with OpenVino - -- Single camera with 720p, 5fps stream for detect -- MQTT connected to same mqtt server as home assistant -- VAAPI hardware acceleration for decoding video -- OpenVino detector -- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not -- Continue to keep all video if it qualified as an alert or detection for 30 days -- Save snapshots for 30 days -- Motion mask for the camera timestamp - -```yaml -mqtt: - host: 192.168.X.X # <---- same mqtt broker that home assistant uses - user: mqtt-user - password: xxxxxxxxxx - -ffmpeg: - hwaccel_args: preset-vaapi - -detectors: - ov: - type: openvino - device: AUTO - -model: - width: 300 - height: 300 - input_tensor: nhwc - input_pixel_format: bgr - path: /openvino-model/ssdlite_mobilenet_v2.xml - labelmap_path: /openvino-model/coco_91cl_bkgr.txt - -record: - enabled: True - motion: - days: 7 - alerts: - retain: - days: 30 - mode: motion - detections: - retain: - days: 30 - mode: motion - -snapshots: - enabled: True - retain: - default: 30 - -cameras: - name_of_your_camera: - detect: - width: 1280 - height: 720 - fps: 5 - ffmpeg: - inputs: - - path: rtsp://10.0.10.10:554/rtsp - roles: - - detect - motion: - mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 -``` diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index a44006b634..2c7c8e4cbb 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -3,17 +3,28 @@ id: license_plate_recognition title: License Plate Recognition (LPR) --- -Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street. +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + +Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car`, `motorcycle`, `bus`, `truck`, `school_bus`, or `garbage_truck`, depending on which of those labels your model detects. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition. +:::info + +License plate recognition requires a one-time internet connection to download OCR and detection models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + When a plate is recognized, the details are: - Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object. - Viewable in the Details pane in Review/History. - Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates). - Filterable through the More Filters menu in Explore. -- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object. +- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the vehicle tracked object. - Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`. ## Model Requirements @@ -24,7 +35,7 @@ Users without a model that detects license plates can still run LPR. Frigate use :::note -In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle` before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a `car` or `motorcycle` will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below. +In the default mode, Frigate's LPR needs to first detect a vehicle before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a vehicle will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below. ::: @@ -34,14 +45,35 @@ License plate recognition works by running AI models locally on your system. The ## Configuration -License plate recognition is disabled by default. Enable it in your config file: +License plate recognition is disabled by default and must be enabled before it can be used. + + + + +Navigate to . + +- Set **Enable LPR** to on + + + ```yaml lpr: enabled: True ``` -Like other enrichments in Frigate, LPR **must be enabled globally** to use the feature. You should disable it for specific cameras at the camera level if you don't want to run LPR on cars on those cameras: + + + +Like other enrichments in Frigate, LPR **must be enabled globally** to use the feature. Disable it for specific cameras at the camera level if you don't want to run LPR on cars on those cameras. + + + + +Navigate to for the desired camera and disable the **Enable LPR** toggle. + + + ```yaml {4,5} cameras: @@ -51,65 +83,144 @@ cameras: enabled: False ``` -For non-dedicated LPR cameras, ensure that your camera is configured to detect objects of type `car` or `motorcycle`, and that a car or motorcycle is actually being detected by Frigate. Otherwise, LPR will not run. + + + +For non-dedicated LPR cameras, ensure that your camera is configured to detect vehicle objects, and that a vehicle is actually being detected by Frigate. Otherwise, LPR will not run. The object types that can carry a plate are defined by your model's `attributes_map`, so if your model detects other vehicle labels, you can add them there. Like the other real-time processors in Frigate, license plate recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements. ## Advanced Configuration -Fine-tune the LPR feature using these optional parameters at the global level of your config. The only optional parameters that can be set at the camera level are `enabled`, `min_area`, and `enhancement`. +Fine-tune the LPR feature using these optional parameters. The only optional parameters that can be set at the camera level are `enabled`, `min_area`, and `enhancement`. ### Detection -- **`detection_threshold`**: License plate object detection confidence score required before recognition runs. + + + +Navigate to . + +- **Detection threshold**: License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; `threshold` and `min_score` object filters should be used for models like Frigate+ that have license plate detection built in. - Default: `0.7` - - Note: This is field only applies to the standalone license plate detection model, `threshold` and `min_score` object filters should be used for models like Frigate+ that have license plate detection built in. -- **`min_area`**: Defines the minimum area (in pixels) a license plate must be before recognition runs. - - Default: `1000` pixels. Note: this is intentionally set very low as it is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. - - Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. -- **`device`**: Device to use to run license plate detection _and_ recognition models. +- **Minimum plate area**: Minimum area (in pixels) a license plate must be before recognition runs. This is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. + - Default: `1000` pixels +- **Device**: Device to use to run license plate detection _and_ recognition models. Auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. - Default: `None` - - This is auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU. -- **`model_size`**: The size of the model used to identify regions of text on plates. +- **Model size**: The size of the model used to identify regions of text on plates. The `small` model is fast and identifies groups of Latin and Chinese characters. The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the `small` model. - Default: `small` - - This can be `small` or `large`. - - The `small` model is fast and identifies groups of Latin and Chinese characters. - - The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model. - - If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates. + + + + +```yaml +lpr: + enabled: True + detection_threshold: 0.7 + min_area: 1000 + device: CPU + model_size: small +``` + + + ### Recognition -- **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`. - - Default: `0.9`. -- **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label` to an object. - - Use this to filter out short, incomplete, or incorrect detections. -- **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. - - `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7" - - `"^[A-Z]{2}[0-9]{2} [A-Z]{3}$"` matches plates like "AB12 XYZ" or "XY68 ABC" - - Websites like https://regex101.com/ can help test regular expressions for your plates. + + + +Navigate to . + +- **Recognition threshold**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`. + - Default: `0.9` +- **Min plate length**: Minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label`. Use this to filter out short, incomplete, or incorrect detections. +- **Plate format regex**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates. + + + + +```yaml +lpr: + enabled: True + recognition_threshold: 0.9 + min_plate_length: 4 + format: "^[A-Z]{2}[0-9]{2} [A-Z]{3}$" +``` + + + ### Matching -- **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` and `motorcycle` objects when a recognized plate matches a known value. - - These labels appear in the UI, filters, and notifications. - - Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`. -- **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. - - For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. - - This parameter will _not_ operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`. + + + +Navigate to . + +- **Known plates**: Assign custom `sub_label` values to vehicle objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`. +- **Match distance**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to `1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. This parameter will _not_ operate on known plates that are defined as regular expressions. + + + + +```yaml +lpr: + enabled: True + match_distance: 1 + known_plates: + Wife's Car: + - "ABC-1234" + Johnny: + - "J*N-*234" +``` + + + ### Image Enhancement -- **`enhancement`**: A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. This preprocessing step can sometimes improve accuracy but may also have the opposite effect. + + + +Navigate to . + +- **Enhancement level**: A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras. - Default: `0` (no enhancement) - - Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters, actually making them much harder for Frigate to recognize. - - This setting is best adjusted at the camera level if running LPR on multiple cameras. - - If Frigate is already recognizing plates correctly, leave this setting at the default of `0`. However, if you're experiencing frequent character issues or incomplete plates and you can already easily read the plates yourself, try increasing the value gradually, starting at 5 and adjusting as needed. You should see how different enhancement levels affect your plates. Use the `debug_save_plates` configuration option (see below). + + + + +```yaml +lpr: + enabled: True + enhancement: 1 +``` + + + + +If Frigate is already recognizing plates correctly, leave enhancement at the default of `0`. However, if you're experiencing frequent character issues or incomplete plates and you can already easily read the plates yourself, try increasing the value gradually, starting at 3 and adjusting as needed. Use the `debug_save_plates` configuration option (see below) to see how different enhancement levels affect your plates. ### Normalization Rules -- **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially and are applied _before_ the `format` regex, if specified. Each rule must have a `pattern` (which can be a string or a regex) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0'). + + -These rules must be defined at the global level of your `lpr` config. +Navigate to . + +Under **Replacement rules**, add regex rules to normalize detected plate strings before matching. Rules fire in order. For example: + +| Pattern | Replacement | Description | +| ---------------- | ----------- | -------------------------------------------------- | +| `[%#*?]` | _(empty)_ | Remove noise symbols | +| `[= ]` | `-` | Normalize `=` or space to dash | +| `O` | `0` | Swap `O` to `0` (common OCR error) | +| `I` | `1` | Swap `I` to `1` | +| `(\w{3})(\w{3})` | `\1-\2` | Split 6 chars into groups (e.g., ABC123 → ABC-123) | + + + ```yaml lpr: @@ -126,6 +237,11 @@ lpr: replacement: '\1-\2' ``` + + + +These rules must be defined at the global level of your `lpr` config. + - Rules fire in order: In the example above: clean noise first, then separators, then swaps, then splits. - Backrefs (`\1`, `\2`) allow dynamic replacements (e.g., capture groups). - Any changes made by the rules are printed to the LPR debug log. @@ -133,13 +249,50 @@ lpr: ### Debugging -- **`debug_save_plates`**: Set to `True` to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `/`, and named based on the capture timestamp. - - These saved images are not full plates but rather the specific areas of text detected on the plates. It is normal for the text detection model to sometimes find multiple areas of text on the plate. Use them to analyze what text Frigate recognized and how image enhancement affects detection. - - **Note:** Frigate does **not** automatically delete these debug images. Once LPR is functioning correctly, you should disable this option and manually remove the saved files to free up storage. + + + +Navigate to . + +- **Save debug plates**: Set to on to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `/`, and named based on the capture timestamp. + + + + +```yaml +lpr: + enabled: True + debug_save_plates: True +``` + + + + +The saved images are not full plates but rather the specific areas of text detected on the plates. It is normal for the text detection model to sometimes find multiple areas of text on the plate. Use them to analyze what text Frigate recognized and how image enhancement affects detection. + +**Note:** Frigate does **not** automatically delete these debug images. Once LPR is functioning correctly, you should disable this option and manually remove the saved files to free up storage. ## Configuration Examples -These configuration parameters are available at the global level of your config. The only optional parameters that should be set at the camera level are `enabled`, `min_area`, and `enhancement`. +These configuration parameters are available at the global level. The only optional parameters that should be set at the camera level are `enabled`, `min_area`, and `enhancement`. + + + + +Navigate to . + +| Field | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------- | +| **Enable LPR** | Set to on | +| **Minimum plate area** | Set to `1500` to ignore plates with an area (length x width) smaller than 1500 pixels | +| **Min plate length** | Set to `4` to only recognize plates with 4 or more characters | +| **Known plates > Wife's Car** | `ABC-1234`, `ABC-I234` (accounts for potential confusion between the number one and capital letter I) | +| **Known plates > Johnny** | `J*N-*234` (matches JHN-1234 and JMN-I234; `*` matches any number of characters) | +| **Known plates > Sally** | `[S5]LL 1234` (matches both SLL 1234 and 5LL 1234) | +| **Known plates > Work Trucks** | `EMP-[0-9]{3}[A-Z]` (matches plates like EMP-123A, EMP-456Z) | + + + ```yaml lpr: @@ -158,27 +311,20 @@ lpr: - "EMP-[0-9]{3}[A-Z]" # Matches plates like EMP-123A, EMP-456Z ``` -```yaml -lpr: - enabled: True - min_area: 4000 # Run recognition on larger plates only (4000 pixels represents a 63x63 pixel square in your image) - recognition_threshold: 0.85 - format: "^[A-Z]{2} [A-Z][0-9]{4}$" # Only recognize plates that are two letters, followed by a space, followed by a single letter and 4 numbers - match_distance: 1 # Allow one character variation in plate matching - replace_rules: - - pattern: "O" - replacement: "0" # Replace the letter O with the number 0 in every plate - known_plates: - Delivery Van: - - "RJ K5678" - - "UP A1234" - Supervisor: - - "MN D3163" -``` + + :::note -If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level: +If a camera is configured to detect vehicles but you don't want Frigate to run LPR for that camera, disable LPR at the camera level: + + + + +Navigate to for the desired camera and disable the **Enable LPR** toggle. + + + ```yaml cameras: @@ -188,13 +334,16 @@ cameras: ... ``` + + + ::: ## Dedicated LPR Cameras Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to capture license plates on distant vehicles, often with fine-tuned settings to capture plates at night. -To mark a camera as a dedicated LPR camera, add `type: "lpr"` the camera configuration. +To mark a camera as a dedicated LPR camera, set `type: "lpr"` in the camera configuration. :::note @@ -210,6 +359,55 @@ Users running a Frigate+ model (or any model that natively detects `license_plat An example configuration for a dedicated LPR camera using a `license_plate`-detecting model: + + + +Navigate to and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available). + +Navigate to and add your camera streams. + +Navigate to . + +| Field | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Enable object detection** | Set to on | +| **Detect FPS** | Set to `5`. Increase to `10` if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended. | +| **Minimum initialization frames** | Set to `2` | +| **Detect width** | Set to `1920` | +| **Detect height** | Set to `1080` | + +Navigate to . + +| Field | Description | +| ---------------------------------------------- | ------------------- | +| **Objects to track** | Add `license_plate` | +| **Object filters > License Plate > Threshold** | Set to `0.7` | + +Navigate to . + +| Field | Description | +| -------------------- | --------------------------------------------------------------------- | +| **Motion threshold** | Set to `30` | +| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. | +| **Improve contrast** | Set to off | + +Also add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate. + +Navigate to . + +| Field | Description | +| -------------------- | -------------------------------------------------------- | +| **Enable recording** | Set to on. Disable recording if you only want snapshots. | + +Navigate to . + +| Field | Description | +| -------------------- | ----------- | +| **Enable snapshots** | Set to on | + + + + ```yaml # LPR global configuration lpr: @@ -248,6 +446,9 @@ cameras: - license_plate ``` + + + With this setup: - License plates are treated as normal objects in Frigate. @@ -255,14 +456,69 @@ With this setup: - Snapshots will have license plate bounding boxes on them. - The `frigate/events` MQTT topic will publish tracked object updates. - Debug view will display `license_plate` bounding boxes. -- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the `car` / `motorcycle` and the `license_plate` in the snapshots on the Frigate+ website, even if the car is barely visible. +- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the vehicle and the `license_plate` in the snapshots on the Frigate+ website, even if the vehicle is barely visible. ### Using the Secondary LPR Pipeline (Without Frigate+) -If you are not running a Frigate+ model, you can use Frigate’s built-in secondary dedicated LPR pipeline. In this mode, Frigate bypasses the standard object detection pipeline and runs a local license plate detector model on the full frame whenever motion activity occurs. +If you are not running a Frigate+ model, you can use Frigate's built-in secondary dedicated LPR pipeline. In this mode, Frigate bypasses the standard object detection pipeline and runs a local license plate detector model on the full frame whenever motion activity occurs. An example configuration for a dedicated LPR camera using the secondary pipeline: + + + +Navigate to and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available and the correct Docker image is used). Set **Detection threshold** to `0.7` (change if necessary). + +Navigate to for your dedicated LPR camera. + +| Field | Description | +| --------------------- | -------------------------------------------------------------------------------- | +| **Enable LPR** | Set to on | +| **Enhancement level** | Set to `3` (optional, enhances the image before trying to recognize characters) | + +Navigate to and add your camera streams. + +Navigate to . + +| Field | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **Enable object detection** | Set to off to disable Frigate's standard object detection pipeline | +| **Detect FPS** | Set to `5`. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU. | +| **Detect width** | Set to `1920` (recommended value, but depends on your camera) | +| **Detect height** | Set to `1080` (recommended value, but depends on your camera) | + +Navigate to . + +| Field | Description | +| -------------------- | -------------------------------------------------------------------------------------- | +| **Objects to track** | Set to an empty list, required when not using a Frigate+ model for dedicated LPR mode | + +Navigate to . + +| Field | Description | +| -------------------- | --------------------------------------------------------------------- | +| **Motion threshold** | Set to `30` | +| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. | +| **Improve contrast** | Set to off | + +Navigate to and add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate. + +Navigate to . + +| Field | Description | +| -------------------- | -------------------------------------------------------- | +| **Enable recording** | Set to on. Disable recording if you only want snapshots. | + +Navigate to . + +| Field | Description | +| ----------------------------------------- | --------------- | +| **Detections config > Enable detections** | Set to on | +| **Detections config > Retain > Default** | Set to `7` days | + + + + ```yaml # LPR global configuration lpr: @@ -299,6 +555,9 @@ cameras: default: 7 ``` + + + With this setup: - The standard object detection pipeline is bypassed. Any detected license plates on dedicated LPR cameras are treated similarly to manual events in Frigate. You must **not** specify `license_plate` as an object to track. @@ -333,7 +592,9 @@ By selecting the appropriate configuration, users can optimize their dedicated L ## FAQ -### Why isn't my license plate being detected and recognized? +### Detection and Recognition + + Ensure that: @@ -348,41 +609,70 @@ Recognized plates will show as object labels in the debug view and will appear i If you are still having issues detecting plates, start with a basic configuration and see the debugging tips below. -### Can I run LPR without detecting `car` or `motorcycle` objects? + -In normal LPR mode, Frigate requires a `car` or `motorcycle` to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above. +Can I run LPR without detecting vehicle objects?}> -### How can I improve detection accuracy? +In normal LPR mode, Frigate requires a vehicle to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above. + + + + - Use high-quality cameras with good resolution. - Adjust `detection_threshold` and `recognition_threshold` values. - Define a `format` regex to filter out invalid detections. -### Does LPR work at night? + + + Yes, but performance depends on camera quality, lighting, and infrared capabilities. Make sure your camera can capture clear images of plates at night. -### Can I limit LPR to specific zones? + + + LPR, like other Frigate enrichments, runs at the camera level rather than the zone level. While you can't restrict LPR to specific zones directly, you can control when recognition runs by setting a `min_area` value to filter out smaller detections. -### How can I match known plates with minor variations? + + + Use `match_distance` to allow small character mismatches. Alternatively, define multiple variations in `known_plates`. -### How do I debug LPR issues? + + +### Performance and Troubleshooting + + Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps. 1. Start with a simplified LPR config. - Remove or comment out everything in your LPR config, including `min_area`, `min_plate_length`, `format`, `known_plates`, or `enhancement` values so that the only values left are `enabled` and `debug_save_plates`. This will run LPR with Frigate's default values. - ```yaml - lpr: - enabled: true - device: CPU - debug_save_plates: true - ``` + + + +Navigate to . + +- Set **Enable LPR** to on +- Set **Device** to `CPU` +- Set **Save debug plates** to on + + + + +```yaml +lpr: + enabled: true + device: CPU + debug_save_plates: true +``` + + + 2. Enable debug logs to see exactly what Frigate is doing. - Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary. Restart Frigate after this change. @@ -391,14 +681,14 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is logger: default: info logs: - # highlight-next-line + # highlight-next-line frigate.data_processing.common.license_plate: debug ``` 3. Ensure your plates are being _detected_. If you are using a Frigate+ or `license_plate` detecting model: - - Watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected. + - Watch the [Debug view](/usage/live#the-single-camera-view) to ensure that `license_plate` is being detected. - View MQTT messages for `frigate/events` to verify detected plates. - You may need to adjust your `min_score` and/or `threshold` for the `license_plate` object if your plates are not being detected. @@ -407,28 +697,39 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is - You may need to adjust your `detection_threshold` if your plates are not being detected. 4. Ensure the characters on detected plates are being _recognized_. + - Check the **Plate recognition** inference time in Enrichment metrics (). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly. - Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear. - - Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working. + - Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the vehicle's label will change to the recognized plate when LPR is enabled and working. - Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration). -### Will LPR slow down my system? + + + LPR's performance impact depends on your hardware. Ensure you have at least 4GB RAM and a capable CPU or GPU for optimal results. If you are running the Dedicated LPR Camera mode, resource usage will be higher compared to users who run a model that natively detects license plates. Tune your motion detection settings for your dedicated LPR camera so that the license plate detection model runs only when necessary. -### I am seeing a YOLOv9 plate detection metric in Enrichment Metrics, but I have a Frigate+ or custom model that detects `license_plate`. Why is the YOLOv9 model running? + + +I am seeing a YOLOv9 plate detection metric in Enrichment Metrics, but I have a Frigate+ or custom model that detects license_plate. Why is the YOLOv9 model running?}> The YOLOv9 license plate detector model will run (and the metric will appear) if you've enabled LPR but haven't defined `license_plate` as an object to track, either at the global or camera level. -If you are detecting `car` or `motorcycle` on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track. +If you are detecting vehicles on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track. -### It looks like Frigate picked up my camera's timestamp or overlay text as the license plate. How can I prevent this? + -This could happen if cars or motorcycles travel close to your camera's timestamp or overlay text. You could either move the text through your camera's firmware, or apply a mask to it in Frigate. + + +This could happen if vehicles travel close to your camera's timestamp or overlay text. You could either move the text through your camera's firmware, or apply a mask to it in Frigate. If you are using a model that natively detects `license_plate`, add an _object mask_ of type `license_plate` and a _motion mask_ over your text. If you are not using a model that natively detects `license_plate` or you are using dedicated LPR camera mode, only a _motion mask_ over your text is required. -### I see "Error running ... model" in my logs, or my inference time is very high. How can I fix this? + + + This usually happens when your GPU is unable to compile or use one of the LPR models. Set your `device` to `CPU` and try again. GPU acceleration only provides a slight performance increase, and the models are lightweight enough to run without issue on most CPUs. + + diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 8e7eff163b..6f32c9ec3e 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -3,11 +3,16 @@ id: live title: Live View --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + Frigate intelligently displays your camera streams on the Live view dashboard. By default, Frigate employs "smart streaming" where camera images update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any motion or active objects are detected, cameras seamlessly switch to a live stream. ### Live View technologies -Frigate intelligently uses three different streaming technologies to display your camera streams on the dashboard and the single camera view, switching between available modes based on network bandwidth, player errors, or required features like two-way talk. The highest quality and fluency of the Live view requires the bundled `go2rtc` to be configured as shown in the [step by step guide](/guides/configuring_go2rtc). +Frigate intelligently uses three different streaming technologies to display your camera streams on the dashboard and the single camera view, switching between available modes based on network bandwidth, player errors, or required features like two-way talk. The highest quality and fluency of the Live view requires the bundled `go2rtc` to be [configured](/configuration/go2rtc). The jsmpeg live view will use more browser and client GPU resources. Using go2rtc is highly recommended and will provide a superior experience. @@ -17,13 +22,19 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt | mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. | | webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. | +:::info + +WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming do not require any internet access. See [Network Requirements](/frigate/network_requirements#webrtc-stun) for details. + +::: + ### Camera Settings Recommendations If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view: - Video codec: **H.264** - provides the most compatible video codec with all Live view technologies and browsers. Avoid any kind of "smart codec" or "+" codec like _H.264+_ or _H.265+_. as these non-standard codecs remove keyframes (see below). - Audio codec: **AAC** - provides the most compatible audio codec with all Live view technologies and browsers that support audio. -- I-frame interval (sometimes called the keyframe interval, the interframe space, or the GOP length): match your camera's frame rate, or choose "1x" (for interframe space on Reolink cameras). For example, if your stream outputs 20fps, your i-frame interval should be 20 (or 1x on Reolink). Values higher than the frame rate will cause the stream to take longer to begin playback. See [this page](https://gardinal.net/understanding-the-keyframe-interval/) for more on keyframes. For many users this may not be an issue, but it should be noted that a 1x i-frame interval will cause more storage utilization if you are using the stream for the `record` role as well. +- I-frame interval (sometimes called the keyframe interval, the interframe space, or the GOP length): match your camera's frame rate, or choose "1x" (for interframe space on Reolink cameras). For example, if your stream outputs 20fps, your i-frame interval should be 20 (or 1x on Reolink). Values higher than the frame rate will cause the stream to take longer to begin playback. See [this page](https://web.archive.org/web/20251213190836/https://gardinal.net/understanding-the-keyframe-interval/) for more on keyframes. For many users this may not be an issue, but it should be noted that a 1x i-frame interval will cause more storage utilization if you are using the stream for the `record` role as well. The default video and audio codec on your camera may not always be compatible with your browser, which is why setting them to H.264 and AAC is recommended. See the [go2rtc docs](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness) for codec support information. @@ -63,19 +74,36 @@ go2rtc: ### Setting Streams For Live UI -You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the `live -> streams` list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. +You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard. :::note -Frigate's default dashboard ("All Cameras") will always use the first entry you've defined in `streams:` when playing live streams from your cameras. +Frigate's default dashboard ("All Cameras") will always use the first entry you've defined in streams when playing live streams from your cameras. ::: -Configure the `streams` option with a "friendly name" for your stream followed by the go2rtc stream name. +Configure a "friendly name" for your stream followed by the go2rtc stream name. Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the streams configuration, only go2rtc stream names. -Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the `streams` configuration, only go2rtc stream names. + + + +1. Navigate to and select your camera. +2. Under **Live stream names**, click **Add stream** to add a new entry. +3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`). +4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value. +5. Repeat for each additional stream you want to expose (e.g., `Sub Stream` → `test_cam_sub`). +6. Use the trash icon on a row to remove a stream, then **Save** the section. + +:::tip + +Configure your go2rtc streams first under so the dropdown is populated with valid options. + +::: + + + ```yaml {3,6,8,25-29} go2rtc: @@ -109,6 +137,9 @@ cameras: Special Stream: test_cam_another_sub ``` + + + ### WebRTC extra configuration: WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration: @@ -165,7 +196,7 @@ services: ::: -See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.3#module-webrtc) for more information about this. +See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#module-webrtc) for more information about this. ### Two way talk @@ -185,7 +216,7 @@ To prevent go2rtc from blocking other applications from accessing your camera's Frigate provides a dialog in the Camera Group Edit pane with several options for streaming on a camera group's dashboard. These settings are _per device_ and are saved in your device's local storage. -- Stream selection using the `live -> streams` configuration option (see _Setting Streams For Live UI_ above) +- Stream selection using the streams configuration option (see _Setting Streams For Live UI_ above) - Streaming type: - _No streaming_: Camera images will only update once per minute and no live streaming will occur. - _Smart Streaming_ (default, recommended setting): Smart streaming will update your camera image once per minute when no detectable activity is occurring to conserve bandwidth and resources, since a static picture is the same as a streaming image with no motion or objects. When motion or objects are detected, the image seamlessly switches to a live stream. @@ -203,19 +234,81 @@ Use a camera group if you want to change any of these settings from the defaults ::: -### Disabling cameras +### jsmpeg Stream Quality -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. +The jsmpeg live view resolution and encoding quality can be adjusted globally or per camera. These settings only affect the jsmpeg player and do not apply when go2rtc is used for live view. -:::note + + -Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate. +Navigate to for global defaults, or and select a camera for per-camera overrides. -::: +| Field | Description | +| ---------------- | --------------------------------------------------------------------------------------------------- | +| **Live height** | Height in pixels for the jsmpeg live stream; must be less than or equal to the detect stream height | +| **Live quality** | Encoding quality for the jsmpeg stream (1 = highest, 31 = lowest) | -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). + + -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. +```yaml +# Global defaults +live: + height: 720 + quality: 8 + +# Per-camera override +cameras: + front_door: + live: + height: 480 + quality: 4 +``` + + + + +### Camera state + +Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**: + +- **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. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)). +- **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.** + +#### Turning a camera on or off + +Turning a camera off is temporary and does not require a restart. The available controls are: + +- 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//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) + +#### 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. + +#### Runtime toggle persistence + +The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** (along with the equivalent MQTT `/set` topics) write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing: + +- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source. +- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile. + +If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults. ### Live player error messages @@ -241,7 +334,7 @@ When your browser runs into problems playing back your camera streams, it will l - **stalled** - What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving). - - What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval — shorter intervals make playback start and recover faster. You can also try increasing the timeout value in the UI pane of Frigate's settings. + - What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in . - Possible console messages from the player code: - `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.` @@ -249,94 +342,155 @@ When your browser runs into problems playing back your camera streams, it will l ## Live view FAQ -1. **Why don't I have audio in my Live view?** +### Getting Live View Working - You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. + - Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. +You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. -2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?** +If the audio controls don't appear in the UI at all, verify that the Live view is actually using your go2rtc stream. If your go2rtc stream names don't match your Frigate camera name, you must map them with the `live -> streams` config (see [Setting Streams For Live UI](#setting-streams-for-live-ui) above); otherwise the UI falls back to the video-only jsmpeg player. - Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. +Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. - When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. Continuous streaming mode does not have an automatic reset mechanism, but you can use the _Reset_ option to force a reload of your stream. + - If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again. + - Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include: - - Network issues (e.g., MSE or WebRTC network connection problems). - - Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers). - - Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg. - - Browser compatibility problems (e.g., iOS Safari limitations with MSE). +If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. - To view browser console logs: - 1. Open the Frigate Live View in your browser. - 2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab). - 3. Reproduce the error (e.g., load a problematic stream or simulate network issues). - 4. Look for messages prefixed with the camera name. + - These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors: - - Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera_settings_recommendations)). - - Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). - - Test with a different stream via the UI dropdown (if `live -> streams` is configured). - - For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see (WebRTC Extra Configuration)(#webrtc-extra-configuration)). - - If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream. + -3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** +The debug view plays the `detect` stream processed by Frigate itself, while the Live view plays your go2rtc stream directly in the browser. If the debug view works but the Live view doesn't, your browser usually can't decode what the camera is sending, most often H.265 video or an incompatible audio track. - On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. +Work through the [go2rtc troubleshooting guide](/troubleshooting/go2rtc#live-view-is-black-buffering-or-stuck-in-low-bandwidth-mode) to isolate the problem. Two fixes resolve the majority of cases: -4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?** +1. Restream through go2rtc's FFmpeg module by prefixing your source with `ffmpeg:`, for example `- ffmpeg:rtsp://user:password@192.168.1.5:554/stream`. +2. If that doesn't help, transcode to compatible codecs: `- ffmpeg:rtsp://user:password@192.168.1.5:554/stream#video=h264#audio=aac#hardware`. - This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. + -5. **How does "smart streaming" work?** + - Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. +For a full-resolution, low-latency live view in Home Assistant dashboards, use the [Advanced Camera Card](https://card.camera) with the [go2rtc live provider](https://card.camera/#/configuration/cameras/live-provider?id=go2rtc), which streams directly from Frigate's bundled go2rtc. This also supports audio and [two-way talk](#two-way-talk) on capable cameras. See the [Home Assistant integration docs](/integrations/home-assistant) for setup. - This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. + - Smart streaming depends on having your camera's motion `threshold` and `contour_area` config values dialed in. Use the Motion Tuner in Settings in the UI to tune these values in real-time. +### Streaming Behavior - This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. + -6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?** +Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. - If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. +This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. -7. **My camera streams have lots of visual artifacts / distortion.** +Smart streaming depends on having your camera's motion `threshold` and `contour_area` config values dialed in. Use the Motion Tuner in Settings in the UI to tune these values in real-time. - Some cameras don't include the hardware to support multiple connections to the high resolution stream, and this can cause unexpected behavior. In this case it is recommended to [restream](./restream.md) the high resolution stream so that it can be used for live view and recordings. +This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. -8. **Why does my camera stream switch aspect ratios on the Live dashboard?** + - Your camera may change aspect ratios on the dashboard because Frigate uses different streams for different purposes. With go2rtc and Smart Streaming, Frigate shows a static image from the `detect` stream when no activity is present, and switches to the live stream when motion is detected. The camera image will change size if your streams use different aspect ratios. + - To prevent this, make the `detect` stream match the go2rtc live stream's aspect ratio (resolution does not need to match, just the aspect ratio). You can either adjust the camera's output resolution or set the `width` and `height` values in your config's `detect` section to a resolution with an aspect ratio that matches. +On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. - Example: Resolutions from two streams - - Mismatched (may cause aspect ratio switching on the dashboard): - - Live/go2rtc stream: 1920x1080 (16:9) - - Detect stream: 640x352 (~1.82:1, not 16:9) + - - Matched (prevents switching): - - Live/go2rtc stream: 1920x1080 (16:9) - - Detect stream: 640x360 (16:9) + - You can update the detect settings in your camera config to match the aspect ratio of your go2rtc live stream. For example: +Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. - ```yaml - cameras: - front_door: - detect: - width: 640 - height: 360 # set this to 360 instead of 352 - ffmpeg: - inputs: - - path: rtsp://127.0.0.1:8554/front_door # main stream 1920x1080 - roles: - - record - - path: rtsp://127.0.0.1:8554/front_door_sub # sub stream 640x352 - roles: - - detect - ``` +When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. Continuous streaming mode does not have an automatic reset mechanism, but you can use the _Reset_ option to force a reload of your stream. + +If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again. + +Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include: + +- Network issues (e.g., MSE or WebRTC network connection problems). +- Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers). +- Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg. +- Browser compatibility problems (e.g., iOS Safari limitations with MSE). + +To view browser console logs: + +1. Open the Frigate Live View in your browser. +2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab). +3. Reproduce the error (e.g., load a problematic stream or simulate network issues). +4. Look for messages prefixed with the camera name. + +These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors: + +- Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera-settings-recommendations)). +- Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). +- Test with a different stream via the UI dropdown (if `live -> streams` is configured). +- For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see [WebRTC Extra Configuration](#webrtc-extra-configuration)). +- If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream. + + + + + +A delay when a stream first starts is usually caused by your camera's I-frame (keyframe) interval. Playback cannot begin until a keyframe arrives, so an interval set higher than your camera's frame rate makes the stream take longer to start. Set the I-frame interval to match the frame rate (or "1x" on Reolink) per the [camera settings recommendations](#camera-settings-recommendations). + +A stream that starts on time but falls further behind live is buffering, which is usually the browser struggling to decode too many high-resolution streams at once. Select a lower-bandwidth substream for your dashboards (see [Setting Streams For Live UI](#setting-streams-for-live-ui)), reduce the number of streams open at once, or improve the network connection between your browser and Frigate. Frigate's player automatically speeds up playback to catch up to live after buffering, and falls back to low bandwidth mode if it stalls for too long. The _Reset_ option forces a fresh connection at the live edge. + + + + + +Frigate prefers MSE because it delivers a better out-of-the-box experience than WebRTC on nearly every axis that matters for a security camera system. MSE is an open standard optimized and supported by all modern browsers, works without any extra configuration (WebRTC requires port forwarding and candidate setup, and lacks H.265 support in some browsers), and requires no internet access for NAT traversal. More importantly, MSE runs over TCP, so every frame arrives and is decoded in order, so nothing is ever silently skipped. WebRTC optimizes for latency over UDP by discarding late or incomplete frames, which works against you on cellular or spotty Wi-Fi: you can end up with frozen video, visual corruption, or gaps in the feed without ever knowing you missed something. Frigate's enhanced MSE player has adaptive speed playback and has been tuned for latency and connection robustness that meets or exceeds WebRTC, so you get near-real-time playback with a guarantee that when the video plays, every frame is actually there - which, for an NVR whose whole purpose is letting you see what happened, matters more than shaving fractions of a second off a latency number. That's why Frigate defaults to MSE and reserves WebRTC for cases that require it, like two-way talk. + + + +### Video Quality Issues + + + +This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. + + + + + +Some cameras don't include the hardware to support multiple connections to the high resolution stream, and this can cause unexpected behavior. In this case it is recommended to [restream](./restream.md) the high resolution stream so that it can be used for live view and recordings. + + + + + +Your camera may change aspect ratios on the dashboard because Frigate uses different streams for different purposes. With go2rtc and Smart Streaming, Frigate shows a static image from the `detect` stream when no activity is present, and switches to the live stream when motion is detected. The camera image will change size if your streams use different aspect ratios. + +To prevent this, make the `detect` stream match the go2rtc live stream's aspect ratio (resolution does not need to match, just the aspect ratio). You can either adjust the camera's output resolution or set the `width` and `height` values in your config's `detect` section to a resolution with an aspect ratio that matches. + +Example: Resolutions from two streams + +- Mismatched (may cause aspect ratio switching on the dashboard): + - Live/go2rtc stream: 1920x1080 (16:9) + - Detect stream: 640x352 (~1.82:1, not 16:9) + +- Matched (prevents switching): + - Live/go2rtc stream: 1920x1080 (16:9) + - Detect stream: 640x360 (16:9) + +You can update the detect settings in your camera config to match the aspect ratio of your go2rtc live stream. For example: + +```yaml +cameras: + front_door: + detect: + width: 640 + height: 360 # set this to 360 instead of 352 + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_door # main stream 1920x1080 + roles: + - record + - path: rtsp://127.0.0.1:8554/front_door_sub # sub stream 640x352 + roles: + - detect +``` + +The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ). + + diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 0fcf366eda..50c9fffc31 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -3,11 +3,15 @@ id: masks title: Masks --- -Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior. +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior. ## Motion masks -Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._ +Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the [Debug view](/usage/live#the-single-camera-view) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._ See [further clarification](#further-clarification) below on why you may not want to use a motion mask. @@ -21,41 +25,79 @@ Object filter masks can be used to filter out stubborn false positives in fixed ## Which tool do I need? -| What you're trying to do | Recommended tool | How it works | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | -| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | -| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | -| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | +| What you're trying to do | Recommended tool | How it works | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | +| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | +| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | +| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | ## Using the mask creator -To create a poly mask: + + -1. Visit the Web UI -2. Click/tap the gear icon and open "Settings" -3. Select "Mask / zone editor" -4. At the top right, select the camera you wish to create a mask or zone for -5. Click the plus icon under the type of mask or zone you would like to create -6. Click on the camera's latest image to create the points for a masked area. Click the first point again to close the polygon. -7. When you've finished creating your mask, press Save. +Navigate to and select a camera. Use the mask editor to draw motion masks and object filter masks directly on the camera feed. Each mask can be given a friendly name and toggled on or off. + + + Your config file will be updated with the relative coordinates of the mask/zone: ```yaml motion: - mask: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" + mask: + # Motion mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Timestamp area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" ``` -Multiple masks can be listed in your config. +Multiple motion masks can be listed in your config: ```yaml motion: mask: - - 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802 - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456 + mask1: + friendly_name: "Timestamp area" + enabled: true + coordinates: "0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802" + mask2: + friendly_name: "Tree area" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456" ``` +Object filter masks are configured under the object filters section for each object type: + +```yaml +objects: + filters: + person: + mask: + person_filter1: + friendly_name: "Roof area" + enabled: true + coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400" + car: + mask: + car_filter1: + friendly_name: "Sidewalk area" + enabled: true + coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000" +``` + + + + +## Enabling/Disabling Masks + +Both motion masks and object filter masks can be toggled on or off without removing them from the configuration. Disabled masks are completely ignored at runtime - they will not affect motion detection or object filtering. This is useful for temporarily disabling a mask during certain seasons or times of day without modifying the configuration. + ### Further Clarification This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3): @@ -97,10 +139,10 @@ That may be the case for you. Frigate will definitely work harder tracking peopl ## Common mistakes **"I added a motion mask to ignore my driveway/sidewalk."** -A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone. +A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones). Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone. **"I added an object filter mask because I don't care about cars in my yard."** -Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md). +Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones). If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md). **"I masked everything except a thin strip on my stoop."** -Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact. +Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact. diff --git a/docs/docs/configuration/metrics.md b/docs/docs/configuration/metrics.md index 662404205b..69db159cc5 100644 --- a/docs/docs/configuration/metrics.md +++ b/docs/docs/configuration/metrics.md @@ -3,19 +3,42 @@ id: metrics title: Metrics --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + # Metrics Frigate exposes Prometheus metrics at the `/api/metrics` endpoint that can be used to monitor the performance and health of your Frigate instance. +## Enabling Telemetry + +Prometheus metrics are exposed via the telemetry configuration. Enable or configure telemetry to control metric availability. + + + + +Navigate to to configure metrics and telemetry settings. + + + + +Metrics are available at `/api/metrics` by default. No additional Frigate configuration is required to expose them. + + + + ## Available Metrics ### System Metrics + - `frigate_cpu_usage_percent{pid="", name="", process="", type="", cmdline=""}` - Process CPU usage percentage - `frigate_mem_usage_percent{pid="", name="", process="", type="", cmdline=""}` - Process memory usage percentage - `frigate_gpu_usage_percent{gpu_name=""}` - GPU utilization percentage - `frigate_gpu_mem_usage_percent{gpu_name=""}` - GPU memory usage percentage ### Camera Metrics + - `frigate_camera_fps{camera_name=""}` - Frames per second being consumed from your camera - `frigate_detection_fps{camera_name=""}` - Number of times detection is run per second - `frigate_process_fps{camera_name=""}` - Frames per second being processed @@ -25,21 +48,27 @@ Frigate exposes Prometheus metrics at the `/api/metrics` endpoint that can be us - `frigate_audio_rms{camera_name=""}` - Audio RMS for camera ### Detector Metrics + - `frigate_detector_inference_speed_seconds{name=""}` - Time spent running object detection in seconds - `frigate_detection_start{name=""}` - Detector start time (unix timestamp) ### Storage Metrics + - `frigate_storage_free_bytes{storage=""}` - Storage free bytes - `frigate_storage_total_bytes{storage=""}` - Storage total bytes - `frigate_storage_used_bytes{storage=""}` - Storage used bytes - `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info +These gauges report the operating system's figures for the whole filesystem (the same numbers as `df`), not Frigate's own recording footprint. For how this differs from the recordings usage shown in the UI, see [Understanding storage usage](/configuration/record#understanding-storage-usage). + ### Service Metrics + - `frigate_service_uptime_seconds` - Uptime in seconds - `frigate_service_last_updated_timestamp` - Stats recorded time (unix timestamp) - `frigate_device_temperature{device=""}` - Device Temperature ### Event Metrics + - `frigate_camera_events{camera="", label=""}` - Count of camera events since exporter started ## Configuring Prometheus @@ -48,10 +77,10 @@ To scrape metrics from Frigate, add the following to your Prometheus configurati ```yaml scrape_configs: - - job_name: 'frigate' - metrics_path: '/api/metrics' + - job_name: "frigate" + metrics_path: "/api/metrics" static_configs: - - targets: ['frigate:5000'] + - targets: ["frigate:5000"] scrape_interval: 15s ``` diff --git a/docs/docs/configuration/motion_detection.md b/docs/docs/configuration/motion_detection.md index c22491fd06..a7928954e2 100644 --- a/docs/docs/configuration/motion_detection.md +++ b/docs/docs/configuration/motion_detection.md @@ -3,11 +3,15 @@ id: motion_detection title: Motion Detection --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + # Tuning Motion Detection Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection. -Once motion is detected, it tries to group up nearby areas of motion together in hopes of identifying a rectangle in the image that will capture the area worth inspecting. These are the red "motion boxes" you see in the debug viewer. +Once motion is detected, it tries to group up nearby areas of motion together in hopes of identifying a rectangle in the image that will capture the area worth inspecting. These are the red "motion boxes" you see in the [debug viewer](/usage/live#the-single-camera-view). ## The Goal @@ -21,7 +25,7 @@ First, mask areas with regular motion not caused by the objects you want to dete ## Prepare For Testing -The easiest way to tune motion detection is to use the Frigate UI under Settings > Motion Tuner. This screen allows the changing of motion detection values live to easily see the immediate effect on what is detected as motion. +The recommended way to tune motion detection is to use the built-in Motion Tuner. Navigate to and select the camera you want to tune. This screen lets you adjust motion detection values live and immediately see the effect on what is detected as motion, making it the fastest way to find optimal settings for each camera. ## Tuning Motion Detection During The Day @@ -37,8 +41,21 @@ Remember that motion detection is just used to determine when object detection s The threshold value dictates how much of a change in a pixels luminance is required to be considered motion. + + + +Navigate to to set the threshold globally. + +To override for a specific camera, navigate to and select the camera, or use the to adjust it live. + +| Field | Description | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Motion threshold** | The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30) | + + + + ```yaml -# default threshold value motion: # Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below) # Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. @@ -46,14 +63,30 @@ motion: threshold: 30 ``` -Lower values mean motion detection is more sensitive to changes in color, making it more likely for example to detect motion when a brown dogs blends in with a brown fence or a person wearing a red shirt blends in with a red car. If the threshold is too low however, it may detect things like grass blowing in the wind, shadows, etc. to be detected as motion. + + + +Lower values mean motion detection is more sensitive to changes in color, making it more likely for example to detect motion when a brown dog blends in with a brown fence or a person wearing a red shirt blends in with a red car. If the threshold is too low however, it may detect things like grass blowing in the wind, shadows, etc. to be detected as motion. Watching the motion boxes in the debug view, increase the threshold until you only see motion that is visible to the eye. Once this is done, it is important to test and ensure that desired motion is still detected. ### Contour Area + + + +Navigate to to set the contour area globally. + +To override for a specific camera, navigate to and select the camera, or use the to adjust it live. + +| Field | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Contour area** | Minimum size in pixels in the resized motion image that counts as motion. Increasing this value will prevent smaller areas of motion from being detected. Decreasing will make motion detection more sensitive to smaller moving objects. As a rule of thumb: 10 = high sensitivity, 30 = medium sensitivity, 50 = low sensitivity. (default: 10) | + + + + ```yaml -# default contour_area value motion: # Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below) # Increasing this value will prevent smaller areas of motion from being detected. Decreasing will @@ -65,6 +98,9 @@ motion: contour_area: 10 ``` + + + Once the threshold calculation is run, the pixels that have changed are grouped together. The contour area value is used to decide which groups of changed pixels qualify as motion. Smaller values are more sensitive meaning people that are far away, small animals, etc. are more likely to be detected as motion, but it also means that small changes in shadows, leaves, etc. are detected as motion. Higher values are less sensitive meaning these things won't be detected as motion but with the risk that desired motion won't be detected until closer to the camera. Watching the motion boxes in the debug view, adjust the contour area until there are no motion boxes smaller than the smallest you'd expect frigate to detect something moving. @@ -81,27 +117,87 @@ However, if the preferred day settings do not work well at night it is recommend ## Tuning For Large Changes In Motion +### Lightning Threshold + + + + +Navigate to and expand the advanced fields to find the lightning threshold setting. + +To override for a specific camera, navigate to and select the camera. + +| Field | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Lightning threshold** | The percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera. (default: 0.8) | + + + + ```yaml -# default lightning_threshold: motion: - # Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection - # needs to recalibrate. (default: shown below) - # Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion. - # Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching - # a doorbell camera. + # Optional: The percentage of the image used to detect lightning or + # other substantial changes where motion detection needs to + # recalibrate. (default: shown below) + # Increasing this value will make motion detection more likely + # to consider lightning or IR mode changes as valid motion. + # Decreasing this value will make motion detection more likely + # to ignore large amounts of motion such as a person + # approaching a doorbell camera. lightning_threshold: 0.8 ``` + + + +Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. `lightning_threshold` defines the percentage of the image used to detect these substantial changes. Increasing this value makes motion detection more likely to treat large changes (like IR mode switches) as valid motion. Decreasing it makes motion detection more likely to ignore large amounts of motion, such as a person approaching a doorbell camera. + +Note that `lightning_threshold` does **not** stop motion-based recordings from being saved. It only prevents additional motion analysis after the threshold is exceeded, reducing false positive object detections during high-motion periods (e.g. storms or PTZ sweeps) without interfering with recordings. + :::warning -Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed. +Some cameras, like doorbell cameras, may have missed detections when someone walks directly in front of the camera and the `lightning_threshold` causes motion detection to recalibrate. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed. ::: -:::note +### Skip Motion On Large Scene Changes -Lightning threshold does not stop motion based recordings from being saved. + + + +Navigate to and expand the advanced fields to find the skip motion threshold setting. + +To override for a specific camera, navigate to and select the camera. + +| Field | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Skip motion threshold** | Fraction of the frame that must change in a single update before Frigate will completely ignore any motion in that frame. Values range between 0.0 and 1.0; leave unset (null) to disable. For example, setting this to 0.7 causes Frigate to skip reporting motion boxes when more than 70% of the image appears to change (e.g. during lightning storms, IR/color mode switches, or other sudden lighting events). | + + + + +```yaml +motion: + # Optional: Fraction of the frame that must change in a single update + # before Frigate will completely ignore any motion in that frame. + # Values range between 0.0 and 1.0, leave unset (null) to disable. + # Setting this to 0.7 would cause Frigate to **skip** reporting + # motion boxes when more than 70% of the image appears to change + # (e.g. during lightning storms, IR/color mode switches, or other + # sudden lighting events). + skip_motion_threshold: 0.7 +``` + + + + +This option is handy when you want to prevent large transient changes from triggering recordings or object detection. It differs from `lightning_threshold` because it completely suppresses motion instead of just forcing a recalibration. + +:::warning + +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. ::: -Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera. +## Reviewing Detected Motion + +To review what the detector picked up, or to search past recordings for motion in a specific region, see [Reviewing Motion](/usage/review#reviewing-motion) on the Review page. diff --git a/docs/docs/configuration/notifications.md b/docs/docs/configuration/notifications.md index b5e1600e4b..67b4f2374d 100644 --- a/docs/docs/configuration/notifications.md +++ b/docs/docs/configuration/notifications.md @@ -3,30 +3,53 @@ id: notifications title: Notifications --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; + # Notifications Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption. +:::info + +Push notifications require internet access from the Frigate server to the browser vendor's push service (e.g., Google FCM, Mozilla autopush). See [Network Requirements](/frigate/network_requirements#push-notifications) for details. + +::: + ## Setting up Notifications In order to use notifications the following requirements must be met: -- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)). +- Frigate must be accessed via a secure `https` connection while signed in as a Frigate user ([see the authorization docs](/configuration/authentication)). - A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported. - In order for notifications to be usable externally, Frigate must be accessible externally. - For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features. ### Configuration -To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save. +Enable notifications and fill out the required fields. -Optionally, you can change the default cooldown period for notifications through the `cooldown` parameter in your config file. This parameter can also be overridden at the camera level. +Optionally, change the default cooldown period for notifications. The cooldown can also be overridden at the camera level. Notifications will be prevented if either: - The global cooldown period hasn't elapsed since any camera's last notification - The camera-specific cooldown period hasn't elapsed for the specific camera +#### Global notifications + + + + +1. Navigate to . + - Set **Email** to your email address + - Enable notifications for the desired cameras + + + + ```yaml notifications: enabled: True @@ -34,6 +57,21 @@ notifications: cooldown: 10 # wait 10 seconds before sending another notification from any camera ``` + + + +#### Per-camera notifications + + + + +1. Navigate to and select the desired camera. + - Set **Enable notifications** to on + - Set **Cooldown period** to the desired number of seconds to wait before sending another notification from this camera (e.g. `30`) + + + + ```yaml cameras: doorbell: @@ -43,9 +81,18 @@ cameras: cooldown: 30 # wait 30 seconds before sending another notification from the doorbell camera ``` + + + ### Registration -Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent. +Once notifications are enabled, press the `Register This Device` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent. + +:::warning + +Each registration is attached to the Frigate user account you are signed in as, so you must register over a secure connection to the authenticated port (`8971`). Reverse proxies and tunnels should point at port `8971`. + +::: ## Supported Notifications @@ -64,3 +111,62 @@ Different platforms handle notifications differently, some settings changes may ### Android Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well. + +## Notifications FAQ + + + +Push notifications involve Frigate, your browser, and your browser vendor's push service, so it helps to work from the server outward. + +1. Enable debug logs for the push client by adding `frigate.comms.webpush: debug` to your `logger` configuration. Restart Frigate after this change. + + ```yaml + logger: + default: info + logs: + # highlight-next-line + frigate.comms.webpush: debug + ``` + + These logs show exactly where a notification stopped, including: + - `Email must be provided for push notifications to be sent` means the global `email` field is empty and nothing will ever be sent. + - `Sending test notification` and `Sending push notification for , review ID ` mean Frigate handed the message off to the push service. + - `Skipping notification for - in global cooldown period` (or `camera-specific cooldown period`) means your [cooldown](#configuration) values suppressed it. + - `Notifications for are currently suspended` means notifications were suspended from or MQTT. + - `Notification endpoint expired for , received 410` means that device's subscription is no longer valid and it must be re-registered. + - `Failed to send notification to :: ` means the push service rejected the message. A `401` or `403` usually points at a VAPID or `email` problem, and a `5xx` is a problem on the push service's end. + - If you see no messages at all when an alert occurs, the notification was never queued. Confirm an actual **alert** was created (notifications are not sent for detections), and that notifications are enabled both globally and for that camera. + +2. Verify the basics that most reports come down to: + - Frigate must be reached over `https` with a certificate your device trusts. Browsers silently refuse to register a service worker otherwise, and a self-signed certificate that is not installed as trusted on the device will fail. + - On iOS, notifications only work when Frigate has been installed to the Home Screen via **Share > Add to Home Screen** and opened from that icon. Safari and Chrome tabs cannot receive web push on iOS. + - Each device must be registered individually, and Frigate must be restarted after registering before anything can be sent, including test notifications. + - The Frigate server needs outbound internet access to the browser vendor's push service. See [Network Requirements](/frigate/network_requirements#push-notifications). + +3. Test from the UI. Use the `Send a test notification` button in . If the log shows `Sending test notification` but nothing arrives on the device, the problem is between the push service and your device rather than in Frigate. + +4. Check the browser side on the device that is not receiving notifications: + - Confirm the site's notification permission is set to **Allow** in your browser or OS settings, and that a focus/do not disturb mode is not hiding them. + - In desktop browsers, open Developer Tools > Application > Service Workers and confirm `notifications-worker.js` is registered and activated. Unregistering it and registering the device again will rebuild a broken subscription. + - Check the browser console and your reverse proxy logs for failures loading `/notifications-worker.js` or errors on `/api/notifications/register`. + + + + + +Push subscriptions are issued by the browser vendor and can be revoked, most often after a browser update, after clearing site data, or when a device has been offline for an extended period. When this happens the device still appears registered in Frigate, but the push service rejects the message. The debug logs will show `Notification endpoint expired` with a `404` or `410` status. + +Unregister and re-register the affected device from , then restart Frigate. + + + + + +Work through these in order: + +- Notifications are only sent for **alerts**. If the camera is producing detections instead, adjust the camera's `review > alerts > labels` so the objects you care about are classified as alerts. +- Confirm notifications are enabled for that camera in . +- Check the camera's `cooldown` value, and remember that the global cooldown applies across all cameras. A busy camera can consume the global cooldown and suppress a quieter one. +- If [authentication](/configuration/authentication) is enabled with roles, users only receive notifications for the cameras their role grants access to. + + diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 4a0f014d49..602ebfcb85 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -4,11 +4,19 @@ title: Object Detectors --- import CommunityBadge from '@site/src/components/CommunityBadge'; +import ConfigTabs from '@site/src/components/ConfigTabs'; +import TabItem from '@theme/TabItem'; +import NavPath from '@site/src/components/NavPath'; +import ModelConfigDropdown from '@site/src/components/ModelConfigDropdown'; +import objectDetectorsModels from '@site/data/object_detectors_models.yaml'; -# Supported Hardware +### Supported hardware + +Object detection is what allows Frigate to identify _what_ is in your camera's view (people, cars, animals, and more) rather than just reacting to pixel changes. When Frigate's motion detection finds activity in a frame, that region is sent to an **object detector**, which returns the objects it recognizes along with their location and a confidence score. These detections are what drive tracked objects, alerts, detections, and notifications. + +Object detection is computationally intensive, so Frigate is designed to run it on a dedicated AI accelerator or GPU rather than the CPU. A **detector** is the specific hardware-and-model backend Frigate uses to run inference. Choosing a detector that matches your hardware is one of the most important steps in getting good performance, and the right choice depends on what device Frigate is running on. :::info - Frigate supports multiple different detectors that work on different types of hardware: **Most Hardware** @@ -16,7 +24,6 @@ Frigate supports multiple different detectors that work on different types of ha - [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices. - [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. - [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms. -- [DeGirum](#degirum): Service for using hardware devices in the cloud or locally. Hardware and models provided on the cloud on [their website](https://hub.degirum.com). **AMD** @@ -49,6 +56,10 @@ Frigate supports multiple different detectors that work on different types of ha - [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs. +**AXERA** + +- [AXEngine](#axera): axmodels can run on AXERA AI acceleration. + **For Testing** - [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results. @@ -63,6 +74,22 @@ This does not affect using hardware for accelerating other tasks such as [semant ::: +### Choosing a model size + +Along with picking a detector for your hardware, you will choose a model's **input resolution** (such as `320x320` or `640x640`) and, for model families like YOLOv9, a **variant size** (`tiny`, `small`, etc.). Both affect the balance between accuracy and the inference time your hardware can sustain. + +**Resolution (320x320 vs 640x640):** Frigate is optimized for `320x320` models, and `320x320` is the best choice for the vast majority of setups. Frigate is specifically designed to compensate for the smaller model by cropping a region of motion from the full frame and zooming into it before running detection, so a `320x320` model is actually _better_ at small and distant objects, not worse. A `640x640` model is slower and uses more resources, and its main benefit is fitting more objects into a single inference when many objects are spread across a large area. Recent versions of Frigate have improved support for `640x640` models, but `320x320` remains the recommended starting point for nearly all setups. + +**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras. + +**Acceptable inference time depends on your hardware.** Inference time alone does not tell the whole story, because different hardware has different capacity. A GPU can run multiple instances of the same model concurrently, so an inference time around 30ms can still keep up with several cameras. A Google Coral runs only a single instance of the model, so it needs a much lower inference time (around 10ms) to keep up. + +:::tip + +The best detection accuracy comes from a model trained on images that look like what Frigate actually sees: security camera footage cropped to regions of interest. You can train or fine-tune your own model on images like this and run it as a custom model (see the per-detector sections below), but [Frigate+](/plus) makes this much easier by handling the training for you on images submitted from your own cameras. For YOLOv9, the `s` (small) variant at `320x320` resolution is a good place to start. + +::: + # Officially Supported Detectors Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. @@ -81,6 +108,14 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg ### Single USB Coral + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. + + + + ```yaml detectors: coral: @@ -88,8 +123,19 @@ detectors: device: usb ``` + + + ### Multiple USB Corals + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each. + + + + ```yaml detectors: coral1: @@ -100,10 +146,21 @@ detectors: device: usb:1 ``` + + + ### Native Coral (Dev Board) _warning: may have [compatibility issues](https://github.com/blakeblackshear/frigate/issues/1706) after `v0.9.x`_ + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty. + + + + ```yaml detectors: coral: @@ -111,8 +168,19 @@ detectors: device: "" ``` + + + ### Single PCIE/M.2 Coral + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`. + + + + ```yaml detectors: coral: @@ -120,8 +188,19 @@ detectors: device: pci ``` + + + ### Multiple PCIE/M.2 Corals + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each. + + + + ```yaml detectors: coral1: @@ -132,8 +211,19 @@ detectors: device: pci:1 ``` + + + ### Mixing Corals + + + +Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`). + + + + ```yaml detectors: coral_usb: @@ -144,49 +234,12 @@ detectors: device: pci ``` -### EdgeTPU Supported Models + + -| Model | Notes | -| ----------------------- | ------------------------------------------- | -| [Mobiledet](#mobiledet) | Default model | -| [YOLOv9](#yolov9) | More accurate but slower than default model | +### Configuration {#configuration-edgetpu} -#### Mobiledet - -A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`. - -#### YOLOv9 - -YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are supported, but not included by default. [Instructions](#yolov9-for-google-coral-support) for downloading a model with support for the Google Coral. - -:::tip - -**Frigate+ Users:** Follow the [instructions](/integrations/plus#use-models) to set a model ID in your config file. - -::: - -
- YOLOv9 Setup & Config - -After placing the downloaded files for the tflite model and labels in your config folder, you can use the following configuration: - -```yaml -detectors: - coral: - type: edgetpu - device: usb - -model: - model_type: yolo-generic - width: 320 # <--- should match the imgsize of the model, typically 320 - height: 320 # <--- should match the imgsize of the model, typically 320 - path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite - labelmap_path: /config/labels-coco17.txt -``` - -Note that due to hardware limitations of the Coral, the labelmap is a subset of the COCO labels and includes only 17 object classes. - -
+ --- @@ -194,97 +247,20 @@ Note that due to hardware limitations of the Coral, the labelmap is a subset of This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleration Modules. The integration automatically detects your hardware architecture via the Hailo CLI and selects the appropriate default model if no custom model is specified. -See the [installation docs](../frigate/installation.md#hailo-8l) for information on configuring the Hailo hardware. +See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware. -### Configuration +:::info + +If no custom model is provided, the Hailo detector downloads a default model from the Hailo Model Zoo on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details. + +::: + +### Configuration {#configuration-hailo} When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**. If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`. -#### YOLO - -Use this configuration for YOLO-based models. When no custom model path or URL is provided, the detector automatically downloads the default model based on the detected hardware: - -- **Hailo-8 hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) -- **Hailo-8L hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) - -```yaml -detectors: - hailo: - type: hailo8l - device: PCIe - -model: - width: 320 - height: 320 - input_tensor: nhwc - input_pixel_format: rgb - input_dtype: int - model_type: yolo-generic - labelmap_path: /labelmap/coco-80.txt - - # The detector automatically selects the default model based on your hardware: - # - For Hailo-8 hardware: YOLOv6n (default: yolov6n.hef) - # - For Hailo-8L hardware: YOLOv6n (default: yolov6n.hef) - # - # Optionally, you can specify a local model path to override the default. - # If a local path is provided and the file exists, it will be used instead of downloading. - # Example: - # path: /config/model_cache/hailo/yolov6n.hef - # - # You can also override using a custom URL: - # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef - # just make sure to give it the write configuration based on the model -``` - -#### SSD - -For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. - -```yaml -detectors: - hailo: - type: hailo8l - device: PCIe - -model: - width: 300 - height: 300 - input_tensor: nhwc - input_pixel_format: rgb - model_type: ssd - # Specify the local model path (if available) or URL for SSD MobileNet v1. - # Example with a local path: - # path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef - # - # Or override using a custom URL: - # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/ssd_mobilenet_v1.hef -``` - -#### Custom Models - -The Hailo detector supports all YOLO models compiled for Hailo hardware that include post-processing. You can specify a custom URL or a local path to download or use your model directly. If both are provided, the detector checks the local path first. - -```yaml -detectors: - hailo: - type: hailo8l - device: PCIe - -model: - width: 640 - height: 640 - input_tensor: nhwc - input_pixel_format: rgb - input_dtype: int - model_type: yolo-generic - labelmap_path: /labelmap/coco-80.txt - # Optional: Specify a local model path. - # path: /config/model_cache/hailo/custom_model.hef - # - # Alternatively, or as a fallback, provide a custom URL: - # path: https://custom-model-url.com/path/to/model.hef -``` + For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo @@ -321,268 +297,42 @@ detectors: ::: -### OpenVINO Supported Models +### Intel NPU host requirements {#intel-npu-requirements} -| Model | GPU | NPU | Notes | -| ------------------------------------- | --- | --- | ------------------------------------------------------------ | -| [YOLOv9](#yolo-v3-v4-v7-v9) | ✅ | ✅ | Recommended for GPU & NPU | -| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc | -| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | | -| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models | -| [YOLOX](#yolox) | ✅ | ? | | -| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | | +The NPU firmware is loaded by the host kernel and is not part of the Frigate image. Everything else the NPU needs is bundled in the container, so host NPU libraries should never be mounted in. -#### SSDLite MobileNet v2 +Frigate bundles a specific version of Intel's [linux-npu-driver](https://github.com/intel/linux-npu-driver/releases), and the host firmware must come from that release or a newer one. Firmware older than the bundled driver may fail with `MAPPED_INFERENCE_VERSION is NOT compatible with the ELF`, where `Expected` is the version the firmware supports and `received` is the version the bundled compiler produced. Distributions often package older firmware than the driver Frigate ships, so check the build date on the host with `sudo dmesg | grep -i vpu` and update it there if needed. -An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model. +Intel NPUs cannot be used under Home Assistant OS, which does not include the NPU firmware. -
- MobileNet v2 Config +### Configuration {#configuration-openvino} -Use the model configuration shown below when using the OpenVINO detector with the default OpenVINO model: + -```yaml -detectors: - ov: - type: openvino - device: GPU # Or NPU - -model: - width: 300 - height: 300 - input_tensor: nhwc - input_pixel_format: bgr - path: /openvino-model/ssdlite_mobilenet_v2.xml - labelmap_path: /openvino-model/coco_91cl_bkgr.txt -``` - -
- -#### YOLOX - -This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models. - -#### YOLO-NAS - -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. - -
- YOLO-NAS Setup & Config - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - ov: - type: openvino - device: GPU - -model: - model_type: yolonas - width: 320 # <--- should match whatever was set in notebook - height: 320 # <--- should match whatever was set in notebook - input_tensor: nchw - input_pixel_format: bgr - path: /config/yolo_nas_s.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -
- -#### YOLO (v3, v4, v7, v9) - -YOLOv3, YOLOv4, YOLOv7, and [YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. - -:::tip - -The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv9 models, but may support other YOLO model architectures as well. - -::: - -
- YOLOv Setup & Config - -:::warning - -If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model. - -::: - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - ov: - type: openvino - device: GPU # or NPU - -model: - model_type: yolo-generic - width: 320 # <--- should match the imgsize set during model export - height: 320 # <--- should match the imgsize set during model export - input_tensor: nchw - input_dtype: float - path: /config/model_cache/yolo.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -
- -#### RF-DETR - -[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more informatoin on downloading the RF-DETR model for use in Frigate. - -:::warning - -Due to the size and complexity of the RF-DETR model, it is only recommended to be run with discrete Arc Graphics Cards. - -::: - -
- RF-DETR Setup & Config - -After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: - -```yaml -detectors: - ov: - type: openvino - device: GPU - -model: - model_type: rfdetr - width: 320 - height: 320 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/rfdetr.onnx -``` - -
- -#### D-FINE / DEIMv2 - -[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate. - -:::warning - -Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model - -::: - -
- D-FINE Setup & Config - -After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration: - -```yaml -detectors: - ov: - type: openvino - device: CPU - -model: - model_type: dfine - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/dfine-s.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -
- -
- DEIMv2 Setup & Config - -After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: - -```yaml -detectors: - ov: - type: openvino - device: CPU - -model: - model_type: dfine - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/deimv2_hgnetv2_n.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -
+--- ## Apple Silicon detector The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`. -### Setup +### Setup {#setup-apple-silicon} 1. Setup the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) and run the client 2. Configure the detector in Frigate and startup Frigate -### Configuration +### Configuration {#configuration-apple-silicon} Using the detector config below will connect to the client: -```yaml -detectors: - apple-silicon: - type: zmq - endpoint: tcp://host.docker.internal:5555 -``` - -### Apple Silicon Supported Models - -There is no default model provided, the following formats are supported: - -#### YOLO (v3, v4, v7, v9) - -YOLOv3, YOLOv4, YOLOv7, and [YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. - -:::tip - -The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv9 models, but may support other YOLO model architectures as well. See [the models section](#downloading-yolo-models) for more information on downloading YOLO models for use in Frigate. - -::: - -When Frigate is started with the following config it will connect to the detector client and transfer the model automatically: - -```yaml -detectors: - apple-silicon: - type: zmq - endpoint: tcp://host.docker.internal:5555 - -model: - model_type: yolo-generic - width: 320 # <--- should match the imgsize set during model export - height: 320 # <--- should match the imgsize set during model export - input_tensor: nchw - input_dtype: float - path: /config/model_cache/yolo.onnx - labelmap_path: /labelmap/coco-80.txt -``` + Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. ## AMD/ROCm GPU detector -### Setup +### Setup {#setup-rocm} -Support for AMD GPUs is provided using the [ONNX detector](#ONNX). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. +Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. ### Docker settings for GPU access @@ -658,7 +408,7 @@ We unset the `HSA_OVERRIDE_GFX_VERSION` to prevent an existing override from mes $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/rocm/bin/rocminfo |grep gfx)' ``` -### ROCm Supported Models +### Configuration {#configuration-rocm} :::tip @@ -671,11 +421,13 @@ The AMD GPU kernel is known problematic especially when converting models to mxr ::: -See [ONNX supported models](#supported-models) for supported models, there are some caveats: +See [ONNX supported models](#onnx) for supported models, there are some caveats: - D-FINE / DEIMv2 models are not supported - YOLO-NAS models are known to not run well on integrated GPUs + + ## ONNX ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available. @@ -710,192 +462,11 @@ detectors: ::: -### ONNX Supported Models +### Configuration {#configuration-onnx} -| Model | Nvidia GPU | AMD GPU | Notes | -| ----------------------------- | ---------- | ------- | --------------------------------------------------- | -| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | -| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance | -| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs | -| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | -| [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs | + -There is no default model provided, the following formats are supported: - -#### YOLO-NAS - -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. - -
- YOLO-NAS Setup & Config - -:::warning - -If you are using a Frigate+ YOLO-NAS model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model. - -::: - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: yolonas - width: 320 # <--- should match whatever was set in notebook - height: 320 # <--- should match whatever was set in notebook - input_pixel_format: bgr - input_tensor: nchw - path: /config/yolo_nas_s.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -
- -#### YOLO (v3, v4, v7, v9) - -YOLOv3, YOLOv4, YOLOv7, and [YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. - -:::tip - -The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv9 models, but may support other YOLO model architectures as well. See [the models section](#downloading-yolo-models) for more information on downloading YOLO models for use in Frigate. - -::: - -
- YOLOv Setup & Config - -:::warning - -If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model. - -::: - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: yolo-generic - width: 320 # <--- should match the imgsize set during model export - height: 320 # <--- should match the imgsize set during model export - input_tensor: nchw - input_dtype: float - path: /config/model_cache/yolo.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -
- -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -#### YOLOx - -[YOLOx](https://github.com/Megvii-BaseDetection/YOLOX) models are supported, but not included by default. See [the models section](#downloading-yolo-models) for more information on downloading the YOLOx model for use in Frigate. - -
- YOLOx Setup & Config - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: yolox - width: 416 # <--- should match the imgsize set during model export - height: 416 # <--- should match the imgsize set during model export - input_tensor: nchw - input_dtype: float_denorm - path: /config/model_cache/yolox_tiny.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. - -
- -#### RF-DETR - -[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more information on downloading the RF-DETR model for use in Frigate. - -
- RF-DETR Setup & Config - -After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: rfdetr - width: 320 - height: 320 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/rfdetr.onnx -``` - -
- -#### D-FINE / DEIMv2 - -[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate. - -
- D-FINE Setup & Config - -After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: dfine - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/dfine_m_obj2coco.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -
- -
- DEIMv2 Setup & Config - -After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: - -```yaml -detectors: - onnx: - type: onnx - -model: - model_type: dfine - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float - path: /config/model_cache/deimv2_hgnetv2_n.onnx - labelmap_path: /labelmap/coco-80.txt -``` - -
- -Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. +--- ## CPU Detector (not recommended) @@ -911,18 +482,9 @@ The number of threads used by the interpreter can be specified using the `"num_t A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`. -```yaml -detectors: - cpu1: - type: cpu - num_threads: 3 - cpu2: - type: cpu - num_threads: 3 +### Configuration {#configuration-cpu} -model: - path: "/custom_model.tflite" -``` + When using CPU detectors, you can add one CPU detector per camera. Adding more detectors than the number of cameras should not improve performance. @@ -930,19 +492,15 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d The Deepstack / CodeProject.AI Server detector for Frigate allows you to integrate Deepstack and CodeProject.AI object detection capabilities into Frigate. CodeProject.AI and DeepStack are open-source AI platforms that can be run on various devices such as the Raspberry Pi, Nvidia Jetson, and other compatible hardware. It is important to note that the integration is performed over the network, so the inference times may not be as fast as native Frigate detectors, but it still provides an efficient and reliable solution for object detection and tracking. -### Setup +### Setup {#setup-deepstack} To get started with CodeProject.AI, visit their [official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to follow the instructions to download and install the AI server on your preferred device. Detailed setup instructions for CodeProject.AI are outside the scope of the Frigate documentation. -To integrate CodeProject.AI into Frigate, you'll need to make the following changes to your Frigate configuration file: +To integrate CodeProject.AI into Frigate, configure the detector as follows: -```yaml -detectors: - deepstack: - api_url: http://:/v1/vision/detection - type: deepstack - api_timeout: 0.1 # seconds -``` +### Configuration {#configuration-deepstack} + + Replace `` and `` with the IP address and port of your CodeProject.AI server. @@ -958,155 +516,9 @@ See the [installation docs](../frigate/installation.md#memryx-mx3) for informati To configure a MemryX detector, simply set the `type` attribute to `memryx` and follow the configuration guide below. -### Configuration +### Configuration {#configuration-memryx} -To configure the MemryX detector, use the following example configuration: - -#### Single PCIe MemryX MX3 - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 -``` - -#### Multiple PCIe MemryX MX3 Modules - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 - - memx1: - type: memryx - device: PCIe:1 - - memx2: - type: memryx - device: PCIe:2 -``` - -### Supported Models - -MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. - -#### YOLO-NAS - -The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage). - -**Note:** The default model for the MemryX detector is YOLO-NAS 320x320. - -The input size for **YOLO-NAS** can be set to either **320x320** (default) or **640x640**. - -- The default size of **320x320** is optimized for lower CPU usage and faster inference times. - -##### Configuration - -Below is the recommended configuration for using the **YOLO-NAS** (small) model with the MemryX detector: - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 - -model: - model_type: yolonas - width: 320 # (Can be set to 640 for higher resolution) - height: 320 # (Can be set to 640 for higher resolution) - input_tensor: nchw - input_dtype: float - labelmap_path: /labelmap/coco-80.txt - # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. - # path: /config/yolonas.zip - # The .zip file must contain: - # ├── yolonas.dfp (a file ending with .dfp) - # └── yolonas_post.onnx (optional; only if the model includes a cropped post-processing network) -``` - -#### YOLOv9 - -The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage). - -##### Configuration - -Below is the recommended configuration for using the **YOLOv9** (small) model with the MemryX detector: - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 - -model: - model_type: yolo-generic - width: 320 # (Can be set to 640 for higher resolution) - height: 320 # (Can be set to 640 for higher resolution) - input_tensor: nchw - input_dtype: float - labelmap_path: /labelmap/coco-80.txt - # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. - # path: /config/yolov9.zip - # The .zip file must contain: - # ├── yolov9.dfp (a file ending with .dfp) -``` - -#### YOLOX - -The model is sourced from the [OpenCV Model Zoo](https://github.com/opencv/opencv_zoo) and precompiled to DFP. - -##### Configuration - -Below is the recommended configuration for using the **YOLOX** (small) model with the MemryX detector: - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 - -model: - model_type: yolox - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float_denorm - labelmap_path: /labelmap/coco-80.txt - # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. - # path: /config/yolox.zip - # The .zip file must contain: - # ├── yolox.dfp (a file ending with .dfp) -``` - -#### SSDLite MobileNet v2 - -The model is sourced from the [OpenMMLab Model Zoo](https://mmdeploy-oss.openmmlab.com/model/mmdet-det/ssdlite-e8679f.onnx) and has been converted to DFP. - -##### Configuration - -Below is the recommended configuration for using the **SSDLite MobileNet v2** model with the MemryX detector: - -```yaml -detectors: - memx0: - type: memryx - device: PCIe:0 - -model: - model_type: ssd - width: 320 - height: 320 - input_tensor: nchw - input_dtype: float - labelmap_path: /labelmap/coco-80.txt - # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. - # path: /config/ssdlite_mobilenet.zip - # The .zip file must contain: - # ├── ssdlite_mobilenet.dfp (a file ending with .dfp) - # └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network) -``` + #### Using a Custom Model @@ -1227,20 +639,7 @@ The TensorRT detector uses `.trt` model files that are located in `/config/model Use the config below to work with generated TRT models: -```yaml -detectors: - tensorrt: - type: tensorrt - device: 0 #This is the default, select the first GPU - -model: - path: /config/model_cache/tensorrt/yolov7-320.trt - labelmap_path: /labelmap/coco-80.txt - input_tensor: nchw - input_pixel_format: rgb - width: 320 # MUST match the chosen model i.e yolov7-320 -> 320, yolov4-416 -> 416 - height: 320 # MUST match the chosen model i.e yolov7-320 -> 320 yolov4-416 -> 416 -``` + ## Synaptics @@ -1254,28 +653,11 @@ This implementation is based on sdk `v1.5.0`. See the [installation docs](../frigate/installation.md#synaptics) for information on configuring the SL-series NPU hardware. -### Configuration +### Configuration {#configuration-synaptics} When configuring the Synap detector, you have to specify the model: a local **path**. -#### SSD Mobilenet - -A synap model is provided in the container at /mobilenet.synap and is used by this detector type by default. The model comes from [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80). - -Use the model configuration shown below when using the synaptics detector with the default synap model: - -```yaml -detectors: # required - synap_npu: # required - type: synaptics # required - -model: # required - path: /synaptics/mobilenet.synap # required - width: 224 # required - height: 224 # required - tensor_format: nhwc # default value (optional. If you change the model, it is required) - labelmap_path: /labelmap/coco-80.txt # required -``` + ## Rockchip platform @@ -1289,6 +671,12 @@ Hardware accelerated object detection is supported on the following SoCs: This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.3.2. +:::info + +If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details. + +::: + :::tip When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming NPU resources are available. An example configuration would be: @@ -1324,16 +712,6 @@ $ cat /sys/kernel/debug/rknpu/load This `config.yml` shows all relevant options to configure the detector and explains them. All values shown are the default values (except for two). Lines that are required at least to use the detector are labeled as required, all other lines are optional. -```yaml -detectors: # required - rknn: # required - type: rknn # required - # number of NPU cores to use - # 0 means choose automatically - # increase for better performance if you have a multicore NPU e.g. set to 3 on rk3588 - num_cores: 0 -``` - The inference time was determined on a rk3588 with 3 NPU cores. | Model | Size in mb | Inference time in ms | @@ -1348,75 +726,13 @@ The inference time was determined on a rk3588 with 3 NPU cores. - All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. - You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models. -#### YOLO-NAS - -```yaml -model: # required - # name of model (will be automatically downloaded) or path to your own .rknn model file - # possible values are: - # - deci-fp16-yolonas_s - # - deci-fp16-yolonas_m - # - deci-fp16-yolonas_l - # your yolonas_model.rknn - path: deci-fp16-yolonas_s - model_type: yolonas - width: 320 - height: 320 - input_pixel_format: bgr - input_tensor: nhwc - labelmap_path: /labelmap/coco-80.txt -``` - -:::warning - -The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html - -::: - -#### YOLO (v9) - -```yaml -model: # required - # name of model (will be automatically downloaded) or path to your own .rknn model file - # possible values are: - # - frigate-fp16-yolov9-t - # - frigate-fp16-yolov9-s - # - frigate-fp16-yolov9-m - # - frigate-fp16-yolov9-c - # - frigate-fp16-yolov9-e - # your yolo_model.rknn - path: frigate-fp16-yolov9-t - model_type: yolo-generic - width: 320 - height: 320 - input_tensor: nhwc - labelmap_path: /labelmap/coco-80.txt -``` - -#### YOLOx - -```yaml -model: # required - # name of model (will be automatically downloaded) or path to your own .rknn model file - # possible values are: - # - rock-i8-yolox_nano - # - rock-i8-yolox_tiny - # - rock-fp16-yolox_nano - # - rock-fp16-yolox_tiny - # your yolox_model.rknn - path: rock-i8-yolox_nano - model_type: yolox - width: 416 - height: 416 - input_tensor: nhwc - labelmap_path: /labelmap/coco-80.txt -``` + ### Converting your own onnx model to rknn format To convert a onnx model to the rknn format using the [rknn-toolkit2](https://github.com/airockchip/rknn-toolkit2/) you have to: -- Place one ore more models in onnx format in the directory `config/model_cache/rknn_cache/onnx` on your docker host (this might require `sudo` privileges). +- Place one or more models in onnx format in the directory `config/model_cache/rknn_cache/onnx` on your docker host (this might require `sudo` privileges). - Save the configuration file under `config/conv2rknn.yaml` (see below for details). - Run `docker exec python3 /opt/conv2rknn.py`. If the conversion was successful, the rknn models will be placed in `config/model_cache/rknn_cache`. @@ -1434,267 +750,37 @@ config: quant_img_RGB2BGR: true ``` -Explanation of the paramters: +Explanation of the parameters: - `soc`: A list of all SoCs you want to build the rknn model for. If you don't specify this parameter, the script tries to find out your SoC and builds the rknn model for this one. - `quantization`: true: 8 bit integer (i8) quantization, false: 16 bit float (fp16). Default: false. - `output_name`: The output name of the model. The following variables are available: - `quant`: "i8" or "fp16" depending on the config - - `input_basename`: the basename of the input model (e.g. "my_model" if the input model is calles "my_model.onnx") + - `input_basename`: the basename of the input model (e.g. "my_model" if the input model is called "my_model.onnx") - `soc`: the SoC this model was build for (e.g. "rk3588") - `tk_version`: Version of `rknn-toolkit2` (e.g. "2.3.0") - **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`. - `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf). -## DeGirum +## AXERA -DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes. +Hardware accelerated object detection is supported on the following SoCs: -### Configuration +- AX650N +- AX8850N -#### AI Server Inference +This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2). -Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started: +See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware. -```yaml -degirum_detector: - container_name: degirum - image: degirum/aiserver:latest - privileged: true - ports: - - "8778:8778" -``` +:::info -All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble. - -Once completed, changing the `config.yml` file is simple. - -```yaml -degirum_detector: - type: degirum - location: degirum # Set to service name (degirum_detector), container_name (degirum), or a host:port (192.168.29.4:8778) - zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. If you aren't pulling a model from the AI Hub, leave this and 'token' blank. - token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the [AI Hub](https://hub.degirum.com). This can be left blank if you're pulling a model from the public zoo and running inferences on your local hardware using @local or a local DeGirum AI Server -``` - -Setting up a model in the `config.yml` is similar to setting up an AI server. -You can set it to: - -- A model listed on the [AI Hub](https://hub.degirum.com), given that the correct zoo name is listed in your detector - - If this is what you choose to do, the correct model will be downloaded onto your machine before running. -- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure). -- A path to some model.json. - -```yaml -model: - path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file - width: 300 # width is in the model name as the first number in the "int"x"int" section - height: 300 # height is in the model name as the second number in the "int"x"int" section - input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here -``` - -#### Local Inference - -It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be: - -1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed. -2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install. -3. Create a DeGirum detector in your `config.yml` file. - -```yaml -degirum_detector: - type: degirum - location: "@local" # For accessing AI Hub devices and models - zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. - token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the [AI Hub](https://hub.degirum.com). This can be left blank if you're pulling a model from the public zoo and running inferences on your local hardware using @local or a local DeGirum AI Server -``` - -Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file. - -```yaml -model: - path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 - width: 300 # width is in the model name as the first number in the "int"x"int" section - height: 300 # height is in the model name as the second number in the "int"x"int" section - input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here -``` - -#### AI Hub Cloud Inference - -If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences, - -1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com). -2. Get an access token. -3. Create a DeGirum detector in your `config.yml` file. - -```yaml -degirum_detector: - type: degirum - location: "@cloud" # For accessing AI Hub devices and models - zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. - token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the (AI Hub)[https://hub.degirum.com). -``` - -Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file. - -```yaml -model: - path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 - width: 300 # width is in the model name as the first number in the "int"x"int" section - height: 300 # height is in the model name as the second number in the "int"x"int" section - input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here -``` - -# Models - -Some model types are not included in Frigate by default. - -## Downloading Models - -Here are some tips for getting different model types - -### Downloading D-FINE Model - -D-FINE can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=s` in the first line to `s`, `m`, or `l` size. - -```sh -docker build . --build-arg MODEL_SIZE=s --output . -f- <<'EOF' -FROM python:3.11 AS build -RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ -WORKDIR /dfine -RUN git clone https://github.com/Peterande/D-FINE.git . -RUN uv pip install --system -r requirements.txt -RUN uv pip install --system onnx onnxruntime onnxsim onnxscript -# Create output directory and download checkpoint -RUN mkdir -p output -ARG MODEL_SIZE -RUN wget https://github.com/Peterande/storage/releases/download/dfinev1.0/dfine_${MODEL_SIZE}_obj2coco.pth -O output/dfine_${MODEL_SIZE}_obj2coco.pth -# Modify line 58 of export_onnx.py to change batch size to 1 -RUN sed -i '58s/data = torch.rand(.*)/data = torch.rand(1, 3, 640, 640)/' tools/deployment/export_onnx.py -RUN python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_${MODEL_SIZE}_obj2coco.yml -r output/dfine_${MODEL_SIZE}_obj2coco.pth -FROM scratch -ARG MODEL_SIZE -COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL_SIZE}.onnx -EOF -``` - -### Downloading DEIMv2 Model - -[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families: - -- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n` -- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x` - -Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`). - -```sh -docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF' -FROM python:3.11-slim AS build -RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ -WORKDIR /deimv2 -RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git . -# Install CPU-only PyTorch first to avoid pulling CUDA variant -RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu -RUN uv pip install --no-cache --system -r requirements.txt -RUN uv pip install --no-cache --system onnx safetensors huggingface_hub -RUN mkdir -p output -ARG BACKBONE -ARG MODEL_SIZE -# Download from Hugging Face and convert safetensors to pth -RUN python3 -c "\ -from huggingface_hub import hf_hub_download; \ -from safetensors.torch import load_file; \ -import torch; \ -backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \ -size = '${MODEL_SIZE}'.upper(); \ -st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \ -torch.save({'model': st}, 'output/deimv2.pth')" -RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py -# HuggingFace safetensors omits frozen constants that the model constructor initializes -RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py -RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth -FROM scratch -ARG BACKBONE -ARG MODEL_SIZE -COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx -EOF -``` - -### Downloading RF-DETR Model - -RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size. - -```sh -docker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF' -FROM python:3.12 AS build -RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ -WORKDIR /rfdetr -RUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript -ARG MODEL_SIZE -RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)" -FROM scratch -ARG MODEL_SIZE -COPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx -EOF -``` - -### Downloading YOLO-NAS Model - -You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). - -:::warning - -The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html +The AXEngine detector downloads its default model from HuggingFace on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details. ::: -The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. +### Configuration {#configuration-axengine} -### Downloading YOLO Models +When configuring the AXEngine detector, you have to specify the model name. -#### YOLOx - -YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime). - -#### YOLOv3, YOLOv4, and YOLOv7 - -To export as ONNX: - -```sh -git clone https://github.com/NateMeyer/tensorrt_demos -cd tensorrt_demos/yolo -./download_yolo.sh -python3 yolo_to_onnx.py -m yolov7-320 -``` - -#### YOLOv9 for Google Coral Support - -[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes. - -#### YOLOv9 for other detectors - -YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`). - -```sh -docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF' -FROM python:3.11 AS build -RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/ -WORKDIR /yolov9 -ADD https://github.com/WongKinYiu/yolov9.git . -RUN uv pip install --system -r requirements.txt -RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript -ARG MODEL_SIZE -ARG IMG_SIZE -ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt -RUN sed -i "s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g" models/experimental.py -RUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx -FROM scratch -ARG MODEL_SIZE -ARG IMG_SIZE -COPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx -EOF -``` + diff --git a/docs/docs/configuration/object_filters.md b/docs/docs/configuration/object_filters.md index 3f36086c01..b2e7fca319 100644 --- a/docs/docs/configuration/object_filters.md +++ b/docs/docs/configuration/object_filters.md @@ -3,11 +3,15 @@ id: object_filters title: Filters --- -There are several types of object filters that can be used to reduce false positive rates. +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +There are several types of object filters that can be used to reduce [false positive](/frigate/glossary#false-positive) rates. ## Object Scores -For object filters in your configuration, any single detection below `min_score` will be ignored as a false positive. `threshold` is based on the median of the history of scores (padded to 3 values) for a tracked object. Consider the following frames when `min_score` is set to 0.6 and threshold is set to 0.85: +For object filters, any single detection below `min_score` will be ignored as a false positive. `threshold` is based on the median of the history of scores (padded to 3 values) for a tracked object. Consider the following frames when `min_score` is set to 0.6 and threshold is set to 0.85: | Frame | Current Score | Score History | Computed Score | Detected Object | | ----- | ------------- | --------------------------------- | -------------- | --------------- | @@ -20,13 +24,59 @@ For object filters in your configuration, any single detection below `min_score` In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example. +The **top score** is the highest computed score the tracked object has ever reached during its lifetime. Because the computed score rises and falls as new frames come in, the top score can be thought of as the peak confidence Frigate had in the object. In Frigate's UI (such as the Tracking Details pane in Explore), you may see all three values: + +- **Score**: the raw detector score for that single frame. +- **Computed Score**: the median of the most recent score history at that moment. This is the value compared against `threshold`. +- **Top Score**: the highest computed score reached so far for the tracked object. + ### Minimum Score Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid tracked objects to be lost or disjointed. ### Threshold -`threshold` is used to determine that the object is a true positive. Once an object is detected with a score >= `threshold` object is considered a true positive. If `threshold` is too low then some higher scoring false positives may create an tracked object. If `threshold` is too high then true positive tracked objects may be missed due to the object never scoring high enough. +`threshold` is used to determine that the object is a true positive. Once an object is detected with a score >= `threshold` object is considered a true positive. If `threshold` is too low then some higher scoring false positives may create a tracked object. If `threshold` is too high then true positive tracked objects may be missed due to the object never scoring high enough. + +## Configuring Object Scores + + + + +Navigate to to set score filters globally. + +| Field | Description | +| --------------------------------------- | ---------------------------------------------------------------- | +| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking | +| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive | + +To override score filters for a specific camera, navigate to and select the camera. + + + + +```yaml +objects: + filters: + person: + min_score: 0.5 + threshold: 0.7 +``` + +To override at the camera level: + +```yaml +cameras: + front_door: + objects: + filters: + person: + min_score: 0.5 + threshold: 0.7 +``` + + + ## Object Shape @@ -46,12 +96,56 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a " ::: +### Configuring Shape Filters + + + + +Navigate to to set shape filters globally. + +| Field | Description | +| --------------------------------------- | ------------------------------------------------------------------------ | +| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | +| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | + +To override shape filters for a specific camera, navigate to and select the camera. + + + + +```yaml +objects: + filters: + person: + min_area: 5000 + max_area: 100000 + min_ratio: 0.5 + max_ratio: 2.0 +``` + +To override at the camera level: + +```yaml +cameras: + front_door: + objects: + filters: + person: + min_area: 5000 + max_area: 100000 +``` + + + + ## Other Tools ### Zones -[Required zones](/configuration/zones.md) can be a great tool to reduce false positives that may be detected in the sky or other areas that are not of interest. The required zones will only create tracked objects for objects that enter the zone. +[Required zones](/configuration/zones.md#restricting-alerts-and-detections-to-specific-zones) can be a great tool to reduce false positives that may be detected in the sky or other areas that are not of interest. The required zones will only create tracked objects for objects that enter the zone. ### Object Masks -[Object Filter Masks](/configuration/masks) are a last resort but can be useful when false positives are in the relatively same place but can not be filtered due to their size or shape. +[Object Filter Masks](/configuration/masks#object-filter-masks) are a last resort but can be useful when false positives are in the relatively same place but can not be filtered due to their size or shape. Object filter masks can be configured in . diff --git a/docs/docs/configuration/objects.md b/docs/docs/configuration/objects.md index 796d312581..e5a24ea081 100644 --- a/docs/docs/configuration/objects.md +++ b/docs/docs/configuration/objects.md @@ -3,6 +3,9 @@ id: objects title: Available Objects --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; import labels from "../../../labelmap.txt"; Frigate includes the object labels listed below from the Google Coral test data. @@ -10,7 +13,7 @@ Frigate includes the object labels listed below from the Google Coral test data. Please note: - `car` is listed twice because `truck` has been renamed to `car` by default. These object types are frequently confused. -- `person` is the only tracked object by default. See the [full configuration reference](reference.md) for an example of expanding the list of tracked objects. +- `person` is the only tracked object by default. To track additional objects, configure them in the objects settings.
    {labels.split("\n").map((label) => ( @@ -18,6 +21,135 @@ Please note: ))}
+## Configuring Tracked Objects + +By default, Frigate only tracks `person`. To track additional object types, add them to the tracked objects list. + + + + +1. Navigate to . + - Add the desired object types to the **Objects to track** list (e.g., `person`, `car`, `dog`) + +To override the tracked objects list for a specific camera: + +1. Navigate to . + - Add the desired object types to the **Objects to track** list + + + + +```yaml +objects: + track: + - person + - car + - dog +``` + +To override at the camera level: + +```yaml +cameras: + front_door: + objects: + track: + - person + - car +``` + + + + +## Filtering Objects + +Object filters help reduce false positives by constraining the size, shape, and confidence thresholds for each object type. Filters can be configured globally or per camera. + + + + +Navigate to . + +| Field | Description | +| --------------------------------------- | ------------------------------------------------------------------------ | +| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) | +| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box | +| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box | +| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking | +| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive | + +To override filters for a specific camera, navigate to . + + + + +```yaml +objects: + filters: + person: + min_area: 5000 + max_area: 100000 + min_ratio: 0.5 + max_ratio: 2.0 + min_score: 0.5 + threshold: 0.7 +``` + +To override at the camera level: + +```yaml +cameras: + front_door: + objects: + filters: + person: + min_area: 5000 + threshold: 0.7 +``` + + + + +## Object Filter Masks + +Object filter masks prevent specific object types from being detected in certain areas of the camera frame. These masks check the bottom center of the bounding box. A global mask applies to all object types, while per-object masks apply only to the specified type. + + + + +Navigate to and select a camera. Use the mask editor to draw object filter masks directly on the camera feed. Global object masks and per-object masks can both be configured from this view. + + + + +```yaml +objects: + # Global mask applied to all object types + mask: + mask1: + friendly_name: "Object filter mask area" + enabled: true + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" + # Per-object mask + filters: + person: + mask: + mask1: + friendly_name: "Person filter mask" + enabled: true + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" +``` + + + + +:::note + +The global mask is combined with any object-specific mask. Both are checked based on the bottom center of the bounding box. + +::: + ## Custom Models Models for both CPU and EdgeTPU (Coral) are bundled in the image. You can use your own models with volume mounts: @@ -26,4 +158,4 @@ Models for both CPU and EdgeTPU (Coral) are bundled in the image. You can use yo - EdgeTPU Model: `/edgetpu_model.tflite` - Labels: `/labelmap.txt` -You also need to update the [model config](advanced.md#model) if they differ from the defaults. +You also need to update the [model config](advanced/system.md#model) if they differ from the defaults. diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md new file mode 100644 index 0000000000..87475d74b3 --- /dev/null +++ b/docs/docs/configuration/profiles.md @@ -0,0 +1,258 @@ +--- +id: profiles +title: Profiles +--- + +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Profiles allow you to define named sets of camera configuration overrides that can be activated and deactivated at runtime without restarting Frigate. This is useful for scenarios like switching between "Home" and "Away" modes, daytime and nighttime configurations, or any situation where you want to quickly change how multiple cameras behave. + +## How Profiles Work + +Profiles operate as a two-level system: + +1. **Profile definitions** are declared at the top level of your config under `profiles`. Each definition has a machine name (the key) and a `friendly_name` for display in the UI. +2. **Camera profile overrides** are declared under each camera's `profiles` section, keyed by the profile name. Only the settings you want to change need to be specified. Everything else is inherited from the camera's base configuration. + +When a profile is activated, Frigate merges each camera's profile overrides on top of its base config. When the profile is deactivated, all cameras revert to their original settings. Only one profile can be active at a time. + +:::info + +Profile changes are applied in-memory and take effect immediately. No restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.profiles` file). + +::: + +## Configuration + +The easiest way to define profiles is to use the Frigate UI. Profiles can also be configured manually in your configuration file. + +### Creating and Managing Profiles + + + + +1. **Create a profile**: Navigate to . 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 , 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 , then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it. + + + + +First, define your profiles at the top level of your Frigate config. Every profile name referenced by a camera must be defined here. + +```yaml +profiles: + home: + friendly_name: Home + away: + friendly_name: Away + night: + friendly_name: Night Mode +``` + +Under each camera, add a `profiles` section with overrides for each profile. You only need to include the settings you want to change. + +```yaml +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://camera:554/stream + roles: + - detect + - record + detect: + enabled: true + record: + enabled: true + profiles: + away: + detect: + enabled: true + notifications: + enabled: true + objects: + track: + - person + - car + - package + review: + alerts: + labels: + - person + - car + - package + home: + detect: + enabled: true + notifications: + enabled: false + objects: + track: + - person +``` + + + + +### Supported Override Sections + +The following camera configuration sections can be overridden in a profile: + +| Section | Description | +| ------------------ | ----------------------------------------- | +| `enabled` | Enable or disable the camera entirely | +| `audio` | Audio detection settings | +| `birdseye` | Birdseye view settings | +| `detect` | Object detection settings | +| `face_recognition` | Face recognition settings | +| `lpr` | License plate recognition settings | +| `motion` | Motion detection settings | +| `notifications` | Notification settings | +| `objects` | Object tracking and filter settings | +| `record` | Recording settings | +| `review` | Review alert and detection settings | +| `snapshots` | Snapshot settings | +| `zones` | Zone definitions (merged with base zones) | + +:::note + +Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For masks and zones, profile zones **override** the camera's base masks and zones. If configuring profiles via YAML, you should not define masks or zones in profiles that are not defined in the base config. + +::: + +## Activating Profiles + +Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), the [HTTP API](../integrations/api/camera-set-camera-camera-name-set-feature-sub-command-put.api.mdx), 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. + +Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch. + +## Example: Home / Away Setup + +A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`. + + + + +1. Navigate to 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 or from the **Profiles** option in Frigate's main menu. + + + + +```yaml +profiles: + home: + friendly_name: Home + away: + friendly_name: Away + +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://camera:554/stream + roles: + - detect + - record + detect: + enabled: true + record: + enabled: true + notifications: + enabled: false + profiles: + away: + notifications: + enabled: true + review: + alerts: + labels: + - person + - car + home: + notifications: + enabled: false + + indoor_cam: + ffmpeg: + inputs: + - path: rtsp://camera:554/indoor + roles: + - detect + - record + detect: + enabled: false + record: + enabled: false + profiles: + away: + enabled: true + detect: + enabled: true + record: + enabled: true + home: + enabled: false +``` + + + + +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. + +### How do I make a YAML profile track no objects at all? + +Set the tracked object list explicitly to an empty list in the profile: + +```yaml +cameras: + front_door: + profiles: + home: + objects: + track: [] +``` + +Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`. + +### 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. + +### Can I schedule profiles to be enabled or disabled at certain times? + +Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could. + +If you prefer something lightweight, a simple script driven by a cron job that toggles profiles on a schedule works too. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index e805cc2d7d..93d26d8801 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -3,9 +3,19 @@ id: record title: Recording --- -Recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM-DD/HH//MM.SS.mp4` in **UTC time**. These recordings are written directly from your camera stream without re-encoding. Each camera supports a configurable retention policy in the config. Frigate chooses the largest matching retention value between the recording retention and the tracked object retention when determining if a recording should be removed. +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; -New recording segments are written from the camera stream to cache, they are only moved to disk if they match the setup recording retention policy. +Recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM-DD/HH//MM.SS.mp4` in **UTC time**. These recordings are written directly from your camera stream without re-encoding. Each camera supports a configurable retention policy. Frigate chooses the largest matching retention value between the recording retention and the tracked object retention when determining if a recording should be removed. + +New recording segments are written from the camera stream to cache, they are only moved to disk if they pass a validation check and match the setup recording retention policy. + +:::tip + +To keep a specific clip beyond your retention window, [export](/usage/exports) it rather than increasing retention for the whole camera. Exports are saved separately and are never removed by retention. + +::: H265 recordings can be viewed in Chrome 108+, Edge and Safari only. All other browsers require recordings to be encoded with H264. @@ -13,7 +23,23 @@ H265 recordings can be viewed in Chrome 108+, Edge and Safari only. All other br ### Most conservative: Ensure all video is saved -For users deploying Frigate in environments where it is important to have contiguous video stored even if there was no detectable motion, the following config will store all video for 3 days. After 3 days, only video containing motion will be saved for 7 days. After 7 days, only video containing motion and overlapping with alerts or detections will be retained until 30 days have passed. +For users deploying Frigate in environments where it is important to have contiguous video stored even if there was no detectable motion, the following configuration will store all video for 3 days. After 3 days, only video containing motion will be saved for 7 days. After 7 days, only video containing motion and overlapping with alerts or detections will be retained until 30 days have passed. + + + + +Navigate to . + +- Set **Enable recording** to on +- Set **Continuous retention > Retention days** to `3` +- Set **Motion retention > Retention days** to `7` +- Set **Alert retention > Event retention > Retention days** to `30` +- Set **Alert retention > Event retention > Retention mode** to `all` +- Set **Detection retention > Event retention > Retention days** to `30` +- Set **Detection retention > Event retention > Retention mode** to `all` + + + ```yaml record: @@ -32,9 +58,27 @@ record: mode: all ``` + + + ### Reduced storage: Only saving video when motion is detected -In order to reduce storage requirements, you can adjust your config to only retain video where motion / activity was detected. +To reduce storage requirements, configure recording to only retain video where motion or activity was detected. + + + + +Navigate to . + +- Set **Enable recording** to on +- Set **Motion retention > Retention days** to `3` +- Set **Alert retention > Event retention > Retention days** to `30` +- Set **Alert retention > Event retention > Retention mode** to `motion` +- Set **Detection retention > Event retention > Retention days** to `30` +- Set **Detection retention > Event retention > Retention mode** to `motion` + + + ```yaml record: @@ -51,9 +95,25 @@ record: mode: motion ``` + + + ### Minimum: Alerts only -If you only want to retain video that occurs during activity caused by tracked object(s), this config will discard video unless an alert is ongoing. +If you only want to retain video that occurs during activity caused by tracked object(s), this configuration will discard video unless an alert is ongoing. + + + + +Navigate to . + +- Set **Enable recording** to on +- Set **Continuous retention > Retention days** to `0` +- Set **Alert retention > Event retention > Retention days** to `30` +- Set **Alert retention > Event retention > Retention mode** to `motion` + + + ```yaml record: @@ -66,9 +126,78 @@ record: mode: motion ``` -## Will Frigate delete old recordings if my storage runs out? + + -If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible. +## Pre-capture and Post-capture + +The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera. + + + + +Navigate to for global defaults, or to override for a specific camera. + +| Field | Description | +| ---------------------------------------------- | ---------------------------------------------------- | +| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event | +| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event | +| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event | +| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event | + + + + +```yaml +record: + enabled: True + alerts: + pre_capture: 5 # seconds before the alert to include + post_capture: 5 # seconds after the alert to include + detections: + pre_capture: 5 # seconds before the detection to include + post_capture: 5 # seconds after the detection to include +``` + + + + +- **Default**: 5 seconds for both pre and post capture. +- **Pre-capture maximum**: 60 seconds. +- These settings apply per review category (alerts and detections), not per object type. + +### How pre/post capture interacts with retention mode + +The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk. + +- **`mode: all`**: Retains every segment within the capture window, regardless of whether motion was detected. +- **`mode: motion`** (default): Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range. +- **`mode: active_objects`**: Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded. + +This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion. + +To guarantee the full pre/post capture duration is always retained: + +```yaml +record: + enabled: True + alerts: + pre_capture: 10 + post_capture: 10 + retain: + days: 30 + mode: all # retains all segments within the capture window +``` + +:::note + +Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value. + +::: + +### Where to view pre/post capture footage + +Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk**. They do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there. ## Configuring Recording Retention @@ -82,7 +211,21 @@ Retention configs support decimals meaning they can be configured to retain `0.5 ### Continuous and Motion Recording -The number of days to retain continuous and motion recordings can be set via the following config where X is a number, by default continuous recording is disabled. +The number of days to retain continuous and motion recordings can be configured. By default, continuous recording is disabled. + + + + +Navigate to . + +| Field | Description | +| ----------------------------------------- | -------------------------------------------- | +| **Enable recording** | Enable or disable recording for all cameras | +| **Continuous retention > Retention days** | Number of days to keep continuous recordings | +| **Motion retention > Retention days** | Number of days to keep motion recordings | + + + ```yaml record: @@ -93,11 +236,28 @@ record: days: 2 # <- number of days to keep motion recordings ``` -Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean) + + + +Continuous recording supports different retention modes [which are described below](#configuring-recording-retention). ### Object Recording -The number of days to record review items can be specified for review items classified as alerts as well as tracked objects. +The number of days to retain recordings for review items can be specified for items classified as alerts as well as tracked objects. + + + + +Navigate to . + +| Field | Description | +| ---------------------------------------------------------- | ------------------------------------------- | +| **Enable recording** | Enable or disable recording for all cameras | +| **Alert retention > Event retention > Retention days** | Number of days to keep alert recordings | +| **Detection retention > Event retention > Retention days** | Number of days to keep detection recordings | + + + ```yaml record: @@ -110,9 +270,10 @@ record: days: 10 # <- number of days to keep detections recordings ``` -This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs. + + -**WARNING**: Recordings still must be enabled in the config. If a camera has recordings disabled in the config, enabling via the methods listed above will have no effect. +This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs. ## Can I have "continuous" recordings, but only at certain times? @@ -122,25 +283,52 @@ Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only reco Footage can be exported from Frigate by right-clicking (desktop) or long pressing (mobile) on a review item in the Review pane or by clicking the Export button in the History view. Exported footage is then organized and searchable through the Export view, accessible from the main navigation bar. -### Time-lapse export +### Custom export with FFmpeg arguments -Time lapse exporting is available only via the [HTTP API](../integrations/api/export-recording-export-camera-name-start-start-time-end-end-time-post.api.mdx). +For advanced use cases, the [custom export HTTP API](../integrations/api/export-recording-custom-export-custom-camera-name-start-start-time-end-end-time-post.api.mdx) lets you pass custom FFmpeg arguments when exporting a recording: -When exporting a time-lapse the default speed-up is 25x with 30 FPS. This means that every 25 seconds of (real-time) recording is condensed into 1 second of time-lapse video (always without audio) with a smoothness of 30 FPS. - -To configure the speed-up factor, the frame rate and further custom settings, the configuration parameter `timelapse_args` can be used. The below configuration example would change the time-lapse speed to 60x (for fitting 1 hour of recording into 1 minute of time-lapse) with 25 FPS: - -```yaml {3-4} -record: - enabled: True - export: - timelapse_args: "-vf setpts=PTS/60 -r 25" ``` +POST /export/custom/{camera_name}/start/{start_time}/end/{end_time} +``` + +The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS) with audio removed (`-an`). When providing your own `ffmpeg_input_args`, include `-an` if you want audio stripped from the export. + +The following example exports a time-lapse at 60x speed with 25 FPS: + +```json +{ + "name": "Front Door Time-lapse", + "ffmpeg_output_args": "-vf setpts=PTS/60 -r 25" +} +``` + +#### CPU fallback + +If hardware acceleration is configured and the export fails (e.g., the GPU is unavailable), set `cpu_fallback: true` in the request body to automatically retry using software encoding. + +```json +{ + "name": "My Export", + "ffmpeg_output_args": "-c:v libx264 -crf 23", + "cpu_fallback": true +} +``` + +:::note + +Non-admin users are restricted from using FFmpeg arguments that can access the filesystem (e.g., `-filter_complex`, file paths, and protocol references). Admin users have full control over FFmpeg arguments. + +::: :::tip -When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large. -To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario. +When `hwaccel_args` is configured, hardware encoding is used for exports. This can be overridden per camera (e.g., when camera resolution exceeds hardware encoder limits) by setting a camera-level `hwaccel_args`. Using an unrecognized value or empty string falls back to software encoding (libx264). + +::: + +:::tip + +To reduce output file size, add the FFmpeg parameter `-qp n` to `ffmpeg_output_args` (where `n` is the quantization parameter). Adjust the value to balance quality and file size for your scenario. ::: @@ -148,19 +336,78 @@ To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (whe Apple devices running the Safari browser may fail to playback h.265 recordings. The [apple compatibility option](../configuration/camera_specific.md#h265-cameras-via-safari) should be used to ensure seamless playback on Apple devices. -## Syncing Recordings With Disk +## Syncing Media Files With Disk -In some cases the recordings files may be deleted but Frigate will not know this has happened. Recordings sync can be enabled which will tell Frigate to check the file system and delete any db entries for files which don't exist. +Media files (event snapshots, event thumbnails, review thumbnails, previews, exports, and recordings) can become orphaned when database entries are deleted but the corresponding files remain on disk. -```yaml -record: - sync_recordings: True -``` +Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database. -This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart. +The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint. + +Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record). :::warning -The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary. +This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended. ::: + +## Understanding storage usage + +The storage usage Frigate reports will not exactly match what the operating system reports with `df` or `du`. This is expected, not a bug. The sections below explain how Frigate derives its storage figures and why they differ from the disk's own accounting. + +### How Frigate measures recording usage + +The **Recordings** value on the Storage Metrics page (), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O. + +The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_, not the drive's real free space, which will be lower whenever anything else is stored on the disk. + +### What counts toward usage, and why it won't match `df` + +Only **recording segments** (`/media/frigate/recordings`) are included in the recordings storage total. Plenty of other things consume real disk space but are **not** part of that number: + +- **Snapshots and thumbnails** (`/media/frigate/clips`): see [Snapshots](/configuration/snapshots). These are retained independently of recordings. +- **Preview videos** and **review thumbnails** (also under `/media/frigate/clips`). +- **Exports** (`/media/frigate/exports`): exports are never removed by retention. +- **The database, downloaded detection models, and face / license plate training images** (stored under `/config`). +- **Debug images from enrichments** (`/media/frigate/clips`): when enabled, License Plate Recognition's `debug_save_plates` and GenAI's `debug_save_thumbnails` save plate crops and request images for troubleshooting. + +These files are the usual explanation for an "other" or seemingly unaccounted bucket of space: it is real, it is Frigate's, and it simply isn't part of the _recordings_ total. They are also why comparing the **Recordings** figure to `df -h` always shows a gap: `df` additionally counts any non-Frigate data on the disk, filesystem overhead and reserved blocks (ext4 reserves ~5% for root by default, so a disk can read "full" before recordings approach the total), and recently deleted recordings whose space has not yet been reclaimed. + +:::tip + +The Storage page is not intended to be a system-wide disk monitor: it shows how much space _Frigate's recordings_ use. To see true disk usage, use `df -h` (free space) and `du -sh` (per-directory usage) on the host. + +::: + +### Free space and the `/media/frigate` mount + +Frigate reports the capacity and free space of whatever filesystem is actually mounted at `/media/frigate` **inside the container**. If an external drive or network share isn't truly mounted there (a missing `/etc/fstab` entry, a share that was offline when the container started, or a host that doesn't pass the path through), the container falls back to the host's OS disk, and Frigate will correctly report that smaller disk instead of the drive you intended. + +If the reported capacity doesn't match your drive, the mount is the place to look, not Frigate. Verify what is actually mounted from inside the container: + +```bash +docker exec -it frigate df -h /media/frigate +docker exec -it frigate mount | grep media +``` + +See the [storage mount layout](/frigate/installation#storage) for how the volumes are expected to be configured. + +### The `/tmp/cache` area is separate + +Recording segments are first written to `/tmp/cache`, a small, in-memory (`tmpfs`) area, before being checked and moved to `/media/frigate/recordings`. Because it is separate and small, `/tmp/cache` can fill up and produce `No space left on device` errors even when the recordings disk has plenty of room. They are different storage areas. See [Recordings troubleshooting](/troubleshooting/recordings) for diagnosing cache and slow-storage issues. + +### When the metrics don't match what's on disk + +Because usage is tracked in the database, deleting recording files directly on disk, or files left behind after an upgrade, will not update the reported usage, and can even push it above 100%. Frigate is unaware of files it didn't record and won't count or remove them automatically. Use [Syncing Media Files With Disk](#syncing-media-files-with-disk) to reconcile the database with what is actually on disk. + +## Will Frigate delete old recordings if my storage runs out? + +Yes. Frigate continuously checks the **free space of the disk** holding `/media/frigate/recordings`. This is different from adding up the size of every recording: free space is a single number the operating system already tracks, so Frigate can ask for it instantly without reading through your files or spinning up the disk, which is exactly why it relies on this check rather than scanning the drive. When less than roughly one hour of recording space remains (estimated from the current recording bitrate, **not** a fixed percentage), Frigate deletes the oldest recordings to reclaim space and logs a message. This emergency cleanup removes the oldest recordings first **regardless of retention settings**. + +Two consequences follow from this being based on whole-disk free space: + +- Because the check uses the disk's real free space, **anything** filling the drive, including non-Frigate files, can trigger deletion of your oldest recordings. +- Cleanup can run while a meaningful percentage of the disk is still free (for example, with high bitrates or many cameras), because the threshold is "less than ~1 hour of recording headroom," not "X% full." + +Frequent emergency cleanups usually mean your configured retention exceeds what the disk can hold. Reduce your retention days so the normal retention cleanup keeps up and the emergency path rarely triggers. diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index 5955770a22..e428250ce0 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -3,11 +3,15 @@ id: restream title: Restream --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + ## RTSP Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://:8554/`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. -Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.10) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration) for more advanced configurations and features. +Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.14) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#configuration) for more advanced configurations and features. :::note @@ -52,6 +56,16 @@ Some cameras only support one active connection or you may just want to have a s One connection is made to the camera. One for the restream, `detect` and `record` connect to the restream. +Configure the go2rtc stream and point the camera inputs at the local restream. + + + + +Navigate to and add stream entries for each camera. Then navigate to for each camera. For each input, choose **Restream (go2rtc)** and pick the matching stream from the dropdown. Frigate uses the local restream URL (`rtsp://127.0.0.1:8554/`) and the `preset-rtsp-restream` input args for that input automatically. (Choose **Manual input path** instead to type a URL directly.) + + + + ```yaml go2rtc: streams: @@ -87,10 +101,21 @@ cameras: - audio # <- only necessary if audio detection is enabled ``` + + + ### With Sub Stream Two connections are made to the camera. One for the sub stream, one for the restream, `record` connects to the restream. + + + +Navigate to and add stream entries for each camera and its sub stream. Then navigate to for each camera and add separate inputs for the main and sub streams. Set each input's source to **Restream (go2rtc)** and pick the matching stream from the dropdown. Frigate uses the local restream URL and the `preset-rtsp-restream` input args for that input automatically. + + + + ```yaml go2rtc: streams: @@ -138,6 +163,9 @@ cameras: - detect ``` + + + ## Handling Complex Passwords go2rtc expects URL-encoded passwords in the config, [urlencoder.org](https://urlencoder.org) can be used for this purpose. @@ -169,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au To prevent this, you must configure two separate stream instances: 1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel) -2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications) +2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications) Configuration example: @@ -187,6 +215,8 @@ In this configuration: - `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access. - `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel. +Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream. + ## Security: Restricted Stream Sources For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured. @@ -208,7 +238,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g ## Advanced Restream Configurations -The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below: +The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below: :::warning diff --git a/docs/docs/configuration/review.md b/docs/docs/configuration/review.md index d8769749b8..14360769c3 100644 --- a/docs/docs/configuration/review.md +++ b/docs/docs/configuration/review.md @@ -3,6 +3,10 @@ id: review title: Review --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + The Review page of the Frigate UI is for quickly reviewing historical footage of interest from your cameras. _Review items_ are indicated on a vertical timeline and displayed as a grid of previews - bandwidth-optimized, low frame rate, low resolution videos. Hovering over or swiping a preview plays the video and marks it as reviewed. If more in-depth analysis is required, the preview can be clicked/tapped and the full frame rate, full resolution recording is displayed. Review items are filterable by date, object type, and camera. @@ -19,11 +23,11 @@ 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](/configuration/zones#restricting-alerts-and-detections-to-specific-zones) for them. :::note -Alerts and detections categorize the tracked objects in review items, but Frigate must first detect those objects with your configured object detector (Coral, OpenVINO, etc). By default, the object tracker only detects `person`. Setting `labels` for `alerts` and `detections` does not automatically enable detection of new objects. To detect more than `person`, you should add the following to your config: +Alerts and detections categorize the tracked objects in review items, but Frigate must first detect those objects with your configured object detector (Coral, OpenVINO, etc). By default, the object tracker only detects `person`. Setting `labels` for `alerts` and `detections` does not automatically enable detection of new objects. To detect more than `person`, you should add more labels via or and select your camera. Alternatively, add the following to your config: ```yaml objects: @@ -38,7 +42,17 @@ See the [objects documentation](objects.md) for the list of objects that Frigate ## Restricting alerts to specific labels -By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config: +By default a review item will only be marked as an alert if a person or car is detected. Configure the alert labels to include any object or audio label. + + + + +Navigate to or and select your camera. + +Expand **Alerts config** and configure which labels and zones should generate alerts. + + + ```yaml # can be overridden at the camera level @@ -52,10 +66,23 @@ review: - speech ``` + + + ## Restricting detections to specific labels By default all detections that do not qualify as an alert qualify as a detection. However, detections can further be filtered to only include certain labels or certain zones. + + + +Navigate to or and select your camera. + +Expand **Detections config** and configure which labels should qualify as detections. + + + + ```yaml # can be overridden at the camera level review: @@ -65,11 +92,23 @@ review: - dog ``` + + + ## Excluding a camera from alerts or detections -To exclude a specific camera from alerts or detections, simply provide an empty list to the alerts or detections field _at the camera level_. +To exclude a specific camera from alerts or detections, provide an empty list to the alerts or detections labels field at the camera level. -For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config: +For example, to exclude objects on the camera _gatecamera_ from any detections: + + + + +1. Navigate to and select the **gatecamera** camera. + - Expand **Detections config** and turn off all of the object label switches. + + + ```yaml {3-5} cameras: @@ -79,6 +118,34 @@ cameras: labels: [] ``` + + + +## Categorizing manual events + +Events created with the [create manual event API](../integrations/api/create-event-events-camera-name-label-create-post.api.mdx) are categorized with the same label lists, using the label from the request path: + +1. If alerts are enabled and the label is listed in `review -> alerts -> labels`, the review item is an alert. +2. Otherwise, if detections are enabled and the label is listed in `review -> detections -> labels`, the review item is a detection. +3. If the label is in neither list, the review item is an alert, or no review item is created if alerts are disabled. + +This means manual events are alerts unless you explicitly list their label as a detection label. For example, to have PIR sensors create detections instead of alerts, post to `/api/events/front_door/pir_sensor/create` with the following config: + +```yaml {5-7} +cameras: + front_door: + review: + detections: + labels: + - pir_sensor +``` + +:::note + +Required zones do not apply to manual events, since they are created through the API rather than by the object tracker. Setting `review -> alerts -> labels` to an empty list also does not stop manual events from becoming alerts, as a label in neither list still falls back to an alert. + +::: + ## Restricting review items to specific zones By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones) @@ -88,3 +155,7 @@ 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 can also surface periods of motion that didn't produce a tracked object, and lets you search past recordings for motion in a region you draw. See [Reviewing Motion](/usage/review#reviewing-motion) in the Usage docs for how to use **Motion Previews** and **Motion Search**, and [Tuning Motion Detection](motion_detection.md) for configuring the underlying motion detector. diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index 19346454bd..30a60d505f 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -3,12 +3,22 @@ id: semantic_search title: Semantic Search --- -Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one. This feature works by creating _embeddings_ — numerical vector representations — for both the images and text descriptions of your tracked objects. By comparing these embeddings, Frigate assesses their similarities to deliver relevant search results. +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one. This feature works by creating _embeddings_, numerical vector representations, for both the images and text descriptions of your tracked objects. By comparing these embeddings, Frigate assesses their similarities to deliver relevant search results. Frigate uses models from [Jina AI](https://huggingface.co/jinaai) to create and save embeddings to Frigate's database. All of this runs locally. Semantic Search is accessed via the _Explore_ view in the Frigate UI. +:::info + +Semantic search requires a one-time internet connection to download embedding models from HuggingFace. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details. + +::: + ## Minimum System Requirements Semantic Search works by running a large AI model locally on your system. Small or underpowered systems like a Raspberry Pi will not run Semantic Search reliably or at all. @@ -19,7 +29,17 @@ For best performance, 16GB or more of RAM and a dedicated GPU are recommended. ## Configuration -Semantic Search is disabled by default, and must be enabled in your config file or in the UI's Enrichments Settings page before it can be used. Semantic Search is a global configuration setting. +Semantic Search is disabled by default and must be enabled before it can be used. Semantic Search is a global configuration setting. + + + + +Navigate to . + +- Set **Enable semantic search** to on + + + ```yaml semantic_search: @@ -27,6 +47,9 @@ semantic_search: reindex: False ``` + + + :::tip The embeddings database can be re-indexed from the existing tracked objects in your database by pressing the "Reindex" button in the Enrichments Settings in the UI or by adding `reindex: True` to your `semantic_search` configuration and restarting Frigate. Depending on the number of tracked objects you have, it can take a long while to complete and may max out your CPU while indexing. @@ -41,7 +64,20 @@ The [V1 model from Jina](https://huggingface.co/jinaai/jina-clip-v1) has a visio The V1 text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the object description docs](/configuration/genai/objects.md) for more information on how to automatically generate tracked object descriptions. -Differently weighted versions of the Jina models are available and can be selected by setting the `model_size` config option as `small` or `large`: +Differently weighted versions of the Jina models are available and can be selected by setting the model size. + + + + +Navigate to . + +| Field | Description | +| ------------------------------------------------ | -------------------------------------------------------------------------- | +| **Semantic search model or GenAI provider name** | Select `jinav1` to use the Jina AI CLIP V1 model | +| **Model size** | `small` (quantized, CPU-friendly) or `large` (full model, GPU-accelerated) | + + + ```yaml semantic_search: @@ -50,6 +86,9 @@ semantic_search: model_size: small ``` + + + - Configuring the `large` model employs the full Jina model and will automatically run on the GPU if applicable. - Configuring the `small` model employs a quantized version of the Jina model that uses less RAM and runs on CPU with a very negligible difference in embedding quality. @@ -59,7 +98,20 @@ Frigate also supports the [V2 model from Jina](https://huggingface.co/jinaai/jin V2 offers only a 3% performance improvement over V1 in both text-image and text-text retrieval tasks, an upgrade that is unlikely to yield noticeable real-world benefits. Additionally, V2 has _significantly_ higher RAM and GPU requirements, leading to increased inference time and memory usage. If you plan to use V2, ensure your system has ample RAM and a discrete GPU. CPU inference (with the `small` model) using V2 is not recommended. -To use the V2 model, update the `model` parameter in your config: +To use the V2 model, set the model to `jinav2`. + + + + +Navigate to . + +| Field | Description | +| ------------------------------------------------ | ----------------------------------------------------- | +| **Semantic search model or GenAI provider name** | Select `jinav2` to use the Jina AI CLIP V2 model | +| **Model size** | `large` is recommended for V2 (requires discrete GPU) | + + + ```yaml semantic_search: @@ -68,6 +120,9 @@ semantic_search: model_size: large ``` + + + For most users, especially native English speakers, the V1 model remains the recommended choice. :::note @@ -76,10 +131,74 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings ::: +### GenAI Provider + +Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images). + +To use llama.cpp for semantic search: + +1. Configure a GenAI provider with `embeddings` in its `roles`. +2. Set the semantic search model to the GenAI config key (e.g. `default`). +3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support. + + + + +Navigate to . + +| Field | Description | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| **Semantic search model or GenAI provider name** | Set to the GenAI config key (e.g. `default`) to use a configured GenAI provider for embeddings | + +The GenAI provider must also be configured with the `embeddings` role under . + + + + +```yaml +genai: + default: + provider: llamacpp + base_url: http://localhost:8080 + model: your-model-name + roles: + - embeddings + - descriptions + - chat + +semantic_search: + enabled: True + model: default +``` + + + + +The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details. + +:::note + +Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible. + +::: + ### GPU Acceleration The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation. + + + +Navigate to . + +| Field | Description | +| -------------- | ---------------------------------------------------------------------- | +| **Model size** | Set to `large` to enable GPU acceleration | +| **Device** | (Optional) Specify a GPU device index in a multi-GPU system (e.g. `0`) | + + + + ```yaml semantic_search: enabled: True @@ -88,6 +207,9 @@ semantic_search: device: 0 ``` + + + :::info If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU will be detected and used automatically. @@ -100,16 +222,11 @@ See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_ ## Usage and Best Practices -1. Semantic Search is used in conjunction with the other filters available on the Explore page. Use a combination of traditional filtering and Semantic Search for the best results. -2. Use the thumbnail search type when searching for particular objects in the scene. Use the description search type when attempting to discern the intent of your object. -3. Because of how the AI models Frigate uses have been trained, the comparison between text and image embedding distances generally means that with multi-modal (`thumbnail` and `description`) searches, results matching `description` will appear first, even if a `thumbnail` embedding may be a better match. Play with the "Search Type" setting to help find what you are looking for. Note that if you are generating descriptions for specific objects or zones only, this may cause search results to prioritize the objects with descriptions even if the the ones without them are more relevant. -4. Make your search language and tone closely match exactly what you're looking for. If you are using thumbnail search, **phrase your query as an image caption**. Searching for "red car" may not work as well as "red sedan driving down a residential street on a sunny day". -5. Semantic search on thumbnails tends to return better results when matching large subjects that take up most of the frame. Small things like "cat" tend to not work well. -6. Experiment! Find a tracked object you want to test and start typing keywords and phrases to see what works for you. +For tips on getting the best results from Semantic Search (choosing between thumbnail and description search, phrasing queries effectively, and combining search with the other Explore filters), see [Usage and best practices](/usage/explore#usage-and-best-practices) in the Usage docs. ## Triggers -Triggers utilize Semantic Search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab. +Triggers utilize Semantic Search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab. :::note @@ -119,16 +236,15 @@ Semantic Search must be enabled to use Triggers. ### Configuration -Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `friendly_name`, a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires - `notification`, `sub_label`, and `attribute`. +Triggers are defined within the `semantic_search` configuration for each camera. Each trigger consists of a `friendly_name`, a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires - `notification`, `sub_label`, and `attribute`. Triggers are best configured through the Frigate UI. #### Managing Triggers in the UI -1. Navigate to the **Settings** page and select the **Triggers** tab. -2. Choose a camera from the dropdown menu to view or manage its triggers. -3. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. -4. In the **Create Trigger** wizard: +1. Navigate to and select a camera from the dropdown menu. +2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. +3. In the **Create Trigger** wizard: - Enter a **Name** for the trigger (e.g., "Red Car Alert"). - Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera"). - Select the **Type** (`Thumbnail` or `Description`). @@ -139,14 +255,14 @@ Triggers are best configured through the Frigate UI. If native webpush notifications are enabled, check the `Send Notification` box to send a notification. Check the `Add Sub Label` box to add the trigger's friendly name as a sub label to any triggering tracked objects. Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT. -5. Save the trigger to update the configuration and store the embedding in the database. +4. Save the trigger to update the configuration and store the embedding in the database. When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate. ### Usage and Best Practices 1. **Thumbnail Triggers**: Select a representative image (event ID) from the Explore page that closely matches the object you want to detect. For best results, choose images where the object is prominent and fills most of the frame. -2. **Description Triggers**: Write concise, specific text descriptions (e.g., "Person in a red jacket") that align with the tracked object’s description. Avoid vague terms to improve matching accuracy. +2. **Description Triggers**: Write concise, specific text descriptions (e.g., "Person in a red jacket") that align with the tracked object's description. Avoid vague terms to improve matching accuracy. 3. **Threshold Tuning**: Adjust the threshold to balance sensitivity and specificity. A higher threshold (e.g., 0.8) requires closer matches, reducing false positives but potentially missing similar objects. A lower threshold (e.g., 0.6) is more inclusive but may trigger more often. 4. **Using Explore**: Use the context menu or right-click / long-press on a tracked object in the Grid View in Explore to quickly add a trigger based on the tracked object's thumbnail. 5. **Editing triggers**: For the best experience, triggers should be edited via the UI. However, Frigate will ensure triggers edited in the config will be synced with triggers created and edited in the UI. @@ -161,6 +277,6 @@ When a trigger fires, the UI highlights the trigger with a blue dot for 3 second #### Why can't I create a trigger on thumbnails for some text, like "person with a blue shirt" and have it trigger when a person with a blue shirt is detected? -TL;DR: Text-to-image triggers aren’t supported because CLIP can confuse similar images and give inconsistent scores, making automation unreliable. The same word–image pair can give different scores and the score ranges can be too close together to set a clear cutoff. +TL;DR: Text-to-image triggers aren't supported because CLIP can confuse similar images and give inconsistent scores, making automation unreliable. The same word-image pair can give different scores and the score ranges can be too close together to set a clear cutoff. -Text-to-image triggers are not supported due to fundamental limitations of CLIP-based similarity search. While CLIP works well for exploratory, manual queries, it is unreliable for automated triggers based on a threshold. Issues include embedding drift (the same text–image pair can yield different cosine distances over time), lack of true semantic grounding (visually similar but incorrect matches), and unstable thresholding (distance distributions are dataset-dependent and often too tightly clustered to separate relevant from irrelevant results). Instead, it is recommended to set up a workflow with thumbnail triggers: first use text search to manually select 3–5 representative reference tracked objects, then configure thumbnail triggers based on that visual similarity. This provides robust automation without the semantic ambiguity of text to image matching. +Text-to-image triggers are not supported due to fundamental limitations of CLIP-based similarity search. While CLIP works well for exploratory, manual queries, it is unreliable for automated triggers based on a threshold. Issues include embedding drift (the same text-image pair can yield different cosine distances over time), lack of true semantic grounding (visually similar but incorrect matches), and unstable thresholding (distance distributions are dataset-dependent and often too tightly clustered to separate relevant from irrelevant results). Instead, it is recommended to set up a workflow with thumbnail triggers: first use text search to manually select 3-5 representative reference tracked objects, then configure thumbnail triggers based on that visual similarity. This provides robust automation without the semantic ambiguity of text to image matching. diff --git a/docs/docs/configuration/snapshots.md b/docs/docs/configuration/snapshots.md index 01c034a040..b2aba9463f 100644 --- a/docs/docs/configuration/snapshots.md +++ b/docs/docs/configuration/snapshots.md @@ -3,31 +3,146 @@ id: snapshots title: Snapshots --- -Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `-.jpg`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx) +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; -Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service. +A snapshot is a single still image that captures a tracked object at its best moment: the clearest frame Frigate saw while following that object across the scene. Unlike a [recording](./record.md), which is continuous video, a snapshot is one representative image saved per tracked object once tracking ends. -To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones) +When snapshots are enabled, Frigate saves one image to `/media/frigate/clips` for each tracked object, named `--clean.webp`. A clean image is always stored without any annotations (no timestamp, bounding boxes, or cropping) so you have an unmodified copy of the original frame. Annotations like bounding boxes and timestamps are applied on demand when a snapshot is requested [via the HTTP API](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx). See [Rendering](#rendering) below. -Snapshots sent via MQTT are configured in the [config file](/configuration) under `cameras -> your_camera -> mqtt` +A few things to keep in mind: + +- Snapshots are saved per tracked object, so a camera with no detected objects produces no snapshots even if recording is enabled. +- Snapshots and recordings are configured and retained independently. Enabling one does not enable the other. +- Snapshots are accessible in the UI in the Explore pane, which allows for quick submission to the Frigate+ service. +- To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones). +- Snapshots sent via MQTT are configured separately under the camera MQTT settings, not here. + +## Enabling Snapshots + +Enable snapshot saving and configure the default settings that apply to all cameras. + + + + +Navigate to . + +- Set **Enable snapshots** to on + + + + +```yaml +snapshots: + enabled: True +``` + + + + +To override snapshot settings for a specific camera: + + + + +Navigate to and select your camera. + +- Set **Enable snapshots** to on + + + + +```yaml +cameras: + front_door: + snapshots: + enabled: True +``` + + + + +## Snapshot Options + +Configure how snapshots are rendered and stored. These settings control the defaults applied when snapshots are requested via the API. + + + + +Navigate to . + +| Field | Description | +| ------------------------ | ------------------------------------------------------------------------------ | +| **Enable snapshots** | Enable or disable saving snapshots for tracked objects | +| **Timestamp overlay** | Overlay a timestamp on snapshots from API | +| **Bounding box overlay** | Draw bounding boxes for tracked objects on snapshots from API | +| **Crop snapshot** | Crop snapshots from API to the detected object's bounding box | +| **Snapshot height** | Height in pixels to resize snapshots to; leave empty to preserve original size | +| **Snapshot quality** | Encode quality for saved snapshots (0-100) | +| **Required zones** | Zones an object must enter for a snapshot to be saved | + + + + +```yaml +snapshots: + enabled: True + timestamp: False + bounding_box: True + crop: False + height: 175 + required_zones: [] + quality: 60 +``` + + + + +## Snapshot Retention + +Configure how long snapshots are retained on disk. Per-object retention overrides allow different retention periods for specific object types. + + + + +Navigate to . + +| Field | Description | +| -------------------------------------------------- | ----------------------------------------------------------------------------------- | +| **Snapshot retention > Default retention** | Number of days to retain snapshots (default: 10) | +| **Snapshot retention > Object retention > Person** | Per-object overrides for retention days (e.g., keep `person` snapshots for 15 days) | + + + + +```yaml +snapshots: + enabled: True + retain: + default: 10 + objects: + person: 15 +``` + + + ## Frame Selection -Frigate does not save every frame — it picks a single "best" frame for each tracked object and uses it for both the snapshot and clean copy. As the object is tracked across frames, Frigate continuously evaluates whether the current frame is better than the previous best based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. The snapshot is written to disk once tracking ends using whichever frame was determined to be the best. +Frigate does not save every frame. It picks a single "best" frame for each tracked object based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. That best frame is written to disk once tracking ends. -MQTT snapshots are published more frequently — each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under `cameras -> your_camera -> mqtt`. +MQTT snapshots are published more frequently: each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under the camera MQTT settings. -## Clean Copy +## Rendering -Frigate can produce up to two snapshot files per event, each used in different places: +Frigate stores a single clean snapshot on disk: -| Version | File | Annotations | Used by | -| --- | --- | --- | --- | -| **Regular snapshot** | `-.jpg` | Respects your `timestamp`, `bounding_box`, `crop`, and `height` settings | API (`/api/events//snapshot.jpg`), MQTT (`/
If you have a USB Coral, you will need to add a detectors section to your config. @@ -216,7 +254,9 @@ If you have a USB Coral, you will need to add a detectors section to your config
Use USB Coral detector -`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes) +:::note + +You need to pass the USB Coral device to the Docker container. Add the following to your `docker-compose.yml` and run `docker compose up -d`: ```yaml {4-6} services: @@ -228,6 +268,16 @@ services: ... ``` +::: + + + + +Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb`. + + + + ```yaml {3-6,11-12} mqtt: ... @@ -244,17 +294,20 @@ cameras: ... ``` + + +
More details on available detectors can be found [here](../configuration/object_detectors.md). -Restart Frigate and you should start seeing detections for `person`. If you want to track other objects, they will need to be added according to the [configuration file reference](../configuration/reference.md). +Restart Frigate and you should start seeing detections for `person`. If you want to track other objects, they can be configured in or via the [configuration file reference](../configuration/advanced/reference.md). ### Step 5: Setup motion masks -Now that you have optimized your configuration for decoding the video stream, you will want to check to see where to implement motion masks. To do this, navigate to the camera in the UI, select "Debug" at the top, and enable "Motion boxes" in the options below the video feed. Watch for areas that continuously trigger unwanted motion to be detected. Common areas to mask include camera timestamps and trees that frequently blow in the wind. The goal is to avoid wasting object detection cycles looking at these areas. +Now that you have optimized your configuration for decoding the video stream, you will want to check to see where to implement motion masks. Click on the camera from the main dashboard, then select the gear icon in the top right, enable the [Debug view](/usage/live#the-single-camera-view), and finally enable the switch for Motion Boxes. Watch for areas that continuously trigger unwanted motion to be detected. Common areas to mask include camera timestamps and trees that frequently blow in the wind. The goal is to avoid wasting object detection cycles looking at these areas. -Now that you know where you need to mask, use the "Mask & Zone creator" in the options pane to generate the coordinates needed for your config file. More information about masks can be found [here](../configuration/masks.md). +Use the mask editor to draw polygon masks directly on the camera feed. Navigate to and set up a motion mask over the area. More information about masks can be found [here](../configuration/masks.md). :::warning @@ -262,7 +315,7 @@ Note that motion masks should not be used to mark out areas where you do not wan ::: -Your configuration should look similar to this now. +If you are using YAML to configure Frigate instead of the UI, your configuration should look similar to this now: ```yaml {16-18} mqtt: @@ -282,14 +335,24 @@ cameras: - detect motion: mask: - - 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432 + motion_area: + friendly_name: "Motion mask" + enabled: true + coordinates: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432" ``` ### Step 6: Enable recordings In order to review activity in the Frigate UI, recordings need to be enabled. -To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI. + + + +1. If you have separate streams for detect and record, navigate to , select your camera, and add a second input with the `record` role pointing to your high-resolution stream +2. Navigate to (or for a specific camera) and set **Enable recording** to on + + + ```yaml {16-17} mqtt: ... @@ -312,6 +375,9 @@ cameras: motion: ... ``` + + + If you don't have separate streams for detect and record, you would just add the record role to the list on the first input. :::note @@ -322,21 +388,20 @@ If you only plan to use Frigate for recording, it is still recommended to define ::: -By default, Frigate will retain video of all tracked objects for 10 days. The full set of options for recording can be found [here](../configuration/reference.md). +By default, Frigate will retain video of all tracked objects for 10 days. The full set of options for recording can be found [here](../configuration/advanced/reference.md). ### Step 7: Complete config At this point you have a complete config with basic functionality. -- View [common configuration examples](../configuration/index.md#common-configuration-examples) for a list of common configuration examples. -- View [full config reference](../configuration/reference.md) for a complete list of configuration options. +- View [common configuration examples](../configuration/config.md#common-configuration-examples) for a list of common configuration examples. +- View [full config reference](../configuration/advanced/reference.md) for a complete list of configuration options. ### Follow up Now that you have a working install, you can use the following documentation for additional features: -1. [Configuring go2rtc](configuring_go2rtc.md) - Additional live view options and RTSP relay -2. [Zones](../configuration/zones.md) -3. [Review](../configuration/review.md) -4. [Masks](../configuration/masks.md) -5. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant +1. [Zones](../configuration/zones.md) +2. [Review](../configuration/review.md) +3. [Masks](../configuration/masks.md) +4. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant diff --git a/docs/docs/guides/reverse_proxy.md b/docs/docs/guides/reverse_proxy.md index 5edfb5c60d..bf33bcc278 100644 --- a/docs/docs/guides/reverse_proxy.md +++ b/docs/docs/guides/reverse_proxy.md @@ -10,13 +10,14 @@ A reverse proxy is typically needed if you want to set up Frigate on a custom UR Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs: |Topic|Docs| |-|-| -|TLS|Please see the `tls` [configuration option](../configuration/tls.md)| +|TLS|Please see the `tls` [configuration option](../configuration/tls.md)| |Authentication|Please see the [authentication](../configuration/authentication.md) documentation| -|IPv6|[Enabling IPv6](../configuration/advanced.md#enabling-ipv6) +|IPv6|[Enabling IPv6](../configuration/advanced/system.md#enabling-ipv6) -**Note about TLS** -When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port." +**Note about TLS** +When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port." To disable TLS, set the following in your Frigate configuration: + ```yml tls: enabled: false @@ -24,18 +25,26 @@ tls: :::warning A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices. -This page does not attempt to outline the specific steps needed to secure your internal website. +This page does not attempt to outline the specific steps needed to secure your internal website. Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system. ::: +## WebSocket support + +Frigate relies on WebSockets for real-time communication between the browser and the backend. Features such as camera controls (enabling/disabling a camera, audio, detect, recordings, and other toggles), live stream playback, and other live-updating parts of the UI will not function correctly if WebSocket connections are not proxied. + +Your reverse proxy must be configured to forward the `Upgrade` and `Connection` headers so that WebSocket connections can be established. Each proxy example below already includes the directives needed to do this, but if you are adapting your own configuration, ensure these headers are passed through. + +Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled. + ## Proxies There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page. -* [Apache2](#apache2-reverse-proxy) -* [Nginx](#nginx-reverse-proxy) -* [Traefik](#traefik-reverse-proxy) -* [Caddy](#caddy-reverse-proxy) +- [Apache2](#apache2-reverse-proxy) +- [Nginx](#nginx-reverse-proxy) +- [Traefik](#traefik-reverse-proxy) +- [Caddy](#caddy-reverse-proxy) ## Apache2 Reverse Proxy @@ -159,7 +168,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr ## Traefik Reverse Proxy -This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance. +This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance. Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/) ```yml @@ -203,7 +212,7 @@ This example shows Frigate running under a subdomain with logging and a tls cert } frigate.YOUR_DOMAIN.TLD { - reverse_proxy http://localhost:8971 + reverse_proxy http://localhost:8971 import tls import logging frigate.YOUR_DOMAIN.TLD } diff --git a/docs/docs/integrations/home-assistant.md b/docs/docs/integrations/home-assistant.md index 5b9c014377..f5ca05f1a4 100644 --- a/docs/docs/integrations/home-assistant.md +++ b/docs/docs/integrations/home-assistant.md @@ -9,6 +9,8 @@ The best way to integrate with Home Assistant is to use the [official integratio ### Preparation +Frigate itself must be installed and running before setting up the integration. See the [installation documentation](../frigate/installation.md) for details. + The Frigate integration requires the `mqtt` integration to be installed and manually configured first. @@ -122,7 +124,7 @@ Use `http://:8971` as the URL for the integration so that aut The above URL assumes you have [disabled TLS](../configuration/tls). By default, TLS is enabled and Frigate will be using a self-signed certificate. HomeAssistant will fail to connect HTTPS to port 8971 since it fails to verify the self-signed certificate. -Either disable TLS and use HTTP from HomeAssistant, or configure Frigate to be acessible with a valid certificate. +Either disable TLS and use HTTP from HomeAssistant, or configure Frigate to be accessible with a valid certificate. ::: @@ -195,7 +197,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 +206,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 +215,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. @@ -273,7 +281,7 @@ For advanced usecases, this behavior can be changed with the [RTSP URL template](#options) option. When set, this string will override the default stream address that is derived from the default behavior described above. This option supports [jinja2 templates](https://jinja.palletsprojects.com/) and has the `camera` dict -variables from [Frigate API](../integrations/api) +variables from [Frigate API](/integrations/api/frigate-http-api) available for the template. Note that no Home Assistant state is available to the template, only the camera dict from Frigate. diff --git a/docs/docs/integrations/homekit.md b/docs/docs/integrations/homekit.md index 5954af41ce..a4988fd38f 100644 --- a/docs/docs/integrations/homekit.md +++ b/docs/docs/integrations/homekit.md @@ -3,35 +3,100 @@ id: homekit title: HomeKit --- -Frigate cameras can be integrated with Apple HomeKit through go2rtc. This allows you to view your camera streams directly in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices. +Frigate cameras can be exported to Apple HomeKit through go2rtc. Each exported camera appears as an accessory in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices. ## Overview -HomeKit integration is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server to expose your cameras to HomeKit. +Exporting cameras is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server, so your camera is published to HomeKit as an accessory in its own right. -## Setup +:::note -All HomeKit configuration and pairing should be done through the **go2rtc WebUI**. +This is the opposite of importing a HomeKit camera. go2rtc can also pair with an existing HomeKit camera (Aqara, Eve, Eufy, and similar) and use it as a stream source, which is what the `add` page of the go2rtc WebUI is for. That page discovers HomeKit accessories on your network and will not list your Frigate cameras. It is not used for exporting. -### Accessing the go2rtc WebUI - -The go2rtc WebUI is available at: - -``` -http://:1984 -``` - -Replace `` with the IP address or hostname of your Frigate server. - -### Pairing Cameras - -1. Navigate to the go2rtc WebUI at `http://:1984` -2. Use the `add` section to add a new camera to HomeKit -3. Follow the on-screen instructions to generate pairing codes for your cameras +::: ## Requirements -- Frigate must be accessible on your local network using host network_mode -- Your iOS device must be on the same network as Frigate -- Port 1984 must be accessible for the go2rtc WebUI -- For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc) +- Frigate must be running with `network_mode: host` so that HomeKit can discover your cameras over mDNS +- Your Apple device must be on the same network as Frigate +- Port 1984 must be accessible so you can reach the go2rtc WebUI + +HomeKit also places strict limits on the stream itself. go2rtc passes your stream through without resizing or re-encoding it, so the stream you export must already meet these requirements: + +- **Video:** H.264 at 1920x1080, 1280x720, or 320x240 +- **Audio:** Opus, mono, 16 kHz + +A camera's full resolution stream usually does not qualify. See [Exporting a compatible stream](#exporting-a-compatible-stream) below. + +## Configuration + +HomeKit settings are stored in `/config/go2rtc_homekit.yml`. This is a separate file from your Frigate config, because go2rtc needs to write your pairings back to it when you pair a device. + +Edit it using the go2rtc config editor, which writes to that file directly: + +``` +http://:1984/editor.html +``` + +Replace `` with the IP address or hostname of your Frigate server. The editor will be empty until you add a HomeKit section, since this file holds only your HomeKit settings and not the rest of your go2rtc config. + +:::warning + +Do not put the `homekit:` section in the `go2rtc:` section of your Frigate config. + +Frigate regenerates that config on every startup, so go2rtc cannot save your pairings to it. Pairing will appear to succeed and then fail after the next restart with `PairVerify with unknown client_id`. If the section exists in both places, your saved pairings are erased on every restart. + +::: + +Add an entry for each camera you want to export. The key must match the name of a go2rtc stream, and the pin must be 8 digits. This is the number the Home app calls the setup code: + +```yaml +homekit: + front_door: + name: Front Door + pin: "12345678" +``` + +If the key does not match a go2rtc stream, go2rtc logs `[homekit] missing stream:` at startup and the camera will not appear in the Home app. + +:::note + +go2rtc derives each accessory's HomeKit identity from this key, so renaming it later means the camera appears as a new accessory and has to be paired again. Settle on the name before you pair. + +::: + +Frigate keeps only the `homekit:` section of this file when it starts, so do not store streams or other go2rtc settings in it. + +### Exporting a compatible stream + +If a camera's stream does not meet the requirements listed above, define a scaled restream in your Frigate config and point HomeKit at that stream instead of the original: + +```yaml +go2rtc: + streams: + front_door: + - rtsp://user:password@192.168.1.50:554/stream + front_door_homekit: + - "ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000" +``` + +```yaml +# /config/go2rtc_homekit.yml +homekit: + front_door_homekit: + name: Front Door + pin: "12345678" +``` + +Add `#hardware=cuda`, `#hardware=vaapi`, or the appropriate value for your system to transcode using your GPU. Note that NVENC cannot encode H.264 wider than 4096 pixels, so very wide streams must be scaled down as shown above rather than only re-encoded. + +## Pairing Cameras + +1. Restart Frigate after adding the `homekit:` section +2. In the Apple Home app, choose **Add Accessory**, then **More options** to enter a code manually +3. Select your camera and enter the pin you configured as the setup code +4. Confirm that a `pairings:` list now appears under the camera in `/config/go2rtc_homekit.yml` + +Pairings are saved back to that file automatically. If step 4 shows no `pairings:` list, check the Frigate log for `[homekit] can't save`, which means the `homekit:` section is missing from `/config/go2rtc_homekit.yml`. + +For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc). diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 66775a473c..a7a8740bd5 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -5,13 +5,20 @@ title: MQTT These are the MQTT messages generated by Frigate. The default topic_prefix is `frigate`, but can be changed in the config file. +:::info + +MQTT requires a network connection to your broker. This is typically local, but will require internet if using a cloud-hosted MQTT broker. See [Network Requirements](/frigate/network_requirements#mqtt) for details. + +::: + ## General Frigate Topics ### `frigate/available` Designed to be used as an availability topic with Home Assistant. Possible message are: -"online": published when Frigate is running (on startup) -"offline": published after Frigate has stopped +"online": published once Frigate is running and has published its initial state. Note that this is published on every connection to the broker, so it is republished if the broker restarts or the connection drops and recovers, without Frigate itself restarting. +"stopped": published when Frigate is stopped normally +"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message) ### `frigate/restart` @@ -159,7 +166,8 @@ Published when a license plate is recognized on a car object. See the [License P "plate": "123ABC", "score": 0.95, "camera": "driveway_cam", - "timestamp": 1607123958.748393 + "timestamp": 1607123958.748393, + "plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame } ``` @@ -272,11 +280,21 @@ Same data available at `/api/stats` published at a configurable interval. ### `frigate/camera_activity` -Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publising to `frigate/onConnect` +Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publishing to `frigate/onConnect` + +### `frigate/profile/set` + +Topic to activate or deactivate a [profile](/configuration/profiles). Publish a profile name to activate it, or `none` to deactivate the current profile. + +### `frigate/profile/state` + +Topic with the currently active profile name. Published value is the profile name or `none` if no profile is active. This topic is retained. ### `frigate/notifications/set` -Topic to turn notifications on and off. Expected values are `ON` and `OFF`. +Topic to turn notifications on and off for all cameras. Expected values are `ON` and `OFF`. + +Only available when notifications are enabled in the config. Not persisted across Frigate restarts. ### `frigate/notifications/state` @@ -290,7 +308,9 @@ 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. + +These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message. ### `frigate//` @@ -352,15 +372,15 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o ### `frigate//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 persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, 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//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//detect/set` -Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. +Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). ### `frigate//detect/state` @@ -368,15 +388,27 @@ Topic with current state of object detection for a camera. Published values are ### `frigate//audio/set` -Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. +Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). ### `frigate//audio/state` Topic with current state of audio detection for a camera. Published values are `ON` and `OFF`. +### `frigate//audio_transcription/set` + +Topic to turn [live audio transcription](/configuration/audio_detectors#live-transcription) for a camera on and off. Expected values are `ON` and `OFF`. Transcribed text is published to `frigate//audio/transcription`. + +`ON` is ignored unless audio transcription is enabled in the config for the camera. Unlike the other camera toggles, this one is not persisted across Frigate restarts. + +**NOTE:** Requires audio detection and transcription to be enabled + +### `frigate//audio_transcription/state` + +Topic with current state of live audio transcription for a camera. Published values are `ON` and `OFF`. + ### `frigate//recordings/set` -Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. +Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). ### `frigate//recordings/state` @@ -384,7 +416,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a ### `frigate//snapshots/set` -Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. +Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). ### `frigate//snapshots/state` @@ -429,6 +461,30 @@ Topic to adjust motion contour area for a camera. Expected value is an integer. Topic with current motion contour area for a camera. Published value is an integer. +### `frigate//motion_mask//set` + +Topic to turn a specific motion mask for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//motion_mask//state` + +Topic with current state of a specific motion mask for a camera. Published values are `ON` and `OFF`. + +### `frigate//object_mask//set` + +Topic to turn a specific object mask for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//object_mask//state` + +Topic with current state of a specific object mask for a camera. Published values are `ON` and `OFF`. + +### `frigate//zone//set` + +Topic to turn a specific zone for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//zone//state` + +Topic with current state of a specific zone for a camera. Published values are `ON` and `OFF`. + ### `frigate//review_status` Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`. @@ -516,16 +572,20 @@ Topic with current state of the Birdseye mode for a camera. Published values are ### `frigate//notifications/set` -Topic to turn notifications on and off. Expected values are `ON` and `OFF`. +Topic to turn notifications for a camera on and off. Expected values are `ON` and `OFF`. + +`ON` is ignored unless notifications are enabled in the config for the camera. This is not persisted across Frigate restarts. It is the same control the UI labels **Suspend until restart**. ### `frigate//notifications/state` -Topic with current state of notifications. Published values are `ON` and `OFF`. +Topic with current state of notifications. Published values are `ON` and `OFF`. This is the authoritative topic for whether a camera will notify. ### `frigate//notifications/suspend` -Topic to suspend notifications for a certain number of minutes. Expected value is an integer. +Topic to suspend notifications for a certain number of minutes. Expected value is an integer. Separate from `notifications/set`: it does not change `notifications/state`, and is ignored while notifications are off. ### `frigate//notifications/suspended` -Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended. +Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if there is no timed suspension. + +`0` does not mean notifications are enabled: `notifications/set` `OFF` clears the timed suspension, so this publishes `0` while `notifications/state` is `OFF`. diff --git a/docs/docs/integrations/plus.md b/docs/docs/integrations/plus.md index aa3d78df5b..949a9f49be 100644 --- a/docs/docs/integrations/plus.md +++ b/docs/docs/integrations/plus.md @@ -3,8 +3,16 @@ id: plus title: Frigate+ --- +import NavPath from "@site/src/components/NavPath"; + For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/). +:::info + +Frigate+ requires an active internet connection to communicate with `https://api.frigate.video` for model downloads, image uploads, and annotations. See [Network Requirements](/frigate/network_requirements#frigate) for details. + +::: + ## Setup ### Create an account @@ -51,7 +59,7 @@ You can view all of your submitted images at [https://plus.frigate.video](https: Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically. -You can either choose the new model from the Frigate+ pane in the Settings page of the Frigate UI, or manually set the model at the root level in your config: +You can either choose the new model from the pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config: ```yaml detectors: ... diff --git a/docs/docs/integrations/third_party_extensions.md b/docs/docs/integrations/third_party_extensions.md index e0fe835633..64b174a042 100644 --- a/docs/docs/integrations/third_party_extensions.md +++ b/docs/docs/integrations/third_party_extensions.md @@ -17,9 +17,13 @@ Please use your own knowledge to assess and vet them before you install anything The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant dashboard card with deep Frigate integration. +## [cctvQL](https://github.com/arunrajiah/cctvql) + +[cctvQL](https://github.com/arunrajiah/cctvql) is a natural language query layer for Frigate and other CCTV systems. It connects to Frigate's REST API and MQTT broker to let you ask conversational questions about cameras and events (e.g. "Was there motion at the front door last night?"), with support for real-time event streaming, anomaly detection, PTZ control, alert rules, and a Home Assistant custom component. + ## [Double Take](https://github.com/skrashevich/double-take) -[Double Take](https://github.com/skrashevich/double-take) provides an unified UI and API for processing and training images for facial recognition. +[Double Take](https://github.com/skrashevich/double-take) provides a unified UI and API for processing and training images for facial recognition. It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized. This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author. @@ -27,6 +31,10 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht [Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended. +## [Frigate Notify Alert](https://github.com/Sysoev86/frigate-notify-alert) + +[Frigate Notify Alert](https://github.com/Sysoev86/frigate-notify-alert) sends Frigate events to Telegram as a photo + video media group. It supports multiple camera groups (each notifying its own chat), optional zone filtering (notify only when an object enters a chosen zone), and in-chat buttons to pause notifications for a set time. Works with standalone Frigate over MQTT; Home Assistant not required. + ## [Frigate Snap-Sync](https://github.com/thequantumphysicist/frigate-snap-sync/) [Frigate Snap-Sync](https://github.com/thequantumphysicist/frigate-snap-sync/) is a program that works in tandem with Frigate. It responds to Frigate when a snapshot or a review is made (and more can be added), and uploads them to one or more remote server(s) of your choice. @@ -35,13 +43,17 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht [Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail. +## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) + +[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display. + ## [Periscope](https://github.com/maksz42/periscope) [Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS. ## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge) -[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate. +[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is a plugin that allows you to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate. ## [Strix](https://github.com/eduard256/Strix) diff --git a/docs/docs/plus/annotating.md b/docs/docs/plus/annotating.md index dc8e571be4..3725ab5c2c 100644 --- a/docs/docs/plus/annotating.md +++ b/docs/docs/plus/annotating.md @@ -19,7 +19,7 @@ For the best results, follow these guidelines. You may also want to review the d ## AI suggested labels -If you have an active Frigate+ subscription, new uploads will be scanned for the objects configured for you camera and you will see suggested labels as light blue boxes when annotating in Frigate+. These suggestions are processed via a queue and typically complete within a minute after uploading, but processing times can be longer. +If you have an active Frigate+ subscription, new uploads will be scanned for the objects configured for your camera and you will see suggested labels as light blue boxes when annotating in Frigate+. These suggestions are processed via a queue and typically complete within a minute after uploading, but processing times can be longer. ![Suggestions](/img/plus/suggestions.webp) diff --git a/docs/docs/plus/faq.md b/docs/docs/plus/faq.md index 2bcebf1d4b..54b07fb8a4 100644 --- a/docs/docs/plus/faq.md +++ b/docs/docs/plus/faq.md @@ -31,10 +31,9 @@ Note that professional installers are fine under standard subscriptions when eac ### Why can't I submit images to Frigate+? -If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled. +If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that snapshots are enabled for the cameras you'd like to submit images for. ```yaml snapshots: enabled: true - clean_copy: true ``` diff --git a/docs/docs/plus/first_model.md b/docs/docs/plus/first_model.md index e9523f6b98..98095554b5 100644 --- a/docs/docs/plus/first_model.md +++ b/docs/docs/plus/first_model.md @@ -3,6 +3,10 @@ id: first_model title: Requesting your first model --- +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + ## Step 1: Upload and annotate your images Before requesting your first model, you will need to upload and verify at least 10 images to Frigate+. The more images you upload, annotate, and verify the better your results will be. Most users start to see very good results once they have at least 100 verified images per camera. Keep in mind that varying conditions should be included. You will want images from cloudy days, sunny days, dawn, dusk, and night. Refer to the [integration docs](../integrations/plus.md#generate-an-api-key) for instructions on how to easily submit images to Frigate+ directly from Frigate. @@ -16,13 +20,21 @@ For more detailed recommendations, you can refer to the docs on [annotating](./a Once you have an initial set of verified images, you can request a model on the Models page. For guidance on choosing a model type, refer to [this part of the documentation](./index.md#available-model-types). If you are unsure which type to request, you can test the base model for each version from the "Base Models" tab. Each model request requires 1 of the 12 trainings that you receive with your annual subscription. This model will support all [label types available](./index.md#available-label-types) even if you do not submit any examples for those labels. Model creation can take up to 36 hours. ![Plus Models Page](/img/plus/plus-models.jpg) -## Step 3: Set your model id in the config +## Step 3: Set your model You will receive an email notification when your Frigate+ model is ready. ![Model Ready Email](/img/plus/model-ready-email.jpg) Models available in Frigate+ can be used with a special model path. No other information needs to be configured because it fetches the remaining config from Frigate+ automatically. + + + +Navigate to . In the **Detection Model** section, choose the **Frigate+** tab. Select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change. + + + + ```yaml detectors: ... @@ -30,22 +42,46 @@ model: path: plus:// ``` -:::note - -Model IDs are not secret values and can be shared freely. Access to your model is protected by your API key. - -::: - :::tip When setting the plus model id, all other fields should be removed as these are configured automatically with the Frigate+ model config ::: + + + +:::note + +Model IDs are not secret values and can be shared freely. Access to your model is protected by your API key. + +::: + ## Step 4: Adjust your object filters for higher scores Frigate+ models generally have much higher scores than the default model provided in Frigate. You will likely need to increase your `threshold` and `min_score` values. Here is an example of how these values can be refined, but you should expect these to evolve as your model improves. For more information about how `threshold` and `min_score` are related, see the docs on [object filters](../configuration/object_filters.md#object-scores). + + + +Navigate to . Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**. + +| Object | Min Score | Threshold | +| ----------------- | --------- | --------- | +| **dog** | .7 | .9 | +| **cat** | .65 | .8 | +| **face** | .7 | | +| **package** | .65 | .9 | +| **license_plate** | .6 | | +| **amazon** | .75 | | +| **ups** | .75 | | +| **fedex** | .75 | | +| **person** | .65 | .85 | +| **car** | .65 | .85 | + + + + ```yaml objects: filters: @@ -75,3 +111,6 @@ objects: min_score: .65 threshold: .85 ``` + + + diff --git a/docs/docs/troubleshooting/common_errors.md b/docs/docs/troubleshooting/common_errors.md new file mode 100644 index 0000000000..6edb6577f1 --- /dev/null +++ b/docs/docs/troubleshooting/common_errors.md @@ -0,0 +1,240 @@ +--- +id: common_errors +title: Common Error Messages +--- + +import FaqItem from "@site/src/components/FaqItem"; + +This page is an index of error messages you might see in Frigate's logs, what each one means, and where to go next. It is organized by the kind of problem, not by which component logged the message. + +Two things to know before you start: + +- **Many of these messages come from FFmpeg, go2rtc, GPU drivers, or the operating system, not from Frigate itself.** Frigate captures and re-logs their output, so the log level shown in the Frigate UI does not always reflect the original severity. +- **Wrapped errors put the real cause on the next line.** When Frigate logs a generic message like `Error occurred when attempting to maintain recording cache`, the actual exception is logged immediately after it. When a camera's FFmpeg process exits, Frigate logs `The following ffmpeg logs include the last 100 lines prior to exit` and dumps that camera's FFmpeg output. Always read those lines, they are where the answer usually is. + +## Camera connection and streams + + + +These are FFmpeg errors about reaching the camera (or the go2rtc restream). `Connection refused` and `No route to host` mean nothing is listening at that address or the host is unreachable; `401 Unauthorized` is wrong credentials; `404 Not Found` is a wrong stream path (or a `restream` input pointing at a go2rtc stream name that does not exist). A camera that has hit its concurrent-connection limit can also return `refused` or `401` on a URL that works in VLC. + +See [go2rtc troubleshooting](/troubleshooting/go2rtc#1-read-the-go2rtc-logs) for how to isolate the stream. + + + + + +FFmpeg is running but has stopped delivering video for 20 seconds, so Frigate's camera watchdog restarts it. The stream connected at least once, then went quiet: a camera reboot, a network drop, the camera evicting the connection, or a stalled decoder. If it repeats on a loop, the stream is unstable. + + + + + +The detect FFmpeg process exited on its own. This message is only the notification; the cause is in the 100 FFmpeg log lines Frigate dumps right after it (look for a `Failed to sync surface`, `Connection refused`, codec, or audio error in that block). Related watchdog messages include ` exceeded fps limit`, which means the camera is delivering frames faster than `detect.fps` (usually a camera whose real frame rate differs from what is configured). + + + + + +These are FFmpeg messages indicating the camera sent packets with out-of-order timestamps, either on the video or the audio stream. Timestamp jitter like this is common with WiFi cameras and restreamed or proxied sources; other causes are a camera "Smart Codec" / H.264+ / H.265+ mode or a camera clock that jumps. A sustained flood of these messages usually precedes the stream stalling and the watchdog restarting FFmpeg. + +In most cases, the fix is to improve the network, reduce system resource usage, or switch to non-WiFi cameras. In general, WiFi cameras are [not recommended](https://ipcamtalk.com/threads/multiple-cameras-high-bandwidth.77100/#post-861110). + +On the video stream, this can affect recordings: because they are copied without re-encoding, FFmpeg cannot fix the timestamps, and the segment muxer often splits early, producing one-second segments and a cache backlog. See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long). + +On the audio stream, the messages can come from the output's audio encoding. If the audio stream is the problem, it may help to have go2rtc transcode it by adding `#audio=aac` to the camera's go2rtc stream to produce clean timestamps for everything consuming the restream. + + + + + +An FFmpeg message meaning RTP packets arrived out of sequence, which almost always means the stream is using UDP transport. Frigate's RTSP presets force TCP, so seeing this points at a custom `input_args`, `preset-rtsp-udp`, or a go2rtc source that is not using TCP. Switch to TCP unless your camera is [UDP-only](/configuration/camera_specific#udp-only-cameras). + + + + + +FFmpeg decoder messages meaning the received video bitstream was incomplete or damaged. A few of these at every stream start are normal (the decoder connected before the first keyframe) and Frigate discards them. A continuous stream of them means real packet loss, from Wi-Fi or a saturated link, an overloaded camera, or an FFmpeg restart loop caused by another problem. Fix the underlying instability rather than the message. + + + + + +An FFmpeg message meaning it probed the stream but never saw enough decodable video to determine the frame size, often because the probe window ended before the first keyframe on a long-GOP stream, or because the stream is not delivering usable video. If it is a Reolink HTTP stream, use `preset-http-reolink`, which raises the probe size for exactly this case. + + + +## Recording + + + +Frigate's record watchdog is restarting the record FFmpeg process because the camera stopped producing usable recordings. The wording distinguishes the cases: `No new recording segments` means no new segment file reached the cache, so ffmpeg isn't getting video out of the record stream; the two `valid` variants mean recordings are arriving but keep failing validation. Either way the fault is on the camera or network side, and the restart is Frigate trying to recover. + +See [Recordings: no new recording segments were created](/troubleshooting/recordings#no-new-recording-segments-were-created). + + + + + +A cached recording segment failed validation and was deleted, either because it had no readable video stream or because its length was impossible. This nearly always means the camera stopped sending usable video partway through the segment: a camera that rebooted, dropped the connection, or ran out of simultaneous connections, or an unreliable link such as WiFi or a failing switch port. Broken camera timestamps (a "Smart Codec" / H.264+ mode) cause the corrupt-segment variants. The same stream failure trips the record watchdog, so the restarts above usually appear alongside these messages. + +See [Recordings: invalid or missing video stream in segment](/troubleshooting/recordings#invalid-or-missing-video-stream-in-segment). + + + + + +Some camera audio codecs (G.711 variants such as `pcm_alaw` and `pcm_mulaw`) cannot be stored in an MP4 container, so segments never finalize even though live view works. + +See [Recordings: incompatible audio codec](/troubleshooting/recordings#incompatible-audio-codec-recordings-silently-fail-to-save) for the FFmpeg preset that transcodes the audio to AAC. + + + + + +A generic wrapper; the real exception is on the next log line. Frequently it is `[Errno 28] No space left on device` or `[Errno 17] File exists` on a network share. + +See [Recordings cache warnings and errors](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache), which covers this message and the common `Errno` cases. + + + +## Hardware acceleration + + + +A VAAPI/QSV hardware frame-sync failure between FFmpeg and the GPU driver, not a Frigate bug. It usually appears when the detect stream is being scaled or decoded on the GPU. + +See [GPU: Failed to download frame: -5](/troubleshooting/gpu#failed-to-download-frame--5), which lists the fixes in order (switch VAAPI/QSV preset, change `LIBVA_DRIVER_NAME`, use an H.264 substream, match detect resolution and fps to the stream). + + + + + +Both mean the GPU ran out of decode surfaces: `No decoder surfaces left` is NVIDIA NVDEC, `Can't allocate a surface` is Intel QSV. This is surface-pool exhaustion, typically from too many concurrent hardware-decoded cameras on one GPU (consumer NVIDIA cards have a driver-enforced limit on simultaneous decode sessions). Reduce the number of cameras decoding on that GPU, decode some on the CPU, or move to hardware without the session cap. + + + + + +This comes from the NVIDIA container runtime while starting the container, not from Frigate, and the container never starts. The NVIDIA driver is not loaded on the host. Confirm `nvidia-smi` works on the host itself (not inside the container) before troubleshooting Frigate. In a VM or LXC, the driver must be available inside the guest. See [Hardware: Nvidia GPU](/configuration/hardware_acceleration_video). + + + +## Detectors and models + + + +The process was killed by the CPU for executing an unsupported instruction. There are two distinct causes in Frigate: + +- **A Coral EdgeTPU** on a newer kernel with an outdated gasket driver. See [EdgeTPU: Illegal instruction](/troubleshooting/edgetpu#attempting-to-load-tpu-as-pci--fatal-python-error-illegal-instruction). +- **A CPU without AVX/AVX2**, when enabling semantic search, face recognition, license plate recognition, classification, or audio transcription. These features use libraries compiled with AVX and crash immediately on CPUs that lack it (commonly Intel Celeron/Pentium before the 2020 Tiger Lake generation). See the [CPU requirements](/frigate/planning_setup#cpu). + + + + + +ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx). + + + + + +ONNX Runtime CUDA errors. `999` (`cudaErrorUnknown`) is a general, unrecoverable CUDA context failure, usually a driver/runtime version mismatch between the host and the container or a GPU in a bad state. `901` is a CUDA-graph capture error, which points at a custom model whose operations are not capture-safe. For `999`, align the host driver with the container's CUDA version and confirm the GPU is healthy. + + + + + +OpenVINO could not find the configured device (usually `GPU` or `NPU`). Most often the `/dev/dri` render node is not passed into the container, or the wrong render node is mapped when an iGPU and a discrete GPU coexist. + +See [GPU: no supported devices found](/troubleshooting/gpu#cant-get-optimization_capabilities-property-as-no-supported-devices-found). + + + +## Memory and storage + + + +Frigate ran out of shared memory (`/dev/shm`). The container's `shm_size` is too small for the number and resolution of your detect streams, or you added cameras after startup without increasing it. + +See [Calculating required shm-size](/frigate/installation#calculating-required-shm-size). If you cannot increase `shm_size`, lowering the `SHM_MAX_FRAMES` environment variable reduces how many frames Frigate buffers per camera. + + + + + +A filesystem is full: the recordings volume (`/media/frigate`), the cache tmpfs (`/tmp/cache`), or `/dev/shm`. Check which one, and note that inode exhaustion can produce this while `df -h` still shows free space. + +See [Recordings: No space left on device](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache). + + + + + +A silent exit is usually the host or container out-of-memory killer. Because `/dev/shm` and `/tmp/cache` are memory-backed, they count against the container's memory limit, so aggressive shm or cache sizing can trigger it. Give the container more memory, or reduce shm/cache sizing, and check the host's OOM messages (`dmesg`). + + + +## Database + + + +SQLite could not acquire the write lock. Frigate's timeout already scales with camera count, so under normal local-disk operation this essentially only happens when the database is on a network share (SMB/NFS), where file locking is unreliable, or when two instances point at the same file. + +See [Database is locked](/troubleshooting/faqs#error-database-is-locked). + + + + + +The SQLite database file is corrupted, typically after hard power loss, a network-share database, or a filesystem with unsafe write semantics. Frigate does not repair it automatically, but the database can usually be recovered by hand. + +**Stop Frigate first**, then work on the database file directly (by default `/config/frigate.db`). Start by checking what is actually wrong: + +```bash +sqlite3 frigate.db "PRAGMA integrity_check;" +``` + +If the only problems reported are index-related (lines such as `row 14 missing from index recordings_path` or `non-unique entry in index ...`), rebuilding the indexes is usually enough and is the least destructive fix: + +```bash +sqlite3 frigate.db "REINDEX;" +``` + +If the integrity check reports page or byte-level corruption instead (for example `Multiple uses for byte 2706 of page 142272`), dump the readable contents into a new database: + +```bash +# dump what can still be read +sqlite3 frigate.db .dump > frigate.dump + +# keep the corrupt file, then rebuild from the dump +mv frigate.db frigate.db.bak +cat frigate.dump | sqlite3 frigate.db + +# confirm the rebuilt database is clean, this should print "ok" +sqlite3 frigate.db "PRAGMA integrity_check;" +``` + +Rows stored in the corrupted pages cannot be recovered, so expect to lose some tracked objects, review items, or thumbnails. Recordings themselves are files on disk and are not affected. + +As a last resort, stop Frigate, delete `frigate.db`, and restart. Frigate recreates it, but existing recordings lose all of their metadata. If a `backup.db` exists next to your database, Frigate wrote it before the last schema migration and restoring it recovers everything up to that point. + +Repeat corruption usually points at the underlying storage: move the database off a network share, and on Raspberry Pi check power delivery and the SD card or SSD. + + + +## Startup and web access + + + +When your config fails validation at startup, Frigate prints the validation errors (with line numbers), then starts in **safe mode**: a minimal configuration with no cameras and MQTT disabled, so the UI stays reachable. In safe mode the only available page is the Config Editor, which shows the validation errors so you can fix them, then save and restart. Note that recording retention and storage cleanup do **not** run while in safe mode, so do not leave a low-disk system sitting in it. + +`Unable to start Frigate in safe mode` means even the minimal config failed, which points at an error in your `auth`, `proxy`, or `database` section, or a config file that is not valid YAML at all. Safe mode is not sticky; fix the config and restart and Frigate returns to normal. + + + + + +The web server is up but the Frigate backend (port 5001) is not answering yet. By far the most common reason is that the page was loaded during startup: the API binds last, after database migrations (which can take minutes on a large database), model downloads, and process startup, while the web server is already serving. Wait for startup to finish. If it persists, the backend has failed to start, and the reason is earlier in the logs. This also explains a `connection refused to 127.0.0.1:5001` seen while loading `/ws`, because every authenticated request first makes an auth subrequest to that port. + + diff --git a/docs/docs/troubleshooting/cpu.md b/docs/docs/troubleshooting/cpu.md index a9f449ad88..50a38a61a7 100644 --- a/docs/docs/troubleshooting/cpu.md +++ b/docs/docs/troubleshooting/cpu.md @@ -3,7 +3,31 @@ id: cpu title: High CPU Usage --- -High CPU usage can impact Frigate's performance and responsiveness. This guide outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage. +High CPU usage can impact Frigate's performance and responsiveness. This guide explains how to interpret the CPU values Frigate reports and outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage. + +## Understanding Frigate's Reported CPU Usage + +Frigate's CPU percentages often look much higher than what the host reports. Usually both numbers are correct and are simply measured against different denominators, so confirm you actually have a problem before tuning anything. + +### Per-process values are relative to a single core + +The values Frigate reports for FFmpeg, capture, detect, detector, and other processes follow the same convention as `top`: 100% means one CPU core is fully saturated, not that the whole system is saturated. A multithreaded process such as FFmpeg can legitimately report well over 100%. + +Host and hypervisor tools instead report a percentage of the machine's total capacity across all cores. This includes `docker stats`, the `htop` summary, the Proxmox summary graph, the Unraid dashboard, Synology Resource Monitor, and Home Assistant's system monitor sensors. To reconcile the two: + +``` +host percentage ≈ (sum of Frigate's process percentages) / (number of cores) +``` + +On a 4 core system, an FFmpeg process reporting 100% is consuming one quarter of the machine, so the host will show roughly 25 to 30% once the remaining Frigate processes are included. That same 100% on a 16 core system is about 6%. Frigate's own warning thresholds use the per-core convention as well, so an FFmpeg process is flagged at 20% of a single core, not 20% of the system. + +### Instantaneous samples and averages measure different things + +Frigate collects stats every 15 seconds, and the `cpu` value covers only the interval since the previous collection. The `cpu_average` value in the stats API and MQTT payload is the average across the entire life of the process, and it is what the high CPU usage warnings are based on. Host dashboards generally plot data averaged over a longer window, so a single Frigate sample can show a peak that a host graph never displays. A process that has just started, such as FFmpeg after a camera reconnect, reports 0 until it has been sampled twice. + +### The system-wide value depends on what the container can see + +The system CPU value is read from `/proc/stat`. Under Docker that file belongs to the host, so the value covers the entire machine including workloads unrelated to Frigate, and it will not match `docker stats` for the Frigate container. Under an LXC container, lxcfs virtualizes `/proc/stat` and the value reflects only the cores assigned to the container. In a virtual machine, the guest sees only its assigned vCPUs while the hypervisor divides by every physical thread on the node, so guest and host percentages will not agree even when both are accurate. ## 1. Hardware Acceleration for Video Decoding @@ -24,7 +48,7 @@ Video decoding is one of the most CPU-intensive tasks in Frigate. While an AI ac ### Configuration -Frigate provides preset configurations for common hardware acceleration scenarios. Set up `hwaccel_args` based on your hardware in your [configuration](../configuration/reference) as described in the [getting started guide](../guides/getting_started). +Frigate provides preset configurations for common hardware acceleration scenarios. Set up `hwaccel_args` based on your hardware in your [configuration](../configuration/advanced/reference) as described in the [getting started guide](../guides/getting_started). ### Troubleshooting Hardware Acceleration @@ -44,7 +68,7 @@ Choosing the right detector for your hardware is the single most important facto ### Understanding Detector Performance -Frigate uses motion detection as a first-line check before running expensive object detection, as explained in the [motion detection documentation](../configuration/motion_detection). When motion is detected, Frigate creates a "region" (the green boxes in the debug viewer) and sends it to the detector. The detector's inference speed determines how many detections per second your system can handle. +Frigate uses motion detection as a first-line check before running expensive object detection, as explained in the [motion detection documentation](../configuration/motion_detection). When motion is detected, Frigate creates a "region" (the green boxes in the [debug viewer](/usage/live#the-single-camera-view)) and sends it to the detector. The detector's inference speed determines how many detections per second your system can handle. **Calculating Detector Capacity:** Your detector has a finite capacity measured in detections per second. With an inference speed of 10ms, your detector can handle approximately 100 detections per second (1000ms / 10ms = 100).If your cameras collectively require more than this capacity, you'll experience delays, missed detections, or the system will fall behind. @@ -58,7 +82,6 @@ When a single detector cannot keep up with your camera count, some detector type For detailed instructions on configuring multiple detectors, see the [Object Detectors documentation](../configuration/object_detectors). - **When to add a second detector:** - Skipped FPS is consistently > 0 even during normal activity @@ -70,4 +93,22 @@ The model you use significantly impacts detector performance. Frigate provides d **Model Size Trade-offs:** - Smaller models (320x320): Faster inference, Frigate is specifically optimized for a 320x320 size model. -- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame. \ No newline at end of file +- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame. + +For more detail on picking the right size, see [Choosing a model size](../configuration/object_detectors.md#choosing-a-model-size). + +## 3. Reducing Detector CPU Usage + +**Priority: High** + +The **Detector CPU Usage** metric measures the CPU spent converting frames into the tensor format the model expects and post-processing the model's output. It does not include inference, so this value can be high even when you've configured a GPU, NPU, or Coral for object detection. + +This metric scales with how many detections per second Frigate runs and how expensive each one is to prepare. Tuning [motion detection](../configuration/motion_detection) is usually the first recommendation to reduce the number of detections. Additionally, you can: + +- **Lower `detect -> fps`.** 5 is the recommended value for nearly all cameras. Running at 10 doubles the frames eligible for detection and is one of the largest contributors to this metric. +- **Use a 320x320 model.** A 640x640 model has 4 times as many pixels to transpose, convert, and copy on every inference. +- **Prefer a model that takes integer input.** Models configured with `input_dtype: float` require each frame to be converted to float32 and normalized on the CPU first. Models taking `int` input, such as the tflite models used by the Edge TPU, skip that step. +- **Do not match the detect resolution to the model resolution.** The detect stream should match your camera's aspect ratio, for example `1280x720`, not the model's input size. Frigate crops and scales regions of motion itself, so an oversized detect stream only adds work. +- **Tune stationary object behavior.** Objects that never settle into a stationary state are re-detected continuously. Raising `detect -> stationary -> interval` reduces how often detection runs on objects that are already parked. See [stationary objects](../configuration/stationary_objects). + +Adding [more detector instances](#multiple-detector-instances) spreads this work across more CPU cores, but does not reduce the total CPU used. diff --git a/docs/docs/troubleshooting/dummy-camera.md b/docs/docs/troubleshooting/dummy-camera.md index 89495844d0..3f13b1e190 100644 --- a/docs/docs/troubleshooting/dummy-camera.md +++ b/docs/docs/troubleshooting/dummy-camera.md @@ -3,17 +3,87 @@ id: dummy-camera title: Analyzing Object Detection --- -When investigating object detection or tracking problems, it can be helpful to replay an exported video as a temporary "dummy" camera. This lets you reproduce issues locally, iterate on configuration (detections, zones, enrichment settings), and capture logs and clips for analysis. +import NavPath from "@site/src/components/NavPath"; -## When to use +Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios. -- Replaying an exported clip to reproduce incorrect detections -- Testing configuration changes (model settings, trackers, filters) against a known clip -- Gathering deterministic logs and recordings for debugging or issue reports +## Reviewing Detections in the UI -## Example Config +Before setting up a replay, you can often diagnose detection issues by reviewing existing recordings directly in the Frigate UI. -Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml` like this: +### Detail View (History) + +The **Detail Stream** view in History shows recorded video with detection overlays (bounding boxes, path points, and zone highlights) drawn on top. Select a review item to see its tracked objects and lifecycle events. Clicking a lifecycle event seeks the video to that point so you can see exactly what the detector saw. + +### Tracking Details (Explore) + +In **Explore**, clicking a thumbnail opens the **Tracking Details** pane, which shows the full lifecycle of a single tracked object: every detection, zone entry/exit, and attribute change. The video plays back with the bounding box overlaid, letting you step through the object's entire lifecycle. + +### Annotation Offset + +Both views support an **Annotation Offset** setting (`detect.annotation_offset` in your camera config) that shifts the detection overlay in time relative to the recorded video. This compensates for the timing drift between the `detect` and `record` pipelines. + +These streams use fundamentally different clocks with different buffering and latency characteristics, so the detection data and the recorded video are never perfectly synchronized. The annotation offset shifts the overlay to visually align the bounding boxes with the objects in the recorded video. + +#### Why the offset varies between clips + +The base timing drift between detect and record is roughly constant for a given camera, so a single offset value works well on average. However, you may notice the alignment is not pixel-perfect in every clip. This is normal and caused by several factors: + +- **Keyframe-constrained seeking**: When the browser seeks to a timestamp, it can only land on the nearest keyframe. Each recording segment has keyframes at different positions relative to the detection timestamps, so the same offset may land slightly early in one clip and slightly late in another. +- **Segment boundary trimming**: When a recording range starts mid-segment, the video is trimmed to the requested start point. This trim may not align with a keyframe, shifting the effective reference point. +- **Capture-time jitter**: Network buffering, camera buffer flushes, and ffmpeg's own buffering mean the system-clock timestamp and the corresponding recorded frame are not always offset by exactly the same amount. + +The per-clip variation is typically quite low and is mostly an artifact of keyframe granularity rather than a change in the true drift. A "perfect" alignment would require per-frame, keyframe-aware offset compensation, which is not practical. Treat the annotation offset as a best-effort average for your camera. + +## Debug Replay + +Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time. + +The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame. The Debug Replay camera does not save recordings or snapshots or surface anything in Explore, but it otherwise behaves like a regular camera, including running enrichments such as Face Recognition, LPR, and custom classification. + +Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature. + +### When to use + +- Reproducing a detection or tracking issue from a specific time range +- Testing configuration changes (model settings, zones, filters, motion) against a known clip +- Gathering logs and debug overlays for a bug report + +:::note + +Only one replay session can be active at a time. If a session is already running, you will be prompted to navigate to it or stop it first. + +::: + +### Starting Debug Replay + +Debug Replay can be started from several places in the UI. The starting point determines the time range that gets replayed. + +- **History: Actions menu.** Navigate to , open the **Actions** menu in the toolbar, and choose **Debug Replay**. From here you can pick a preset (**Last 1 Minute**, **Last 5 Minutes**), select a range directly on the timeline with **From Timeline**, or enter exact start and end times with **Custom**. This is the most flexible option and the best choice when you want to add padding around a detection. On mobile, the same options appear in the Actions drawer. +- **History: Detail Stream event menu.** While viewing a review item in the Detail Stream, open the menu on a tracked object's event card and choose **Debug Replay**. The replay range is set automatically to that object's start and end times. +- **Explore: search result menu.** From an Explore card, open the kebab menu and choose **Debug Replay**. The range is taken from the tracked object's lifecycle. +- **Explore: Tracking Details Actions menu.** Open a tracked object's **Tracking Details** dialog, then choose **Debug Replay** from the Actions menu. Same automatic range as the search result menu. +- **Exports: export card menu.** From , open the menu on an export and choose **Debug Replay** to loop the exported clip through the detection pipeline for the camera it was exported from. + +The Detail Stream, Explore, and Exports entry points use the underlying recording or export's bounds with a small amount of padding. This can be convenient for quick checks, but if a detection is short or you want extra "settle" time for motion and the detector, start the replay from the History Actions menu instead and widen the range manually. + +### Variables to consider + +- 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. +- The replay camera inherits the source camera's zones. Any automations that trigger on those zone names will fire for the replay camera as well. This can be helpful when debugging zone behavior, but may be unexpected. You can add a condition on the source camera's name in your automation if you want to exclude replay triggers. + +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. + +## Manual Dummy Camera + +For advanced scenarios (such as testing with a clip from a different source, debugging ffmpeg behavior, or running a clip through a completely custom configuration), you can set up a dummy camera manually. + +### Example config + +Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml`: ```yaml cameras: @@ -32,29 +102,21 @@ cameras: enabled: false ``` -- `-re -stream_loop -1` tells `ffmpeg` to play the file in realtime and loop indefinitely, which is useful for long debugging sessions. -- `-fflags +genpts` helps generate presentation timestamps when they are missing in the file. +- `-re -stream_loop -1` tells ffmpeg to play the file in real time and loop indefinitely. +- `-fflags +genpts` generates presentation timestamps when they are missing in the file. -## Steps +### Steps 1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting. 2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later. - If you're debugging a specific camera, copy the settings from that camera (frame rate, model/enrichment settings, zones, etc.) into the temporary camera so the replay closely matches the original environment. Leave `record` and `snapshots` disabled unless you are specifically debugging recording or snapshot behavior. 3. Restart Frigate. -4. Observe the Debug view in the UI and logs as the clip is replayed. Watch detections, zones, or any feature you're looking to debug, and note any errors in the logs to reproduce the issue. +4. Observe the [Debug view](/usage/live#the-single-camera-view) in the UI and logs as the clip is replayed. Watch detections, zones, or any feature you're looking to debug, and note any errors in the logs to reproduce the issue. 5. Iterate on camera or enrichment settings (model, fps, zones, filters) and re-check the replay until the behavior is resolved. 6. Remove the temporary camera from your config after debugging to avoid spurious telemetry or recordings. -## Variables to consider in object tracking +### Troubleshooting -- The exported video will not always line up exactly with how it originally ran through Frigate (or even with the last loop). Different frames may be used on replay, which can change detections and tracking. -- Motion detection depends on the frames used; small frame shifts can change motion regions and therefore what gets passed to the detector. -- Object detection is not deterministic: models and post-processing can yield different results across runs, so you may not get identical detections or track IDs every time. - -When debugging, treat the replay as a close approximation rather than a byte-for-byte replay. Capture multiple runs, enable recording if helpful, and examine logs and saved event clips to understand variability. - -## Troubleshooting - -- No video: verify the path is correct and accessible from the Frigate process/container. -- FFmpeg errors: check the log output for ffmpeg-specific flags and adjust `input_args` accordingly for your file/container. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera. -- No detections: confirm the camera `roles` include `detect`, and model/detector configuration is enabled. +- **No video**: verify the file path is correct and accessible from the Frigate process/container. +- **FFmpeg errors**: check the log output and adjust `input_args` for your file format. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera. +- **No detections**: confirm the camera `roles` include `detect` and that the model/detector configuration is enabled. diff --git a/docs/docs/troubleshooting/edgetpu.md b/docs/docs/troubleshooting/edgetpu.md index 4ee25afd0f..bc50ae5040 100644 --- a/docs/docs/troubleshooting/edgetpu.md +++ b/docs/docs/troubleshooting/edgetpu.md @@ -12,7 +12,7 @@ There are many possible causes for a USB coral not being detected and some are O :::tip -Using `lsusb` or checking the hardware page in HA OS will show as `1a6e:089a Global Unichip Corp.` until Frigate runs an inferance using the coral. So don't worry about the identification until after Frigate has attempted to detect the coral. +Using `lsusb` or checking the hardware page in HA OS will show as `1a6e:089a Global Unichip Corp.` until Frigate runs an inference using the coral. So don't worry about the identification until after Frigate has attempted to detect the coral. ::: @@ -43,13 +43,13 @@ Some users have reported that this older device runs an older kernel causing iss 3. Start the docker container with Coral TPU enabled in the config 4. The TPU would be detected but a few moments later it would disconnect. 5. While leaving the TPU device plugged in, restart the NAS using the reboot command in the UI. Do NOT unplug the NAS/power it off etc. -6. Open the control panel - info scree. The coral TPU will now be recognised as a USB Device - google inc +6. Open the control panel - info screen. The coral TPU will now be recognized as a USB Device - google inc 7. Start the frigate container. Everything should work now! ### QNAP NAS -QNAP NAS devices, such as the TS-253A, may use connected Coral TPU devices if [QuMagie](https://www.qnap.com/en/software/qumagie) is installed along with its QNAP AI Core extension. If any of the features—`facial recognition`, `object recognition`, or `similar photo recognition`—are enabled, Container Station applications such as `Frigate` or `CodeProject.AI Server` will be unable to initialize the TPU device in use. -To allow the Coral TPU device to be discovered, the you must either: +QNAP NAS devices, such as the TS-253A, may use connected Coral TPU devices if [QuMagie](https://www.qnap.com/en/software/qumagie) is installed along with its QNAP AI Core extension. If any of the features (`facial recognition`, `object recognition`, or `similar photo recognition`) are enabled, Container Station applications such as `Frigate` or `CodeProject.AI Server` will be unable to initialize the TPU device in use. +To allow the Coral TPU device to be discovered, you must either: 1. [Disable the AI recognition features in QuMagie](https://docs.qnap.com/application/qumagie/2.x/en-us/configuring-qnap-ai-core-settings-FB13CE03.html), 2. Remove the QNAP AI Core extension or @@ -76,7 +76,7 @@ This is an issue due to outdated gasket driver when being used with new linux ke ### Not detected on Raspberry Pi5 -A kernel update to the RPi5 means an upate to config.txt is required, see [the raspberry pi forum for more info](https://forums.raspberrypi.com/viewtopic.php?t=363682&sid=cb59b026a412f0dc041595951273a9ca&start=25) +A kernel update to the RPi5 means an update to config.txt is required, see [the raspberry pi forum for more info](https://forums.raspberrypi.com/viewtopic.php?t=363682&sid=cb59b026a412f0dc041595951273a9ca&start=25) Specifically, add the following to config.txt @@ -87,7 +87,7 @@ dtoverlay=pcie-32bit-dma-pi5 ## Only One PCIe Coral Is Detected With Coral Dual EdgeTPU -Coral Dual EdgeTPU is one card with two identical TPU cores. Each core has it's own PCIe interface and motherboard needs to have two PCIe busses on the m.2 slot to make them both work. +Coral Dual EdgeTPU is one card with two identical TPU cores. Each core has its own PCIe interface and motherboard needs to have two PCIe busses on the m.2 slot to make them both work. E-key slot implemented to full m.2 electromechanical specification has two PCIe busses. Most motherboard manufacturers implement only one PCIe bus in m.2 E-key connector (this is why only one TPU is working). Some SBCs can have only USB bus on m.2 connector, ie none of TPUs will work. diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index bba4a1c187..ae29f6c9eb 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -29,7 +29,7 @@ You can open `chrome://media-internals/` in another tab and then try to playback ### What do I do if my cameras sub stream is not good enough? -Frigate generally [recommends cameras with configurable sub streams](/frigate/hardware.md). However, if your camera does not have a sub stream that a suitable resolution, the main stream can be resized. +Frigate generally [recommends cameras with configurable sub streams](/frigate/hardware.md). However, if your camera does not have a sub stream that is a suitable resolution, the main stream can be resized. To do this efficiently the following setup is required: @@ -39,6 +39,20 @@ To do this efficiently the following setup is required: When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results. +### How can I rotate my camera's video feed? + +Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first. + +If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams. + +```yaml +go2rtc: + streams: + my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90" +``` + +Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution. + ### My mjpeg stream or snapshots look green and crazy This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards. @@ -49,13 +63,13 @@ This almost always means that the width/height defined for your camera are not c These messages in the logs are expected in certain situations. Frigate checks the integrity of the recordings before storing. Occasionally these cached files will be invalid and cleaned up automatically. -### "On connect called" +### "MQTT connected" repeats in the logs -If you see repeated "On connect called" messages in your logs, check for another instance of Frigate. This happens when multiple Frigate containers are trying to connect to MQTT with the same `client_id`. +If you see repeated "MQTT connected" messages in your logs, check for another instance of Frigate. This happens when multiple Frigate containers are trying to connect to MQTT with the same `client_id`. ### Error: Database Is Locked -SQLite does not work well on a network share, if the `/media` folder is mapped to a network share then [this guide](../configuration/advanced.md#database) should be used to move the database to a location on the internal drive. +SQLite does not work well on a network share, if the `/media` folder is mapped to a network share then [this guide](../configuration/advanced/system.md#database) should be used to move the database to a location on the internal drive. ### Unable to publish to MQTT: client is not connected @@ -65,9 +79,17 @@ This is because Frigate does not run in host mode so localhost points to the Fri ### How do I know if my camera is offline -A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0. +Frigate publishes a per-role health status to [`frigate//status/`](/integrations/mqtt#frigatecamera_namestatusrole), where `` is each enabled role on the camera (`detect`, `record`, and `audio`). The published value is one of: -Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline. +- `online`: Frigate's process for that role is running normally +- `offline`: the process is down and Frigate is restarting it +- `disabled`: the camera is turned off, either at runtime or in the configuration file + +These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message. + +Because the status is per role, a camera whose substream is fine but whose recording stream has dropped will report `online` for `detect` and `offline` for `record`. The status is republished whenever it changes. + +You can also detect an offline camera through `/api/stats`, where `camera_fps` will be 0. ### How can I view the Frigate log files without using the Web UI? @@ -111,18 +133,51 @@ TCP ensures that all data packets arrive in the correct order. This is crucial f You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation. -### Why does Frigate keep creating new events for my parked car? +### Frigate is slow to start up with a "probing detect stream" message in the logs -Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one. +When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics. -Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one. +To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config: + +```yaml +cameras: + my_camera: + detect: + width: 1280 + height: 720 +``` + +### What is the `version` key in my config file? + +`version` records the config format that your config was last migrated to. On startup Frigate compares it against the format the running version expects, and if it is older it copies your config to `/config/backup_config.yaml`, rewrites it to the new format, and updates `version` as the final step. A config with no `version` key is assumed to predate 0.14 and is migrated from there. + +Frigate manages this key for you, so do not set or edit it. Raising it makes Frigate skip migrations your config still needs, and lowering it re-runs migrations against config that has already been converted. Either can leave you with a config that no longer validates. + +### Why does Frigate keep creating new tracked objects for my parked car? + +Stationary tracking is designed to _prevent_ this: a parked car should remain a single tracked object rather than generating new ones. If you're repeatedly getting new tracked objects for the same car, it's likely that Frigate is losing the object and re-detecting it as a new one. + +Open one of the tracked objects in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one. What helps: -- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking. -- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup. -- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md). -- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves. +- **Improve the view**: even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking. +- **Use a more accurate model**: switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup. +- **Don't set `detect -> stationary -> max_frames` for `car`**: it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md). +- **Restrict alerts to the areas you care about** with `required_zones`. See [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves. - **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc. -See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse. +See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold`. Note that raising them too high will make this exact problem worse. + +### How do I correct Frigate when it detects something as the wrong object? + +Frigate's object detection relies on a machine learning [model](../frigate/glossary.md#model), and the free [COCO-trained](https://cocodataset.org/#explore) models that ship with Frigate can misidentify objects in scenes they weren't trained on. There are two ways to handle this, depending on whether you want to _teach_ the model or just _suppress_ the bad result. + +**Train or fine-tune a model with your own images.** The most durable fix is to improve the model itself. The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. Some tools are freely available, and [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup. When Frigate mislabels something, open the tracked object in Explore, select the **Snapshot** tab, and use **Submit to Frigate+** to send the example with the correct label (or mark it as a [false positive](../frigate/glossary.md#false-positive)). Once you've submitted examples and [requested a model](../plus/first_model.md), the retrained model will be more accurate for your cameras. See [Submitting examples to Frigate+](../integrations/plus.md#submit-examples) for the full workflow. + +**Suppress the misidentification with filters.** You can use filters to stop a specific false positive from being tracked: + +- Tune `min_score` / `threshold`, or add `min_area` / `max_area` / `min_ratio` / `max_ratio` filters. See [Object Filters](../configuration/object_filters.md). +- If the false positive is always in the same fixed spot (like a statue or mailbox that reads as a person), add an [object filter mask](../configuration/masks.md#object-filter-masks) over that location. + +Filters and masks only hide the incorrect result - they don't teach Frigate what the object actually is. For that, fine-tune your own model or use Frigate+. diff --git a/docs/docs/troubleshooting/go2rtc.md b/docs/docs/troubleshooting/go2rtc.md new file mode 100644 index 0000000000..00ecd6f46e --- /dev/null +++ b/docs/docs/troubleshooting/go2rtc.md @@ -0,0 +1,237 @@ +--- +id: go2rtc +title: Troubleshooting go2rtc +--- + +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import NavPath from "@site/src/components/NavPath"; + +This page covers common problems with the bundled [go2rtc](/configuration/go2rtc) and how to resolve them, whether your cameras were added with the setup wizard or configured by hand. + +When a stream won't play or behaves oddly, the most important first step is to figure out **where** in the pipeline it breaks. Frigate's live view is a chain (_camera → go2rtc → your browser_), and each stage fails for different reasons. Work through the checks below in order, then jump to the matching problem category. + +## Start by isolating the problem + +### 1. Read the go2rtc logs + +Access the go2rtc logs in the Frigate UI under in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist). + +### 2. Test the stream in the go2rtc web interface + +If the logs look clean, open go2rtc's own web interface on port `1984`. This is the single most useful diagnostic, because it takes Frigate's UI out of the equation entirely. + +- If using Frigate through Home Assistant, enable the web interface at port `1984` (it is disabled by default, see [Home Assistant ports](#home-assistant-and-port-access)). +- If using Docker, forward port `1984` before accessing the web interface. + +Open the stream page for your camera (`http://:1984/stream.html?src=back`) and try each player link: + +- **If nothing plays here**, the problem is between the camera and go2rtc (codec, credentials, or transport), _not_ your browser. Fix it at the source before touching anything in Frigate. +- **If a player works here but Frigate's live view does not**, the problem is browser/codec related. Compare the **MSE** and **WebRTC** links. Frigate prefers MSE and only attempts WebRTC when MSE fails (or for two-way talk). If `mode=mse` plays but `mode=webrtc` does not, you have a [WebRTC codec problem](#webrtc-and-two-way-talk); if neither plays, your browser cannot decode the codec (commonly H.265, see [H.265 / HEVC cameras](#h265--hevc-cameras)). + +### 3. Inspect the negotiated codecs + +You can view detailed stream info, including the exact video and audio codecs go2rtc negotiated with the camera, at `http://frigate_ip:5000/api/go2rtc/streams` (or `http://frigate_ip:5000/api/go2rtc/streams/back` for a single camera). This is the authoritative answer to "what is my camera actually sending?" and is far more reliable than guessing from the camera's web UI. It also shows whether the audio track is `sendonly`/`recvonly`, which matters for [two-way talk](#webrtc-and-two-way-talk). + +### 4. Fix the codec with the FFmpeg module + +If the camera plays in go2rtc but not in your browser, the video or audio codec is unsupported. Browsers can reliably play **H.264** video and **AAC** audio; many cannot play H.265/HEVC, and some camera audio (G.711/PCM, MJPEG containers, etc.) is not playable at all. The fix is to have go2rtc re-encode the stream on demand using its FFmpeg module. + +In the Frigate UI this is the **Use compatibility mode (ffmpeg)** toggle on a stream source; in YAML it is the `ffmpeg:` prefix on the source URL. + + + + +1. Navigate to and expand your camera's stream. +2. On the source you want to convert, click the **Use compatibility mode (ffmpeg)** button (the sliders icon next to the URL). This routes the source through go2rtc's FFmpeg module and reveals the transcoding options. +3. Set **Video** to **Transcode to H.264** if your browser can't play the camera's video codec (e.g. H.265). Leave it on **Copy** to pass the video through untouched. This is much cheaper and should be your default whenever only the audio needs converting. +4. Set **Audio** to **Transcode to AAC** (for MSE) or **Transcode to Opus** (for WebRTC) if the camera's audio codec is unsupported. Leave it on **Copy** to keep the original, or **Exclude** to drop audio entirely. +5. When transcoding **video**, set **Hardware acceleration** to **Automatic (recommended)** so the encode runs on your GPU instead of the CPU. See [hardware-accelerated transcoding](#hardware-accelerated-transcoding-with-ffmpeg-8) for an important FFmpeg 8 caveat. +6. **Save** the section, then reload the live view. + + + + +```yaml +go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + # transcode video to H.264 on the GPU; only needed if the browser can't play the source codec + - "ffmpeg:back#video=h264#hardware" +``` + +To convert audio only (leaving video untouched), or to convert both: + +```yaml +go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + - "ffmpeg:back#audio=aac" # audio only, preferred when the video already plays + # or, to convert both video and audio: + # - "ffmpeg:back#video=h264#audio=aac#hardware" +``` + + + + +:::warning + +The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you. + +A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all. + +::: + +Transcoding video is resource intensive. Always prefer `#video=copy` (the **Copy** option) and only convert the track that is actually unsupported. If you must transcode video and have no hardware encoder available, the built-in jsmpeg view may be the better option. + +## Live view is black, buffering, or stuck in "low-bandwidth mode" + +When the live view shows a black screen, spins forever, or repeatedly drops to the lower-quality jsmpeg player ("low-bandwidth mode"), the stream almost always contains something the browser cannot decode over MSE, usually H.265 video or a non-AAC audio track. Confirm this in the go2rtc web UI (port `1984`): if MSE won't play there, Frigate can't play it either, since it uses the same pipeline. + +The fix is to produce an **H.264 + AAC** stream, either by changing your camera's firmware codecs or by transcoding in go2rtc (see [Fix the codec with the FFmpeg module](#4-fix-the-codec-with-the-ffmpeg-module)). A few other things worth checking: + +- **Set the camera's I-frame (keyframe) interval to match its frame rate** (or "1x" on Reolink), and avoid "smart"/"+" codecs like _H.264+_ or _H.265+_. A long keyframe interval delays the first decodable frame past Frigate's startup timeout, which forces the fallback to jsmpeg. See [camera settings recommendations](/configuration/live#camera-settings-recommendations). +- **A spinner that never clears, even though video plays in VLC**, is often an unplayable _audio_ track stalling playback. Drop or transcode the audio (see below). +- **Remote/VPN viewing that buffers** while the LAN is fine is usually latency/jitter exceeding MSE's startup buffer. Set up [WebRTC](/configuration/live#webrtc-extra-configuration), which drops late frames instead of buffering. + +The general live-view behavior (smart streaming, the MSE → WebRTC → jsmpeg fallback chain, and how to read browser console errors) is documented in detail in the [Live view FAQ](/configuration/live#live-view-faq). + +## H.265 / HEVC cameras + +H.265/HEVC playback in the browser is unreliable and version-dependent. WebRTC does not support H.265 on some browsers, and MSE/HEVC support varies by browser, OS, and whether a hardware decoder is present. An H.265 stream that plays fine in VLC, the go2rtc web UI, and Frigate's recordings can still be blank in a live view. + +For dependable live viewing, use **H.264** for the stream the live view consumes: + +- Point the live view at the camera's H.264 **substream** and keep the H.265 main stream for recording only, or +- Transcode H.265 → H.264 in go2rtc with the FFmpeg module and `#hardware` (software HEVC transcoding is very CPU heavy). + +Treat browser HEVC playback as best-effort. See also [H.265 cameras via Safari](/configuration/camera_specific#h265-cameras-via-safari). + +## No audio in Live view + +Live view audio has strict codec requirements that differ by player: **MSE requires AAC, PCMA, or PCMU**, and **WebRTC requires Opus, PCMA, or PCMU**. Many cameras default to a codec outside these sets (or to PCM/G.711), so the player loads video only and no audio control appears. + +The most robust approach is to provide both an AAC track (for MSE) and an Opus track (for WebRTC) on the same stream by transcoding audio with the FFmpeg module while copying the video: + + + + +1. Navigate to and expand the camera's stream. +2. Add a second **Source** that references the stream by name (e.g. the URL `ffmpeg:back`), enable **Use compatibility mode (ffmpeg)**, and set **Audio** to **Transcode to Opus** for WebRTC support. +3. Keep the original source as **Source 1** so MSE can use the camera's AAC (or transcode the first source's audio to AAC if the camera doesn't provide it). +4. **Save** the section. + + + + +```yaml +go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 # video + AAC for MSE + - "ffmpeg:back#audio=opus" # adds an Opus track for WebRTC +``` + +If the camera's native audio isn't AAC either, transcode both: + +```yaml +go2rtc: + streams: + back: + - "ffmpeg:rtsp://user:password@10.0.10.10:554/live0#video=copy#audio=aac" # video copy + AAC for MSE + - "ffmpeg:back#audio=opus" # Opus for WebRTC +``` + + + + +Setting the camera firmware to AAC (and H.264) avoids transcoding entirely and is always preferable when the camera supports it. For more detail and examples, see [Audio Support](/configuration/live#audio-support). + +## WebRTC and two-way talk + +WebRTC is only attempted when MSE fails or when using a camera's two-way talk feature; the "All Cameras" dashboard never uses it. When it doesn't work, the cause is almost always one of: + +- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above. +- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration). +- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream). + +## High CPU usage + +If go2rtc is using a lot of CPU, it is almost always transcoding in software. An FFmpeg source with a codec modifier like `#video=h264` or `#audio=aac` but **no** `#hardware` re-encodes on the CPU. (Frigate's `ffmpeg.hwaccel_args` only applies to Frigate's own detect/record processes. It does _not_ accelerate go2rtc's transcodes.) + +To keep CPU usage down: + +- Only transcode the track that is genuinely unsupported, and use `#video=copy` to pass video through untouched whenever possible. +- When you must transcode video, always add `#hardware` (the **Automatic** hardware option in the UI) so the encode runs on the GPU. Note the [FFmpeg 8 device requirement](#hardware-accelerated-transcoding-with-ffmpeg-8) below. +- Don't restream a high-resolution main stream just to feed the live view: even with `#video=copy`, muxing a 4K/8MP+ stream is inherently expensive. Use the camera's lower-resolution substream for live and detect, and let Frigate pull the main stream directly for recording. + +## Connection, authentication, and complex passwords + +If go2rtc logs `401 Unauthorized` for a URL that works in VLC, the password almost certainly contains reserved URL characters. **Frigate URL-encodes passwords for its own `cameras.ffmpeg.inputs`, but it does not touch what you write under `go2rtc.streams`**: go2rtc parses that URL itself. You must URL-encode special characters yourself in the `go2rtc.streams` section (`@` → `%40`, `#` → `%23`, `?` → `%3F`, `%` → `%25`, etc.). + +Note the asymmetry: under `cameras.ffmpeg.inputs` you should use the **raw** password (Frigate encodes it for you). Pre-encoding it there causes a double-encode and fails. See [Handling Complex Passwords](/configuration/restream#handling-complex-passwords). + +Repeated `401`/`Connection refused` errors can also mean the camera hit its **concurrent connection limit** or triggered a login lockout. Routing all roles through a single [RTSP restream](/configuration/restream#reduce-connections-to-camera) means the camera only ever sees one connection from go2rtc. + +## Stream names must match everywhere + +A surprising number of "the better live options aren't available" or `404 Not Found` problems come down to a name mismatch. The same string must be used consistently: + +- the **go2rtc stream key** (`go2rtc.streams.`), +- any `ffmpeg:#…` source that references it, +- the camera's restream input path (`rtsp://127.0.0.1:8554/`), and +- the camera name itself (so Frigate auto-maps it for MSE/WebRTC), or an explicit `live -> streams` mapping pointing at the go2rtc stream **name** (never a path). + +If you rename or remove a go2rtc stream while experimenting and the live stream selector then shows a blank entry, clear your browser's site data for the Frigate URL. The selected stream is cached per-device in local storage. + +## Camera-specific behavior + +Several camera brands have well-known quirks with go2rtc. Rather than repeat them here, see the [camera-specific configuration](/configuration/camera_specific) page, which covers them in detail. The highlights: + +- **Reolink**: RTSP is unreliable on many models; the **http-flv** stream through the FFmpeg module is recommended, and you must enable HTTP/RTMP in the camera and **reboot** it. 6MP+ models stream H.265 over http-flv-enhanced, which requires FFmpeg 8.0. See [Reolink Cameras](/configuration/camera_specific#reolink-cameras). +- **TP-Link Tapo**: use go2rtc's native `tapo://` source for stability and two-way audio; a stale RTSP credential can often be revived by clicking play once in the go2rtc web UI. +- **Ubiquiti/UniFi Protect**: use the `rtspx://` scheme (not `rtsps://…?enableSrtp`). +- **Amcrest/Dahua**: use the `/cam/realmonitor?channel=1&subtype=N` scheme, where `subtype=0` is the main stream. See [Amcrest & Dahua](/configuration/camera_specific#amcrest--dahua). + +## Non-RTSP sources and the FFmpeg module + +go2rtc's native zero-copy handling only supports well-formed RTSP H.264/H.265. Anything else (MJPEG, HTTP/HTTP-FLV, RTMP, or unusual codecs) must be handed to the FFmpeg module by prefixing the source with `ffmpeg:`. This is also necessary for some camera streams to be parsed at all, at the cost of slightly slower startup. MJPEG and other non-H.264 sources additionally need `#video=h264` (with `#hardware`) before they can be used for the `record`, `detect`, or restream roles. See [MJPEG Cameras](/configuration/camera_specific#mjpeg-cameras) for a complete example. + +## Hardware-accelerated transcoding with FFmpeg 8 + +Frigate 0.18 ships **FFmpeg 8.0** as the default, and FFmpeg 8 is stricter about hardware-accelerated filtering than earlier versions. Whenever go2rtc transcodes video with hardware acceleration (any source using `#hardware`, `#hardware=vaapi`, or the **Automatic** hardware option in the UI), it builds a filter chain that uploads frames to the GPU with the `hwupload` filter. FFmpeg 8 now refuses to do this unless it is told **which device** to use. Earlier versions selected one automatically. The result is that an otherwise-working transcode fails to start, the live view never loads, and go2rtc logs: + +``` +[hwupload] A hardware device reference is required to upload frames to. +[AVFilterGraph] Error initializing filters +Error opening output files: Invalid argument +``` + +The fix is to tell go2rtc's bundled FFmpeg which hardware device to use via the `go2rtc -> ffmpeg -> global` option. For **VAAPI**-based acceleration (which covers most Intel and AMD GPUs, and is what go2rtc selects automatically on that hardware), point it at your render device: + +```yaml +go2rtc: + ffmpeg: + global: "-vaapi_device /dev/dri/renderD128" + streams: + back: + - "ffmpeg:rtsp://user:password@10.0.10.10:554/live0#video=h264#hardware" +``` + +`/dev/dri/renderD128` is the usual render node; on a system with more than one GPU you may need `renderD129` (or higher), and the device must be passed into the container (e.g. `devices: - /dev/dri:/dev/dri` in Docker Compose). + +If you use a **different hardware acceleration backend**, you will likely need to specify its device in the same way, using the option that matches that backend instead of `-vaapi_device`. See the [go2rtc FFmpeg source documentation](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#source-ffmpeg) and the upstream report ([go2rtc issue #1984](https://github.com/AlexxIT/go2rtc/issues/1984)) for background and other examples. + +:::tip + +If you don't transcode in go2rtc with hardware acceleration, this does not affect you. If you want to avoid the change entirely, you can pin Frigate (and the go2rtc it bundles) back to FFmpeg 7.0 by setting `ffmpeg -> path: "7.0"` in your config. + +::: + +## Home Assistant and port access + +When running Frigate as a Home Assistant App, the go2rtc API (port `1984`), the RTSP restream (port `8554`), and WebRTC (port `8555`) are **disabled and hidden by default**. To use them (for example to reach the go2rtc web interface for troubleshooting, or to open a go2rtc stream externally in an app like VLC), go to , click **Show disabled ports**, enable the port you need, and save. Use the host's IP address rather than an mDNS name like `homeassistant.local`. + +If live view works in the Frigate UI but not in Home Assistant, the most common cause is the go2rtc stream name not matching the camera name: name the primary go2rtc stream exactly like the camera, or add a `live -> streams` mapping, so the integration can resolve the restream. diff --git a/docs/docs/troubleshooting/gpu.md b/docs/docs/troubleshooting/gpu.md index 6399f92d8b..a39386bfa9 100644 --- a/docs/docs/troubleshooting/gpu.md +++ b/docs/docs/troubleshooting/gpu.md @@ -10,4 +10,47 @@ title: GPU Errors Some users have reported issues using some Intel iGPUs with OpenVINO, where the GPU would not be detected. This error can be caused by various problems, so it is important to ensure the configuration is setup correctly. Some solutions users have noted: - In some cases users have noted that an HDMI dummy plug was necessary to be plugged into the motherboard's HDMI port. -- When mixing an Intel iGPU with Nvidia GPU, the devices can be mixed up between `/dev/dri/renderD128` and `/dev/dri/renderD129` so it is important to confirm the correct device, or map the entire `/dev/dri` directory into the Frigate container. \ No newline at end of file +- When mixing an Intel iGPU with Nvidia GPU, the devices can be mixed up between `/dev/dri/renderD128` and `/dev/dri/renderD129` so it is important to confirm the correct device, or map the entire `/dev/dri` directory into the Frigate container. + +## Intel/AMD GPU + +### Hardware acceleration is not being used + +For VAAPI or QSV to work, the GPU's render device must be passed through to the Frigate container. Intel and AMD GPUs expose this as a render node under `/dev/dri`, usually `/dev/dri/renderD128`. If it is not passed through, hardware acceleration is unavailable: ffmpeg fails to initialize it (for example `Failed to open the drm device` or `No VA display found for device`) and GPU usage stays at zero while CPU usage remains high. + +Pass the render device through when starting the container. With `docker compose`: + +```yaml +services: + frigate: + devices: + - /dev/dri/renderD128:/dev/dri/renderD128 # Intel / AMD GPU, update for your hardware +``` + +Or with `docker run`, add `--device /dev/dri/renderD128`. See the [installation docs](/frigate/installation) for a complete example. + +If it still isn't working after passing the device through: + +- **Confirm the render node exists and is the correct one.** Run `ls /dev/dri` on the host. You should see one or more `renderD12X` entries. Systems with more than one GPU (an Intel iGPU plus a discrete GPU) can expose both `/dev/dri/renderD128` and `/dev/dri/renderD129`, and the numbering is not guaranteed. Pass through the correct node, or map the entire directory (`/dev/dri:/dev/dri`, or `--device /dev/dri`) so all render nodes are available. +- **Check device permissions.** The Frigate process must be able to access the render node. This is usually automatic when the container runs as root (the default), but nested setups such as an unprivileged Proxmox/LXC container often require making the device accessible on the host (for example, a world-readable render node) or running the container privileged. Note that running Frigate inside an LXC is not officially supported. See the [installation docs](/frigate/installation#proxmox) for details. + +### Failed to download frame: -5 + +When using VAAPI or QSV hardware acceleration, ffmpeg may crash and restart periodically with a signature like this in the `ffmpeg..detect` log: + +``` +[AVHWFramesContext @ 0x...] Failed to sync surface ... (operation failed). +[hwdownload @ 0x...] Failed to download frame: -5. +[vf#0:0 @ 0x...] Error while filtering: Input/output error +[vf#0:0 @ 0x...] Task finished with error code: -5 (Input/output error) +[frigate.video] : Unable to read frames from ffmpeg process. +``` + +This is a hardware frame synchronization failure between ffmpeg and the GPU driver, not a Frigate bug. It comes from how a specific camera stream interacts with the GPU's decode and scaling path, so it is highly dependent on your hardware, driver, and stream. Frigate's automatic hardware acceleration detection is a best-guess effort, so the fix is usually to tune the configuration for your specific hardware and camera. The solutions below are ordered from most to least likely to help: + +- **Switch between the VAAPI and QSV presets.** On Intel Gen 12 and newer iGPUs, `preset-intel-qsv-h264` / `preset-intel-qsv-h265` is often more stable than the auto-detected `preset-vaapi`. See the [hardware acceleration docs](/configuration/hardware_acceleration_video.md#intel-based-cpus) for the recommended preset for your Intel generation. +- **Try a different VAAPI driver.** The default driver is `iHD`. On older Intel CPUs, `LIBVA_DRIVER_NAME=i965` can be more stable; on AMD GPUs use `LIBVA_DRIVER_NAME=radeonsi`. See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md#intel-based-cpus) for how to set the driver. +- **Use a codec that decodes more reliably.** H.265/HEVC streams may trigger this error far more often than H.264 depending on your CPU generation. If your camera exposes a separate sub-stream, assign an H.264 stream to the `detect` role. Cameras that output full-range YUV (for example some Hikvision models) are especially prone to it. +- **Match the detect resolution to the stream resolution.** When the `detect` resolution differs from the stream, Frigate inserts a GPU scaling filter (`scale_vaapi`), which is where these surface-sync failures can often originate. Set the `detect` `width` and `height` to match the exact resolution of the stream assigned the `detect` role. +- **Match the detect `fps` to the camera stream.** Aggressively dropping frames (for example `detect` `fps: 1` on a stream that runs at 15 fps) can cause timing mismatches in the GPU's frame buffer. Lower the sub-stream's frame rate on the camera itself instead of dropping most frames in Frigate. +- **Fall back to software decoding.** If none of the above resolve it, remove the preset for that camera (`hwaccel_args: []`). Hardware decoding is only an optimization. On a capable CPU, software-decoding a low-resolution sub-stream is inexpensive and gives a stable detect pipeline. diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index b1f180a82d..04b0240557 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -3,17 +3,13 @@ id: recordings title: Recordings Errors --- -## I have Frigate configured for motion recording only, but it still seems to be recording even with no motion. Why? +import FaqItem from "@site/src/components/FaqItem"; -You'll want to: +## Why are my recordings not working? (empty Recordings, "No recordings found for this time") -- Make sure your camera's timestamp is masked out with a motion mask. Even if there is no motion occurring in your scene, your motion settings may be sensitive enough to count your timestamp as motion. -- If you have audio detection enabled, keep in mind that audio that is heard above `min_volume` is considered motion. -- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner. +If Frigate shows live video but the History view is empty, or you see "No recordings found for this time", the cause is almost always in one of the three categories below. Segments are first written to the RAM cache and are only moved to disk if they match a retention policy _and_ the camera's `record` stream is producing valid, storable video. Work through the categories in order: retention configuration is by far the most common cause. -## I see the message: WARNING : Unable to keep up with recording segments in cache for camera. Keeping the 5 most recent segments out of 6 and discarding the rest... - -This error can be caused by a number of different issues. The first step in troubleshooting is to enable debug logging for recording. This will enable logging showing how long it takes for recordings to be moved from RAM cache to the disk. +Before diving in, enable debug logging for the recording maintainer so you can see whether segments are being written to disk at all: ```yaml logger: @@ -21,31 +17,284 @@ logger: frigate.record.maintainer: debug ``` -This will include logs like: +A healthy camera logs lines like `Copied /media/frigate/recordings/{segment_path} in 0.2 seconds`. If you never see these, no segments are reaching disk, which points at the camera/stream or storage sections below. + +### Retention configuration issues + + + +This is the single most common cause. Setting `record.enabled: True` on its own does **not** keep any footage: **continuous recording is disabled by default**, and segments in the cache are only moved to disk if they match a configured retention policy. You must configure at least one of `continuous`, `motion`, `alerts`, or `detections` retention. + +To store all video (the most conservative option), configure continuous retention: + +```yaml +record: + enabled: True + continuous: + days: 3 # keep all footage for 3 days +``` + +See [Recording](/configuration/record) for the full set of common configurations, including reduced-storage and alerts-only setups. + + + + + +If you only configured `motion`, `alerts`, or `detections` retention (with no `continuous`), Frigate keeps footage selectively based on the retention `mode`: + +- **`mode: motion`** (the default) only retains segments that contain motion. If your [motion masks](/configuration/motion_detection) cover the areas where activity happens, or your motion sensitivity is too low, nothing will be retained even though recording is "on". +- **`mode: active_objects`** only retains segments where a tracked object was actively moving. +- **`mode: all`** retains every segment in the window. + +If you expected continuous footage but only configured motion/event retention, add a `continuous` retention period as shown above. To verify motion is actually being detected, watch the motion boxes in the debug view or the Motion Tuner in the UI. + + + + + +`alerts` and `detections` retention only keep footage that overlaps a tracked object, so they depend on object detection running: + +- **Detection must be enabled.** If `detect: enabled: False`, no alerts or detections are ever created, so alert/detection retention keeps nothing. (Continuous and motion retention still work with detection disabled.) +- **The object must be supported by your model.** If you track an object your model doesn't support (for example `deer` or `license_plate` on the default model), Frigate never detects it and never records for it. Check your logs for warnings such as `... is configured to track ['deer'] objects, which are not supported by the current model` and remove unsupported objects or switch to a model (e.g. [Frigate+](/plus/)) that includes them. + + + + + +Configuration keys change between major versions. The old `clips` config, for example, has not existed for a long time. If you copied a config from an old blog post or video, verify every key against the current [reference config](/configuration/advanced/reference). + + + +### Camera and stream issues + + + +Frigate stores recordings in an MP4 container, and some camera audio codecs (most commonly `pcm_alaw`, `pcm_mulaw`, or other G.711 variants) **cannot be placed in an MP4 container**. When this happens, ffmpeg fails to write the segment and no recording is saved, even though the live view works fine. This is a frequent cause on Tapo, TP-Link VIGI, and some Reolink cameras. + +Transcode the audio to AAC (or drop it entirely) using the appropriate [ffmpeg preset](/configuration/ffmpeg_presets): + +```yaml +cameras: + your_camera: + ffmpeg: + output_args: + record: preset-record-generic-audio-aac # transcode audio to AAC + # or preset-record-generic to record with no audio +``` + + + + + +A message like `No new recording segments were created for in the last 120s` means ffmpeg cannot read the `record` stream. To diagnose: + +- Confirm a stream is actually assigned the `record` role in your camera's `ffmpeg.inputs`. +- Open the go2rtc web interface on port `1984` and click each stream to confirm it plays. go2rtc errors such as `wrong response on DESCRIBE` or `start from CONN state` indicate the camera connection is failing. +- Test the exact RTSP URL (with the correct path, port, and credentials) in VLC or `ffplay`. +- If you restream through go2rtc, make sure the `record` input path points at the correct go2rtc stream name. Copying a config between cameras without updating the stream name is a common mistake. + + + +### Storage and mounting issues + + + +If the recordings volume (`/media/frigate`) points at the wrong location, isn't writable, or a network/encrypted mount failed to mount at boot, Frigate cannot save recordings, or it silently writes to the boot drive and then purges aggressively because the drive appears far smaller than expected. + +- Compare the host's real capacity (`df -h`) against what the **Storage** page in the Frigate UI reports. A mismatch (for example Frigate reporting ~220 GB when your storage drive is 4 TB) means the bind mount is resolving to the wrong filesystem. +- Verify the host path in your Docker `volumes` mapping (`- /your/storage:/media/frigate`) exists and is writable by the container. +- For a mount that may fail intermittently, protecting the mount point with `chattr +i` on an empty directory forces Frigate to error out (rather than silently writing to the boot drive) when the mount is missing. +- Check `dmesg` and system logs for filesystem or I/O errors around the time recordings disappeared. + +If recordings _are_ being written but the copy is too slow to keep up, see the ["Unable to keep up with recording segments"](#i-see-the-message-warning--unable-to-keep-up-with-recording-segments-in-cache-for-camera-keeping-the-5-most-recent-segments-out-of-6-and-discarding-the-rest) question below. + + + +## Recordings won't play back + + + +When a recording refuses to play in the Frigate UI and you see an error like `Failed to play recordings (error 3): PIPELINE_ERROR_DECODE`, the message is coming from **your browser**, not from Frigate. `PIPELINE_ERROR_DECODE` is emitted exclusively by the media pipeline in **Chromium-based browsers** (Chrome, Edge, Brave, Vivaldi, Opera, Arc, and the Android WebView used by many in-app browsers) when the browser cannot decode a video or audio packet in the recording. WebKit browsers (Safari) report the same underlying problem with a different message, usually `Media failed to decode` or `DECODER_ERROR_NOT_SUPPORTED`. + +Frigate copies the `record` stream to disk **without re-encoding it**, so the browser must decode exactly what your camera produced, and Chromium's decoder is far stricter about malformed or nonstandard media than VLC or ffmpeg. + +:::warning + +The same recording playing perfectly in VLC, decoding cleanly with `ffprobe`/`ffmpeg`, or having a valid MP4 container does **not** mean the browser can decode it. VLC and ffmpeg are much more tolerant of codec quirks and damaged packets than a browser's media pipeline, so a "valid" file can still trigger `PIPELINE_ERROR_DECODE`. This is outside of Frigate's control, because Frigate never modifies the recording stream. + +::: + +#### Step 1: Confirm it is a browser issue + +Open the same recording in **Firefox** or **Safari**. Firefox and Safari both use a different media engine and cannot produce `PIPELINE_ERROR_DECODE`, so if playback works there you have confirmed a client-side codec or decoder problem rather than a bad recording. Switching browsers is a workaround, not a fix; the remaining steps address the root cause so that Chromium browsers work too. + +#### Step 2: Rule out H.265 / HEVC + +Browser support for H.265 (HEVC) is limited and depends on the operating system, GPU, hardware acceleration, and browser version, which makes it the most common cause of this error. Options, in order of reliability: + +- **Record H.264 instead.** Configure the camera's `record`/main stream to output H.264, the most compatible codec across all browsers. See [camera settings recommendations](/configuration/live#camera-settings-recommendations). +- **Transcode to H.264 with go2rtc.** If you must keep HEVC on the camera, have go2rtc re-encode the recording stream. This increases CPU usage; add `#hardware` to use the GPU where available: + + ```yaml + go2rtc: + streams: + your_camera: + # transcode video to h264 and audio to aac; #hardware uses the GPU if available + - "ffmpeg:rtsp://user:password@CAMERA_IP:554/stream#video=h264#audio=aac#hardware" + cameras: + your_camera: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/your_camera + input_args: preset-rtsp-restream + roles: + - record + ``` + + The `#video=h264` parameter only takes effect with the `ffmpeg:` source module; adding it to a plain `rtsp://` go2rtc source does nothing. + +- **Keep HEVC but improve compatibility.** If your browser and OS do support HEVC, set [`apple_compatibility`](/configuration/camera_specific#h265-cameras-via-safari) on the camera. Some players (Safari and other clients) require a specific HEVC stream format that this option corrects: + + ```yaml + cameras: + your_camera: + ffmpeg: + apple_compatibility: true + ``` + + You may also need to enable HEVC and hardware decoding in the browser itself (for example, Chrome's Settings → System → "Use hardware acceleration when available"). HEVC hardware support varies widely by GPU, OS, and browser version. + +#### Step 3: Clean up damaged packets from the camera + +If the error is **intermittent** (the same recording plays after a page refresh, or fails only after playing for a while), the camera is most likely emitting occasional corrupt or malformed packets. Some camera models are more prone to this than others. Routing the stream through go2rtc's `ffmpeg` module often "cleans up" the stream enough for the browser to decode it, even without changing the codec: + +```yaml +go2rtc: + streams: + your_camera: + - "ffmpeg:rtsp://user:password@CAMERA_IP:554/stream#video=h264#audio=aac" +``` + +#### Step 4: Fix incompatible or corrupt audio + +Audio is one of the most common culprits, and a decode failure on the audio track fails the whole recording. Make sure the camera outputs **AAC** audio, transcode the audio to AAC with go2rtc (`#audio=aac`), or drop audio entirely. See [Incompatible audio codec](#incompatible-audio-codec-recordings-silently-fail-to-save) for a preset-based way to do this. + +#### Step 5: Avoid "smart" / "+" codecs and check the keyframe interval + +- Disable any **"Smart Codec"**, **"H.264+"**, or **"H.265+"** feature in the camera. These nonstandard modes drop keyframes and change encoding parameters mid-stream, producing exactly the kind of packets a browser refuses to decode. (They also cause [short recording segments](#segments-are-only-1-second-long).) +- Set the camera's **I-frame (keyframe) interval equal to the frame rate** (for example `20` for a 20 fps stream). Long keyframe intervals slow the start of playback and make decode errors more likely. + +#### Step 6: Consider bitrate and the client hardware + +The browser decodes the video locally, so a stream that is too demanding can fail on one device while playing on another: + +- A **very high bitrate or resolution** (for example a 4K/8MP HEVC main stream) can overwhelm a low-power tablet, phone, or SBC and stall the decoder. Test the same recording on a desktop; if it plays there, lower the camera's bitrate or record a lower-resolution profile. +- Errors that name the client's GPU decoder, such as `VaapiVideoDecoder: failed Initialize()ing the frame pool`, indicate a browser hardware-decode problem. Toggling the browser's "Use hardware acceleration" setting (on or off) often resolves these. + + + + + +Frigate copies the `record` stream directly without re-encoding, so playback depends on your browser supporting the camera's codec. H265/HEVC recordings may not be playable in some browsers. If recordings appear as audio-only or a black screen, your camera is likely sending a codec your browser can't decode. Configure the camera to output **H264** for maximum compatibility. + +If playback instead fails with an explicit `PIPELINE_ERROR_DECODE` or `Media failed to decode` error, see [Recordings won't play back with "PIPELINE_ERROR_DECODE"](#pipeline-error-decode) above. + + + +## Recording cache warnings and errors + + + +If the record stream uses a "Smart Codec"/H.264+ mode or changes encoding parameters mid-stream, corrupt timestamps cause segments to be split far too frequently and fill the cache. This produces the "Too many unprocessed recording segments" warning. See [that question below](#i-see-the-message-warning--too-many-unprocessed-recording-segments-in-cache-for-camera-this-likely-indicates-an-issue-with-the-detect-stream) for the full diagnosis. + + + + + +Every recording segment is validated before it leaves the cache. Frigate probes each finished `.mp4` in `/tmp/cache` and requires a readable video stream and a valid duration before moving to storage. A segment that fails is deleted, so those ~10 seconds of footage are lost. Three messages come from this check: + +- `Invalid or missing video stream in segment . Discarding.` The segment holds no video, or could not be read at all. +- `Failed to probe corrupt segment ` followed by `Discarding a corrupt recording segment: `. The segment was read, but its length could not be determined. +- `Discarding a corrupt recording segment: ` on its own. The segment's length is impossible (empty, or longer than ten minutes), which points at broken timestamps coming from the camera. + +For each one, the camera watchdog also logs `Invalid recording segment detected for at `. + +:::warning + +This is almost always a **camera or network problem**, not a Frigate one. A segment is only complete once ffmpeg has finished writing it, so anything that interrupts the stream partway through leaves behind a file that cannot be saved. Frigate is reporting the interruption, not causing it. + +::: + +#### Start with the camera and the network + +- **The camera dropped the connection.** Cameras reboot, reinitialize their stream when switching to night mode, and cut clients off when they are overloaded or out of simultaneous connections. Count everything pulling from the camera at once: Frigate's detect and record streams, go2rtc, a phone app, and any other NVR each use one. Routing all roles through a single [RTSP restream](/configuration/restream#reduce-connections-to-camera) so the camera only ever sees one connection often resolves this by itself. +- **The link to the camera is unreliable.** WiFi cameras, powerline adapters, a saturated uplink, a failing switch port, or a marginal cable all produce this pattern, and usually only on one camera at a time. WiFi cameras are [not recommended](https://ipcamtalk.com/threads/multiple-cameras-high-bandwidth.77100/#post-861110). +- **The camera cannot reliably send what it is being asked for.** A high bitrate 4K stream can be more than the camera's own hardware can encode and push out under load. Lower the bitrate, or record a lower-resolution profile. +- **The camera is using a "Smart Codec", H.264+, or H.265+ mode.** These change encoding parameters mid-stream and produce the broken timestamps behind the corrupt-segment variant. Turn the mode off and set the camera's keyframe interval equal to its frame rate. See [Segments are only ~1 second long](#segments-are-only-1-second-long). + +Read the rest of the Frigate and/or go2rtc log around the **first** occurrence. When the camera or the network is at fault, other messages show up with it, such as `No frames received from in 20 seconds`, `Non-monotonic DTS`, `RTP: PT=xx: bad cseq`, `error while decoding MB`, or a connection timeout. Each of those is explained in [Common error messages](/troubleshooting/common_errors). To confirm the camera is the source, open its stream in the [go2rtc web interface](/troubleshooting/go2rtc) on port `1984` or play the same URL in VLC, and leave it running long enough for the failures to happen again. + +#### If the camera and network check out + +- **Audio the recording cannot store.** Some cameras send G.711 audio, which cannot be saved in an MP4 and stops segments from finalizing. See [Incompatible audio codec](#incompatible-audio-codec-recordings-silently-fail-to-save). +- **Frigate itself was stopped or restarted.** A single warning per camera around a restart is expected and needs no action. +- **The system ran out of room or memory.** A full `/tmp/cache`, or the host killing Frigate for using too much memory, cuts off the segment being written. Both leave other errors in the log alongside this one. See [No space left on device](#errno-28-no-space-left-on-device). + + + + + +When a camera stops producing usable recordings for two minutes, Frigate restarts that camera's record process to try to recover. The wording tells you how far the recordings got: + +- **`No new recording segments were created`**: no new segment file showed up in the cache at all, so ffmpeg isn't getting video out of the record stream. The camera is unreachable or refusing the connection, the stream URL, path, or credentials are wrong, or the camera accepted the connection and then sent nothing. See [The record stream isn't connecting](#the-record-stream-isnt-connecting). +- **`No new valid recording segments were created`** and **`No valid segments created since last invalid segment`**: recordings are arriving, but they keep failing validation, so the camera is sending video that cannot be saved. See [Invalid or missing video stream in segment](#invalid-or-missing-video-stream-in-segment) above. + +The restart is Frigate recovering from a problem, not causing one. One of these after a camera reboot or a brief network drop is normal. Seeing them repeat every couple of minutes means the camera or the network is still failing, and the restarts can extend the damage, because each one cuts off the segment that was being written. Work from the earliest failure in that camera's log rather than from the restarts. + + + + + +This warning means the recording maintainer cannot move recording segments from the RAM cache to disk fast enough. When the cache fills up, Frigate discards the oldest segments to avoid running out of memory and crashing, so you lose recorded footage. This is almost always a storage throughput or system resource problem. Work through the steps below to identify which. + +#### Step 1: Enable recording debug logging + +The first step is to measure how long each segment takes to move from the RAM cache to disk. Enable debug logging for the recording maintainer: + +```yaml +logger: + logs: + frigate.record.maintainer: debug +``` + +This adds log lines showing the copy duration for each segment: ``` DEBUG : Copied /media/frigate/recordings/{segment_path} in 0.2 seconds. ``` -It is important to let this run until the errors begin to happen, to confirm that there is not a slow down in the disk at the time of the error. +Let this run until the warnings begin to appear, so you can confirm whether the disk is actually slowing down at the moment the error occurs. -#### Copy Times > 1 second +#### Step 2: Interpret the copy times -If the storage is too slow to keep up with the recordings then the maintainer will fall behind and purge the oldest recordings to ensure the cache does not fill up causing a crash. In this case it is important to diagnose why the copy times are slow. +The copy duration tells you which direction to investigate: -##### Check RAM, swap, cache utilization, and disk utilization +- **Consistently longer than ~1 second**: your storage cannot keep up with the incoming recordings. Continue with Steps 3–5 to diagnose the slow storage. +- **Consistently well under 1 second**: storage is fast enough, and the problem is more likely CPU or resource contention. Skip to Step 6. -If CPU, RAM, disk throughput, or bus I/O is insufficient, nothing inside frigate will help. It is important to review each aspect of available system resources. +#### Step 3: Check RAM, swap, cache, and disk utilization -On linux, some helpful tools/commands in diagnosing would be: +If CPU, RAM, disk throughput, or bus I/O is insufficient, nothing inside Frigate will help. Review each aspect of available system resources while the warnings are occurring. -- docker stats -- htop -- iotop -o -- iostat -sxy --human 1 1 -- vmstat 1 +On Linux, some helpful tools/commands for diagnosing this are: -On modern linux kernels, the system will utilize some swap if enabled. Setting vm.swappiness=1 no longer means that the kernel will only swap in order to avoid OOM. To prevent any swapping inside a container, set allocations memory and memory+swap to be the same and disable swapping by setting the following docker/podman run parameters: +- `docker stats` +- `htop` +- `iotop -o` +- `iostat -sxy --human 1 1` +- `vmstat 1` + +On modern Linux kernels, the system will use some swap if it is enabled. Setting `vm.swappiness=1` no longer means the kernel will only swap in order to avoid OOM. To prevent any swapping inside the container, set the memory and memory+swap allocations to the same value and disable swapping by setting the following docker/podman run parameters: **Docker Compose example** @@ -67,16 +316,175 @@ services: --memory= --memory-swap= --memory-swappiness=0 ``` -NOTE: These are hard-limits for the container, be sure there is enough headroom above what is shown by `docker stats` for your container. It will immediately halt if it hits ``. In general, running all cache and tmp filespace in RAM is preferable to disk I/O where possible. +NOTE: These are hard limits for the container, so be sure there is enough headroom above what `docker stats` shows for your container. It will immediately halt if it hits ``. In general, keeping all cache and tmp filespace in RAM is preferable to disk I/O where possible. -##### Check Storage Type +#### Step 4: Check your storage type -Mounting a network share is a popular option for storing Recordings, but this can lead to reduced copy times and cause problems. Some users have found that using `NFS` instead of `SMB` considerably decreased the copy times and fixed the issue. It is also important to ensure that the network connection between the device running Frigate and the network share is stable and fast. +Mounting a network share is a popular option for storing recordings, but it can lead to reduced copy times and cause problems. Some users have found that using `NFS` instead of `SMB` considerably decreased copy times and fixed the issue. It is also important to ensure that the network connection between the device running Frigate and the network share is stable and fast. A saturated or unreliable link will stall copies. -##### Check mount options +#### Step 5: Check your mount options -Some users found that mounting a drive via `fstab` with the `sync` option caused dramatically reduce performance and led to this issue. Using `async` instead greatly reduced copy times. +Some users found that mounting a drive via `fstab` with the `sync` option dramatically reduced performance and led to this issue. Using `async` instead greatly reduced copy times. -#### Copy Times < 1 second +#### Step 6: Rule out CPU load -If the storage is working quickly then this error may be caused by CPU load on the machine being too high for Frigate to have the resources to keep up. Try temporarily shutting down other services to see if the issue improves. +If the copy times are consistently under 1 second but you still see the warning, the machine's CPU load is likely too high for Frigate to have the resources to keep up. Try temporarily shutting down other services, and any resource-intensive Frigate features, to see if the issue improves. + + + + + +This warning means that the detect stream for the affected camera has fallen behind or stopped processing frames. Frigate's recording cache holds segments waiting to be analyzed by the detector. When more than 6 segments pile up without being processed, Frigate discards the oldest ones to prevent the cache from filling up. + +:::warning + +This error is a **symptom**, not the root cause. The actual cause is always logged **before** these messages start appearing. You must review the full logs from Frigate startup through the first occurrence of this warning to identify the real issue. + +::: + +#### Step 1: Get the full logs + +Collect complete Frigate logs from startup through the first occurrence of the error. Look for errors or warnings that appear **before** the "Too many unprocessed" messages begin. That is where the root cause will be found. + +#### Step 2: Check the cache directory + +Exec into the Frigate container and inspect the recording cache: + +``` +docker exec -it frigate ls -la /tmp/cache +``` + +Each camera should have a small number of `.mp4` segment files. If one camera has significantly more files than others, that camera is the source of the problem. A problem with a single camera can cascade and cause all cameras to show this error. + +#### Step 3: Verify segment duration + +Recording segments should be approximately 10 seconds long. Run `ffprobe` on segments in the cache to check: + +``` +docker exec -it frigate ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 /tmp/cache/@.mp4 +``` + +If segments are only ~1 second instead of ~10 seconds, the camera is sending corrupt timestamp data, causing segments to be split too frequently and filling the cache 10x faster than expected. + +**Common causes of short segments:** + +- **"Smart Codec" or "Smart+" enabled on the camera**: These features dynamically change encoding parameters mid-stream, which corrupts timestamps. Disable them in your camera's settings. +- **Changing codec, bitrate, or resolution mid-stream**: Any encoding changes during an active stream can cause unpredictable segment splitting. +- **Camera firmware bugs**: Check for firmware updates from your camera manufacturer. + +:::tip + +You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce. + +::: + +#### Step 4: Check for a stuck detector + +If the detect stream is not processing frames, segments will accumulate. Common causes: + +- **Detection resolution too high**: Use a substream for detection, not the full resolution main stream. +- **Detection FPS too high**: 5 fps is the recommended maximum for detection. +- **Model too large**: Use smaller model variants (e.g., YOLO `s` or `t` size, not `e` or `x`). Use 320x320 input size rather than 640x640 unless you have a powerful dedicated detector. +- **Virtualization**: Running Frigate in a VM (especially Proxmox) can cause the detector to hang or stall. This is a known issue with GPU/TPU passthrough in virtualized environments and is not something Frigate can fix. Running Frigate in Docker on bare metal is recommended. + +#### Step 5: Check for GPU hangs + +On the host machine, check `dmesg` for GPU-related errors: + +``` +dmesg | grep -i -E "gpu|drm|reset|hang" +``` + +Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date. + +#### Step 6: Verify hardware acceleration configuration + +An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources. + +- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`). +- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`). +- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream. + +#### Step 7: Verify go2rtc stream configuration + +Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely. + +#### Step 8: Check system resources + +If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host: + +- **CPU usage**: An overloaded CPU can prevent the detector from keeping up. +- **RAM and swap**: Excessive swapping dramatically slows all I/O operations. +- **Disk I/O**: Use `iotop` or `iostat` to check for saturation. +- **Storage space**: Verify you have free space on the Frigate storage volume (check the Storage page in the Frigate UI). + +Try temporarily disabling resource-intensive features like `genai` and `face_recognition` to see if the issue resolves. This can help isolate whether the detector is being starved of resources. + + + + + +This message means the recording maintainer hit an error while moving segments from the cache to disk. It is a **generic wrapper**: the actual cause is always logged on the **very next line**. Frigate usually recovers and keeps running, but any affected segments are lost, so it is worth resolving. + +:::warning + +Always read the line immediately following this message. `Error occurred when attempting to maintain recording cache` on its own tells you nothing; the exception on the next line (for example `[Errno 28] No space left on device` or `[Errno 17] File exists`) is the real problem. + +::: + +Because these are operating-system-level errors, they must be resolved on the **host**, not within Frigate's configuration. The most common underlying errors are below. + +#### [Errno 28] No space left on device + +The filesystem Frigate is writing to is full. Things to check: + +- **The recordings volume is genuinely full.** Check free space on the host with `df -h` for the path mapped to `/media/frigate`, and review the **Storage** page in the Frigate UI. +- **The disk shows free space but is still "full".** This usually means the filesystem has run out of **inodes** (check with `df -i`), or recordings are landing on a different, smaller filesystem than you expect because of an incorrect bind mount. See [The storage volume isn't mounted correctly](#the-storage-volume-isnt-mounted-correctly) above. +- **`/tmp/cache` is full.** If you mounted `/tmp/cache` as a small `tmpfs`, a backlog of segments can fill it. Increase the tmpfs size, or address whatever is causing segments to pile up (see the [Too many unprocessed recording segments](#i-see-the-message-warning--too-many-unprocessed-recording-segments-in-cache-for-camera-this-likely-indicates-an-issue-with-the-detect-stream) question above). +- **The host blocks writes before Frigate can purge.** On some systems (for example Unraid with a fill-up threshold), the host stops writes before Frigate's emergency cleanup can run. Leave more headroom on the volume, or lower your retention so Frigate purges sooner. + +#### [Errno 17] File exists (with ffmpeg "Error writing trailer" or "unable to re-open output file") + +Errors like `[Errno 17] File exists: '/media/frigate/recordings/.../'`, often alongside ffmpeg errors such as `Unable to re-open ... output file for shifting data` or `Error writing trailer: No such file or directory`, are a hallmark of an **unreliable network share** (NFS or SMB). The mount is dropping, serving stale directory entries, or mishandling file locking. + +- Confirm the network connection to the NAS is stable and fast. An intermittent link produces these errors sporadically. +- Prefer **NFS over SMB** for the recordings mount; several users have found NFS more reliable and faster. +- Review your `fstab`/mount options for settings that hurt consistency or performance (see the `sync` vs `async` note in the [Unable to keep up with recording segments](#i-see-the-message-warning--unable-to-keep-up-with-recording-segments-in-cache-for-camera-keeping-the-5-most-recent-segments-out-of-6-and-discarding-the-rest) question above). +- Enable `frigate.record.maintainer` debug logging to confirm whether the errors line up with the share becoming unavailable. + +#### Errors referencing a camera you manually renamed or removed + +If the next-line error references a camera name that no longer exists in your config, orphaned data is left over from a rename or removal in a persistent `/tmp/cache` volume. + +- Using a `tmpfs` mount for `/tmp/cache` as recommended in the [installation docs](/frigate/installation#storage) prevents stale cache files under the old camera name from surviving a restart, which avoids this issue entirely. +- If errors persist, stop Frigate and remove any leftover segments for the old camera name from `/tmp/cache`. + + + +## Other recording questions + + + +You'll want to: + +- Make sure your camera's timestamp is masked out with a motion mask. Even if there is no motion occurring in your scene, your motion settings may be sensitive enough to count your timestamp as motion. +- If you have audio detection enabled, keep in mind that audio that is heard above `min_volume` is considered motion. +- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner. + + + + + +The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**. + +In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour. + +This is expected behavior, not a bug: + +- Previews for hours that already completed and were written to disk are unaffected. +- The next full hour after a restart will generate previews normally. +- This is unrelated to `shm_size`; increasing shared memory does not change it. + +To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible. + + diff --git a/docs/docs/usage/explore.md b/docs/docs/usage/explore.md new file mode 100644 index 0000000000..49cdc82023 --- /dev/null +++ b/docs/docs/usage/explore.md @@ -0,0 +1,101 @@ +--- +id: explore +title: Explore +--- + +import NavPath from "@site/src/components/NavPath"; + +**Explore** is where you browse and search every **tracked object** Frigate has saved. By default it groups recent objects by label; when [Semantic Search](/configuration/semantic_search) is enabled, you can also search by natural-language description or visual similarity. Selecting any object opens a detail pane with its snapshot, lifecycle, and metadata. + +This page describes how to _use_ the Explore view. For how the underlying features are _configured_, see [Semantic Search](/configuration/semantic_search) and [Generative AI descriptions](/configuration/genai/genai_objects). + +:::tip + +If you just want to quickly see what happened on your cameras, it's recommended to use [Review](/usage/review) rather than Explore. Review groups overlapping and adjacent activity on a camera into **review items** and sorts them into Alerts, Detections, and Motion, so you can scan and play back footage in a few clicks instead of sifting through individual objects. Reach for Explore when you need to find a _specific_ tracked object after the fact: by label, time, zone, or description. + +::: + +## Browsing tracked objects + +The default view shows your most recent tracked objects grouped into rows by label (_Person_, _Car_, _Dog_, and so on), each row labeled with the object type and a count. The arrow at the end of a row opens the full, filterable grid for that label. + +Clicking a thumbnail opens its [detail dialog](#tracked-object-details); right-clicking or long-pressing a thumbnail opens an [actions menu](#actions-and-bulk-selection). You can switch to a denser grid layout and adjust the number of columns from the view's settings. + +## Searching + +When [Semantic Search](/configuration/semantic_search) is enabled, a search bar appears that combines two things in one input: + +- **Natural-language search**: type a free-text query and press Enter to run a semantic search over your tracked objects. +- **Filter tokens**: type a `key:` to get suggestions, then a value, to add a structured filter. Each filter becomes a removable chip, and you can chain several together. + +You can save a search with the star icon and reload it later, and clear everything with the clear-search icon. A help popover explains the token syntax, for example: + +``` +cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM +``` + +### Filter reference + +The most common filter tokens are: + +| Filter | Description | +| ---------------------------- | ---------------------------------------------------------------------------------- | +| **Cameras** | Limit to one or more cameras. | +| **Labels** | Object labels (person, car, etc.). | +| **Sub Labels** | Recognized sub labels (e.g. a recognized face or name). | +| **Attributes** | Classification attributes applied to the object. | +| **Recognized License Plate** | Match a recognized plate. | +| **Zones** | Objects that entered specific zones. | +| **Before / After** | Restrict to a date range. | +| **Time Range** | Restrict to a time of day (`HH:MM-HH:MM`). | +| **Min / Max Score** | Restrict by the object's confidence score. | +| **Min / Max Speed** | Restrict by estimated speed (when speed estimation is configured). | +| **Has Snapshot / Has Clip** | Only objects that saved a snapshot or recording. | +| **Submitted to Frigate+** | Only objects already submitted (when Frigate+ is enabled). | +| **Search Type** | Whether semantic search matches the object's **Thumbnail** or its **Description**. | + +### Sorting + +When a filter or search is active, a **Sort** control lets you order results by **date**, **object score**, or **estimated speed** (ascending or descending). When a semantic query or similarity search is active, results can also be ordered by **relevance**. + +### Thumbnail and description search + +- The **Search Type** setting controls whether a text query is matched against each object's **thumbnail** or its **description**. Each result indicates which one it matched and the confidence. + +Natural-language search, thumbnail search, and description search all require [Semantic Search](/configuration/semantic_search) to be enabled. + +## Tracked Object Details + +Selecting an object opens the **Tracked Object Details** dialog. Use the arrows (or the left/right keys) to step to the previous or next object. The dialog has two tabs: + +- **Snapshot** or **Thumbnail**: the saved snapshot (or thumbnail). +- **Tracking Details**: the object's lifecycle, available when the object has a recording. It lists each significant moment (detected, entered a zone, became active or stationary, left, and so on); clicking a moment plays that part of the recording with the bounding box overlaid. A settings popover lets you show all zones and adjust the annotation offset. + +The details pane shows the object's **label**, **scores**, **camera**, **timestamp**, estimated **speed**, any **recognized license plate** and **classification attributes**, and its **description**. Admins can edit the sub label, license plate, and attributes inline. + +The **description** can be edited by hand, and, when [Generative AI descriptions](/configuration/genai/genai_objects) are enabled and the object's lifecycle has ended, regenerated from the snapshot or from thumbnails. For `speech` objects, a **Transcribe** action is available when audio transcription is enabled. When [Frigate+](/integrations/plus) is enabled, admins can submit a snapshot to improve their model directly from this pane. + +## Actions and bulk selection + +Right-clicking or long-pressing an object (in the grid or its thumbnail) opens an actions menu with options to **download** the video, snapshot, or a clean snapshot; **view tracking details**; **find similar**; **add a trigger**; **view in History**; and **delete the tracked object**. + +:::note + +Deleting a tracked object removes its snapshot, embeddings, and tracking-details entries, but the recorded footage of that object in [History](/usage/history) is **not** deleted. + +::: + +To act on many objects at once, Ctrl/Cmd-click or right-click to start a selection (selected tiles gain a blue ring), then use the toolbar to select all, clear the selection, or delete (admins). + +## Semantic Search - Usage and best practices {#usage-and-best-practices} + +1. Semantic Search is used in conjunction with the other filters available on the Explore page. Use a combination of traditional filtering and Semantic Search for the best results. +2. Use the thumbnail search type when searching for particular objects in the scene. Use the description search type when attempting to discern the intent of your object. +3. Because of how the AI models Frigate uses have been trained, the comparison between text and image embedding distances generally means that with multi-modal (`thumbnail` and `description`) searches, results matching `description` will appear first, even if a `thumbnail` embedding may be a better match. Play with the "Search Type" setting to help find what you are looking for. Note that if you are generating descriptions for specific objects or zones only, this may cause search results to prioritize the objects with descriptions even if the ones without them are more relevant. +4. Make your search language and tone closely match exactly what you're looking for. If you are using thumbnail search, **phrase your query as an image caption**. Searching for "red car" may not work as well as "red sedan driving down a residential street on a sunny day". +5. Semantic search on thumbnails tends to return better results when matching large subjects that take up most of the frame. Small things like "cat" tend to not work well. +6. Experiment! Find a tracked object you want to test and start typing keywords and phrases to see what works for you. + +## Triggers + +From an object's actions menu, **Add trigger** sets up a per-camera trigger that uses Semantic Search to automate an action (a notification, sub label, or attribute) whenever a similar object appears. Triggers require Semantic Search and are managed under . See [Triggers](/configuration/semantic_search#triggers) for full configuration and best practices. diff --git a/docs/docs/usage/exports.md b/docs/docs/usage/exports.md new file mode 100644 index 0000000000..885daa06f2 --- /dev/null +++ b/docs/docs/usage/exports.md @@ -0,0 +1,43 @@ +--- +id: exports +title: Exports +--- + +**Exports** are how you keep a specific piece of footage permanently. + +Frigate's recordings are governed by your [retention settings](/configuration/record): once footage ages past its retention window (or, depending on your configuration, once it is only kept where motion, alerts, or detections occurred), it is deleted to free up disk space. An **export** saves a copy of a chosen time range to a separate location that is **never removed by retention**, so it stays available until you delete it yourself. + +This is the answer to the common question _"how do I stop Frigate from deleting an important clip?"_ Instead of increasing retention for an entire camera (which uses far more storage to protect a single moment), export just the footage you want to keep. + +:::tip + +Exports are stored under `/media/frigate/exports`, separate from your recordings, and are not counted against or removed by recording retention. They remain on disk until you delete them, so be aware that they accumulate over time. + +::: + +## Creating an export + +There are a few ways to create an export: + +- **From Review**: select (right click or long-press) an individual review item directly, and choose Export from the header menu. You can also select multiple review items and export them all at once, optionally grouping them into a [case](#cases). +- **From History**: open the **Actions** menu and choose **Export**. You can export a preset duration (the last 1, 4, 8, 12, or 24 hours), enter a custom start and end time, or select a range directly on the timeline. A **multi-camera** option lets you export the same time range across several cameras at once. + +In every case you can give the export a name. Frigate then saves the footage from your recordings as a single video file. Larger ranges take time to process; the export is marked _in progress_ until it finishes, and you can keep using Frigate while it runs. + +## Managing exports + +All of your exports live on the **Exports** page, reachable from the main navigation, where you can search for one by name. Each export offers the following actions: + +- **Play** it in the browser, +- **Download** it to save the footage outside of Frigate, +- **Share** it: copies a direct link to the export (or uses your device's share sheet), +- **Rename** it, and +- **Delete** it: deleting is the only way an export is removed. + +You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there. + +## Cases + +A **case** groups related exports together: for example, all the clips from a single incident across multiple cameras. On the **Exports** page you can create a case with a name and description, add existing exports to it (or create a new case while exporting), and **download the entire case as a single archive** to hand off as one package. + +Exports that don't belong to a case appear under **Uncategorized Exports**. Deleting a case lets you either keep its exports (they move back to uncategorized) or delete them along with the case. diff --git a/docs/docs/usage/history.md b/docs/docs/usage/history.md new file mode 100644 index 0000000000..5ed99f440a --- /dev/null +++ b/docs/docs/usage/history.md @@ -0,0 +1,75 @@ +--- +id: history +title: History +--- + +import NavPath from "@site/src/components/NavPath"; + +**History** is Frigate's full-resolution recording viewer. Unlike Live, Review, and Explore, there is no menu item for it. You reach it from within another view, then scrub the timeline, switch cameras, inspect a tracked object's lifecycle, and export or share any moment. + +This page describes how to _use_ the History view. For how recordings are _configured_ (retention, pre/post capture), see [Recording](/configuration/record). + +## Opening History + +You can open History from several places: + +- **From [Review](/usage/review):** clicking a review item opens its recording, scrubbed to just before the activity on that camera. +- **From [Live](/usage/live):** the **History** button in a camera's single-camera view opens that camera about 30 seconds in the past. +- **From a share link:** opening a shared timestamp link (see [Share Timestamp](#the-actions-menu) below) jumps straight to that camera and moment. + +Use the **Back** button to return where you came from, or the **Live** button to jump to the current camera's live view. + +:::tip + +If you see **"No recordings found for this time"**, the most common causes are: recording was not enabled for that camera at the time of the event; the retention window has since expired and those segments were removed; or storage ran low and Frigate deleted them early to free space. See [Recording](/configuration/record) to verify your retention settings. + +::: + +## Timeline, Events, and Detail + +A toggle (a drawer on mobile) switches the side panel between three modes: + +- **Timeline**: a scrubbable vertical timeline of the selected camera. Horizontal lines down the center represent motion, with longer lines indicating more motion at that moment. Review items are marked as shaded areas (**red** for alerts, **orange** for detections), and sections with no colored background are times when no recording exists. +- **Events**: a scrollable list of the camera's review items for the time range; clicking one seeks the player to it. +- **Detail**: the [tracking details inspector](#the-detail-view) for the objects in view. + +While you are selecting a range to export, the panel temporarily switches to Timeline. + +## Scrubbing and previews + +Drag the timeline handlebar to move through time; the main player and any secondary camera previews scrub together so everything stays in sync. Press the zoom buttons on the timeline to change its zoom level (from coarse to fine segments). Sections of the timeline with no recordings are shown as gaps. + +On desktop, when more than one camera is available, a **row of secondary previews** shows the other cameras at the same moment. Clicking one of them makes it the main camera at the current timestamp, so you can follow activity across cameras without losing your place. On mobile, use the camera drawer to switch cameras. + +## Filtering and the calendar + +You can filter History by **cameras** and **date**. The calendar behaves the same as it does in [Review](/usage/review#filtering-and-the-calendar): an **underline** under a day means recordings exist for that day, and a **colored dot** (red for unreviewed alerts, orange for unreviewed detections) marks days with unreviewed activity. + +## The Detail view + +The **Detail** mode turns the side panel into a tracking details inspector. It lists one card per review item, each showing the item's severity, start time, the object labels involved, a count of tracked objects, and the duration. The active card is highlighted as the video plays, and clicking a card seeks to it. + +Expanding a card reveals the **lifecycle** of each tracked object: a row for each significant moment (detected, entered a zone, became active, became stationary, left, and so on), with a progress line that follows the current playback position. Hovering a row shows that moment's score, ratio, and area, and clicking a row seeks the video to that exact timestamp. + +The **Detail View Settings** at the bottom let you toggle whether the active item's objects expand automatically, and adjust the **annotation offset**: a fine timing correction that aligns the bounding-box overlays with the recorded video when your camera's snapshot and recording timestamps drift. Admins can save the offset to the camera's configuration. + +## The Actions menu + +On desktop, the **Actions** menu (the film icon) collects the things you can do with the footage you are viewing: + +- **Export**: save a clip of a chosen time range so it is never removed by retention. The dialog pre-selects the last hour; adjust the range or drag the timeline handles, then export. See [Exports](/usage/exports) for managing and downloading exports. +- **Share Timestamp**: generate a link to the current moment (or a custom timestamp) to share with another Frigate user. This is an internal link, not a public share URL. +- **Motion Search**: scan this camera's recordings for changes in a region you draw. This is the same tool documented under [Reviewing Motion](/usage/review#motion-search). +- **Debug Replay** (admins): replay a recorded range back through Frigate's detection pipeline to see how it would be processed. + +You can also capture an instant snapshot of the current frame, and submit a frame to [Frigate+](/integrations/plus) directly from the player (admins only). + +## AI review summaries + +When [Generative AI review](/configuration/genai/genai_review) is configured, Frigate can generate a title, description, and threat classification for review items and surface them as you scrub through History. A review item that has an AI summary exposes its details in a few places: + +- **Over the video**: when the item is on screen, a popup appears over the player. +- **In the Events side panel**: items with a summary show the title below the thumbnail. +- **In the Detail side panel**: the item's card shows the title alongside its tracking details. + +Clicking any of these opens the **AI Analysis** dialog with the generated detail and any flagged concerns for that item. diff --git a/docs/docs/usage/live.md b/docs/docs/usage/live.md new file mode 100644 index 0000000000..9366add9de --- /dev/null +++ b/docs/docs/usage/live.md @@ -0,0 +1,118 @@ +--- +id: live +title: Live View +--- + +import NavPath from "@site/src/components/NavPath"; + +**Live view** is Frigate's real-time dashboard and the page you land on by default. It shows all of your cameras at a glance, streams your most recent alerts across the top, and lets you open any camera in a full-resolution single-camera view with audio, two-way talk, PTZ, and on-demand recording controls. + +This page describes how to _use_ the Live view. For how to _configure_ live streaming (go2rtc, stream selection, smart streaming, WebRTC, and audio), see the [Live View configuration](/configuration/live) docs. + +## The dashboard at a glance + +The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard. Only **alerts** appear in the filmstrip. To suppress a label or zone from showing there, configure it as a detection instead (see [Alerts and Detections](/configuration/review#alerts-and-detections)). + +By default Frigate uses **smart streaming**: a camera's image updates roughly once per minute while nothing is happening, and switches to a full live stream the moment activity is detected. This conserves bandwidth and resources. You can change this for each camera when using a camera group (see [Streaming settings](#streaming-settings-and-the-right-click-menu) below), and the behavior is explained in detail under [Live view technologies](/configuration/live#live-view-technologies). + +On mobile, a toggle in the header switches between a **grid** layout and a single-column **list** layout. On desktop a **fullscreen** button is available in the lower-right corner. + +## Switching dashboards and camera groups + +The icon rail (top-left on desktop, a horizontal strip on mobile) switches between dashboards: + +- The **home** icon is the **All Cameras** dashboard, which shows every camera enabled for the dashboard. +- Each **camera group** you create appears as its own icon. Selecting a group shows only that group's cameras. + +Camera groups are useful for organizing cameras by location (for example, _Front of House_ or _Backyard_) and for giving each group its own dashboard layout and camera streaming preferences. + +You can also view [Birdseye](/configuration/birdseye) on the dashboard, or open it directly at `http://:5000/#birdseye`. Clicking a camera inside the Birdseye view jumps to that camera's live feed. + +## Creating and editing camera groups + +Admins can manage groups from the pencil icon next to the group rail, which opens the **Camera Groups** dialog. From there you can add a group, or edit and delete existing ones. When creating a group you choose: + +- a **Name** (spaces are converted to underscores), +- the **cameras** to include (each camera has a toggle and a gear that opens its [streaming settings](#streaming-settings-and-the-right-click-menu)), and +- an **icon** used for the group's button in the rail. + +Deleting a group also clears any custom layout you saved for it. + +## Rearranging a camera group layout + +On desktop and tablet, each camera group has its own freely-arrangeable grid. Enter **Edit Layout** mode from the layout button in the lower-right corner: camera tiles gain a drag handle and corner resize handles. Drag a tile to reposition it and drag a corner to resize it (the aspect ratio is preserved). Exit edit mode to save. The layout is stored in your browser per device, so each device can have its own arrangement. + +The default **All Cameras** dashboard is not manually arrangeable. It automatically sizes tiles based on each camera's aspect ratio (wide cameras span two columns, tall cameras span two rows). + +## Reading the tile indicators + +Each camera tile surfaces its current state with a few overlays: + +- A **pulsing red dot** in the corner means **motion is currently detected** on that camera. +- A **red outline** around the tile means an **active tracked object** is on that camera. +- A small **label chip** lists the object types currently detected (for example, _Person_, _Car_). +- A **camera-name label** appears when you have enabled always-on camera names, or when a camera is offline or disabled. +- A **Stream Offline** or **Camera is off** placeholder appears when no frames are being received or the camera has been turned off. + +You can optionally overlay live streaming statistics (stream type, bandwidth, latency, and frame counts) on a tile to diagnose playback issues. + +## Streaming settings and the right-click menu + +Right-clicking (or long-pressing) a camera tile opens a context menu with quick controls: an **audio volume** control for streams that support audio, **Mute / Unmute all cameras**, **show or hide streaming statistics**, the **debug view**, **notification** options, and, for admins, turning the camera on or off. If the audio control doesn't appear, see [Audio Support](/configuration/live#audio-support). Audio requires go2rtc configured with a compatible codec. + +A **Low-bandwidth mode** notice may also appear in the context menu with a **Reset** option when Frigate has fallen back to the lower-quality jsmpeg stream. See the [Live view FAQ](/configuration/live#live-view-faq) for why this happens. + +For non-default groups, the context menu also exposes **Streaming Settings** for that camera, which let you choose: + +- the **stream** to display (the dropdown lists the streams you configured under [`live -> streams`](/configuration/live#setting-streams-for-live-ui), and indicates whether audio is available), +- the **streaming method**: **No Streaming**, **Smart Streaming** (recommended), or **Continuous Streaming** (higher bandwidth), and +- **compatibility mode**, for devices that have trouble rendering the default player. + +These settings are saved per group and per device in your browser, not in your config file. + +## The single-camera view + +Clicking a camera tile opens its full-resolution single-camera view. The top bar provides: + +- **Back** (also the `Esc` key) to return to the dashboard, +- **History** to jump to the [recordings](/usage/history) for this camera, starting about 30 seconds in the past, +- **Fullscreen** and **Picture-in-Picture** (if supported by your browser), +- **Two-way talk** (the microphone button, which requires a supported camera and WebRTC; keyboard shortcut `t`), and +- **Camera audio muting** (the speaker button; keyboard shortcut `m`). + +You can pinch or scroll to zoom into the feed. A **settings** gear provides a **stream** selector (with audio and two-way-talk availability indicators), **Play in background**, **Show stats**, and a **Debug view** that overlays Frigate's detection regions and bounding boxes. + +:::tip + +Two-way talk and camera audio have specific codec and port requirements. See [Audio Support](/configuration/live#audio-support) and [WebRTC](/configuration/live#webrtc-extra-configuration) for setup details. + +::: + +## Camera controls + +Admins get a row of toggles in the single-camera view (a settings drawer on mobile) to turn camera features on and off in real time: + +- **Camera** on/off, +- **Object detection**, +- **Recording** (only available when recording is enabled in the camera's config), +- **Snapshots**, +- **Audio detection**, +- **Live audio transcription** (when audio detection is enabled), and +- **Autotracking** (for [autotracking-capable PTZ cameras](/configuration/autotracking)). + +These toggles change runtime behavior immediately. Whether a change persists across a restart depends on the feature. See the relevant configuration page. + +## On-demand recording and snapshots + +The single-camera view can capture footage on demand: + +- **Start on-demand recording** begins a manual recording based on the camera's recording retention settings (the button pulses while active). If recording is disabled for the camera, only a snapshot is saved. Use **End on-demand recording** to stop. +- **Download instant snapshot** saves a still image of the current frame. + +See [Recording](/configuration/record) and [Snapshots](/configuration/snapshots) for how retention is configured, and [Exports](/usage/exports) for keeping a clip permanently. + +## PTZ controls + +For ONVIF cameras that support it, a control panel provides pan/tilt arrows, **zoom**, **focus**, and saved **presets**. You can also enable a **click-to-move / drag-to-zoom** overlay: click a point in the frame to center the camera there, or drag a box to pan and zoom to that area (dragging top-left to bottom-right zooms in, the reverse zooms out). + +For continuous, automatic tracking of a moving object, see [Autotracking](/configuration/autotracking). diff --git a/docs/docs/usage/review.md b/docs/docs/usage/review.md new file mode 100644 index 0000000000..6416b500e1 --- /dev/null +++ b/docs/docs/usage/review.md @@ -0,0 +1,140 @@ +--- +id: review +title: Review +--- + +import NavPath from "@site/src/components/NavPath"; + +**Review** is where you triage what happened on your cameras. It groups activity into **review items**, segments of time on a single camera that bundle together the objects and audio that were active at once, and sorts them into **Alerts**, **Detections**, and **Motion**. From here you can scrub through activity, mark items as reviewed, filter, export, and jump to the full recording in [History](/usage/history). + +This page describes how to _use_ the Review view. For how alerts and detections are _configured_ (labels, zones, required zones, retention), see the [Review configuration](/configuration/review) docs. + +:::info + +Review items are only created for a camera when **object tracking and recording are enabled** for that camera. See [Recording](/configuration/record). + +::: + +## Alerts, Detections, and Motion + +Not every segment of video captured by Frigate is of the same level of interest. The people who enter your property may be a higher priority than those just walking by on the sidewalk. For this reason, Frigate sorts **review items** by importance into **alerts** and **detections**, with a separate **Motion** category for significant motion. + +The toggle at the top of the page switches between these three severities. One is always selected. + +| Tab | Indicator color | What it shows | +| -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Alerts** | dark red | The activity you most want to see. By default, all `person` and `car` tracked objects are alerts. | +| **Detections** | orange | Everything else Frigate tracked that wasn't promoted to an alert. | +| **Motion** | yellow | Periods of significant motion, with the ability to filter to periods which did **not** produce a tracked object. | + +This same color coding is used for the ring around a selected item and the dots on the calendar. How an object is categorized as an alert vs. a detection, and how required zones refine that, is covered in [Alerts and Detections](/configuration/review#alerts-and-detections). + +The **Alerts** and **Detections** tabs show a count next to their label. With **Show Reviewed** turned off (the default), this is the number of items still left to review; with it on, the count reflects every item in the selected time range. + +## Marking items as reviewed + +Review items are shown as a grid of thumbnail cards next to a vertical activity timeline. Hovering a card (desktop) or swiping to the right (mobile) plays a short preview inline. + +- **Clicking** a card opens its recording in [History](/usage/history) and marks the item as reviewed. +- The object chip on each card is **gray** when the item is unreviewed and turns **green** once it has been reviewed. +- The **Mark these items as reviewed** button marks everything currently shown as reviewed at once. + +Reviewed state is tracked per user, so marking an item reviewed does not hide it for other users. Marking an item reviewed does not delete anything: the footage and the review item itself remain until they expire via retention. + +## Selecting and acting on multiple items + +To act on several items at once, start a selection by **Ctrl/Cmd-clicking** a card (desktop) or **long-pressing** one (mobile). Selected cards gain a colored ring matching their severity. Keyboard shortcuts speed this up: `Ctrl+A` selects all, `R` marks the selection reviewed, and `Esc` clears it. + +With items selected, an action bar appears with options to: + +- **Export** the selected items (a single item exports directly; multiple items open the batch [export](/usage/exports) dialog), +- **Mark as reviewed** or **Mark as unreviewed**, and +- **Delete** them (admins only). + +## Filtering and the calendar + +Use the filter controls in the header to narrow what's shown. The available filters depend on the tab: Alerts and Detections can be filtered by **cameras**, **date**, **labels**, **zones**, and whether items are already reviewed; the Motion tab can be filtered by **cameras**, **date**, and **motion only**. + +The **calendar** filter lets you jump to a specific day (it shows **Last 24 Hours** until you pick one). On each day: + +- An **underline** under the day number means **recordings exist** for that day. Days without recordings are dimmed. +- A **colored dot** under the day number means there is **unreviewed activity** that day: a **red dot** for unreviewed alerts, or an **orange dot** for unreviewed detections when there are no unreviewed alerts. Motion is not represented by a dot. + +Future dates are disabled, and the week start and time zone follow your configuration. + +## 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](/configuration/motion_detection) for how the underlying motion detector is configured. + +The **Motion** tab itself shows a multi-camera grid scrubbed to a shared point in time, with a draggable timeline and a playback-speed selector. A camera tile gains a colored ring when a review item or significant motion overlaps the current time, and clicking a tile opens that camera's recording at that moment. Each camera's options menu (the kebab in the corner of its tile) is where you open **Motion Previews** and **Motion Search**, described below. + +### 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 page, click the kebab 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, open the Actions menu in [History](/usage/history) or click the kebab menu on a camera in the page and choose **Motion Search**. In the dialog: + +1. Pick the camera and time range to scan. In the date pickers, days that have recordings available are underlined. +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 size of a single moving region, as a percentage of the ROI, for a frame to count as significant. Raise it to ignore small movements (leaves, distant motion); lower it when your subject covers only a small slice of the ROI. Every result shows the percentage it scored, so you can use those values to tune this. | +| **Maximum Results** | Maximum number of matching timestamps to return. The search stops once it reaches this many results, so a lower value finishes sooner while a higher value scans further into the range. | +| **Parallel mode** | Decode multiple recording ranges at the same time. Speeds up large time ranges at the cost of higher decoding and CPU usage. | + +Motion Search samples each recording's keyframes automatically, so there is no frame-rate or sampling setting to tune. + +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 results panel shows the time range being scanned, a live progress bar with the timestamp currently being analyzed, and the running result count. A collapsible **Search Metrics** section reports how many segments were scanned and processed, how many were skipped because no motion was recorded in the ROI (using the stored motion heatmap), how many frames were decoded, and the total search time. Skipping segments with no recorded motion in the selected ROI is what makes searching long time ranges practical. + +#### Common use cases + +Frigate's main use case is to record and surface tracked objects, so Motion Search is most useful for the cases where object detection produced nothing: there is no object to find in Explore, but you suspect something happened. + +- **Locating an unattributed change.** You know something appeared, disappeared, or moved in a window of footage (a package now gone, a gate left open), but no detection points to it. A search returns the candidate timestamps instead of scrubbing the timeline by hand. +- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it. +- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly. + +#### Examples + +These show how to choose the ROI and **Minimum Change Area** for two common goals. Minimum Change Area is the size of a single moving region as a percentage of the ROI you draw, so the right value depends on how much of the ROI your subject, and its movement between samples, covers. + +Because samples are a second or more apart, a moving subject usually appears in two places at once in the comparison, so even ordinary motion often scores tens of percent and a low threshold lets in almost everything. The most reliable approach is to **run a search, look at the percentage each result scored, and set Minimum Change Area just below the values for the events you care about.** The default is 20%; the suggestions below are starting points. + +- **When did this item first appear (or disappear)?** A package was dropped off, a car parked, or a trash can was moved, and you want the exact moment. Draw a **tight ROI** around the spot the item occupies and **raise Minimum Change Area** (start around 40–60%). Because the item fills most of a tight ROI, its arrival or removal is a large change, while smaller nearby motion (shadows, a passing pedestrian) stays below the threshold. The **earliest result** is when it appeared; if you only care about that moment, a low Maximum Results finishes faster. If you get no hits, the ROI is probably looser than the item: lower the threshold or tighten the ROI. +- **What's been getting into the garden?** Something has been trampling a flower bed overnight and no object was ever tracked. Draw a **looser ROI** covering the whole bed and use a **lower Minimum Change Area than the case above**: start near the 20% default and lower it (toward 5–10%) only if a small or distant subject is missed, since it covers just a slice of a large region. Expect more results to scan through: step through the timestamps and jump to each to see what triggered it. If wind-blown plants add noise, raise Minimum Change Area or the Sensitivity Threshold. + +#### Expected performance + +Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer. + +To increase the speed of searches: + +- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed, forcing you to lower Minimum Change Area, which lets in more noise. +- Narrow the time range to the window you care about, so there is less footage to examine. +- Lower **Maximum Results** when you only need the first few hits. Because the search stops once it reaches that many results, a smaller value lets a busy range finish early instead of scanning the whole window. +- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher decoding and CPU usage while it runs. + +## AI review summaries + +When [Generative AI review](/configuration/genai/genai_review) is configured, Frigate can generate a title, description, and threat classification for review items and surface them automatically in Review and History. Clicking the summary chip opens an **AI Analysis** dialog with the generated detail and any flagged concerns. + +In Review, an additional icon appears on unreviewed items that the AI classified as **suspicious** (Level 1) or **critical** (Level 2), so the activity that most warrants attention stands out before you open it. The icon goes away once the item has been reviewed. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index e11cdd5555..69ab7c3f70 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -186,6 +186,7 @@ const config: Config = { }, plugins: [ path.resolve(__dirname, "plugins", "raw-loader"), + path.resolve(__dirname, "plugins", "yaml-loader"), [ "docusaurus-plugin-openapi-docs", { diff --git a/docs/package-lock.json b/docs/package-lock.json index be16754be3..2310274651 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -14,9 +14,11 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", @@ -5747,6 +5749,11 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -10897,9 +10904,9 @@ "license": "MIT" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/express/node_modules/range-parser": { @@ -10964,9 +10971,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -12313,9 +12320,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/import-fresh": { @@ -12883,7 +12890,7 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { diff --git a/docs/package.json b/docs/package.json index 0ff76c4739..1649df7e0c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,9 +3,10 @@ "version": "0.0.0", "private": true, "scripts": { + "build:config": "node scripts/build-config.mjs", "docusaurus": "docusaurus", - "start": "npm run regen-docs && docusaurus start --host 0.0.0.0", - "build": "npm run regen-docs && docusaurus build", + "start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0", + "build": "npm run build:config && npm run regen-docs && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -23,9 +24,12 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", + "marked": "^16.4.2", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", diff --git a/docs/plugins/js-yaml-loader.js b/docs/plugins/js-yaml-loader.js new file mode 100644 index 0000000000..66451bd3a9 --- /dev/null +++ b/docs/plugins/js-yaml-loader.js @@ -0,0 +1,9 @@ +const yaml = require("js-yaml"); + +// Webpack loader that compiles a YAML file into a default-exported JS object, +// so docs data can be authored in YAML (block scalars, no quote/newline +// escaping) but imported exactly like the JSON it replaces. +module.exports = function (source) { + const data = yaml.load(source); + return `export default ${JSON.stringify(data)};`; +}; diff --git a/docs/plugins/yaml-loader.js b/docs/plugins/yaml-loader.js new file mode 100644 index 0000000000..475940e6f2 --- /dev/null +++ b/docs/plugins/yaml-loader.js @@ -0,0 +1,23 @@ +const path = require("node:path"); + +// Enables importing YAML data files from docs/data as plain JS objects. +// Scoped to the data directory so it never intercepts other .yaml files +// (e.g. the OpenAPI spec under static/). +module.exports = function (context, options) { + return { + name: "yaml-data-loader", + configureWebpack(config, isServer, utils) { + return { + module: { + rules: [ + { + test: /\.ya?ml$/, + include: path.resolve(__dirname, "..", "data"), + use: path.resolve(__dirname, "js-yaml-loader.js"), + }, + ], + }, + }; + }, + }; +}; diff --git a/docs/scripts/README.md b/docs/scripts/README.md new file mode 100644 index 0000000000..347536a07b --- /dev/null +++ b/docs/scripts/README.md @@ -0,0 +1,184 @@ +# Documentation Scripts + +## generate_ui_tabs.py + +Automatically generates "Frigate UI" tab content for documentation files based on the YAML config examples already in the docs. + +Instead of manually writing UI instructions for every YAML block, this script reads three data sources from the codebase and generates the UI tabs: + +1. **JSON Schema** (from Pydantic config models) -- field names, types, defaults +2. **i18n translation files** -- the exact labels shown in the Settings UI +3. **Section mappings** (from Settings.tsx) -- config key to UI navigation path + +### Prerequisites + +Run from the repository root. The script imports Frigate's Python config models directly, so the `frigate` package must be importable: + +```bash +# From repo root -- no extra install needed if your environment can import frigate +python3 docs/scripts/generate_ui_tabs.py --help +``` + +### Usage + +#### Preview (default) + +Shows what would be generated for each bare YAML block, without modifying any files: + +```bash +# Single file +python3 docs/scripts/generate_ui_tabs.py docs/docs/configuration/record.md + +# All config docs +python3 docs/scripts/generate_ui_tabs.py docs/docs/configuration/ +``` + +#### Inject + +Wraps bare YAML blocks with `` and inserts the generated UI tab. Also adds the required imports (`ConfigTabs`, `TabItem`, `NavPath`) after the frontmatter if missing. + +Already-wrapped blocks are skipped (idempotent). + +```bash +python3 docs/scripts/generate_ui_tabs.py --inject docs/docs/configuration/record.md +``` + +#### Check + +Compares existing UI tabs against what the script would generate from the current schema and i18n files. Prints a unified diff for each drifted block and exits with code 1 if any drift is found. + +Use this in CI to catch stale docs after schema or i18n changes. + +```bash +python3 docs/scripts/generate_ui_tabs.py --check docs/docs/configuration/ +``` + +#### Regenerate + +Replaces the UI tab content in existing `` blocks with freshly generated content. The YAML tab is preserved exactly as-is. Only blocks that have actually changed are rewritten. + +```bash +# Preview changes without writing +python3 docs/scripts/generate_ui_tabs.py --regenerate --dry-run docs/docs/configuration/ + +# Apply changes +python3 docs/scripts/generate_ui_tabs.py --regenerate docs/docs/configuration/ +``` + +#### Output to directory (`--outdir`) + +Write generated files to a separate directory instead of modifying the originals. The source directory structure is mirrored. Files without changes are copied as-is so the output is a complete snapshot suitable for diffing. + +Works with `--inject` and `--regenerate`. + +```bash +# Generate into a named directory +python3 docs/scripts/generate_ui_tabs.py --inject --outdir /tmp/generated docs/docs/configuration/ + +# Then diff original vs generated +diff -rq docs/docs/configuration/ /tmp/generated/ + +# Or let an AI agent compare them +diff -ru docs/docs/configuration/record.md /tmp/generated/record.md +``` + +This is useful for AI agents that need to review the generated output before applying it, or for previewing what `--inject` or `--regenerate` would do across an entire directory. + +#### Verbose mode + +Add `-v` to any mode for detailed diagnostics (skipped blocks, reasons, unchanged blocks): + +```bash +python3 docs/scripts/generate_ui_tabs.py -v docs/docs/configuration/ +``` + +### Typical workflow + +```bash +# 1. Preview what would be generated (output to temp dir, originals untouched) +python3 docs/scripts/generate_ui_tabs.py --inject --outdir /tmp/ui-preview docs/docs/configuration/ +# Compare: diff -ru docs/docs/configuration/ /tmp/ui-preview/ + +# 2. Apply: inject UI tabs into the actual docs +python3 docs/scripts/generate_ui_tabs.py --inject docs/docs/configuration/ + +# 3. Review and hand-edit where needed (the script gets you 90% there) + +# 4. Later, after schema or i18n changes, check for drift +python3 docs/scripts/generate_ui_tabs.py --check docs/docs/configuration/ + +# 5. If drifted, preview then regenerate +python3 docs/scripts/generate_ui_tabs.py --regenerate --outdir /tmp/ui-regen docs/docs/configuration/ +# Compare: diff -ru docs/docs/configuration/ /tmp/ui-regen/ + +# 6. Apply regeneration +python3 docs/scripts/generate_ui_tabs.py --regenerate docs/docs/configuration/ +``` + +### How it decides what to generate + +The script detects two patterns from the YAML block content: + +**Pattern A -- Field table.** When the YAML has inline comments (e.g., `# <- description`), the script generates a markdown table with field names and descriptions: + +```markdown +Navigate to . + +| Field | Description | +|-------|-------------| +| **Continuous retention > Retention days** | Days to retain recordings. | +| **Motion retention > Retention days** | Days to retain recordings. | +``` + +**Pattern B -- Set instructions.** When the YAML has concrete values without comments, the script generates step-by-step instructions: + +```markdown +Navigate to . + +- Set **Enable recording** to on +- Set **Continuous retention > Retention days** to `3` +- Set **Alert retention > Event retention > Retention days** to `30` +- Set **Alert retention > Event retention > Retention mode** to `all` +``` + +**Camera-level config** is auto-detected when the YAML is nested under `cameras:`. The output uses a generic camera reference rather than the example camera name from the YAML: + +```markdown +1. Navigate to and select your camera. + - Set **Enable recording** to on + - Set **Continuous retention > Retention days** to `5` +``` + +### What gets skipped + +- YAML blocks already inside `` (for `--inject`) +- YAML blocks whose top-level key is not a known config section (e.g., `go2rtc`, `docker-compose`, `scrape_configs`) +- Fields listed in `hiddenFields` in the section configs (e.g., `enabled_in_config`) + +### File structure + +``` +docs/scripts/ +├── generate_ui_tabs.py # CLI entry point +├── README.md # This file +└── lib/ + ├── __init__.py + ├── schema_loader.py # Loads JSON schema from Pydantic models + ├── i18n_loader.py # Loads i18n translation JSON files + ├── section_config_parser.py # Parses TS section configs (hiddenFields, etc.) + ├── yaml_extractor.py # Extracts YAML blocks and ConfigTabs from markdown + ├── ui_generator.py # Generates UI tab markdown content + └── nav_map.py # Maps config sections to Settings UI nav paths +``` + +### Data sources + +| Source | Path | What it provides | +|--------|------|------------------| +| Pydantic models | `frigate/config/` | Field names, types, defaults, nesting | +| JSON schema | Generated from Pydantic at runtime | Full schema with `$defs` and `$ref` | +| i18n (global) | `web/public/locales/en/config/global.json` | Field labels for global settings | +| i18n (cameras) | `web/public/locales/en/config/cameras.json` | Field labels for camera settings | +| i18n (menu) | `web/public/locales/en/views/settings.json` | Sidebar menu labels | +| Section configs | `web/src/components/config-form/section-configs/*.ts` | Hidden fields, advanced fields, field order | +| Navigation map | Hardcoded from `web/src/pages/Settings.tsx` | Config section to UI path mapping | diff --git a/docs/scripts/build-config.mjs b/docs/scripts/build-config.mjs new file mode 100644 index 0000000000..78926bed5b --- /dev/null +++ b/docs/scripts/build-config.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +/** + * Build script: reads config.yaml and generates TypeScript files + * for the Docker Compose Generator. + * + * Usage: node scripts/build-config.mjs + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config"); +const YAML_PATH = path.join(CONFIG_DIR, "config.yaml"); + +// Read & parse YAML +const raw = fs.readFileSync(YAML_PATH, "utf8"); +const config = yaml.load(raw); + +if (!config.devices || !config.hardware || !config.ports) { + console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections."); + process.exit(1); +} + +/** + * Generate a .ts file from a section of the YAML config. + */ +function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) { + const jsonItems = JSON.stringify(items, null, 2); + // Indent JSON to fit inside the array literal + const indented = jsonItems + .split("\n") + .map((line, i) => (i === 0 ? line : " " + line)) + .join("\n"); + + const content = `/** + * AUTO-GENERATED FILE — do not edit directly. + * Source: ${yamlFilename} + * To update, edit the YAML file and run: npm run build:config + */ + +import type { ${typeName} } from "./types"; + +export const ${varName}: ${typeName}[] = ${indented}; + +/** Lookup map for quick access by ID */ +export const ${mapVarName}: Map = new Map(${varName}.map((item) => [item.id, item])); +`; + + const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`); + fs.writeFileSync(outPath, content, "utf8"); + console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`); +} + +console.log("Building config from config.yaml..."); + +generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml"); +generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml"); +generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml"); + +console.log("Done!"); diff --git a/docs/scripts/generate_ui_tabs.py b/docs/scripts/generate_ui_tabs.py new file mode 100644 index 0000000000..fa468922c3 --- /dev/null +++ b/docs/scripts/generate_ui_tabs.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +"""Generate Frigate UI tab content for documentation files. + +This script reads YAML code blocks from documentation markdown files and +generates corresponding "Frigate UI" tab instructions based on: +- JSON Schema (from Pydantic config models) +- i18n translation files (for UI field labels) +- Section configs (for hidden/advanced field info) +- Navigation mappings (for Settings UI paths) + +Usage: + # Preview generated UI tabs for a single file + python docs/scripts/generate_ui_tabs.py docs/docs/configuration/record.md + + # Preview all config docs + python docs/scripts/generate_ui_tabs.py docs/docs/configuration/ + + # Inject UI tabs into files (wraps bare YAML blocks with ConfigTabs) + python docs/scripts/generate_ui_tabs.py --inject docs/docs/configuration/record.md + + # Regenerate existing UI tabs from current schema/i18n + python docs/scripts/generate_ui_tabs.py --regenerate docs/docs/configuration/ + + # Check for drift between existing UI tabs and what would be generated + python docs/scripts/generate_ui_tabs.py --check docs/docs/configuration/ + + # Write generated files to a temp directory for comparison (originals unchanged) + python docs/scripts/generate_ui_tabs.py --inject --outdir /tmp/generated docs/docs/configuration/ + + # Show detailed warnings and diagnostics + python docs/scripts/generate_ui_tabs.py --verbose docs/docs/configuration/ +""" + +import argparse +import difflib +import shutil +import sys +import tempfile +from pathlib import Path + +# Ensure frigate package is importable +sys.path.insert(0, str(Path(__file__).resolve().parents[1].parent)) + +from lib.i18n_loader import load_i18n +from lib.nav_map import ALL_CONFIG_SECTIONS +from lib.schema_loader import load_schema +from lib.section_config_parser import load_section_configs +from lib.ui_generator import generate_ui_content, wrap_with_config_tabs +from lib.yaml_extractor import ( + extract_config_tabs_blocks, + extract_yaml_blocks, +) + + +def process_file( + filepath: Path, + schema: dict, + i18n: dict, + section_configs: dict, + inject: bool = False, + verbose: bool = False, + outpath: Path | None = None, +) -> dict: + """Process a single markdown file for initial injection of bare YAML blocks. + + Args: + outpath: If set, write the result here instead of modifying filepath. + + Returns: + Stats dict with counts of blocks found, generated, skipped, etc. + """ + content = filepath.read_text() + blocks = extract_yaml_blocks(content) + + stats = { + "file": str(filepath), + "total_blocks": len(blocks), + "config_blocks": 0, + "already_wrapped": 0, + "generated": 0, + "skipped": 0, + "warnings": [], + } + + if not blocks: + return stats + + # For injection, we need to track replacements + replacements: list[tuple[int, int, str]] = [] + + for block in blocks: + # Skip non-config YAML blocks + if block.section_key is None or ( + block.section_key not in ALL_CONFIG_SECTIONS + and not block.is_camera_level + ): + stats["skipped"] += 1 + if verbose and block.config_keys: + stats["warnings"].append( + f" Line {block.line_start}: Skipped block with keys " + f"{block.config_keys} (not a known config section)" + ) + continue + + stats["config_blocks"] += 1 + + # Skip already-wrapped blocks + if block.inside_config_tabs: + stats["already_wrapped"] += 1 + if verbose: + stats["warnings"].append( + f" Line {block.line_start}: Already inside ConfigTabs, skipping" + ) + continue + + # Generate UI content + ui_content = generate_ui_content( + block, schema, i18n, section_configs + ) + + if ui_content is None: + stats["skipped"] += 1 + if verbose: + stats["warnings"].append( + f" Line {block.line_start}: Could not generate UI content " + f"for section '{block.section_key}'" + ) + continue + + stats["generated"] += 1 + + if inject: + full_block = wrap_with_config_tabs( + ui_content, block.raw, block.highlight + ) + replacements.append((block.line_start, block.line_end, full_block)) + else: + # Preview mode: print to stdout + print(f"\n{'='*60}") + print(f"File: {filepath}") + print(f"Line {block.line_start}: section={block.section_key}, " + f"camera={block.is_camera_level}") + print(f"{'='*60}") + print() + print("--- Generated UI tab ---") + print(ui_content) + print() + print("--- Would produce ---") + print(wrap_with_config_tabs(ui_content, block.raw, block.highlight)) + print() + + # Apply injections in reverse order (to preserve line numbers) + if inject and replacements: + lines = content.split("\n") + for start, end, replacement in reversed(replacements): + # start/end are 1-based line numbers + # The YAML block spans from the ``` line before start to the ``` line at end + # We need to replace from the opening ``` to the closing ``` + block_start = start - 2 # 0-based index of ```yaml line + block_end = end - 1 # 0-based index of closing ``` line + + replacement_lines = replacement.split("\n") + lines[block_start : block_end + 1] = replacement_lines + + new_content = "\n".join(lines) + + # Ensure imports are present + new_content = _ensure_imports(new_content) + + target = outpath or filepath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(new_content) + print(f" Injected {len(replacements)} ConfigTabs block(s) into {target}") + elif outpath is not None: + # No changes but outdir requested -- copy original so the output + # directory contains a complete set of files for diffing. + outpath.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(filepath, outpath) + + return stats + + +def regenerate_file( + filepath: Path, + schema: dict, + i18n: dict, + section_configs: dict, + dry_run: bool = False, + verbose: bool = False, + outpath: Path | None = None, +) -> dict: + """Regenerate UI tabs in existing ConfigTabs blocks. + + Strips the current UI tab content and regenerates it from the YAML tab + using the current schema and i18n data. + + Args: + outpath: If set, write the result here instead of modifying filepath. + + Returns: + Stats dict + """ + content = filepath.read_text() + tab_blocks = extract_config_tabs_blocks(content) + + stats = { + "file": str(filepath), + "total_blocks": len(tab_blocks), + "regenerated": 0, + "unchanged": 0, + "skipped": 0, + "warnings": [], + } + + if not tab_blocks: + return stats + + replacements: list[tuple[int, int, str]] = [] + + for tab_block in tab_blocks: + yaml_block = tab_block.yaml_block + + # Skip non-config blocks + if yaml_block.section_key is None or ( + yaml_block.section_key not in ALL_CONFIG_SECTIONS + and not yaml_block.is_camera_level + ): + stats["skipped"] += 1 + if verbose: + stats["warnings"].append( + f" Line {tab_block.line_start}: Skipped (not a config section)" + ) + continue + + # Generate fresh UI content + new_ui = generate_ui_content( + yaml_block, schema, i18n, section_configs + ) + + if new_ui is None: + stats["skipped"] += 1 + if verbose: + stats["warnings"].append( + f" Line {tab_block.line_start}: Could not regenerate " + f"for section '{yaml_block.section_key}'" + ) + continue + + # Compare with existing + existing_ui = tab_block.ui_content + if _normalize_whitespace(new_ui) == _normalize_whitespace(existing_ui): + stats["unchanged"] += 1 + if verbose: + stats["warnings"].append( + f" Line {tab_block.line_start}: Unchanged" + ) + continue + + stats["regenerated"] += 1 + + new_full = wrap_with_config_tabs( + new_ui, yaml_block.raw, yaml_block.highlight + ) + replacements.append( + (tab_block.line_start, tab_block.line_end, new_full) + ) + + if dry_run or verbose: + print(f"\n{'='*60}") + print(f"File: {filepath}, line {tab_block.line_start}") + print(f"Section: {yaml_block.section_key}") + print(f"{'='*60}") + _print_diff(existing_ui, new_ui, filepath, tab_block.line_start) + + # Apply replacements + if not dry_run and replacements: + lines = content.split("\n") + for start, end, replacement in reversed(replacements): + block_start = start - 1 # 0-based index of line + block_end = end - 1 # 0-based index of line + replacement_lines = replacement.split("\n") + lines[block_start : block_end + 1] = replacement_lines + + new_content = "\n".join(lines) + target = outpath or filepath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(new_content) + print( + f" Regenerated {len(replacements)} ConfigTabs block(s) in {target}", + file=sys.stderr, + ) + elif outpath is not None: + outpath.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(filepath, outpath) + + return stats + + +def check_file( + filepath: Path, + schema: dict, + i18n: dict, + section_configs: dict, + verbose: bool = False, +) -> dict: + """Check for drift between existing UI tabs and what would be generated. + + Returns: + Stats dict with drift info. Non-zero "drifted" means the file is stale. + """ + content = filepath.read_text() + tab_blocks = extract_config_tabs_blocks(content) + + stats = { + "file": str(filepath), + "total_blocks": len(tab_blocks), + "up_to_date": 0, + "drifted": 0, + "skipped": 0, + "warnings": [], + } + + if not tab_blocks: + return stats + + for tab_block in tab_blocks: + yaml_block = tab_block.yaml_block + + if yaml_block.section_key is None or ( + yaml_block.section_key not in ALL_CONFIG_SECTIONS + and not yaml_block.is_camera_level + ): + stats["skipped"] += 1 + continue + + new_ui = generate_ui_content( + yaml_block, schema, i18n, section_configs + ) + + if new_ui is None: + stats["skipped"] += 1 + continue + + existing_ui = tab_block.ui_content + if _normalize_whitespace(new_ui) == _normalize_whitespace(existing_ui): + stats["up_to_date"] += 1 + else: + stats["drifted"] += 1 + print(f"\n{'='*60}") + print(f"DRIFT: {filepath}, line {tab_block.line_start}") + print(f"Section: {yaml_block.section_key}") + print(f"{'='*60}") + _print_diff(existing_ui, new_ui, filepath, tab_block.line_start) + + return stats + + +def _normalize_whitespace(text: str) -> str: + """Normalize whitespace for comparison (strip lines, collapse blanks).""" + lines = [line.rstrip() for line in text.strip().splitlines()] + # Collapse multiple blank lines into one + result: list[str] = [] + prev_blank = False + for line in lines: + if line == "": + if not prev_blank: + result.append(line) + prev_blank = True + else: + result.append(line) + prev_blank = False + return "\n".join(result) + + +def _print_diff(existing: str, generated: str, filepath: Path, line: int): + """Print a unified diff between existing and generated UI content.""" + existing_lines = existing.strip().splitlines(keepends=True) + generated_lines = generated.strip().splitlines(keepends=True) + + diff = difflib.unified_diff( + existing_lines, + generated_lines, + fromfile=f"{filepath}:{line} (existing)", + tofile=f"{filepath}:{line} (generated)", + lineterm="", + ) + diff_text = "\n".join(diff) + if diff_text: + print(diff_text) + else: + print(" (whitespace-only difference)") + + +def _ensure_imports(content: str) -> str: + """Ensure ConfigTabs/TabItem/NavPath imports are present in the file.""" + lines = content.split("\n") + + needed_imports = [] + if "" in content and 'import ConfigTabs' not in content: + needed_imports.append( + 'import ConfigTabs from "@site/src/components/ConfigTabs";' + ) + if "outpath mapping + file_outpaths: dict[Path, Path | None] = {} + for f in files: + if outdir is not None: + try: + rel = f.resolve().relative_to(base_dir) + except ValueError: + rel = Path(f.name) + file_outpaths[f] = outdir / rel + else: + file_outpaths[f] = None + + # Load data sources + print("Loading schema from Pydantic models...", file=sys.stderr) + schema = load_schema() + print("Loading i18n translations...", file=sys.stderr) + i18n = load_i18n() + print("Loading section configs...", file=sys.stderr) + section_configs = load_section_configs() + print(f"Processing {len(files)} file(s)...\n", file=sys.stderr) + + if args.check: + _run_check(files, schema, i18n, section_configs, args.verbose) + elif args.regenerate: + _run_regenerate( + files, schema, i18n, section_configs, + args.dry_run, args.verbose, file_outpaths, + ) + else: + _run_inject( + files, schema, i18n, section_configs, + args.inject, args.verbose, file_outpaths, + ) + + if outdir is not None: + print(f"\nOutput written to: {outdir}", file=sys.stderr) + + +def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths): + """Run default mode: preview or inject bare YAML blocks.""" + total_stats = { + "files": 0, + "total_blocks": 0, + "config_blocks": 0, + "already_wrapped": 0, + "generated": 0, + "skipped": 0, + } + + for filepath in files: + stats = process_file( + filepath, schema, i18n, section_configs, + inject=inject, verbose=verbose, + outpath=file_outpaths.get(filepath), + ) + + total_stats["files"] += 1 + for key in ["total_blocks", "config_blocks", "already_wrapped", + "generated", "skipped"]: + total_stats[key] += stats[key] + + if verbose and stats["warnings"]: + print(f"\n{filepath}:", file=sys.stderr) + for w in stats["warnings"]: + print(w, file=sys.stderr) + + print("\n" + "=" * 60, file=sys.stderr) + print("Summary:", file=sys.stderr) + print(f" Files processed: {total_stats['files']}", file=sys.stderr) + print(f" Total YAML blocks: {total_stats['total_blocks']}", file=sys.stderr) + print(f" Config blocks: {total_stats['config_blocks']}", file=sys.stderr) + print(f" Already wrapped: {total_stats['already_wrapped']}", file=sys.stderr) + print(f" Generated: {total_stats['generated']}", file=sys.stderr) + print(f" Skipped: {total_stats['skipped']}", file=sys.stderr) + print("=" * 60, file=sys.stderr) + + +def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths): + """Run regenerate mode: update existing ConfigTabs blocks.""" + total_stats = { + "files": 0, + "total_blocks": 0, + "regenerated": 0, + "unchanged": 0, + "skipped": 0, + } + + for filepath in files: + stats = regenerate_file( + filepath, schema, i18n, section_configs, + dry_run=dry_run, verbose=verbose, + outpath=file_outpaths.get(filepath), + ) + + total_stats["files"] += 1 + for key in ["total_blocks", "regenerated", "unchanged", "skipped"]: + total_stats[key] += stats[key] + + if verbose and stats["warnings"]: + print(f"\n{filepath}:", file=sys.stderr) + for w in stats["warnings"]: + print(w, file=sys.stderr) + + action = "Would regenerate" if dry_run else "Regenerated" + print("\n" + "=" * 60, file=sys.stderr) + print("Summary:", file=sys.stderr) + print(f" Files processed: {total_stats['files']}", file=sys.stderr) + print(f" ConfigTabs blocks: {total_stats['total_blocks']}", file=sys.stderr) + print(f" {action}: {total_stats['regenerated']}", file=sys.stderr) + print(f" Unchanged: {total_stats['unchanged']}", file=sys.stderr) + print(f" Skipped: {total_stats['skipped']}", file=sys.stderr) + print("=" * 60, file=sys.stderr) + + +def _run_check(files, schema, i18n, section_configs, verbose): + """Run check mode: detect drift without modifying files.""" + total_stats = { + "files": 0, + "total_blocks": 0, + "up_to_date": 0, + "drifted": 0, + "skipped": 0, + } + + for filepath in files: + stats = check_file( + filepath, schema, i18n, section_configs, verbose=verbose, + ) + + total_stats["files"] += 1 + for key in ["total_blocks", "up_to_date", "drifted", "skipped"]: + total_stats[key] += stats[key] + + print("\n" + "=" * 60, file=sys.stderr) + print("Summary:", file=sys.stderr) + print(f" Files processed: {total_stats['files']}", file=sys.stderr) + print(f" ConfigTabs blocks: {total_stats['total_blocks']}", file=sys.stderr) + print(f" Up to date: {total_stats['up_to_date']}", file=sys.stderr) + print(f" Drifted: {total_stats['drifted']}", file=sys.stderr) + print(f" Skipped: {total_stats['skipped']}", file=sys.stderr) + print("=" * 60, file=sys.stderr) + + if total_stats["drifted"] > 0: + print( + f"\n{total_stats['drifted']} block(s) have drifted from schema/i18n. " + "Run with --regenerate to update.", + file=sys.stderr, + ) + sys.exit(1) + else: + print("\nAll UI tabs are up to date.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/docs/scripts/lib/__init__.py b/docs/scripts/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/scripts/lib/i18n_loader.py b/docs/scripts/lib/i18n_loader.py new file mode 100644 index 0000000000..7416e86c7c --- /dev/null +++ b/docs/scripts/lib/i18n_loader.py @@ -0,0 +1,139 @@ +"""Load i18n translation files for Settings UI field labels.""" + +import json +from pathlib import Path +from typing import Any + +# Base path for locale files +WEB_LOCALES = Path(__file__).resolve().parents[3] / "web" / "public" / "locales" / "en" + + +def load_i18n() -> dict[str, Any]: + """Load and merge all relevant i18n files. + + Returns: + Dict with keys: "global", "cameras", "settings_menu" + """ + global_path = WEB_LOCALES / "config" / "global.json" + cameras_path = WEB_LOCALES / "config" / "cameras.json" + settings_path = WEB_LOCALES / "views" / "settings.json" + + result: dict[str, Any] = {} + + with open(global_path) as f: + result["global"] = json.load(f) + + with open(cameras_path) as f: + result["cameras"] = json.load(f) + + with open(settings_path) as f: + settings = json.load(f) + result["settings_menu"] = settings.get("menu", {}) + + # Build a unified enum value → label lookup from all known sources. + # Merges multiple maps so callers don't need to know which file + # a particular enum lives in. + value_labels: dict[str, str] = {} + + config_form = settings.get("configForm", {}) + + # FFmpeg preset labels (preset-vaapi → "VAAPI (Intel/AMD GPU)") + value_labels.update( + config_form.get("ffmpegArgs", {}).get("presetLabels", {}) + ) + + # Timestamp position (tl → "Top left") + value_labels.update(settings.get("timestampPosition", {})) + + # Input role options (detect → "Detect") + value_labels.update( + config_form.get("inputRoles", {}).get("options", {}) + ) + + # GenAI role options (vision → "Vision") + value_labels.update( + config_form.get("genaiRoles", {}).get("options", {}) + ) + + result["value_labels"] = value_labels + + return result + + +def get_field_label( + i18n: dict[str, Any], + section_key: str, + field_path: list[str], + level: str = "global", +) -> str | None: + """Look up the UI label for a field. + + Args: + i18n: Loaded i18n data from load_i18n() + section_key: Config section (e.g., "record") + field_path: Path within section (e.g., ["continuous", "days"]) + level: "global" or "cameras" + + Returns: + The label string, or None if not found. + """ + source = i18n.get(level, {}) + node = source.get(section_key, {}) + + for key in field_path: + if not isinstance(node, dict): + return None + node = node.get(key, {}) + + if isinstance(node, dict): + return node.get("label") + return None + + +def get_field_description( + i18n: dict[str, Any], + section_key: str, + field_path: list[str], + level: str = "global", +) -> str | None: + """Look up the UI description for a field.""" + source = i18n.get(level, {}) + node = source.get(section_key, {}) + + for key in field_path: + if not isinstance(node, dict): + return None + node = node.get(key, {}) + + if isinstance(node, dict): + return node.get("description") + return None + + +def get_value_label( + i18n: dict[str, Any], + value: str, +) -> str | None: + """Look up the display label for an enum/option value. + + Args: + i18n: Loaded i18n data from load_i18n() + value: The raw config value (e.g., "preset-vaapi", "tl") + + Returns: + The human-readable label (e.g., "VAAPI (Intel/AMD GPU)"), or None. + """ + return i18n.get("value_labels", {}).get(value) + + +def get_section_label( + i18n: dict[str, Any], + section_key: str, + level: str = "global", +) -> str | None: + """Get the top-level label for a config section.""" + source = i18n.get(level, {}) + section = source.get(section_key, {}) + if isinstance(section, dict): + return section.get("label") + return None diff --git a/docs/scripts/lib/nav_map.py b/docs/scripts/lib/nav_map.py new file mode 100644 index 0000000000..0fddf40e00 --- /dev/null +++ b/docs/scripts/lib/nav_map.py @@ -0,0 +1,120 @@ +"""Map config section keys to Settings UI navigation paths.""" + +# Derived from web/src/pages/Settings.tsx section mappings +# and web/public/locales/en/views/settings.json menu labels. +# +# Format: section_key -> (group_label, page_label) +# Navigation path: "Settings > {group_label} > {page_label}" + +GLOBAL_NAV: dict[str, tuple[str, str]] = { + "detect": ("Global configuration", "Object detection"), + "ffmpeg": ("Global configuration", "FFmpeg"), + "record": ("Global configuration", "Recording"), + "snapshots": ("Global configuration", "Snapshots"), + "motion": ("Global configuration", "Motion detection"), + "objects": ("Global configuration", "Objects"), + "review": ("Global configuration", "Review"), + "audio": ("Global configuration", "Audio events"), + "live": ("Global configuration", "Live playback"), + "timestamp_style": ("Global configuration", "Timestamp style"), + "notifications": ("Notifications", "Notifications"), +} + +CAMERA_NAV: dict[str, tuple[str, str]] = { + "detect": ("Camera configuration", "Object detection"), + "ffmpeg": ("Camera configuration", "FFmpeg"), + "record": ("Camera configuration", "Recording"), + "snapshots": ("Camera configuration", "Snapshots"), + "motion": ("Camera configuration", "Motion detection"), + "objects": ("Camera configuration", "Objects"), + "review": ("Camera configuration", "Review"), + "audio": ("Camera configuration", "Audio events"), + "audio_transcription": ("Camera configuration", "Audio transcription"), + "notifications": ("Camera configuration", "Notifications"), + "live": ("Camera configuration", "Live playback"), + "birdseye": ("Camera configuration", "Birdseye"), + "face_recognition": ("Camera configuration", "Face recognition"), + "lpr": ("Camera configuration", "License plate recognition"), + "mqtt": ("Camera configuration", "MQTT"), + "onvif": ("Camera configuration", "ONVIF"), + "ui": ("Camera configuration", "Camera UI"), + "timestamp_style": ("Camera configuration", "Timestamp style"), +} + +ENRICHMENT_NAV: dict[str, tuple[str, str]] = { + "semantic_search": ("Enrichments", "Semantic search"), + "genai": ("Enrichments", "Generative AI"), + "face_recognition": ("Enrichments", "Face recognition"), + "lpr": ("Enrichments", "License plate recognition"), + "classification": ("Enrichments", "Object classification"), + "audio_transcription": ("Enrichments", "Audio transcription"), +} + +SYSTEM_NAV: dict[str, tuple[str, str]] = { + "go2rtc_streams": ("System", "go2rtc streams"), + "database": ("System", "Database"), + "mqtt": ("System", "MQTT"), + "tls": ("System", "TLS"), + "auth": ("System", "Authentication"), + "networking": ("System", "Networking"), + "proxy": ("System", "Proxy"), + "ui": ("System", "UI"), + "logger": ("System", "Logging"), + "environment_vars": ("System", "Environment variables"), + "telemetry": ("System", "Telemetry"), + "birdseye": ("System", "Birdseye"), + "detectors": ("System", "Detectors and model"), + "model": ("System", "Detectors and model"), +} + +# All known top-level config section keys +ALL_CONFIG_SECTIONS = ( + set(GLOBAL_NAV) + | set(CAMERA_NAV) + | set(ENRICHMENT_NAV) + | set(SYSTEM_NAV) + | {"cameras"} +) + + +def get_nav_path(section_key: str, level: str = "global") -> str | None: + """Get the full navigation path for a config section. + + Args: + section_key: Config section key (e.g., "record") + level: "global", "camera", "enrichment", or "system" + + Returns: + NavPath string like "Settings > Global configuration > Recording", + or None if not found. + """ + nav_tables = { + "global": GLOBAL_NAV, + "camera": CAMERA_NAV, + "enrichment": ENRICHMENT_NAV, + "system": SYSTEM_NAV, + } + + table = nav_tables.get(level) + if table is None: + return None + + entry = table.get(section_key) + if entry is None: + return None + + group, page = entry + return f"Settings > {group} > {page}" + + +def detect_level(section_key: str) -> str: + """Detect whether a config section is global, camera, enrichment, or system.""" + if section_key in SYSTEM_NAV: + return "system" + if section_key in ENRICHMENT_NAV: + return "enrichment" + if section_key in GLOBAL_NAV: + return "global" + if section_key in CAMERA_NAV: + return "camera" + return "global" diff --git a/docs/scripts/lib/schema_loader.py b/docs/scripts/lib/schema_loader.py new file mode 100644 index 0000000000..a1e88a9896 --- /dev/null +++ b/docs/scripts/lib/schema_loader.py @@ -0,0 +1,88 @@ +"""Load JSON schema from Frigate's Pydantic config models.""" + +from typing import Any + + +def load_schema() -> dict[str, Any]: + """Generate and return the full JSON schema for FrigateConfig.""" + from frigate.config.config import FrigateConfig + from frigate.util.schema import get_config_schema + + return get_config_schema(FrigateConfig) + + +def resolve_ref(schema: dict[str, Any], ref: str) -> dict[str, Any]: + """Resolve a $ref pointer within the schema.""" + # ref format: "#/$defs/RecordConfig" + parts = ref.lstrip("#/").split("/") + node = schema + for part in parts: + node = node[part] + return node + + +def resolve_schema_node( + schema: dict[str, Any], node: dict[str, Any] +) -> dict[str, Any]: + """Resolve a schema node, following $ref and allOf if present.""" + if "$ref" in node: + node = resolve_ref(schema, node["$ref"]) + if "allOf" in node: + merged: dict[str, Any] = {} + for item in node["allOf"]: + resolved = resolve_schema_node(schema, item) + merged.update(resolved) + return merged + return node + + +def get_section_schema( + schema: dict[str, Any], section_key: str +) -> dict[str, Any] | None: + """Get the resolved schema for a top-level config section.""" + props = schema.get("properties", {}) + if section_key not in props: + return None + return resolve_schema_node(schema, props[section_key]) + + +def get_field_info( + schema: dict[str, Any], section_key: str, field_path: list[str] +) -> dict[str, Any] | None: + """Get schema info for a specific field path within a section. + + Args: + schema: Full JSON schema + section_key: Top-level section (e.g., "record") + field_path: List of nested keys (e.g., ["continuous", "days"]) + + Returns: + Resolved schema node for the field, or None if not found. + """ + section = get_section_schema(schema, section_key) + if section is None: + return None + + node = section + for key in field_path: + props = node.get("properties", {}) + if key not in props: + return None + node = resolve_schema_node(schema, props[key]) + + return node + + +def is_boolean_field(field_schema: dict[str, Any]) -> bool: + """Check if a schema node represents a boolean field.""" + return field_schema.get("type") == "boolean" + + +def is_enum_field(field_schema: dict[str, Any]) -> bool: + """Check if a schema node is an enum.""" + return "enum" in field_schema + + +def is_object_field(field_schema: dict[str, Any]) -> bool: + """Check if a schema node is an object with properties.""" + return field_schema.get("type") == "object" or "properties" in field_schema diff --git a/docs/scripts/lib/section_config_parser.py b/docs/scripts/lib/section_config_parser.py new file mode 100644 index 0000000000..805ab21455 --- /dev/null +++ b/docs/scripts/lib/section_config_parser.py @@ -0,0 +1,130 @@ +"""Parse TypeScript section config files for hidden/advanced field info.""" + +import json +import re +from pathlib import Path +from typing import Any + +SECTION_CONFIGS_DIR = ( + Path(__file__).resolve().parents[3] + / "web" + / "src" + / "components" + / "config-form" + / "section-configs" +) + + +def _extract_string_array(text: str, field_name: str) -> list[str]: + """Extract a string array value from TypeScript object literal text.""" + pattern = rf"{field_name}\s*:\s*\[(.*?)\]" + match = re.search(pattern, text, re.DOTALL) + if not match: + return [] + content = match.group(1) + return re.findall(r'"([^"]*)"', content) + + +def _parse_section_file(filepath: Path) -> dict[str, Any]: + """Parse a single section config .ts file.""" + text = filepath.read_text() + + # Extract base block + base_match = re.search(r"base\s*:\s*\{(.*?)\n \}", text, re.DOTALL) + base_text = base_match.group(1) if base_match else "" + + # Extract global block + global_match = re.search(r"global\s*:\s*\{(.*?)\n \}", text, re.DOTALL) + global_text = global_match.group(1) if global_match else "" + + # Extract camera block + camera_match = re.search(r"camera\s*:\s*\{(.*?)\n \}", text, re.DOTALL) + camera_text = camera_match.group(1) if camera_match else "" + + result: dict[str, Any] = { + "fieldOrder": _extract_string_array(base_text, "fieldOrder"), + "hiddenFields": _extract_string_array(base_text, "hiddenFields"), + "advancedFields": _extract_string_array(base_text, "advancedFields"), + } + + # Merge global-level hidden fields + global_hidden = _extract_string_array(global_text, "hiddenFields") + if global_hidden: + result["globalHiddenFields"] = global_hidden + + # Merge camera-level hidden fields + camera_hidden = _extract_string_array(camera_text, "hiddenFields") + if camera_hidden: + result["cameraHiddenFields"] = camera_hidden + + return result + + +def load_section_configs() -> dict[str, dict[str, Any]]: + """Load all section configs from TypeScript files. + + Returns: + Dict mapping section name to parsed config. + """ + # Read sectionConfigs.ts to get the mapping of section keys to filenames + registry_path = SECTION_CONFIGS_DIR.parent / "sectionConfigs.ts" + registry_text = registry_path.read_text() + + configs: dict[str, dict[str, Any]] = {} + + for ts_file in SECTION_CONFIGS_DIR.glob("*.ts"): + if ts_file.name == "types.ts": + continue + + section_name = ts_file.stem + configs[section_name] = _parse_section_file(ts_file) + + # Map section config keys from the registry (handles renames like + # "timestamp_style: timestampStyle") + key_map: dict[str, str] = {} + for match in re.finditer( + r"(\w+)(?:\s*:\s*\w+)?\s*,", registry_text[registry_text.find("{") :] + ): + key = match.group(1) + key_map[key] = key + + # Handle explicit key mappings like `timestamp_style: timestampStyle` + for match in re.finditer(r"(\w+)\s*:\s*(\w+)\s*,", registry_text): + key_map[match.group(1)] = match.group(2) + + return configs + + +def get_hidden_fields( + configs: dict[str, dict[str, Any]], + section_key: str, + level: str = "global", +) -> set[str]: + """Get the set of hidden fields for a section at a given level. + + Args: + configs: Loaded section configs + section_key: Config section name (e.g., "record") + level: "global" or "camera" + + Returns: + Set of hidden field paths (e.g., {"enabled_in_config", "sync_recordings"}) + """ + config = configs.get(section_key, {}) + hidden = set(config.get("hiddenFields", [])) + + if level == "global": + hidden.update(config.get("globalHiddenFields", [])) + elif level == "camera": + hidden.update(config.get("cameraHiddenFields", [])) + + return hidden + + +def get_advanced_fields( + configs: dict[str, dict[str, Any]], + section_key: str, +) -> set[str]: + """Get the set of advanced fields for a section.""" + config = configs.get(section_key, {}) + return set(config.get("advancedFields", [])) diff --git a/docs/scripts/lib/ui_generator.py b/docs/scripts/lib/ui_generator.py new file mode 100644 index 0000000000..7b9a592865 --- /dev/null +++ b/docs/scripts/lib/ui_generator.py @@ -0,0 +1,283 @@ +"""Generate UI tab markdown content from parsed YAML blocks.""" + +from typing import Any + +from .i18n_loader import get_field_description, get_field_label, get_value_label +from .nav_map import ALL_CONFIG_SECTIONS, detect_level, get_nav_path +from .schema_loader import is_boolean_field, is_object_field +from .section_config_parser import get_hidden_fields +from .yaml_extractor import YamlBlock, get_leaf_paths + + +def _format_value( + value: object, + field_schema: dict[str, Any] | None, + i18n: dict[str, Any] | None = None, +) -> str: + """Format a YAML value for UI display. + + Looks up i18n labels for enum/option values when available. + """ + if field_schema and is_boolean_field(field_schema): + return "on" if value else "off" + if isinstance(value, bool): + return "on" if value else "off" + if isinstance(value, list): + if len(value) == 0: + return "an empty list" + items = [] + for v in value: + label = get_value_label(i18n, str(v)) if i18n else None + items.append(f"`{label}`" if label else f"`{v}`") + return ", ".join(items) + if value is None: + return "empty" + + # Try i18n label for the raw value (enum translations) + if i18n and isinstance(value, str): + label = get_value_label(i18n, value) + if label: + return f"`{label}`" + + return f"`{value}`" + + +def _build_field_label( + i18n: dict[str, Any], + section_key: str, + field_path: list[str], + level: str, +) -> str: + """Build the display label for a field using i18n labels. + + For a path like ["continuous", "days"], produces + "Continuous retention > Retention days" using the actual i18n labels. + """ + parts: list[str] = [] + + for depth in range(len(field_path)): + sub_path = field_path[: depth + 1] + label = get_field_label(i18n, section_key, sub_path, level) + + if label: + parts.append(label) + else: + # Fallback to title-cased field name + parts.append(field_path[depth].replace("_", " ").title()) + + return " > ".join(parts) + + +def _is_hidden( + field_key: str, + full_path: list[str], + hidden_fields: set[str], +) -> bool: + """Check if a field should be hidden from UI output.""" + # Check exact match + if field_key in hidden_fields: + return True + + # Check dotted path match (e.g., "alerts.enabled_in_config") + dotted = ".".join(str(p) for p in full_path) + if dotted in hidden_fields: + return True + + # Check wildcard patterns (e.g., "filters.*.mask") + for pattern in hidden_fields: + if "*" in pattern: + parts = pattern.split(".") + if len(parts) == len(full_path): + match = all( + p == "*" or p == fp for p, fp in zip(parts, full_path) + ) + if match: + return True + + return False + + +def generate_ui_content( + block: YamlBlock, + schema: dict[str, Any], + i18n: dict[str, Any], + section_configs: dict[str, dict[str, Any]], +) -> str | None: + """Generate UI tab markdown content for a YAML block. + + Args: + block: Parsed YAML block from a doc file + schema: Full JSON schema + i18n: Loaded i18n translations + section_configs: Parsed section config data + + Returns: + Generated markdown string for the UI tab, or None if the block + can't be converted (not a config block, etc.) + """ + if block.section_key is None: + return None + + # Determine which config data to walk + if block.is_camera_level: + # Camera-level: unwrap cameras.{name}.{section} + cam_data = block.parsed.get("cameras", {}) + cam_name = block.camera_name or next(iter(cam_data), None) + if not cam_name: + return None + inner = cam_data.get(cam_name, {}) + if not isinstance(inner, dict): + return None + level = "camera" + else: + inner = block.parsed + # Determine level from section key + level = detect_level(block.section_key) + + # Collect sections to process (may span multiple top-level keys) + sections_to_process: list[tuple[str, dict]] = [] + for key in inner: + if key in ALL_CONFIG_SECTIONS or key == block.section_key: + val = inner[key] + if isinstance(val, dict): + sections_to_process.append((key, val)) + else: + # Simple scalar at section level (e.g., record.enabled = True) + sections_to_process.append((key, {key: val})) + + # If inner is the section itself (e.g., parsed = {"record": {...}}) + if not sections_to_process and block.section_key in inner: + section_data = inner[block.section_key] + if isinstance(section_data, dict): + sections_to_process = [(block.section_key, section_data)] + + if not sections_to_process: + # Try treating the whole inner dict as the section data + sections_to_process = [(block.section_key, inner)] + + # Choose pattern based on whether YAML has comments (descriptive) or values + use_table = block.has_comments + + lines: list[str] = [] + step_num = 1 + + for section_key, section_data in sections_to_process: + # Get navigation path + i18n_level = "cameras" if level == "camera" else "global" + nav_path = get_nav_path(section_key, level) + if nav_path is None: + # Try global as fallback + nav_path = get_nav_path(section_key, "global") + if nav_path is None: + continue + + # Get hidden fields for this section + hidden = get_hidden_fields(section_configs, section_key, level) + + # Get leaf paths from the YAML data + leaves = get_leaf_paths(section_data) + + # Filter out hidden fields + visible_leaves: list[tuple[tuple[str, ...], object]] = [] + for path, value in leaves: + path_list = list(path) + if not _is_hidden(path_list[-1], path_list, hidden): + visible_leaves.append((path, value)) + + if not visible_leaves: + continue + + if use_table: + # Pattern A: Field table with descriptions + lines.append( + f'Navigate to .' + ) + lines.append("") + lines.append("| Field | Description |") + lines.append("|-------|-------------|") + + for path, _value in visible_leaves: + path_list = list(path) + label = _build_field_label( + i18n, section_key, path_list, i18n_level + ) + desc = get_field_description( + i18n, section_key, path_list, i18n_level + ) + if not desc: + desc = "" + lines.append(f"| **{label}** | {desc} |") + else: + # Pattern B: Set instructions + multi_section = len(sections_to_process) > 1 + + if multi_section: + camera_note = "" + if block.is_camera_level: + camera_note = ( + " and select your camera" + ) + lines.append( + f'{step_num}. Navigate to {camera_note}.' + ) + else: + if block.is_camera_level: + lines.append( + f'1. Navigate to and select your camera.' + ) + else: + lines.append( + f'Navigate to .' + ) + lines.append("") + + from .schema_loader import get_field_info + + for path, value in visible_leaves: + path_list = list(path) + label = _build_field_label( + i18n, section_key, path_list, i18n_level + ) + field_info = get_field_info(schema, section_key, path_list) + formatted = _format_value(value, field_info, i18n) + + if multi_section or block.is_camera_level: + lines.append(f" - Set **{label}** to {formatted}") + else: + lines.append(f"- Set **{label}** to {formatted}") + + step_num += 1 + + if not lines: + return None + + return "\n".join(lines) + + +def wrap_with_config_tabs(ui_content: str, yaml_raw: str, highlight: str | None = None) -> str: + """Wrap UI content and YAML in ConfigTabs markup. + + Args: + ui_content: Generated UI tab markdown + yaml_raw: Original YAML text + highlight: Optional highlight spec (e.g., "{3-4}") + + Returns: + Full ConfigTabs MDX block + """ + highlight_str = f" {highlight}" if highlight else "" + + return f""" + + +{ui_content} + + + + +```yaml{highlight_str} +{yaml_raw} +``` + + +""" diff --git a/docs/scripts/lib/yaml_extractor.py b/docs/scripts/lib/yaml_extractor.py new file mode 100644 index 0000000000..c01451cfcc --- /dev/null +++ b/docs/scripts/lib/yaml_extractor.py @@ -0,0 +1,283 @@ +"""Extract YAML code blocks from markdown documentation files.""" + +import re +from dataclasses import dataclass, field + +import yaml + + +@dataclass +class YamlBlock: + """A YAML code block extracted from a markdown file.""" + + raw: str # Original YAML text + parsed: dict # Parsed YAML content + line_start: int # Line number in the markdown file (1-based) + line_end: int # End line number + highlight: str | None = None # Highlight spec (e.g., "{3-4}") + has_comments: bool = False # Whether the YAML has inline comments + inside_config_tabs: bool = False # Already wrapped in ConfigTabs + section_key: str | None = None # Detected top-level config section + is_camera_level: bool = False # Whether this is camera-level config + camera_name: str | None = None # Camera name if camera-level + config_keys: list[str] = field( + default_factory=list + ) # Top-level keys in the YAML + + +def extract_yaml_blocks(content: str) -> list[YamlBlock]: + """Extract all YAML fenced code blocks from markdown content. + + Args: + content: Markdown file content + + Returns: + List of YamlBlock instances + """ + blocks: list[YamlBlock] = [] + lines = content.split("\n") + i = 0 + in_config_tabs = False + + while i < len(lines): + line = lines[i] + + # Track ConfigTabs context + if "" in line: + in_config_tabs = True + elif "" in line: + in_config_tabs = False + + # Look for YAML fence opening + fence_match = re.match(r"^```yaml\s*(\{[^}]*\})?\s*$", line) + if fence_match: + highlight = fence_match.group(1) + start_line = i + 1 # 1-based + yaml_lines: list[str] = [] + i += 1 + + # Collect until closing fence + while i < len(lines) and not lines[i].startswith("```"): + yaml_lines.append(lines[i]) + i += 1 + + end_line = i + 1 # 1-based, inclusive of closing fence + raw = "\n".join(yaml_lines) + + # Check for inline comments + has_comments = any( + re.search(r"#\s*(<-|[A-Za-z])", yl) for yl in yaml_lines + ) + + # Parse YAML + try: + parsed = yaml.safe_load(raw) + except yaml.YAMLError: + i += 1 + continue + + if not isinstance(parsed, dict): + i += 1 + continue + + # Detect config section and level + config_keys = list(parsed.keys()) + section_key = None + is_camera = False + camera_name = None + + if "cameras" in parsed and isinstance(parsed["cameras"], dict): + is_camera = True + cam_entries = parsed["cameras"] + if len(cam_entries) == 1: + camera_name = list(cam_entries.keys())[0] + inner = cam_entries[camera_name] + if isinstance(inner, dict): + inner_keys = list(inner.keys()) + if len(inner_keys) >= 1: + section_key = inner_keys[0] + elif len(config_keys) >= 1: + section_key = config_keys[0] + + blocks.append( + YamlBlock( + raw=raw, + parsed=parsed, + line_start=start_line, + line_end=end_line, + highlight=highlight, + has_comments=has_comments, + inside_config_tabs=in_config_tabs, + section_key=section_key, + is_camera_level=is_camera, + camera_name=camera_name, + config_keys=config_keys, + ) + ) + + i += 1 + + return blocks + + +@dataclass +class ConfigTabsBlock: + """An existing ConfigTabs block in a markdown file.""" + + line_start: int # 1-based line of + line_end: int # 1-based line of + ui_content: str # Content inside the UI TabItem + yaml_block: YamlBlock # The YAML block inside the YAML TabItem + raw_text: str # Full raw text of the ConfigTabs block + + +def extract_config_tabs_blocks(content: str) -> list[ConfigTabsBlock]: + """Extract existing ConfigTabs blocks from markdown content. + + Parses the structure: + + + ...ui content... + + + ```yaml + ...yaml... + ``` + + + + Returns: + List of ConfigTabsBlock instances + """ + blocks: list[ConfigTabsBlock] = [] + lines = content.split("\n") + i = 0 + + while i < len(lines): + if "" not in lines[i]: + i += 1 + continue + + block_start = i # 0-based + + # Find + j = i + 1 + while j < len(lines) and "" not in lines[j]: + j += 1 + + if j >= len(lines): + i += 1 + continue + + block_end = j # 0-based, line with + block_text = "\n".join(lines[block_start : block_end + 1]) + + # Extract UI content (between and ) + ui_match = re.search( + r'\s*\n(.*?)\n\s*', + block_text, + re.DOTALL, + ) + ui_content = ui_match.group(1).strip() if ui_match else "" + + # Extract YAML block from inside the yaml TabItem + yaml_tab_match = re.search( + r'\s*\n(.*?)\n\s*', + block_text, + re.DOTALL, + ) + + yaml_block = None + if yaml_tab_match: + yaml_tab_text = yaml_tab_match.group(1) + fence_match = re.search( + r"```yaml\s*(\{[^}]*\})?\s*\n(.*?)\n```", + yaml_tab_text, + re.DOTALL, + ) + if fence_match: + highlight = fence_match.group(1) + yaml_raw = fence_match.group(2) + has_comments = bool( + re.search(r"#\s*(<-|[A-Za-z])", yaml_raw) + ) + + try: + parsed = yaml.safe_load(yaml_raw) + except yaml.YAMLError: + parsed = {} + + if isinstance(parsed, dict): + config_keys = list(parsed.keys()) + section_key = None + is_camera = False + camera_name = None + + if "cameras" in parsed and isinstance( + parsed["cameras"], dict + ): + is_camera = True + cam_entries = parsed["cameras"] + if len(cam_entries) == 1: + camera_name = list(cam_entries.keys())[0] + inner = cam_entries[camera_name] + if isinstance(inner, dict): + inner_keys = list(inner.keys()) + if len(inner_keys) >= 1: + section_key = inner_keys[0] + elif len(config_keys) >= 1: + section_key = config_keys[0] + + yaml_block = YamlBlock( + raw=yaml_raw, + parsed=parsed, + line_start=block_start + 1, + line_end=block_end + 1, + highlight=highlight, + has_comments=has_comments, + inside_config_tabs=True, + section_key=section_key, + is_camera_level=is_camera, + camera_name=camera_name, + config_keys=config_keys, + ) + + if yaml_block: + blocks.append( + ConfigTabsBlock( + line_start=block_start + 1, # 1-based + line_end=block_end + 1, # 1-based + ui_content=ui_content, + yaml_block=yaml_block, + raw_text=block_text, + ) + ) + + i = j + 1 + + return blocks + + +def get_leaf_paths( + data: dict, prefix: tuple[str, ...] = () +) -> list[tuple[tuple[str, ...], object]]: + """Walk a parsed YAML dict and return all leaf key paths with values. + + Args: + data: Parsed YAML dict + prefix: Current key path prefix + + Returns: + List of (key_path_tuple, value) pairs. + e.g., [( ("record", "continuous", "days"), 3 ), ...] + """ + results: list[tuple[tuple[str, ...], object]] = [] + + for key, value in data.items(): + path = prefix + (str(key),) + if isinstance(value, dict): + results.extend(get_leaf_paths(value, path)) + else: + results.append((path, value)) + + return results diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ea0d2f5c81..4c656df8c2 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -12,94 +12,132 @@ const sidebars: SidebarsConfig = { "frigate/updating", "frigate/camera_setup", "frigate/video_pipeline", + "frigate/network_requirements", "frigate/glossary", ], Guides: [ "guides/getting_started", - "guides/configuring_go2rtc", "guides/ha_notifications", "guides/ha_network_storage", "guides/reverse_proxy", ], - Configuration: { - "Configuration Files": [ - "configuration/index", - "configuration/reference", - { - type: "link", - label: "Go2RTC Configuration Reference", - href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration", - } as PropSidebarItemLink, - ], - Detectors: [ - "configuration/object_detectors", - "configuration/audio_detectors", - ], - Enrichments: [ - "configuration/semantic_search", - "configuration/face_recognition", - "configuration/license_plate_recognition", - "configuration/bird_classification", - { - type: "category", - label: "Custom Classification", - link: { - type: "generated-index", - title: "Custom Classification", - description: "Configuration for custom classification models", + Usage: [ + "usage/live", + "usage/review", + "usage/history", + "usage/explore", + "usage/exports", + ], + Configuration: [ + "configuration/config", + "configuration/config_overrides", + { + type: "category", + label: "Detectors", + items: [ + "configuration/object_detectors", + "configuration/audio_detectors", + ], + }, + { + type: "category", + label: "Enrichments", + items: [ + "configuration/semantic_search", + "configuration/face_recognition", + "configuration/license_plate_recognition", + "configuration/bird_classification", + { + type: "category", + label: "Custom Classification", + link: { + type: "generated-index", + title: "Custom Classification", + description: "Configuration for custom classification models", + }, + items: [ + "configuration/custom_classification/state_classification", + "configuration/custom_classification/object_classification", + ], }, - items: [ - "configuration/custom_classification/state_classification", - "configuration/custom_classification/object_classification", - ], - }, - { - type: "category", - label: "Generative AI", - link: { - type: "generated-index", - title: "Generative AI", - description: "Generative AI Features", + { + type: "category", + label: "Generative AI", + link: { + type: "generated-index", + title: "Generative AI", + description: "Generative AI Features", + }, + items: [ + "configuration/genai/genai_config", + "configuration/genai/genai_review", + "configuration/genai/genai_objects", + ], }, - items: [ - "configuration/genai/genai_config", - "configuration/genai/genai_review", - "configuration/genai/genai_objects", - ], - }, - ], - Cameras: [ - "configuration/cameras", - "configuration/review", - "configuration/record", - "configuration/snapshots", - "configuration/motion_detection", - "configuration/birdseye", - "configuration/live", - "configuration/restream", - "configuration/autotracking", - "configuration/camera_specific", - ], - Objects: [ - "configuration/object_filters", - "configuration/masks", - "configuration/zones", - "configuration/objects", - "configuration/stationary_objects", - ], - "Hardware Acceleration": [ - "configuration/hardware_acceleration_video", - "configuration/hardware_acceleration_enrichments", - ], - "Extra Configuration": [ - "configuration/authentication", - "configuration/notifications", - "configuration/ffmpeg_presets", - "configuration/pwa", - "configuration/tls", - "configuration/advanced", - ], - }, + ], + }, + { + type: "category", + label: "Cameras", + items: [ + "configuration/cameras", + "configuration/review", + "configuration/record", + "configuration/snapshots", + "configuration/motion_detection", + "configuration/birdseye", + "configuration/live", + "configuration/restream", + "configuration/autotracking", + "configuration/camera_specific", + ], + }, + { + type: "category", + label: "Objects", + items: [ + "configuration/object_filters", + "configuration/masks", + "configuration/zones", + "configuration/objects", + "configuration/stationary_objects", + ], + }, + { + type: "category", + label: "Hardware Acceleration", + items: [ + "configuration/hardware_acceleration_video", + "configuration/hardware_acceleration_enrichments", + ], + }, + { + type: "category", + label: "Extra Configuration", + items: [ + "configuration/authentication", + "configuration/notifications", + "configuration/profiles", + "configuration/go2rtc", + "configuration/ffmpeg_presets", + "configuration/pwa", + "configuration/tls", + ], + }, + { + type: "category", + label: "Advanced Configuration", + items: [ + "configuration/advanced/system", + "configuration/advanced/reference", + { + type: "link", + label: "Go2RTC Configuration Reference", + href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.14#configuration", + } as PropSidebarItemLink, + ], + }, + ], Integrations: [ "integrations/plus", "integrations/home-assistant", @@ -128,6 +166,8 @@ const sidebars: SidebarsConfig = { ], Troubleshooting: [ "troubleshooting/faqs", + "troubleshooting/common_errors", + "troubleshooting/go2rtc", "troubleshooting/recordings", "troubleshooting/dummy-camera", { diff --git a/docs/src/components/ConfigTabs/index.jsx b/docs/src/components/ConfigTabs/index.jsx new file mode 100644 index 0000000000..0fbc51897b --- /dev/null +++ b/docs/src/components/ConfigTabs/index.jsx @@ -0,0 +1,34 @@ +import React, { Children, cloneElement } from "react"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +export default function ConfigTabs({ children }) { + const wrapped = Children.map(children, (child) => { + if (child?.props?.value === "ui") { + return cloneElement(child, { + className: "config-tab-ui", + }); + } + if (child?.props?.value === "yaml") { + return cloneElement(child, { + className: "config-tab-yaml", + }); + } + return child; + }); + + return ( +
+ + {wrapped} + +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx new file mode 100644 index 0000000000..b8a8a8fc85 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import Admonition from "@theme/Admonition"; +import DeviceSelector from "./components/DeviceSelector"; +import HardwareOptions from "./components/HardwareOptions"; +import PortConfigSection from "./components/PortConfig"; +import StoragePaths from "./components/StoragePaths"; +import NvidiaGpuConfig from "./components/NvidiaGpuConfig"; +import OtherOptions from "./components/OtherOptions"; +import GeneratedOutput from "./components/GeneratedOutput"; +import { useConfigGenerator } from "./hooks/useConfigGenerator"; +import styles from "./styles.module.css"; + +/** + * Simple markdown-link-to-React renderer for help text. + * Only supports [text](url) syntax — no nested brackets. + */ +function renderHelpText(text: string): React.ReactNode { + const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g); + return parts.map((part, i) => { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return ( + + {match[1]} + + ); + } + return {part}; + }); +} + +export default function DockerComposeGenerator() { + const { + deviceId, device, hardwareEnabled, + portEnabled, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + } = useConfigGenerator(); + + return ( +
+
+ + + {device.helpText && ( + + {renderHelpText(device.helpText)} + + )} + + {device.needsNvidiaConfig && ( + + )} + + + + + + + + + + +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx new file mode 100644 index 0000000000..ddad160502 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { useColorMode } from "@docusaurus/theme-common"; +import { devices } from "../config"; +import type { DeviceConfig } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + selectedId: string; + onSelect: (id: string) => void; +} + +/** + * Determine the icon type from the icon string: + * - Starts with " tag. + */ +function hasBackgroundProps(style: React.CSSProperties | undefined): boolean { + if (!style) return false; + return Object.keys(style).some((key) => { + const k = key.toLowerCase().replace(/-/g, ""); + return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage"; + }); +} + +/** + * Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" }) + * so they can be consumed by CSS rules targeting child elements like . + */ +function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties { + if (!style) return {}; + const vars: Record = {}; + for (const [key, value] of Object.entries(style)) { + const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + vars[`--${prefix}-${cssKey}`] = value; + } + return vars as React.CSSProperties; +} + +function DeviceIcon({ device }: { device: DeviceConfig }) { + const { isDarkTheme } = useColorMode(); + const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon; + const iconStyle = (isDarkTheme && device.iconDarkStyle + ? device.iconDarkStyle + : device.iconStyle) as React.CSSProperties | undefined; + const svgStyle = (isDarkTheme && device.svgDarkStyle + ? device.svgDarkStyle + : device.svgStyle) as React.CSSProperties | undefined; + + const iconType = getIconType(iconStr); + + if (iconType === "svg") { + return ( +
+ ); + } + + if (iconType === "image") { + // When iconStyle contains background-* properties, render as background-image + // on the container div instead of an tag, enabling background-size/position control. + if (hasBackgroundProps(iconStyle)) { + return ( +
+ ); + } + return ( +
+ {device.name} +
+ ); + } + + return ( +
+ {iconStr} +
+ ); +} + +function DeviceCard({ + device, + active, + onClick, +}: { + device: DeviceConfig; + active: boolean; + onClick: () => void; +}) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onClick(); + }} + > + +
{device.name}
+
{device.description}
+
+ ); +} + +export default function DeviceSelector({ selectedId, onSelect }: Props) { + return ( +
+

Device Type

+
+ {devices.map((d) => ( + onSelect(d.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx new file mode 100644 index 0000000000..f170637aa0 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx @@ -0,0 +1,60 @@ +import React, { useState, useCallback } from "react"; +import CodeBlock from "@theme/CodeBlock"; +import Admonition from "@theme/Admonition"; +import styles from "../styles.module.css"; + +interface Props { + yaml: string; + configPath: string; + mediaPath: string; + hasAnyHardware: boolean; + deviceId: string; +} + +export default function GeneratedOutput({ + yaml, + configPath, + mediaPath, + hasAnyHardware, + deviceId, +}: Props) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(yaml).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [yaml]); + + return ( +
+
+

Generated Configuration

+ +
+ + {!configPath && ( + +

You haven't specified a config file directory. You may want to modify the default path.

+
+ )} + {!mediaPath && ( + +

You haven't specified a recording storage directory. You may want to modify the default path.

+
+ )} + {deviceId === "stable" && !hasAnyHardware && ( + +

You haven't selected any hardware acceleration. Please check if you have supported hardware available.

+
+ )} + + + {yaml} + +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx new file mode 100644 index 0000000000..9c261ed419 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { hardwareOptions } from "../config"; +import type { HardwareOption } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + deviceId: string; + hardwareEnabled: Record; + onToggle: (hwId: string) => void; + isDisabled: (hwId: string) => boolean; +} + +function renderDescription(text: string): React.ReactNode { + const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g); + return parts.map((part, i) => { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return {match[1]}; + } + return {part}; + }); +} + +function HardwareCheckbox({ + hw, disabled, checked, onToggle, +}: { + hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void; +}) { + return ( +
+ + {checked && hw.description && ( +
{renderDescription(hw.description)}
+ )} +
+ ); +} + +export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) { + return ( +
+

Generic Hardware Devices

+ {deviceId !== "stable" && ( +

+ Some options have been auto-configured based on your device type. +

+ )} +
+ {hardwareOptions.map((hw) => { + const disabled = isDisabled(hw.id); + const checked = disabled ? false : !!hardwareEnabled[hw.id]; + return ( + onToggle(hw.id)} /> + ); + })} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx new file mode 100644 index 0000000000..9c9be5e6a9 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styles from "../styles.module.css"; + +interface Props { + gpuCount: string; + gpuDeviceId: string; + gpuDeviceIdError: boolean; + onGpuCountChange: (value: string) => void; + onGpuDeviceIdChange: (value: string) => void; +} + +export default function NvidiaGpuConfig({ + gpuCount, + gpuDeviceId, + gpuDeviceIdError, + onGpuCountChange, + onGpuDeviceIdChange, +}: Props) { + const showDeviceId = gpuCount !== ""; + + return ( +
+
+ + onGpuCountChange(e.target.value.replace(/\D/g, ""))} + /> +
+ {showDeviceId && ( +
+ + onGpuDeviceIdChange(e.target.value)} + /> + {gpuDeviceIdError ? ( +

+ ⚠️ GPU device IDs are required when GPU count is a number +

+ ) : ( +

+ Single GPU: 0  |  Multiple GPUs: 0,1,2 +

+ )} +
+ )} +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx new file mode 100644 index 0000000000..8d1efef0fc --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx @@ -0,0 +1,122 @@ +import React, { useMemo } from "react"; +import CodeInline from "@theme/CodeInline"; +import styles from "../styles.module.css"; + +const AUTO_TIMEZONE_VALUE = "__auto__"; + +function getTimezoneList(): string[] { + if (typeof Intl !== "undefined") { + const intl = Intl as typeof Intl & { + supportedValuesOf?: (key: string) => string[]; + }; + const supported = intl.supportedValuesOf?.("timeZone"); + if (supported && supported.length > 0) { + return [...supported].sort(); + } + } + + const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone; + return fallback ? [fallback] : ["UTC"]; +} + +interface Props { + rtspPassword: string; + timezone: string; + shmSize: string; + shmSizeError: boolean; + onRtspPasswordChange: (value: string) => void; + onTimezoneChange: (value: string) => void; + onShmSizeChange: (value: string) => void; +} + +export default function OtherOptions({ + rtspPassword, + timezone, + shmSize, + shmSizeError, + onRtspPasswordChange, + onTimezoneChange, + onShmSizeChange, +}: Props) { + const timezones = useMemo(() => getTimezoneList(), []); + const systemTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"; + const selectedValue = timezone || AUTO_TIMEZONE_VALUE; + + return ( +
+

Other Options

+
+
+ + +
+
+ + onShmSizeChange(e.target.value)} + /> + {shmSizeError ? ( +

+ ⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb) +

+ ) : ( +

+ See{" "} + + calculating required SHM size + {" "} + for the correct value. +

+ )} +
+
+ + onRtspPasswordChange(e.target.value)} + /> +

+ Optional. You can specify{" "} + {"{FRIGATE_RTSP_PASSWORD}"}{" "} + in the config file to reference camera stream passwords. This is NOT + the Frigate login password. +

+
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx new file mode 100644 index 0000000000..c4e5acf713 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import Admonition from "@theme/Admonition"; +import { ports } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + portEnabled: Record; + onTogglePort: (portId: string) => void; +} + +function PortItem({ + port, + enabled, + onToggle, +}: { + port: typeof ports[number]; + enabled: boolean; + onToggle: () => void; +}) { + const showWarning = port.warningContent && ( + port.warningWhen === "checked" ? enabled : + port.warningWhen === "unchecked" ? !enabled : enabled + ); + + return ( +
+ + {port.description && ( +
{port.description}
+ )} + {showWarning && ( + + {port.warningContent} + + )} +
+ ); +} + +export default function PortConfigSection({ + portEnabled, + onTogglePort, +}: Props) { + return ( +
+

Port Configuration

+
+ {ports.map((port) => ( + onTogglePort(port.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx new file mode 100644 index 0000000000..1e20189cea --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import styles from "../styles.module.css"; + +interface Props { + configPath: string; + mediaPath: string; + configPathError: boolean; + mediaPathError: boolean; + onConfigPathChange: (value: string) => void; + onMediaPathChange: (value: string) => void; +} + +export default function StoragePaths({ + configPath, + mediaPath, + configPathError, + mediaPathError, + onConfigPathChange, + onMediaPathChange, +}: Props) { + return ( +
+

Storage Paths

+
+
+ + onConfigPathChange(e.target.value)} + /> + {configPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+ + onMediaPathChange(e.target.value)} + /> + {mediaPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/config/config.yaml b/docs/src/components/DockerComposeGenerator/config/config.yaml new file mode 100644 index 0000000000..42199ffca1 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/config.yaml @@ -0,0 +1,297 @@ +# Unified configuration for Docker Compose Generator +# This file defines all devices, hardware options, and ports for Frigate Docker Compose generation + +devices: + - id: "stable" + name: "Standard x86_64" + description: "Generic PC / server" + icon: "💻" + imageTag: "stable" + autoHardware: [] + + - id: "intel" + name: "Intel Device" + description: "Intel GPU / NPU" + icon: '' + imageTag: "stable" + autoHardware: + - "gpu" + - "intelNpu" + helpText: "Intel Device automatically configures /dev/dri and /dev/accel device mappings." + helpType: "info" + + - id: "stable-tensorrt" + name: "NVIDIA GPU" + description: "NVIDIA acceleration" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt" + autoHardware: [] + helpText: "Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker) to be installed. GPU deploy resources are configured automatically." + helpType: "warning" + needsNvidiaConfig: true + + - id: "stable-tensorrt-jp6" + name: "NVIDIA Jetson" + description: "Jetson development board" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt-jp6" + autoHardware: [] + helpText: "NVIDIA Jetson devices automatically configure runtime: nvidia." + helpType: "info" + runtime: "nvidia" + + - id: "stable-rocm" + name: "AMD GPU" + description: "ROCm acceleration" + icon: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconStyle: + filter: invert(1) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + iconDark: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconDarkStyle: + filter: invert(0) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + imageTag: "stable-rocm" + autoHardware: + - "gpu" + helpText: "AMD GPU automatically configures LIBVA_DRIVER_NAME environment variable and /dev/dri device mapping." + helpType: "info" + env: + LIBVA_DRIVER_NAME: "radeonsi" + + - id: "apple-silicon" + name: "Apple Silicon" + description: "Mac M-series processor" + icon: '' + svgStyle: + width: 90px + height: 90px + svgDarkStyle: + width: 90px + height: 90px + fill: white + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: [] + helpText: "Apple Silicon (M-series) requires an [external detector](/configuration/object_detectors#apple-silicon-detector) running on the host." + helpType: "warning" + extraHosts: + - "host.docker.internal:host-gateway" + + - id: "raspberry-pi" + name: "Raspberry Pi" + description: "ARM device" + icon: '' + svgStyle: + width: 40px + height: 40px + transform: translateX(-3px) + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: + - "video11" + helpText: "Raspberry Pi automatically configures the video11 device (RPi 4) and uses the arm64 image." + helpType: "info" + + - id: "stable-rk" + name: "Rockchip" + description: "Rockchip SoC board" + icon: "https://www.rock-chips.com/favicon.ico" + imageTag: "stable-rk" + autoHardware: + - "gpu" + helpText: "Rockchip devices automatically configure /dev/dri device mapping." + helpType: "info" + devices: + - host: "/dev/dma_heap" + comment: "Rockchip DMA heap" + - host: "/dev/rga" + comment: "Rockchip RGA" + - host: "/dev/mpp_service" + comment: "Rockchip MPP service" + volumes: + - host: "/sys/" + container: "/sys/" + readOnly: true + comment: "Rockchip system info" + securityOpt: + - "apparmor=unconfined" + - "systempaths=unconfined" + + - id: "stable-synaptics" + name: "Synaptics" + description: "Synaptics NPU" + icon: "🔷" + imageTag: "stable-synaptics" + autoHardware: [] + helpText: "Synaptics devices automatically configure /dev/synap and video devices." + helpType: "info" + devices: + - host: "/dev/synap" + comment: "Synaptics NPU" + - host: "/dev/video0" + comment: "Video device 0" + - host: "/dev/video1" + comment: "Video device 1" + +hardware: + - id: "usbCoral" + label: "USB Coral (TPU)" + description: "Enable this if you have a Google Coral USB TPU. Other Coral versions require different device paths." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/bus/usb" + container: "/dev/bus/usb" + comment: "USB Coral — modify for other versions" + + - id: "pcieCoral" + label: "PCIe Coral (TPU)" + description: "Enable this if you have a Google Coral PCIe/M.2 TPU. You also need to [install the driver](https://github.com/jnicolson/gasket-builder)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/apex_0" + container: "/dev/apex_0" + comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder" + + - id: "gpu" + label: "Intel/AMD GPU (/dev/dri)" + description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + devices: + - host: "/dev/dri" + container: "/dev/dri" + comment: "Intel/AMD GPU hardware acceleration" + + - id: "intelNpu" + label: "Intel NPU (/dev/accel)" + description: "Pass through /dev/accel for Intel NPU acceleration." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + devices: + - host: "/dev/accel" + container: "/dev/accel" + comment: "Intel NPU" + + - id: "hailo" + label: "Hailo NPU (/dev/hailo0)" + description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration. You also need to [install the driver](#hailo-8)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/hailo0" + comment: "Hailo NPU" + + - id: "memryx" + label: "MemryX MX3 (/dev/memx0)" + description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration. You also need to [install the driver](#memryx-mx3)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/memx0" + comment: "MemryX MX3 NPU" + volumes: + - host: "/run/mxa_manager" + container: "/run/mxa_manager" + comment: "MemryX manager" + + - id: "axera" + label: "AXERA Accelerator" + description: "Pass through AXERA accelerator devices. Requires the [AXCL driver](#axera) to be installed first." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/axcl_host" + comment: "AXERA accelerator device" + - host: "/dev/ax_mmb_dev" + comment: "AXERA MMB device" + - host: "/dev/msg_userdev" + comment: "AXERA message device" + volumes: + - host: "/usr/bin/axcl" + container: "/usr/bin/axcl" + comment: "AXERA binaries" + - host: "/usr/lib/axcl" + container: "/usr/lib/axcl" + comment: "AXERA libraries" + + - id: "video11" + label: "Raspberry Pi (/dev/video11)" + description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration." + disabledWhen: + - "stable-tensorrt" + - "stable-tensorrt-jp6" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + - "intel" + - "apple-silicon" + - "stable" + devices: + - host: "/dev/video11" + container: "/dev/video11" + comment: "Raspberry Pi 4B" + +ports: + - id: "8971" + host: 8971 + container: 8971 + protocol: "tcp" + description: "Authenticated UI and API access (default HTTPS)" + defaultEnabled: true + warningContent: "This is the access port for Frigate. Closing it means you will no longer be able to access the instance." + warningWhen: "unchecked" + + - id: "8554" + host: 8554 + container: 8554 + protocol: "tcp" + description: "Access RTSP feeds from go2rtc" + defaultEnabled: true + + - id: "8555-tcp" + host: 8555 + container: 8555 + protocol: "tcp" + description: "WebRTC over TCP" + defaultEnabled: true + + - id: "8555-udp" + host: 8555 + container: 8555 + protocol: "udp" + description: "WebRTC over UDP" + defaultEnabled: true + + - id: "1984" + host: 1984 + container: 1984 + protocol: "tcp" + description: "Go2RTC Web UI" + defaultEnabled: false diff --git a/docs/src/components/DockerComposeGenerator/config/index.ts b/docs/src/components/DockerComposeGenerator/config/index.ts new file mode 100644 index 0000000000..5acaba9f1b --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/index.ts @@ -0,0 +1,12 @@ +export { devices, deviceMap } from "./devices"; +export { hardwareOptions, hardwareMap } from "./hardware"; +export { ports, portMap } from "./ports"; + +export type { + DeviceConfig, + DeviceMapping, + VolumeMapping, + HardwareOption, + PortConfig, + NvidiaDeployConfig, +} from "./types"; diff --git a/docs/src/components/DockerComposeGenerator/config/types.ts b/docs/src/components/DockerComposeGenerator/config/types.ts new file mode 100644 index 0000000000..87bcb608dd --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/types.ts @@ -0,0 +1,154 @@ +/** + * Type definitions for the Docker Compose Generator configuration. + * All device, hardware, and port options are declaratively defined + * so that adding a new device only requires editing config files. + */ + +/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */ +export interface DeviceMapping { + /** Host device path */ + host: string; + /** Container device path (defaults to host if omitted) */ + container?: string; + /** Inline comment for this device line */ + comment?: string; +} + +/** A single volume mapping entry */ +export interface VolumeMapping { + /** Host path */ + host: string; + /** Container path */ + container: string; + /** Whether the mount is read-only */ + readOnly?: boolean; + /** Inline comment */ + comment?: string; +} + +/** NVIDIA deploy configuration for docker-compose */ +export interface NvidiaDeployConfig { + /** "all" or a specific number */ + count: string; + /** Specific GPU device IDs (when count is a number) */ + deviceIds?: string[]; +} + +/** Full device type definition */ +export interface DeviceConfig { + /** Unique identifier, e.g. "intel" */ + id: string; + /** Display name, e.g. "Intel GPU" */ + name: string; + /** Short description */ + description: string; + /** + * Icon for the device card. Supports: + * - Emoji string (e.g. "🖥️") + * - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png") + * - Inline SVG markup (e.g. "...") + */ + icon: string; + /** + * Additional CSS properties applied to the icon element. + * - For image-type icons: if any `background-*` property (e.g. `background-size`, + * `background-position`) is present, the image is rendered as a CSS `background-image` + * on the container div, enabling full background positioning control. + * Otherwise the image is rendered as an `` tag and styles apply to it. + * - For emoji/SVG icons: styles apply to the container div. + */ + iconStyle?: Record; + /** + * Additional CSS properties applied directly to the inner `` element + * when the icon is an inline SVG. Use this to override the default + * `width: 100%; height: 100%` or set `fill`, `transform`, etc. + * Ignored for emoji and image-type icons. + */ + svgStyle?: Record; + /** + * Icon for dark mode. Same format as `icon`. When provided, this icon + * replaces `icon` when the user is in dark mode. + */ + iconDark?: string; + /** Additional CSS properties for the dark mode icon container */ + iconDarkStyle?: Record; + /** + * SVG-specific styles for dark mode. Same as `svgStyle` but applied + * when dark mode is active. Merged over `svgStyle` in dark mode. + */ + svgDarkStyle?: Record; + /** Docker image tag, e.g. "stable" */ + imageTag: string; + /** + * Image tag suffix appended to the base tag. + * e.g. "-standard-arm64" produces "stable-standard-arm64" + */ + imageTagSuffix?: string; + /** Hardware option IDs to auto-enable when this device is selected */ + autoHardware: string[]; + /** Help text shown as an admonition when this device is selected */ + helpText?: string; + /** Admonition type for help text */ + helpType?: "info" | "warning" | "danger"; + /** Device mappings always added for this device type */ + devices?: DeviceMapping[]; + /** Volume mappings always added for this device type */ + volumes?: VolumeMapping[]; + /** Extra environment variables for this device type */ + env?: Record; + /** NVIDIA deploy config (only for tensorrt) */ + nvidiaDeploy?: NvidiaDeployConfig; + /** Runtime setting, e.g. "nvidia" for Jetson */ + runtime?: string; + /** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */ + extraHosts?: string[]; + /** Security options, e.g. ["apparmor=unconfined"] */ + securityOpt?: string[]; + /** Whether this device type needs the NVIDIA GPU config UI */ + needsNvidiaConfig?: boolean; +} + +/** Generic hardware acceleration option definition */ +export interface HardwareOption { + /** Unique identifier, e.g. "usbCoral" */ + id: string; + /** Display label */ + label: string; + /** + * Description shown below the checkbox when this option is enabled. + * Supports markdown link syntax: [text](url) + */ + description?: string; + /** Device IDs that disable this option */ + disabledWhen?: string[]; + /** Device mappings added when this option is enabled */ + devices?: DeviceMapping[]; + /** Volume mappings added when this option is enabled */ + volumes?: VolumeMapping[]; + /** Extra environment variables */ + env?: Record; +} + +/** Port definition */ +export interface PortConfig { + /** Unique identifier (also the default host port as string) */ + id: string; + /** Host port number */ + host: number; + /** Container port number */ + container: number; + /** Protocol */ + protocol?: "tcp" | "udp"; + /** Description of the port's purpose */ + description: string; + /** Whether enabled by default */ + defaultEnabled: boolean; + /** Whether this port is locked (always enabled, cannot be toggled off) */ + locked?: boolean; + /** Admonition type for the warning */ + warningType?: "warning" | "danger"; + /** Warning content (markdown) */ + warningContent?: string; + /** When to show the warning: when the port is checked or unchecked */ + warningWhen?: "checked" | "unchecked"; +} diff --git a/docs/src/components/DockerComposeGenerator/generator/index.ts b/docs/src/components/DockerComposeGenerator/generator/index.ts new file mode 100644 index 0000000000..f6091f7c01 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/generator/index.ts @@ -0,0 +1,250 @@ +import type { + DeviceConfig, + DeviceMapping, + VolumeMapping, +} from "../config/types"; +import { hardwareMap } from "../config"; + +// --------------------------------------------------------------------------- +// Input type +// --------------------------------------------------------------------------- + +export interface GeneratorInput { + device: DeviceConfig; + selectedHardware: string[]; + enabledPorts: string[]; + configPath: string; + mediaPath: string; + rtspPassword?: string; + timezone: string; + shmSize: string; + nvidiaGpuCount?: string; + nvidiaGpuDeviceId?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function deviceLine(dm: DeviceMapping): string { + const host = dm.host; + const container = dm.container ?? dm.host; + const mapping = host === container ? host : `${host}:${container}`; + const comment = dm.comment ? ` # ${dm.comment}` : ""; + return ` - ${mapping}${comment}`; +} + +function volumeLine(vm: VolumeMapping): string { + const ro = vm.readOnly ? ":ro" : ""; + const comment = vm.comment ? ` # ${vm.comment}` : ""; + return ` - ${vm.host}:${vm.container}${ro}${comment}`; +} + +// --------------------------------------------------------------------------- +// YAML builder — each section returns an array of lines +// --------------------------------------------------------------------------- + +function buildImage(device: DeviceConfig): string[] { + const tag = device.imageTagSuffix + ? `${device.imageTag}${device.imageTagSuffix}` + : device.imageTag; + return [` image: ghcr.io/blakeblackshear/frigate:${tag}`]; +} + +function buildDevices( + device: DeviceConfig, + hwDevices: DeviceMapping[] +): string[] { + const all: DeviceMapping[] = [ + ...(device.devices ?? []), + ...hwDevices, + ]; + if (all.length === 0) return []; + return [ + " devices:", + ...all.map(deviceLine), + ]; +} + +function buildVolumes( + device: DeviceConfig, + hwVolumes: VolumeMapping[], + configPath: string, + mediaPath: string +): string[] { + const all: VolumeMapping[] = [ + ...(device.volumes ?? []), + ...hwVolumes, + ]; + return [ + " volumes:", + " - /etc/localtime:/etc/localtime:ro # Sync host time", + ` - ${configPath}:/config # Config file directory`, + ` - ${mediaPath}:/media/frigate # Recording storage directory`, + " - type: tmpfs # 1GB in-memory filesystem for recording segment storage", + " target: /tmp/cache", + " tmpfs:", + " size: 1000000000", + ...all.map(volumeLine), + ]; +} + +function buildPorts(enabledPorts: string[]): string[] { + return [ + " ports:", + ...enabledPorts, + ]; +} + +function buildEnvironment( + device: DeviceConfig, + hwEnv: Record, + rtspPassword: string | undefined, + timezone: string +): string[] { + const allEnv: Record = { + ...hwEnv, + ...(device.env ?? {}), + }; + + const lines: string[] = [" environment:"]; + + if (rtspPassword) { + lines.push( + ` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own` + ); + } + + lines.push(` TZ: "${timezone}" # Timezone`); + + for (const [key, value] of Object.entries(allEnv)) { + lines.push(` ${key}: "${value}"`); + } + + return lines; +} + +function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] { + if (device.id === "stable-tensorrt") { + const count = input.nvidiaGpuCount || "all"; + const isAll = count === "all"; + const deviceId = input.nvidiaGpuDeviceId?.trim(); + + if (isAll) { + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + " count: all # Use all GPUs", + " capabilities: [gpu]", + ]; + } + + if (deviceId) { + const ids = deviceId + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => `'${s}'`) + .join(", "); + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + ` device_ids: [${ids}] # GPU device IDs`, + ` count: ${count} # GPU count`, + " capabilities: [gpu]", + ]; + } + + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + ` count: ${count} # GPU count`, + " capabilities: [gpu]", + ]; + } + + return []; +} + +function buildRuntime(device: DeviceConfig): string[] { + if (device.runtime) { + return [` runtime: ${device.runtime}`]; + } + return []; +} + +function buildExtraHosts(device: DeviceConfig): string[] { + if (!device.extraHosts?.length) return []; + return [ + " extra_hosts:", + ...device.extraHosts.map( + (h, i) => + ` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}` + ), + ]; +} + +function buildSecurityOpt(device: DeviceConfig): string[] { + if (!device.securityOpt?.length) return []; + return [ + " security_opt:", + ...device.securityOpt.map((s) => ` - ${s}`), + ]; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate a docker-compose YAML string from the given input. + * The output is pure YAML with inline comments (no Shiki annotations). + */ +export function generateDockerCompose(input: GeneratorInput): string { + const { device } = input; + + // Collect hardware-level devices, volumes, and env + const hwDevices: DeviceMapping[] = []; + const hwVolumes: VolumeMapping[] = []; + const hwEnv: Record = {}; + + for (const hwId of input.selectedHardware) { + const hw = hardwareMap.get(hwId); + if (!hw) continue; + // Skip GPU device mapping for tensorrt images (it uses deploy instead) + if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue; + hwDevices.push(...(hw.devices ?? [])); + hwVolumes.push(...(hw.volumes ?? [])); + Object.assign(hwEnv, hw.env ?? {}); + } + + const lines: string[] = [ + "services:", + " frigate:", + " container_name: frigate", + " privileged: true # This may not be necessary for all setups", + " restart: unless-stopped", + " stop_grace_period: 30s # Allow enough time to shut down the various services", + ...buildImage(device), + ` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`, + ...buildRuntime(device), + ...buildDeploy(device, input), + ...buildExtraHosts(device), + ...buildSecurityOpt(device), + ...buildDevices(device, hwDevices), + ...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath), + ...buildPorts(input.enabledPorts), + ...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone), + ]; + + return lines.join("\n"); +} diff --git a/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts new file mode 100644 index 0000000000..19c3976d8d --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts @@ -0,0 +1,195 @@ +import { useState, useCallback, useMemo } from "react"; +import { deviceMap, hardwareMap, portMap } from "../config"; +import { generateDockerCompose } from "../generator"; +import type { GeneratorInput } from "../generator"; + +/** + * Main hook that holds all form state and generates the Docker Compose output. + * Configuration is loaded synchronously from build-time generated .ts files. + */ +export function useConfigGenerator() { + const [deviceId, setDeviceId] = useState("stable"); + + const [hardwareEnabled, setHardwareEnabled] = useState>(() => { + const defaultDevice = deviceMap.get("stable"); + const initial: Record = {}; + if (defaultDevice) { + for (const hwId of defaultDevice.autoHardware) { + initial[hwId] = true; + } + } + return initial; + }); + + const [portEnabled, setPortEnabled] = useState>(() => { + const initial: Record = {}; + for (const p of portMap.values()) { + initial[p.id] = p.defaultEnabled; + } + return initial; + }); + + const [nvidiaGpuCount, setNvidiaGpuCount] = useState(""); + const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState(""); + const [configPath, setConfigPath] = useState(""); + const [mediaPath, setMediaPath] = useState(""); + const [rtspPassword, setRtspPassword] = useState(""); + const [timezone, setTimezone] = useState(""); + const [shmSize, setShmSize] = useState("512mb"); + const [shmSizeError, setShmSizeError] = useState(false); + const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false); + const [configPathError, setConfigPathError] = useState(false); + const [mediaPathError, setMediaPathError] = useState(false); + + const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]); + + const selectDevice = useCallback((id: string) => { + const newDevice = deviceMap.get(id); + if (!newDevice) return; + setDeviceId(id); + setHardwareEnabled(() => { + const next: Record = {}; + for (const hwId of newDevice.autoHardware) { + next[hwId] = true; + } + return next; + }); + setNvidiaGpuCount(""); + setNvidiaGpuDeviceId(""); + setGpuDeviceIdError(false); + }, []); + + const toggleHardware = useCallback((hwId: string) => { + setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] })); + }, []); + + const togglePort = useCallback((portId: string) => { + const port = portMap.get(portId); + if (port?.locked) return; + setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] })); + }, []); + + const isHardwareDisabled = useCallback( + (hwId: string): boolean => { + const hw = hardwareMap.get(hwId); + if (!hw) return false; + return hw.disabledWhen?.includes(deviceId) ?? false; + }, + [deviceId] + ); + + const validateShmSize = useCallback((value: string): boolean => { + if (!value) return true; + return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value); + }, []); + + const validatePath = useCallback((value: string): boolean => { + if (!value) return true; + return /^[a-zA-Z0-9_\-/./]+$/.test(value); + }, []); + + const handleShmSizeChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^0-9.bkmgBKMG]/g, ""); + const valid = validateShmSize(filtered); + setShmSize(filtered); + setShmSizeError(!valid && filtered !== ""); + }, + [validateShmSize] + ); + + const handleConfigPathChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, ""); + const valid = validatePath(filtered); + setConfigPath(filtered); + setConfigPathError(!valid && filtered !== ""); + }, + [validatePath] + ); + + const handleMediaPathChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, ""); + const valid = validatePath(filtered); + setMediaPath(filtered); + setMediaPathError(!valid && filtered !== ""); + }, + [validatePath] + ); + + const handleNvidiaGpuCountChange = useCallback((value: string) => { + // Only allow digits + setNvidiaGpuCount(value); + if (value === "") { + setNvidiaGpuDeviceId(""); + setGpuDeviceIdError(false); + } else { + setGpuDeviceIdError(false); + } + }, []); + + const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => { + setNvidiaGpuDeviceId(value.trim()); + setGpuDeviceIdError(false); + }, []); + + const enabledPortLines = useMemo(() => { + const lines: string[] = []; + for (const [id, enabled] of Object.entries(portEnabled)) { + if (!enabled) continue; + const p = portMap.get(id); + if (!p) continue; + const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : ""; + const comment = p.description ? ` # ${p.description}` : ""; + lines.push(` - "${p.host}:${p.container}${proto}"${comment}`); + } + return lines; + }, [portEnabled]); + + const selectedHardwareIds = useMemo(() => { + return Object.entries(hardwareEnabled) + .filter(([id, enabled]) => { + if (!enabled) return false; + const hw = hardwareMap.get(id); + if (!hw) return false; + if (hw.disabledWhen?.includes(deviceId)) return false; + return true; + }) + .map(([id]) => id); + }, [hardwareEnabled, deviceId]); + + const generatedYaml = useMemo(() => { + const input: GeneratorInput = { + device, + selectedHardware: selectedHardwareIds, + enabledPorts: enabledPortLines, + configPath: configPath || "/path/to/your/config", + mediaPath: mediaPath || "/path/to/your/storage", + rtspPassword, + timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC", + shmSize: shmSize || "512mb", + nvidiaGpuCount, + nvidiaGpuDeviceId, + }; + return generateDockerCompose(input); + }, [ + device, selectedHardwareIds, enabledPortLines, + configPath, mediaPath, rtspPassword, timezone, shmSize, + nvidiaGpuCount, nvidiaGpuDeviceId, + ]); + + const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length; + + return { + deviceId, device, hardwareEnabled, portEnabled, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + }; +} diff --git a/docs/src/components/DockerComposeGenerator/index.ts b/docs/src/components/DockerComposeGenerator/index.ts new file mode 100644 index 0000000000..76dd587560 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/index.ts @@ -0,0 +1 @@ +export { default } from "./DockerComposeGenerator"; diff --git a/docs/src/components/DockerComposeGenerator/styles.module.css b/docs/src/components/DockerComposeGenerator/styles.module.css new file mode 100644 index 0000000000..d2e1b62dd8 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/styles.module.css @@ -0,0 +1,381 @@ +/* =================================================================== + Docker Compose Generator — styles + Uses Docusaurus / Infima CSS variables for theme compatibility. + =================================================================== */ + +.generator { + margin: 2rem 0; +} + +.card { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 12px; + padding: 2rem; + box-shadow: var(--ifm-global-shadow-lw); +} + +[data-theme="light"] .card { + background: var(--ifm-color-emphasis-100); + border: 1px solid var(--ifm-color-emphasis-300); +} + +/* --- Form sections --- */ + +.formSection { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--ifm-color-emphasis-400); +} + +.formSection:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.formSection h4 { + margin: 0 0 1rem 0; + color: var(--ifm-font-color-base); + font-size: 1.1rem; + font-weight: var(--ifm-font-weight-semibold); +} + +/* --- Form controls --- */ + +.formGroup { + margin-bottom: 1rem; +} + +.formGroup:last-child { + margin-bottom: 0; +} + +.label { + display: block; + margin-bottom: 0.25rem; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); + font-size: 0.9rem; +} + +.input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +[data-theme="light"] .input { + background: #fff; + border: 1px solid #d0d7de; +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +[data-theme="dark"] .input { + border-color: var(--ifm-color-emphasis-300); +} + +.inputError { + border-color: #e74c3c; + animation: shake 0.3s ease-in-out; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 75% { + transform: translateX(5px); + } +} + +/* --- Select dropdown --- */ + +.select { + cursor: pointer; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: var(--ifm-background-color) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") + no-repeat right 0.75rem center / 12px 12px; + padding-right: 2rem; +} + +[data-theme="light"] .select { + background: #fff + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E") + no-repeat right 0.75rem center / 12px 12px; +} + +.helpText { + margin: 0.5rem 0 0 0; + font-size: 0.85rem; + color: var(--ifm-font-color-secondary); + line-height: 1.5; +} + +.helpText a { + color: var(--ifm-color-primary); +} + +/* --- Device grid --- */ + +.deviceGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; + margin-top: 0.5rem; +} + +.deviceCard { + padding: 0.75rem; + border: 2px solid var(--ifm-color-emphasis-400); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + background: var(--ifm-background-color); + display: flex; + flex-direction: column; + align-items: center; +} + +[data-theme="light"] .deviceCard { + border: 2px solid #d0d7de; + background: #fff; +} + +.deviceCard:hover { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); + transform: translateY(-2px); +} + +.deviceCardActive { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-primary-lightest); + box-shadow: 0 0 0 1px var(--ifm-color-primary); +} + +[data-theme="light"] .deviceCardActive { + background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff); +} + +[data-theme="dark"] .deviceCardActive { + background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b); +} + +[data-theme="dark"] .deviceCardActive .deviceName { + color: var(--ifm-color-primary-light); +} + +[data-theme="dark"] .deviceCardActive .deviceDesc { + color: var(--ifm-color-primary-light); + opacity: 0.85; +} + +.deviceIcon { + font-size: 2rem; + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.deviceIconSvg { + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + /* Allow iconStyle width/height to override */ + flex-shrink: 0; +} + +.deviceIconSvg svg { + width: var(--svg-width, 100%); + height: var(--svg-height, 100%); + fill: var(--svg-fill, currentColor); + transform: var(--svg-transform, none); +} + +.deviceIconImage { + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.deviceIconImage img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.deviceName { + font-weight: var(--ifm-font-weight-semibold); + color: var(--ifm-font-color-base); + margin-bottom: 0.15rem; + font-size: 0.9rem; +} + +.deviceDesc { + font-size: 0.75rem; + color: var(--ifm-font-color-secondary); + line-height: 1.3; +} + +/* --- Checkbox grid --- */ + +.checkboxGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@media (max-width: 576px) { + .checkboxGrid { + grid-template-columns: 1fr; + } +} + +.hardwareItem { + margin-bottom: 0; +} + +.hardwareDescription { + margin: 0.15rem 0 0.4rem 1.6rem; + font-size: 0.8rem; + color: var(--ifm-font-color-secondary); + line-height: 1.5; +} + +.hardwareDescription a { + color: var(--ifm-color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.4rem 0.5rem; + border-radius: 6px; + transition: background-color 0.2s; + font-size: 0.9rem; +} + +.checkboxLabel:hover { + background: var(--ifm-color-emphasis-100); +} + +.checkboxLabel input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + cursor: pointer; + flex-shrink: 0; +} + +.checkboxLabel span { + color: var(--ifm-font-color-base); +} + +.checkboxDisabled { + cursor: not-allowed; +} + +.checkboxDisabled:hover { + background: transparent; +} + +.checkboxDisabled input[type="checkbox"] { + cursor: not-allowed; + opacity: 0.5; +} + +/* --- Form grid (side-by-side) --- */ + +.formGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +@media (max-width: 576px) { + .formGrid { + grid-template-columns: 1fr; + } +} + +.formGrid .formGroup { + margin-bottom: 0; +} + +/* --- Port section --- */ + +.portSection { + margin-bottom: 0.75rem; +} + +.warningBadge { + margin-left: auto; + color: #e67e22; + font-size: 0.85rem; +} + +/* --- NVIDIA config --- */ + +.nvidiaConfig { + margin-top: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--ifm-background-color); + border-radius: 8px; + border-left: 3px solid var(--ifm-color-primary); +} + +[data-theme="light"] .nvidiaConfig { + background: #f6f8fa; + border-left: 3px solid var(--ifm-color-primary); +} + +/* --- Result section --- */ + +.resultSection { + margin-top: 2rem; +} + +.resultHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.resultHeader h4 { + margin: 0; + color: var(--ifm-font-color-base); +} diff --git a/docs/src/components/FaqItem/index.jsx b/docs/src/components/FaqItem/index.jsx new file mode 100644 index 0000000000..af70d1100f --- /dev/null +++ b/docs/src/components/FaqItem/index.jsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from "react"; +import Heading from "@theme/Heading"; +import styles from "./styles.module.css"; + +// A single FAQ entry. +// +// The question is a real anchored heading (via @theme/Heading), so on desktop +// it gets the standard hover "#" hash link and the answer is always shown. On +// mobile the heading text is a button that toggles its answer, keeping long +// FAQ pages short. The desktop/mobile split is pure CSS (Docusaurus breakpoint: +// 996px), so there is no hydration flash. The answer is always rendered into +// the DOM, so search engines and the docs AI bot can read it regardless of +// layout or collapsed state. The heading id resolves deep links on both layouts +// and auto-expands the entry on mobile when it is the link target. +export default function FaqItem({ id, question, children }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + const openIfTargeted = () => { + if (window.location.hash === `#${id}`) { + setOpen(true); + } + }; + openIfTargeted(); + window.addEventListener("hashchange", openIfTargeted); + return () => window.removeEventListener("hashchange", openIfTargeted); + }, [id]); + + const toggle = () => { + const next = !open; + setOpen(next); + // Reflect the entry in the URL like clicking the heading anchor, so an + // opened answer is shareable. Use replaceState to avoid history spam and + // an abrupt scroll. Clear it on close if it currently points here. + if (next) { + if (window.location.hash !== `#${id}`) { + window.history.replaceState(null, "", `#${id}`); + } + } else if (window.location.hash === `#${id}`) { + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + }; + + return ( +
+ + + +
+ {children} +
+
+ ); +} diff --git a/docs/src/components/FaqItem/styles.module.css b/docs/src/components/FaqItem/styles.module.css new file mode 100644 index 0000000000..bf348dc88a --- /dev/null +++ b/docs/src/components/FaqItem/styles.module.css @@ -0,0 +1,93 @@ +/* + * FAQ entry: collapsible on mobile, static heading + expanded answer on + * desktop. The split is pure CSS (Docusaurus breakpoint: 996px) so there is + * no hydration flash. The answer is always rendered into the DOM, so search + * engines and the docs AI bot can read it regardless of layout or state. + */ + +.item { + scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem); +} + +.heading { + margin: 0; +} + +/* Mobile: the heading text is a full-width clickable toggle row. */ +.toggle { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.85rem 0; + border: none; + border-bottom: 1px solid var(--ifm-color-emphasis-200); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.toggle::before { + content: ""; + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transition: transform var(--ifm-transition-fast, 200ms) ease; +} + +.item[data-open] .toggle::before { + transform: rotate(45deg); +} + +.question { + flex: 1; + min-width: 0; +} + +.content { + display: none; + padding: 0 0 0.85rem; +} + +.item[data-open] .content { + display: block; +} + +/* Hide the hover hash link on mobile (no hover; avoids a stray empty line). */ +.heading :global(.hash-link) { + display: none; +} + +/* Desktop: render as a normal expanded heading + answer. */ +@media (min-width: 997px) { + .heading { + margin: 1.75rem 0 0.85rem; + } + + .toggle { + display: inline; + width: auto; + padding: 0; + border: none; + cursor: default; + } + + .toggle::before { + display: none; + } + + .content { + display: block; + padding: 0 0 0.5rem 1rem; + border-left: 2px solid var(--ifm-color-emphasis-200); + } + + .heading :global(.hash-link) { + display: inline; + } +} diff --git a/docs/src/components/ModelConfigDropdown/index.jsx b/docs/src/components/ModelConfigDropdown/index.jsx new file mode 100644 index 0000000000..737f47ec23 --- /dev/null +++ b/docs/src/components/ModelConfigDropdown/index.jsx @@ -0,0 +1,171 @@ +import React, { useState } from "react"; +import CodeBlock from "@theme/CodeBlock"; +import ConfigTabs from "@site/src/components/ConfigTabs"; +import TabItem from "@theme/TabItem"; +import { marked } from "marked"; +import styles from "./styles.module.css"; + +marked.setOptions({ gfm: true }); + +/** + * @typedef {Object} Model + * @property {string} key + * @property {string} label + * @property {boolean} recommended + * @property {string} download Markdown for the "download the model" step. + * @property {string} ui Markdown for the Frigate UI configuration step. + * @property {string} yaml Raw YAML for the configuration step. + */ + +// Render a markdown string to React nodes. Fenced code blocks become Docusaurus +// CodeBlock components (so they get syntax highlighting and a copy button); +// everything else is marked-parsed to HTML. +function renderBlocks(md, keyPrefix) { + if (!md.trim()) return []; + const tokens = marked.lexer(md); + const nodes = []; + let buffer = []; + let idx = 0; + + const flush = () => { + if (buffer.length) { + buffer.links = tokens.links; + nodes.push( +
, + ); + buffer = []; + } + }; + + tokens.forEach((token) => { + if (token.type === "code") { + flush(); + const language = (token.lang || "text").split(/\s+/)[0]; + nodes.push( + + {token.text} + , + ); + } else { + buffer.push(token); + } + }); + flush(); + return nodes; +} + +// marked does not understand Docusaurus admonitions (:::warning ... :::), so +// render those blocks ourselves and render everything around them normally. +function renderMarkdown(md) { + if (!md) return null; + const admonition = /:::(\w+)[ \t]*([^\n]*)\n([\s\S]*?)\n:::/g; + const nodes = []; + let lastIndex = 0; + let match; + let k = 0; + while ((match = admonition.exec(md)) !== null) { + nodes.push(...renderBlocks(md.slice(lastIndex, match.index), `seg${k}`)); + const [, type, title, body] = match; + const heading = (title || type).trim(); + nodes.push( +
+
{heading}
+ {renderBlocks(body, `adm${k}`)} +
, + ); + lastIndex = admonition.lastIndex; + k++; + } + nodes.push(...renderBlocks(md.slice(lastIndex), `seg${k}`)); + return nodes; +} + +function Markdown({ children }) { + return
{renderMarkdown(children)}
; +} + +function RecommendedBadge() { + return Recommended; +} + +/** + * @param {{ models: Model[] }} props + */ +export default function ModelConfigDropdown({ models }) { + const [selectedModelIndex, setSelectedModelIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + const selectedModel = models[selectedModelIndex]; + const hasChoices = models.length > 1; + + const handleModelSelect = (index) => { + setSelectedModelIndex(index); + setIsOpen(false); + }; + + return ( +
+
+
+

Step 1 — Choose a model

+
setIsOpen(!isOpen) : undefined} + > +
+ + {selectedModel.label} + {selectedModel.recommended && } + + {hasChoices && ( + {isOpen ? "▲" : "▼"} + )} +
+
+ + {isOpen && hasChoices && ( +
+ {models.map((model, index) => ( +
handleModelSelect(index)} + > + {model.label} + {model.recommended && } +
+ ))} +
+ )} +
+ +
+

Step 2 — Download the model

+ {selectedModel.download} +
+ +
+

Step 3 — Configure the detector

+ + + {selectedModel.ui} + + + {selectedModel.yaml} + + +
+
+
+ ); +} diff --git a/docs/src/components/ModelConfigDropdown/styles.module.css b/docs/src/components/ModelConfigDropdown/styles.module.css new file mode 100644 index 0000000000..06f0333344 --- /dev/null +++ b/docs/src/components/ModelConfigDropdown/styles.module.css @@ -0,0 +1,275 @@ +/* =================================================================== + ModelConfigDropdown — styles + =================================================================== */ + +.wrapper { + margin: 1.5rem 0; +} + +/* --- Dropdown button --- */ + +.dropdown { + display: inline-block; + width: 360px; + max-width: 100%; + text-align: left; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 8px; + background: var(--ifm-background-color); + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +[data-theme="light"] .dropdown { + border: 1px solid #d0d7de; + background: #fff; +} + +[data-theme="dark"] .dropdown { + border: 1px solid var(--ifm-color-emphasis-300); + background: #21262d; +} + +.dropdown:hover { + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +[data-theme="dark"] .dropdown:hover { + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +.dropdown.open { + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +[data-theme="dark"] .dropdown.open { + border-color: var(--ifm-color-primary); +} + +/* Single-model detectors render the label without a clickable menu. */ +.dropdown.static { + cursor: default; +} + +.dropdown.static:hover { + border-color: var(--ifm-color-emphasis-400); + box-shadow: none; +} + +[data-theme="light"] .dropdown.static:hover { + border-color: #d0d7de; +} + +[data-theme="dark"] .dropdown.static:hover { + border-color: var(--ifm-color-emphasis-300); +} + +.dropdownContent { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.8rem 1rem; +} + +/* --- Model menu --- */ + +.menu { + margin-top: 0.25rem; + width: 360px; + max-width: 100%; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 8px; + overflow: hidden; + background: var(--ifm-background-color); +} + +[data-theme="light"] .menu { + border: 1px solid #d0d7de; + background: #fff; +} + +[data-theme="dark"] .menu { + border: 1px solid var(--ifm-color-emphasis-300); + background: #21262d; +} + +.menuItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.95rem; + color: var(--ifm-font-color-base); + transition: background 0.15s; +} + +.menuItem:not(:last-child) { + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.menuItem:hover { + background: var(--ifm-color-emphasis-100); +} + +.menuItemActive { + font-weight: var(--ifm-font-weight-semibold); + background: var(--ifm-color-primary-lightest); +} + +[data-theme="dark"] .menuItem:hover { + background: #2b3139; +} + +[data-theme="dark"] .menuItemActive { + background: #2b3139; +} + +.modelName { + font-weight: var(--ifm-font-weight-semibold); + color: var(--ifm-font-color-base); + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.recommendedBadge { + display: inline-block; + background: var(--ifm-color-success); + color: #fff; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.arrow { + font-size: 0.7rem; + color: var(--ifm-font-color-secondary); + transition: transform 0.2s; +} + +.dropdown.open .arrow { + transform: rotate(180deg); +} + +/* --- Panel --- */ + +.panel { + margin-top: 0.5rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 8px; + overflow: hidden; + background: var(--ifm-background-color); +} + +[data-theme="light"] .panel { + border: 1px solid #d0d7de; + background: #fff; +} + +[data-theme="dark"] .panel { + border: 1px solid var(--ifm-color-emphasis-300); + background: #21262d; +} + +/* --- Steps --- */ + +.step { + padding: 1rem; +} + +.step:not(:last-child) { + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.stepTitle { + margin: 0 0 0.75rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--ifm-font-color-base); +} + +/* Rendered markdown (download + Frigate UI instructions). */ + +.markdown { + font-size: 0.9rem; + line-height: 1.6; +} + +.markdown > :last-child { + margin-bottom: 0; +} + +.markdown a { + color: var(--ifm-color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.markdown table { + display: table; + width: 100%; + margin: 0.75rem 0; + font-size: 0.85rem; +} + +/* Docusaurus-style admonitions rendered from markdown. */ + +.admonition { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + border-left: 4px solid var(--ifm-color-info); + border-radius: 4px; + background: var(--ifm-color-info-contrast-background); + font-size: 0.85rem; +} + +.admonition > :last-child { + margin-bottom: 0; +} + +.admonitionTitle { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 0.75rem; + margin-bottom: 0.4rem; + color: var(--ifm-color-info); +} + +.admonition_warning { + border-left-color: var(--ifm-color-warning); + background: var(--ifm-color-warning-contrast-background); +} + +.admonition_warning .admonitionTitle { + color: var(--ifm-color-warning-dark); +} + +.admonition_danger { + border-left-color: var(--ifm-color-danger); + background: var(--ifm-color-danger-contrast-background); +} + +.admonition_danger .admonitionTitle { + color: var(--ifm-color-danger-dark); +} + +.admonition_tip { + border-left-color: var(--ifm-color-success); + background: var(--ifm-color-success-contrast-background); +} + +.admonition_tip .admonitionTitle { + color: var(--ifm-color-success-dark); +} diff --git a/docs/src/components/NavPath/index.jsx b/docs/src/components/NavPath/index.jsx new file mode 100644 index 0000000000..e5ec86bdc9 --- /dev/null +++ b/docs/src/components/NavPath/index.jsx @@ -0,0 +1,30 @@ +import React from "react"; + +export default function NavPath({ path }) { + const segments = path.split(" > "); + return ( + + {segments.map((seg, i) => ( + + {i > 0 && ( + + → + + )} + {seg} + + ))} + + ); +} diff --git a/docs/src/components/ShmCalculator/index.jsx b/docs/src/components/ShmCalculator/index.jsx new file mode 100644 index 0000000000..b7e13ed79f --- /dev/null +++ b/docs/src/components/ShmCalculator/index.jsx @@ -0,0 +1,201 @@ +import React, { useState, useEffect } from "react"; +import Admonition from "@theme/Admonition"; +import styles from "./styles.module.css"; + +const ShmCalculator = () => { + const [width, setWidth] = useState(1280); + const [height, setHeight] = useState(720); + const [cameraCount, setCameraCount] = useState(1); + const [result, setResult] = useState("26.32MB"); + const [singleCameraShm, setSingleCameraShm] = useState("26.32MB"); + const [totalShm, setTotalShm] = useState("26.32MB"); + + const calculate = () => { + if (!width || !height || !cameraCount) { + setResult("Please enter valid values"); + setSingleCameraShm("-"); + setTotalShm("-"); + return; + } + + // Single camera base SHM calculation (excluding logs) + // Formula: (width * height * 1.5 * 20 + 270480) / 1048576 + const singleCameraBase = + (width * height * 1.5 * 20 + 270480) / 1048576; + setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`); + + // Total SHM calculation (multiple cameras, including logs) + const totalBase = singleCameraBase * cameraCount; + const finalResult = totalBase + 40; // Default includes logs +40mb + + setTotalShm(`${(totalBase + 40).toFixed(2)}mb`); + + // Format result + if (finalResult < 1) { + setResult(`${(finalResult * 1024).toFixed(2)}kb`); + } else if (finalResult >= 1024) { + setResult(`${(finalResult / 1024).toFixed(2)}gb`); + } else { + setResult(`${finalResult.toFixed(2)}mb`); + } + }; + + const formatWithUnit = (value) => { + const match = value.match(/^([\d.]+)(mb|kb|gb)$/i); + if (match) { + return ( + <> + {match[1]}{match[2]} + + ); + } + return value; + }; + + const applyPreset = (w, h, count) => { + setWidth(w); + setHeight(h); + setCameraCount(count); + calculate(); + }; + + useEffect(() => { + calculate(); + }, [width, height, cameraCount]); + + return ( +
+
+

SHM Calculator

+

+ Calculate required shared memory (SHM) based on camera resolution and + count +

+ + + The resolution below is the detect stream resolution, + not the record stream resolution. SHM size is + determined by the detect resolution used for object detection.{" "} + + Learn more about choosing a detect resolution. + + + + {width * height > 1280 * 720 && ( + + Using a detect resolution higher than 720p is not recommended. + Higher resolutions do not improve object detection accuracy and will + consume significantly more resources. + + )} + +
+
+
+ + setWidth(Number(e.target.value))} + /> +
+
+ +
+
+ + setHeight(Number(e.target.value))} + /> +
+
+
+ +
+ + setCameraCount(Number(e.target.value))} + /> +
+ +
+

Calculation Result

+
+ {formatWithUnit(result)} +
+
+

+ Single Camera: {formatWithUnit(singleCameraShm)} +

+

+ Formula: (width × height × 1.5 × 20 + 270480) ÷ + 1048576 +

+ {cameraCount > 1 && ( +

+ Total ({cameraCount} cameras): {formatWithUnit(totalShm)} +

+ )} +

+ With Logs: + 40mb +

+
+
+ +
+

Common Presets

+
+ + + + +
+
+
+
+ ); +}; + +export default ShmCalculator; diff --git a/docs/src/components/ShmCalculator/styles.module.css b/docs/src/components/ShmCalculator/styles.module.css new file mode 100644 index 0000000000..5b48f4942d --- /dev/null +++ b/docs/src/components/ShmCalculator/styles.module.css @@ -0,0 +1,131 @@ +.shmCalculator { + margin: 2rem 0; + max-width: 600px; +} + +.card { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-border-color); + border-radius: 12px; + padding: 2rem; + box-shadow: var(--ifm-global-shadow-lw); +} + +[data-theme='light'] .card { + background: var(--ifm-color-emphasis-100); + border: 1px solid var(--ifm-color-emphasis-300); +} + +.title { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); +} + +.description { + margin: 0 0 1.5rem 0; + color: var(--ifm-font-color-secondary); + font-size: 0.9rem; +} + +.formGroup { + margin-bottom: 1rem; +} + +.label { + display: block; + margin-bottom: 0.25rem; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); + font-size: 0.9rem; +} + +.input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--ifm-border-color); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +[data-theme='light'] .input { + background: #fff; + border: 1px solid #d0d7de; +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +.resultSection { + margin-top: 1rem; + padding: 1.5rem; + background: var(--ifm-background-color); + border-radius: 8px; + border: 1px solid var(--ifm-border-color); +} + +[data-theme='light'] .resultSection { + background: #f6f8fa; + border: 1px solid #d0d7de; +} + +.resultSection h4 { + margin: 0 0 1rem 0; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); +} + +.resultValue { + text-align: center; + padding: 1rem; + background: var(--ifm-color-primary); + border-radius: 6px; + margin-bottom: 1rem; +} + +.resultNumber { + font-size: 2rem; + font-weight: var(--ifm-font-weight-bold); + color: #fff; +} + +.formulaDisplay { + font-size: 0.85rem; + color: var(--ifm-font-color-secondary); + line-height: 1.6; +} + +.formulaDisplay p { + margin: 0.25rem 0; +} + +.formulaDisplay strong { + color: var(--ifm-font-color-base); +} + +.unit { + text-transform: uppercase; +} + +.presets { + margin-top: 1.5rem; +} + +.presets h4 { + margin: 0 0 0.75rem 0; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); +} + +.presetButtons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 5d8fc5055f..6d9b7c82f9 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -241,4 +241,50 @@ margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); border-left: 3px solid #ff000080; +} + +/* ConfigTabs wrapper */ +.config-tabs-wrapper { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + overflow: hidden; + margin-bottom: 16px; +} + +.config-tabs-wrapper .tabs-container { + margin-bottom: 0 !important; +} + +.config-tabs-wrapper .tabs { + background: var(--ifm-color-emphasis-100); + border-bottom: 1px solid var(--ifm-color-emphasis-300); + margin-bottom: 0; + padding: 0 12px; +} + +.config-tabs-wrapper .tabs__item { + padding: 8px 16px; + border-radius: 0; +} + +.config-tabs-wrapper .tabs__item--active { + border-bottom-color: var(--ifm-color-primary); +} + +.config-tabs-wrapper .config-tab-ui { + padding: 4px 16px 16px; +} + +.config-tabs-wrapper .config-tab-ui > :last-child { + margin-bottom: 0; +} + +.config-tabs-wrapper div[class*="codeBlockContainer"] { + border-top-left-radius: 0; + border-top-right-radius: 0; + margin: 0; +} + +.config-tabs-wrapper .tabs-container > .margin-top--md:has(.config-tab-yaml:not([hidden])) { + margin-top: 0 !important; } \ No newline at end of file diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index f1a00fe61b..18a75df5da 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -1,28 +1,52 @@ +# Generated by generate_api_auth_spec.py — do not edit by hand. +# Regenerate with: python3 generate_api_auth_spec.py +# The empty info.title is intentional: a docusaurus-openapi-docs convention +# that suppresses the generated API introduction page. openapi: 3.1.0 info: - # To avoid the introduction page we set the title to empty string - # https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/blob/4e771d309f6defe395449b26cc3c65814d72cbcc/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts#L92-L129 - title: "" + title: '' version: 0.1.0 - servers: - url: https://demo.frigate.video/api - url: http://localhost:5001/api - paths: + /auth/first_time_login: + get: + tags: + - Auth + summary: First Time Login + description: |- + **Access:** Public — no authentication required. + + Return whether the admin first-time login help flag is set in config. + + This endpoint is intentionally unauthenticated so the login page can + query it before a user is authenticated. + operationId: first_time_login_auth_first_time_login_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: [] + x-required-role: public /auth: get: tags: - Auth summary: Authenticate request description: |- - Authenticates the current request based on proxy headers or JWT token. - This endpoint verifies authentication credentials and manages JWT token refresh. - On success, no JSON body is returned; authentication state is communicated via response headers and cookies. + **Access:** Public — no authentication required. + + Authenticates the current request based on proxy headers or JWT token. This endpoint verifies authentication credentials and manages JWT token refresh. On success, no JSON body is returned; authentication state is communicated via response headers and cookies. operationId: auth_auth_get responses: - "202": - description: Authentication Accepted (no response body, different headers depending on auth method) + '202': + description: Authentication Accepted (no response body) + content: + application/json: + schema: {} headers: remote-user: description: Authenticated username or "viewer" in proxy-only mode @@ -33,54 +57,59 @@ paths: schema: type: string Set-Cookie: - description: May include refreshed JWT cookie ("frigate-token") when applicable + description: May include refreshed JWT cookie when applicable schema: type: string - "401": + '401': description: Authentication Failed + security: [] + x-required-role: public /profile: get: tags: - Auth summary: Get user profile description: |- - Returns the current authenticated user's profile including username, role, and allowed cameras. - This endpoint requires authentication and returns information about the user's permissions. + **Access:** Any authenticated user. + + Returns the current authenticated user's profile including username, role, and allowed cameras. This endpoint requires authentication and returns information about the user's permissions. operationId: profile_profile_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "401": - description: Unauthorized + security: + - frigateUserAuth: [] + x-required-role: any /logout: get: tags: - Auth summary: Logout user description: |- - Logs out the current user by clearing the session cookie. - After logout, subsequent requests will require re-authentication. + **Access:** Public — no authentication required. + + Logs out the current user by clearing the session cookie. After logout, subsequent requests will require re-authentication. operationId: logout_logout_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "303": - description: See Other (redirects to login page) + security: [] + x-required-role: public /login: post: tags: - Auth summary: Login with credentials description: |- - Authenticates a user with username and password. - Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests. - The JWT token can also be retrieved from the response and used as a Bearer token in the Authorization header. + **Access:** Public — no authentication required. + + Authenticates a user with username and password. Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests. The JWT token can also be retrieved from the response and used as a Bearer token in the Authorization header. Example using Bearer token: ``` @@ -92,86 +121,79 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppPostLoginBody" + $ref: '#/components/schemas/AppPostLoginBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "401": - description: Login Failed - Invalid credentials - content: - application/json: - schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: [] + x-required-role: public /users: get: tags: - Auth summary: Get all users description: |- - Returns a list of all users with their usernames and roles. - Requires admin role. Each user object contains the username and assigned role. + **Access:** Admin role required. + + Returns a list of all users with their usernames and roles. Requires admin role. Each user object contains the username and assigned role. operationId: get_users_users_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "403": - description: Forbidden - Admin role required + security: + - frigateAdminAuth: [] + x-required-role: admin post: tags: - Auth summary: Create new user description: |- - Creates a new user with the specified username, password, and role. - Requires admin role. Password must meet strength requirements: - - Minimum 8 characters - - At least one uppercase letter - - At least one digit - - At least one special character (!@#$%^&*(),.?":{}\|<>) + **Access:** Admin role required. + + Creates a new user with the specified username, password, and role. Requires admin role. Password must be at least 12 characters long. operationId: create_user_users_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AppPostUsersBody" + $ref: '#/components/schemas/AppPostUsersBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "400": - description: Bad Request - Invalid username or role - content: - application/json: - schema: {} - "403": - description: Forbidden - Admin role required - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /users/{username}: delete: tags: - Auth summary: Delete user description: |- - Deletes a user by username. The built-in admin user cannot be deleted. - Requires admin role. Returns success message or error if user not found. + **Access:** Admin role required. + + Deletes a user by username. The built-in admin user cannot be deleted. Requires admin role. Returns success message or error if user not found. operationId: delete_user_users__username__delete parameters: - name: username @@ -180,36 +202,30 @@ paths: schema: type: string title: Username - description: The username of the user to delete responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "403": - description: Forbidden - Cannot delete admin user or admin role required - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /users/{username}/password: put: tags: - Auth summary: Update user password description: |- - Updates a user's password. Users can only change their own password unless they have admin role. - Requires the current password to verify identity for non-admin users. - Password must meet strength requirements: - - Minimum 8 characters - - At least one uppercase letter - - At least one digit - - At least one special character (!@#$%^&*(),.?":{}\|<>) + **Access:** Any authenticated user. - If user changes their own password, a new JWT cookie is automatically issued. + Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued. operationId: update_password_users__username__password_put parameters: - name: username @@ -218,41 +234,36 @@ paths: schema: type: string title: Username - description: The username of the user whose password to update requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AppPutPasswordBody" + $ref: '#/components/schemas/AppPutPasswordBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "400": - description: Bad Request - Current password required or password doesn't meet requirements - "401": - description: Unauthorized - Current password is incorrect - "403": - description: Forbidden - Viewers can only update their own password - "404": - description: Not Found - User not found - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /users/{username}/role: put: tags: - Auth summary: Update user role description: |- - Updates a user's role. The built-in admin user's role cannot be modified. - Requires admin role. Valid roles are defined in the configuration. + **Access:** Admin role required. + + Updates a user's role. The built-in admin user's role cannot be modified. Requires admin role. Valid roles are defined in the configuration. operationId: update_role_users__username__role_put parameters: - name: username @@ -261,53 +272,776 @@ paths: schema: type: string title: Username - description: The username of the user whose role to update requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AppPutRoleBody" + $ref: '#/components/schemas/AppPutRoleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "400": - description: Bad Request - Invalid role - "403": - description: Forbidden - Cannot modify admin user's role or admin role required - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /go2rtc/streams: + get: + tags: + - Camera + summary: Go2Rtc Streams + operationId: go2rtc_streams_go2rtc_streams_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /go2rtc/streams/{stream_name}: + get: + tags: + - Camera + summary: Go2Rtc Camera Stream + operationId: go2rtc_camera_stream_go2rtc_streams__stream_name__get + parameters: + - name: stream_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Stream Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + put: + tags: + - Camera + summary: Go2Rtc Add Stream + description: |- + **Access:** Admin role required. + + Add or update a go2rtc stream configuration. + operationId: go2rtc_add_stream_go2rtc_streams__stream_name__put + parameters: + - name: stream_name + in: path + required: true + schema: + type: string + title: Stream Name + - name: src + in: query + required: false + schema: + type: string + default: '' + title: Src + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Camera + summary: Go2Rtc Delete Stream + description: |- + **Access:** Admin role required. + + Delete a go2rtc stream. + operationId: go2rtc_delete_stream_go2rtc_streams__stream_name__delete + parameters: + - name: stream_name + in: path + required: true + schema: + type: string + title: Stream Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /ffprobe: + get: + tags: + - Camera + summary: Ffprobe + operationId: ffprobe_ffprobe_get + parameters: + - name: paths + in: query + required: false + schema: + type: string + default: '' + title: Paths + - name: detailed + in: query + required: false + schema: + type: boolean + default: false + title: Detailed + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' + /keyframe_analysis: + get: + tags: + - Camera + summary: Keyframe Analysis + description: |- + **Access:** Admin role required. + + Probe a camera's record stream and classify its keyframe spacing. + + Detects smart/+ codecs and long/variable GOPs that degrade recording. + operationId: keyframe_analysis_keyframe_analysis_get + parameters: + - name: camera + in: query + required: false + schema: + type: string + default: '' + title: Camera + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /ffprobe/snapshot: + get: + tags: + - Camera + summary: Ffprobe Snapshot + description: |- + **Access:** Admin role required. + + Get a snapshot from a stream URL using ffmpeg. + operationId: ffprobe_snapshot_ffprobe_snapshot_get + parameters: + - name: url + in: query + required: false + schema: + type: string + default: '' + title: Url + - name: timeout + in: query + required: false + schema: + type: integer + default: 10 + title: Timeout + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /reolink/detect: + get: + tags: + - Camera + summary: Reolink Detect + description: |- + **Access:** Admin role required. + + Detect Reolink camera capabilities and recommend optimal protocol. + + Queries the Reolink camera API to determine the camera's resolution + and recommends either http-flv (for 5MP and below) or rtsp (for higher resolutions). + operationId: reolink_detect_reolink_detect_get + parameters: + - name: host + in: query + required: false + schema: + type: string + default: '' + title: Host + - name: username + in: query + required: false + schema: + type: string + default: '' + title: Username + - name: password + in: query + required: false + schema: + type: string + default: '' + title: Password + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /onvif/probe: + get: + tags: + - Camera + summary: Probe ONVIF device + description: |- + **Access:** Admin role required. + + Probe an ONVIF device to determine capabilities and optionally test available stream URIs. Query params: host (required), port (default 80), username, password, test (boolean), auth_type (basic or digest, default basic). + operationId: onvif_probe_onvif_probe_get + parameters: + - name: host + in: query + required: false + schema: + type: string + title: Host + - name: port + in: query + required: false + schema: + type: integer + default: 80 + title: Port + - name: username + in: query + required: false + schema: + type: string + default: '' + title: Username + - name: password + in: query + required: false + schema: + type: string + default: '' + title: Password + - name: test + in: query + required: false + schema: + type: boolean + default: false + title: Test + - name: auth_type + in: query + required: false + schema: + type: string + default: basic + title: Auth Type + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cameras/{camera_name}: + delete: + tags: + - Camera + summary: Delete Camera + description: |- + **Access:** Admin role required. + + Delete a camera and all its associated data. + + Removes the camera from config, stops processes, and cleans up + all database entries and media files. + + Args: + camera_name: Name of the camera to delete + delete_exports: Whether to also delete exports for this camera + operationId: delete_camera_cameras__camera_name__delete + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: delete_exports + in: query + required: false + schema: + type: boolean + default: false + title: Delete Exports + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /camera/{camera_name}/set/{feature}/{sub_command}: + put: + tags: + - Camera + summary: Camera Set + description: |- + **Access:** Admin role required. + + Set a camera feature state. Use camera_name='*' to target all cameras. + + The value to set is sent in the request body as `{"value": ""}`. + + | Feature | Accepted values | + | --- | --- | + | `enabled` | `ON`, `OFF` | + | `detect` | `ON`, `OFF` | + | `motion` | `ON`, `OFF` | + | `recordings` | `ON`, `OFF` | + | `snapshots` | `ON`, `OFF` | + | `audio` | `ON`, `OFF` | + | `audio_transcription` | `ON`, `OFF` | + | `notifications` | `ON`, `OFF` | + | `review_alerts` | `ON`, `OFF` | + | `review_detections` | `ON`, `OFF` | + | `object_descriptions` | `ON`, `OFF` | + | `review_descriptions` | `ON`, `OFF` | + | `improve_contrast` | `ON`, `OFF` | + | `ptz_autotracker` | `ON`, `OFF` | + | `birdseye` | `ON`, `OFF` | + | `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` | + | `motion_contour_area` | integer | + | `motion_threshold` | integer | + | `motion_mask` | `ON`, `OFF` | + | `object_mask` | `ON`, `OFF` | + | `zone` | `ON`, `OFF` | + | `profile` | a profile name, or `none` to deactivate | + + `motion_mask`, `object_mask`, and `zone` require the `sub_command` path + parameter to be set to the name of the mask or zone. All other features + reject a sub-command. + + `profile` applies globally rather than per camera, so it requires + `camera_name` to be `*`. + + These features map to the equivalent MQTT topics, which document the + behavior of each value in more detail. + operationId: + camera_set_camera__camera_name__set__feature___sub_command__put + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: feature + in: path + required: true + schema: + type: string + title: Feature + - name: sub_command + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Sub Command + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CameraSetBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /camera/{camera_name}/set/{feature}: + put: + tags: + - Camera + summary: Camera Set + description: |- + **Access:** Admin role required. + + Set a camera feature state. Use camera_name='*' to target all cameras. + + The value to set is sent in the request body as `{"value": ""}`. + + | Feature | Accepted values | + | --- | --- | + | `enabled` | `ON`, `OFF` | + | `detect` | `ON`, `OFF` | + | `motion` | `ON`, `OFF` | + | `recordings` | `ON`, `OFF` | + | `snapshots` | `ON`, `OFF` | + | `audio` | `ON`, `OFF` | + | `audio_transcription` | `ON`, `OFF` | + | `notifications` | `ON`, `OFF` | + | `review_alerts` | `ON`, `OFF` | + | `review_detections` | `ON`, `OFF` | + | `object_descriptions` | `ON`, `OFF` | + | `review_descriptions` | `ON`, `OFF` | + | `improve_contrast` | `ON`, `OFF` | + | `ptz_autotracker` | `ON`, `OFF` | + | `birdseye` | `ON`, `OFF` | + | `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` | + | `motion_contour_area` | integer | + | `motion_threshold` | integer | + | `motion_mask` | `ON`, `OFF` | + | `object_mask` | `ON`, `OFF` | + | `zone` | `ON`, `OFF` | + | `profile` | a profile name, or `none` to deactivate | + + `motion_mask`, `object_mask`, and `zone` require the `sub_command` path + parameter to be set to the name of the mask or zone. All other features + reject a sub-command. + + `profile` applies globally rather than per camera, so it requires + `camera_name` to be `*`. + + These features map to the equivalent MQTT topics, which document the + behavior of each value in more detail. + operationId: camera_set_camera__camera_name__set__feature__put + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: feature + in: path + required: true + schema: + type: string + title: Feature + - name: sub_command + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Sub Command + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CameraSetBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /chat/tools: + get: + tags: + - Chat + summary: Get available tools + description: |- + **Access:** Admin role required. + + Returns OpenAI-compatible tool definitions for function calling. + operationId: get_tools_chat_tools_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /chat/execute: + post: + tags: + - Chat + summary: Execute a tool + description: |- + **Access:** Admin role required. + + Execute a tool function call from an LLM. + operationId: execute_tool_chat_execute_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ToolExecuteRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /chat/completion: + post: + tags: + - Chat + summary: Chat completion with tool calling + description: |- + **Access:** Admin role required. + + Send a chat message to the configured GenAI provider with tool calling support. The LLM can call Frigate tools to answer questions about your cameras and events. + operationId: chat_completion_chat_completion_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatCompletionRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /vlm/monitor: + post: + tags: + - Chat + summary: Start a VLM watch job + description: |- + **Access:** Admin role required. + + Start monitoring a camera with the vision provider. The VLM analyzes live frames until the specified condition is met, then sends a notification. Only one watch job can run at a time. + operationId: start_vlm_monitor_vlm_monitor_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VLMMonitorRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + get: + tags: + - Chat + summary: Get current VLM watch job + description: |- + **Access:** Admin role required. + + Returns the current (or most recently completed) VLM watch job. + operationId: get_vlm_monitor_vlm_monitor_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Chat + summary: Cancel the current VLM watch job + description: |- + **Access:** Admin role required. + + Cancels the running watch job if one exists. + operationId: cancel_vlm_monitor_vlm_monitor_delete + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /faces: get: tags: - Classification summary: Get all registered faces description: |- + **Access:** Admin role required. + Returns a dictionary mapping face names to lists of image filenames. Each key represents a registered face name, and the value is a list of image files associated with that face. Supported image formats include .webp, .png, .jpg, and .jpeg. operationId: get_faces_faces_get responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/FacesResponse" + $ref: '#/components/schemas/FacesResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/reprocess: post: tags: - Classification summary: Reprocess a face training image description: |- + **Access:** Admin role required. + Reprocesses a face training image to update the prediction. Requires face recognition to be enabled in the configuration. The training file must exist in the faces/train directory. Returns a success response or an error @@ -320,23 +1054,28 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/train/{name}/classify: post: tags: - Classification summary: Classify and save a face training image description: |- + **Access:** Admin role required. + Adds a training image to a specific face name for face recognition. Accepts either a training file from the train directory or an event_id to extract the face from. The image is saved to the face's directory and the face classifier @@ -358,24 +1097,29 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/{name}/create: post: tags: - Classification summary: Create a new face name description: |- + **Access:** Admin role required. + Creates a new folder for a face name in the faces directory. This is used to organize face training images. The face name is sanitized and spaces are replaced with underscores. Returns a success message or an error if @@ -389,26 +1133,30 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/{name}/register: post: tags: - Classification summary: Register a face image - description: >- - Registers a face image for a specific face name by uploading an image - file. + description: |- + **Access:** Admin role required. + + Registers a face image for a specific face name by uploading an image file. The uploaded image is processed and added to the face recognition system. Returns a success response with details about the registration, or an error if face recognition is not enabled or the image cannot be processed. @@ -425,27 +1173,31 @@ paths: content: multipart/form-data: schema: - $ref: >- - #/components/schemas/Body_register_face_faces__name__register_post + $ref: '#/components/schemas/Body_register_face_faces__name__register_post' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/recognize: post: tags: - Classification summary: Recognize a face from an uploaded image description: |- + **Access:** Admin role required. + Recognizes a face from an uploaded image file by comparing it against registered faces in the system. Returns the recognized face name and confidence score, or an error if face recognition is not enabled or the image cannot be processed. @@ -455,28 +1207,74 @@ paths: content: multipart/form-data: schema: - $ref: "#/components/schemas/Body_recognize_face_faces_recognize_post" + $ref: '#/components/schemas/Body_recognize_face_faces_recognize_post' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/FaceRecognitionResponse" - "422": + $ref: '#/components/schemas/FaceRecognitionResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{name}/reclassify: + post: + tags: + - Classification + summary: Reclassify a face image to a different name + description: |- + **Access:** Admin role required. + + Moves a single face image from one person's folder to another. + The image is moved and renamed, and the face classifier is cleared to + incorporate the change. Returns a success message or an error if the + image or target name is invalid. + operationId: reclassify_face_image_faces__name__reclassify_post + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/{name}/delete: post: tags: - Classification summary: Delete face images - description: >- - Deletes specific face images for a given face name. The image IDs must - belong + description: |- + **Access:** Admin role required. + + Deletes specific face images for a given face name. The image IDs must belong to the specified face folder. To delete an entire face folder, all image IDs in that folder must be sent. Returns a success message or an error if face recognition is not enabled. operationId: deregister_faces_faces__name__delete_post @@ -492,26 +1290,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DeleteFaceImagesBody" + $ref: '#/components/schemas/DeleteFaceImagesBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/{old_name}/rename: put: tags: - Classification summary: Rename a face name description: |- + **Access:** Admin role required. + Renames a face name in the system. The old name must exist and the new name must be valid. Returns a success message or an error if face recognition is not enabled. operationId: rename_face_faces__old_name__rename_put @@ -527,26 +1330,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RenameFaceBody" + $ref: '#/components/schemas/RenameFaceBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /lpr/reprocess: put: tags: - Classification summary: Reprocess a license plate description: |- + **Access:** Admin role required. + Reprocesses a license plate image to update the plate. Requires license plate recognition to be enabled in the configuration. The event_id must exist in the database. Returns a success message or an error if license plate @@ -560,39 +1368,49 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /reindex: put: tags: - Classification summary: Reindex embeddings description: |- + **Access:** Admin role required. + Reindexes the embeddings for all tracked objects. Requires semantic search to be enabled in the configuration. Returns a success message or an error if semantic search is not enabled. operationId: reindex_embeddings_reindex_put responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" + $ref: '#/components/schemas/GenericResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin /audio/transcribe: put: tags: - Classification summary: Transcribe audio description: |- + **Access:** Admin role required. + Transcribes audio from a specific event. Requires audio transcription to be enabled in the configuration. The event_id must exist in the database. Returns a success message or an error if audio transcription is not enabled or the event_id is invalid. @@ -602,52 +1420,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudioTranscriptionBody" + $ref: '#/components/schemas/AudioTranscriptionBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /classification/attributes: - get: - tags: - - Classification - summary: Get custom classification attributes - description: |- - Returns custom classification attributes for a given object type. - Only includes models with classification_type set to 'attribute'. - By default returns a flat sorted list of all attribute labels. - If group_by_model is true, returns attributes grouped by model name. - operationId: get_custom_attributes_classification_attributes_get - parameters: - - name: object_type - in: query - schema: - type: string - - name: group_by_model - in: query - schema: - type: boolean - default: false - responses: - "200": - description: Successful Response - "422": - description: Validation Error + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/{name}/dataset: get: tags: - Classification summary: Get classification dataset description: |- + **Access:** Admin role required. + Gets the dataset for a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: get_classification_dataset_classification__name__dataset_get @@ -659,23 +1456,70 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/attributes: + get: + tags: + - Classification + summary: Get custom classification attributes + description: |- + **Access:** Authenticated user with access to all cameras. + + Returns custom classification attributes for a given object type. + Only includes models with classification_type set to 'attribute'. + By default returns a flat sorted list of all attribute labels. + If group_by_model is true, returns attributes grouped by model name. + operationId: get_custom_attributes_classification_attributes_get + parameters: + - name: object_type + in: query + required: false + schema: + type: string + title: Object Type + - name: group_by_model + in: query + required: false + schema: + type: boolean + default: false + title: Group By Model + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: all_cameras /classification/{name}/train: get: tags: - Classification summary: Get classification train images description: |- + **Access:** Admin role required. + Gets the train images for a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: get_classification_images_classification__name__train_get @@ -687,22 +1531,27 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin post: tags: - Classification summary: Train a classification model description: |- + **Access:** Admin role required. + Trains a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: train_configured_model_classification__name__train_post @@ -714,28 +1563,32 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/{name}/dataset/{category}/delete: post: tags: - Classification summary: Delete classification dataset images - description: >- - Deletes specific dataset images for a given classification model and - category. + description: |- + **Access:** Admin role required. + + Deletes specific dataset images for a given classification model and category. The image IDs must belong to the specified category. Returns a success message or an error if the name or category is invalid. - operationId: >- + operationId: delete_classification_dataset_images_classification__name__dataset__category__delete_post parameters: - name: name @@ -757,28 +1610,126 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{category}/reclassify: + post: + tags: + - Classification + summary: Reclassify a dataset image to a different category + description: |- + **Access:** Admin role required. + + Moves a single dataset image from one category to another. + The image is re-saved as PNG in the target category and removed from the source. + operationId: + reclassify_classification_image_classification__name__dataset__category__reclassify_post + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: category + in: path + required: true + schema: + type: string + title: Category + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{old_category}/rename: + put: + tags: + - Classification + summary: Rename a classification category + description: |- + **Access:** Admin role required. + + Renames a classification category for a given classification model. + The old category must exist and the new name must be valid. Returns a success message or an error if the name is invalid. + operationId: + rename_classification_category_classification__name__dataset__old_category__rename_put + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: old_category + in: path + required: true + schema: + type: string + title: Old Category + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/{name}/dataset/categorize: post: tags: - Classification summary: Categorize a classification image - description: >- - Categorizes a specific classification image for a given classification - model and category. + description: |- + **Access:** Admin role required. + + Categorizes a specific classification image for a given classification model and category. The image must exist in the specified category. Returns a success message or an error if the name or category is invalid. - operationId: >- + operationId: categorize_classification_image_classification__name__dataset_categorize_post parameters: - name: name @@ -794,27 +1745,74 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{category}/create: + post: + tags: + - Classification + summary: Create an empty classification category folder + description: |- + **Access:** Admin role required. + + Creates an empty folder for a classification category. + This is used to create folders for categories that don't have images yet. + Returns a success message or an error if the name is invalid. + operationId: + create_classification_category_classification__name__dataset__category__create_post + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: category + in: path + required: true + schema: + type: string + title: Category + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/{name}/train/delete: post: tags: - Classification summary: Delete classification train images description: |- + **Access:** Admin role required. + Deletes specific train images for a given classification model. The image IDs must belong to the specified train folder. Returns a success message or an error if the name is invalid. - operationId: >- + operationId: delete_classification_train_images_classification__name__train_delete_post parameters: - name: name @@ -830,18 +1828,122 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/generate_examples/state: + post: + tags: + - Classification + summary: Generate state classification examples + description: |- + **Access:** Admin role required. + + Generate examples for state classification. + operationId: + generate_state_examples_classification_generate_examples_state_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateStateExamplesBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/generate_examples/object: + post: + tags: + - Classification + summary: Generate object classification examples + description: |- + **Access:** Admin role required. + + Generate examples for object classification. + operationId: + generate_object_examples_classification_generate_examples_object_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateObjectExamplesBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}: + delete: + tags: + - Classification + summary: Delete a classification model + description: |- + **Access:** Admin role required. + + Deletes a specific classification model and all its associated data. + Works even if the model is not in the config (e.g., partially created during wizard). + Returns a success message. + operationId: delete_classification_model_classification__name__delete + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /review: get: tags: @@ -875,7 +1977,6 @@ paths: required: false schema: type: integer - default: 0 title: Reviewed - name: limit in: query @@ -887,7 +1988,7 @@ paths: in: query required: false schema: - $ref: "#/components/schemas/SeverityEnum" + $ref: '#/components/schemas/SeverityEnum' - name: before in: query required: false @@ -901,21 +2002,25 @@ paths: type: number title: After responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewSegmentResponse" + $ref: '#/components/schemas/ReviewSegmentResponse' title: Response Review Review Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /review_ids: get: tags: @@ -930,21 +2035,25 @@ paths: type: string title: Ids responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewSegmentResponse" + $ref: '#/components/schemas/ReviewSegmentResponse' title: Response Review Ids Review Ids Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /review/summary: get: tags: @@ -981,18 +2090,22 @@ paths: default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSummaryResponse" - "422": + $ref: '#/components/schemas/ReviewSummaryResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /reviews/viewed: post: tags: @@ -1004,20 +2117,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ReviewModifyMultipleBody" + $ref: '#/components/schemas/ReviewModifyMultipleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /reviews/delete: post: tags: @@ -1029,26 +2146,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ReviewModifyMultipleBody" + $ref: '#/components/schemas/ReviewModifyMultipleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /review/activity/motion: get: tags: - Review summary: Motion Activity - description: Get motion and audio activity. + description: |- + **Access:** Any authenticated user. + + Get motion and audio activity. operationId: motion_activity_review_activity_motion_get parameters: - name: cameras @@ -1078,21 +2202,24 @@ paths: default: 30 title: Scale responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewActivityMotionResponse" + $ref: '#/components/schemas/ReviewActivityMotionResponse' title: Response Motion Activity Review Activity Motion Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /review/event/{event_id}: get: tags: @@ -1107,18 +2234,22 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSegmentResponse" - "422": + $ref: '#/components/schemas/ReviewSegmentResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /review/{review_id}: get: tags: @@ -1133,18 +2264,22 @@ paths: type: string title: Review Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSegmentResponse" - "422": + $ref: '#/components/schemas/ReviewSegmentResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /review/{review_id}/viewed: delete: tags: @@ -1159,25 +2294,32 @@ paths: type: string title: Review Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /review/summarize/start/{start_ts}/end/{end_ts}: post: tags: - Review summary: Generate Review Summary - description: Use GenAI to summarize review items over a period of time. - operationId: >- + description: |- + **Access:** Authenticated user with access to all cameras. + + Use GenAI to summarize review items over a period of time. + operationId: generate_review_summary_review_summarize_start__start_ts__end__end_ts__post parameters: - name: start_ts @@ -1193,17 +2335,20 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: all_cameras /: get: tags: @@ -1211,12 +2356,15 @@ paths: summary: Is Healthy operationId: is_healthy__get responses: - "200": + '200': description: Successful Response content: text/plain: schema: type: string + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /config/schema.json: get: tags: @@ -1224,48 +2372,14 @@ paths: summary: Config Schema operationId: config_schema_config_schema_json_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - /go2rtc/streams: - get: - tags: - - App - summary: Go2Rtc Streams - operationId: go2rtc_streams_go2rtc_streams_get - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - /go2rtc/streams/{camera_name}: - get: - tags: - - App - summary: Go2Rtc Camera Stream - operationId: go2rtc_camera_stream_go2rtc_streams__camera_name__get - parameters: - - name: camera_name - in: path - required: true - schema: - type: string - title: Camera Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /version: get: tags: @@ -1273,12 +2387,15 @@ paths: summary: Version operationId: version_version_get responses: - "200": + '200': description: Successful Response content: text/plain: schema: type: string + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /stats: get: tags: @@ -1286,11 +2403,15 @@ paths: summary: Stats operationId: stats_stats_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /stats/history: get: tags: @@ -1305,30 +2426,90 @@ paths: type: string title: Keys responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /metrics: get: tags: - App summary: Metrics - description: Expose Prometheus metrics endpoint and update metrics with latest stats + description: |- + **Access:** Any authenticated user. + + Expose Prometheus metrics endpoint and update metrics with latest stats operationId: metrics_metrics_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + /genai/models: + get: + tags: + - App + summary: List available GenAI models + description: |- + **Access:** Admin role required. + + Returns available models for each configured GenAI provider. + operationId: genai_models_genai_models_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /genai/probe: + post: + tags: + - App + summary: Probe a GenAI provider without saving config + description: |- + **Access:** Admin role required. + + 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. + 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' + security: + - frigateAdminAuth: [] + x-required-role: admin /config: get: tags: @@ -1336,11 +2517,91 @@ paths: summary: Config operationId: config_config_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /profiles: + get: + tags: + - App + summary: Get Profiles + description: |- + **Access:** Any authenticated user. + + List all available profiles and the currently active profile. + operationId: get_profiles_profiles_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + /profile/active: + get: + tags: + - App + summary: Get Active Profile + description: |- + **Access:** Admin role required. + + Get the currently active profile. + operationId: get_active_profile_profile_active_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /ffmpeg/presets: + get: + tags: + - App + summary: Ffmpeg Presets + description: |- + **Access:** Admin role required. + + Return available ffmpeg preset keys for config UI usage. + operationId: ffmpeg_presets_ffmpeg_presets_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /config/raw_paths: + get: + tags: + - App + summary: Config Raw Paths + description: |- + **Access:** Admin role required. + + Admin-only endpoint that returns camera paths and go2rtc streams without credential masking. + operationId: config_raw_paths_config_raw_paths_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /config/raw: get: tags: @@ -1348,11 +2609,15 @@ paths: summary: Config Raw operationId: config_raw_config_raw_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /config/save: post: tags: @@ -1373,17 +2638,21 @@ paths: schema: title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /config/set: put: tags: @@ -1395,45 +2664,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppConfigSetBody" + $ref: '#/components/schemas/AppConfigSetBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /ffprobe: - get: - tags: - - App - summary: Ffprobe - operationId: ffprobe_ffprobe_get - parameters: - - name: paths - in: query - required: false - schema: - type: string - default: "" - title: Paths - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /vainfo: get: tags: @@ -1441,11 +2688,15 @@ paths: summary: Vainfo operationId: vainfo_vainfo_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /nvinfo: get: tags: @@ -1453,18 +2704,25 @@ paths: summary: Nvinfo operationId: nvinfo_nvinfo_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /logs/{service}: get: tags: - App - Logs summary: Logs - description: Get logs for the requested service (frigate/nginx/go2rtc) + description: |- + **Access:** Admin role required. + + Get logs for the requested service (frigate/nginx/go2rtc) operationId: logs_logs__service__get parameters: - name: service @@ -1483,7 +2741,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Download - name: stream in: query @@ -1491,7 +2749,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' default: false title: Stream - name: start @@ -1500,7 +2758,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 0 title: Start - name: end @@ -1509,20 +2767,23 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: End responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /restart: post: tags: @@ -1530,11 +2791,100 @@ paths: summary: Restart operationId: restart_restart_post responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' + /media/sync: + post: + tags: + - App + summary: Start media sync job + description: |- + **Access:** Admin role required. + + Start an asynchronous media sync job to find and (optionally) remove orphaned media files. + Returns 202 with job details when queued, or 409 if a job is already running. + operationId: sync_media_media_sync_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MediaSyncBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /media/sync/current: + get: + tags: + - App + summary: Get current media sync job + description: |- + **Access:** Admin role required. + + Retrieve the current running media sync job, if any. Returns the job details + or null when no job is active. + operationId: get_media_sync_current_media_sync_current_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /media/sync/status/{job_id}: + get: + tags: + - App + summary: Get media sync job status + description: |- + **Access:** Admin role required. + + Get status and results for the specified media sync job id. Returns 200 with + job details including results, or 404 if the job is not found. + operationId: get_media_sync_status_media_sync_status__job_id__get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /labels: get: tags: @@ -1547,20 +2897,24 @@ paths: required: false schema: type: string - default: "" + default: '' title: Camera responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /sub_labels: get: tags: @@ -1574,20 +2928,40 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Split Joined responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /audio_labels: + get: + tags: + - App + summary: Get Audio Labels + operationId: get_audio_labels_audio_labels_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /plus/models: get: tags: @@ -1603,17 +2977,21 @@ paths: default: false title: Filterbycurrentmodeldetector responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /recognized_license_plates: get: tags: @@ -1627,20 +3005,24 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Split Joined responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /timeline: get: tags: @@ -1668,26 +3050,33 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Source Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /timeline/hourly: get: tags: - App summary: Hourly Timeline - description: Get hourly summary for timeline. + description: |- + **Access:** Any authenticated user. + + Get hourly summary for timeline. operationId: hourly_timeline_timeline_hourly_get parameters: - name: cameras @@ -1696,7 +3085,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras - name: labels @@ -1705,7 +3094,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Labels - name: after @@ -1714,7 +3103,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: After - name: before in: query @@ -1722,7 +3111,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Before - name: limit in: query @@ -1730,7 +3119,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 200 title: Limit - name: timezone @@ -1739,40 +3128,44 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /preview/{camera_name}/start/{start_ts}/end/{end_ts}: get: tags: - Preview summary: Get preview clips for time range description: |- + **Access:** Any authenticated user. + Gets all preview clips for a specified camera and time range. Returns a list of preview video clips that overlap with the requested time period, ordered by start time. Use camera_name='all' to get previews from all cameras. Returns an error if no previews are found. - operationId: preview_ts_preview__camera_name__start__start_ts__end__end_ts__get + operationId: + preview_ts_preview__camera_name__start__start_ts__end__end_ts__get parameters: - name: camera_name in: path required: true schema: - anyOf: - - type: string - - type: "null" + type: string title: Camera Name - name: start_ts in: path @@ -1787,35 +3180,2621 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/PreviewModel" - title: >- - Response Preview Ts Preview Camera Name Start Start Ts + $ref: '#/components/schemas/PreviewModel' + title: Response Preview Ts Preview Camera Name Start Start Ts End End Ts Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}: get: tags: - Preview summary: Get preview clips for specific hour description: |- + **Access:** Any authenticated user. + Gets all preview clips for a specific hour in a given timezone. Converts the provided date/time from the specified timezone to UTC and retrieves all preview clips for that hour. Use camera_name='all' to get previews from all cameras. The tz_name should be a timezone like 'America/New_York' (use commas instead of slashes). - operationId: >- + operationId: preview_hour_preview__year_month___day___hour___camera_name___tz_name__get + parameters: + - name: year_month + in: path + required: true + schema: + type: string + title: Year Month + - name: day + in: path + required: true + schema: + type: integer + title: Day + - name: hour + in: path + required: true + schema: + type: integer + title: Hour + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: tz_name + in: path + required: true + schema: + type: string + title: Tz Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PreviewModel' + title: Response Preview Hour Preview Year Month Day Hour + Camera Name Tz Name Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames: + get: + tags: + - Preview + summary: Get cached preview frame filenames + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets a list of cached preview frame filenames for a specific camera and time range. + Returns an array of filenames for preview frames that fall within the specified time period, + sorted in chronological order. These are individual frame images cached for quick preview display. + operationId: + get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + type: string + title: Response Get Preview Frames From Cache Preview Camera + Name Start Start Ts End End Ts Frames Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /notifications/pubkey: + get: + tags: + - Notifications + summary: Get VAPID public key + description: |- + **Access:** Any authenticated user. + + Gets the VAPID public key for the notifications. + Returns the public key or an error if notifications are not enabled. + operationId: get_vapid_pub_key_notifications_pubkey_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + /notifications/register: + post: + tags: + - Notifications + summary: Register notifications + description: |- + **Access:** Any authenticated user. + + Registers a notifications subscription. + Returns a success message or an error if the subscription is not provided. + operationId: register_notifications_notifications_register_post + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /exports: + get: + tags: + - Export + summary: Get exports + description: |- + **Access:** Any authenticated user. + + Gets all exports from the database for cameras the user has access to. + Returns a list of exports ordered by date (most recent first). + operationId: get_exports_exports_get + parameters: + - name: export_case_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + - name: cameras + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Cameras + - name: start_date + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Start Date + - name: end_date + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: End Date + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExportModel' + title: Response Get Exports Exports Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /cases: + get: + tags: + - Export + summary: Get export cases + description: |- + **Access:** Any authenticated user. + + Gets all export cases from the database. + operationId: get_export_cases_cases_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExportCaseModel' + title: Response Get Export Cases Cases Get + security: + - frigateUserAuth: [] + x-required-role: any + post: + tags: + - Export + summary: Create export case + description: |- + **Access:** Admin role required. + + Creates a new export case. + operationId: create_export_case_cases_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportCaseCreateBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportCaseModel' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cases/{case_id}: + get: + tags: + - Export + summary: Get a single export case + description: |- + **Access:** Any authenticated user. + + Gets a specific export case by ID. + operationId: get_export_case_cases__case_id__get + parameters: + - name: case_id + in: path + required: true + schema: + type: string + title: Case Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportCaseModel' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + patch: + tags: + - Export + summary: Update export case + description: |- + **Access:** Admin role required. + + Updates an existing export case. + operationId: update_export_case_cases__case_id__patch + parameters: + - name: case_id + in: path + required: true + schema: + type: string + title: Case Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportCaseUpdateBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Export + summary: Delete export case + description: |- + **Access:** Admin role required. + + Deletes an export case. + Exports that reference this case will have their export_case set to null. + operationId: delete_export_case_cases__case_id__delete + parameters: + - name: case_id + in: path + required: true + schema: + type: string + title: Case Id + - name: delete_exports + in: query + required: false + schema: + type: boolean + default: false + title: Delete Exports + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cases/{case_id}/download: + get: + tags: + - Export + summary: Download export case as zip + description: |- + **Access:** Any authenticated user. + + Streams a zip archive containing every completed export's mp4 for the given case. + operationId: download_export_case_cases__case_id__download_get + parameters: + - name: case_id + in: path + required: true + schema: + type: string + title: Case Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /jobs/export: + get: + tags: + - Export + summary: Get active export jobs + description: |- + **Access:** Any authenticated user. + + Gets queued and running export jobs. + operationId: get_active_export_jobs_jobs_export_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExportJobModel' + title: Response Get Active Export Jobs Jobs Export Get + security: + - frigateUserAuth: [] + x-required-role: any + /jobs/export/{export_id}: + get: + tags: + - Export + summary: Get export job status + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets queued, running, or completed status for a specific export job. + operationId: get_export_job_status_jobs_export__export_id__get + parameters: + - name: export_id + in: path + required: true + schema: + type: string + title: Export Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportJobModel' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/batch: + post: + tags: + - Export + summary: Start recording export batch + description: |- + **Access:** Any authenticated user. + + Starts recording exports for a batch of items, each with its own camera and time range, and assigns them to a single export case. Attaching to an existing case is temporarily admin-only until case-level ACLs exist. + operationId: export_recordings_batch_exports_batch_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchExportBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BatchExportResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /export/{camera_name}/start/{start_time}/end/{end_time}: + post: + tags: + - Export + summary: Start recording export + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Starts an export of a recording for the specified time range. + The export can be from recordings or preview footage. Returns the export ID if + successful, or an error message if the camera is invalid or no recordings/previews + are found for the time range. + operationId: + export_recording_export__camera_name__start__start_time__end__end_time__post + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_time + in: path + required: true + schema: + type: number + title: Start Time + - name: end_time + in: path + required: true + schema: + type: number + title: End Time + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportRecordingsBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/StartExportResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /export/{event_id}/rename: + patch: + tags: + - Export + summary: Rename export + description: |- + **Access:** Admin role required. + + Renames an export. + NOTE: This changes the friendly name of the export, not the filename. + operationId: export_rename_export__event_id__rename_patch + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportRenameBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /export/custom/{camera_name}/start/{start_time}/end/{end_time}: + post: + tags: + - Export + summary: Start custom recording export + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Starts an export of a recording for the specified time range using custom FFmpeg arguments. + The export can be from recordings or preview footage. Returns the export ID if + successful, or an error message if the camera is invalid or no recordings/previews + are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided, + defaults to timelapse export settings. + operationId: + export_recording_custom_export_custom__camera_name__start__start_time__end__end_time__post + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_time + in: path + required: true + schema: + type: number + title: Start Time + - name: end_time + in: path + required: true + schema: + type: number + title: End Time + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportRecordingsCustomBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/StartExportResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/{export_id}: + get: + tags: + - Export + summary: Get a single export + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets a specific export by ID. The user must have access to the camera + associated with the export. + operationId: get_export_exports__export_id__get + parameters: + - name: export_id + in: path + required: true + schema: + type: string + title: Export Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportModel' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/delete: + post: + tags: + - Export + summary: Bulk delete exports + description: |- + **Access:** Admin role required. + + Deletes one or more exports by ID. All IDs must exist and none can be in-progress. + operationId: bulk_delete_exports_exports_delete_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportBulkDeleteBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /exports/reassign: + post: + tags: + - Export + summary: Bulk reassign exports to a case + description: |- + **Access:** Admin role required. + + Assigns or unassigns one or more exports to/from a case. All IDs must exist. + operationId: bulk_reassign_exports_exports_reassign_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportBulkReassignBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events: + get: + tags: + - Events + summary: Get events + description: |- + **Access:** Any authenticated user. + + Returns a list of events. + operationId: events_events_get + parameters: + - name: camera + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Camera + - name: cameras + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Cameras + - name: label + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Label + - name: labels + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Labels + - name: sub_label + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Sub Label + - name: sub_labels + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Sub Labels + - name: attributes + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Attributes + - name: zone + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Zone + - name: zones + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Zones + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + default: 100 + title: Limit + - name: after + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: After + - name: before + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Before + - name: time_range + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: 00:00,24:00 + title: Time Range + - name: has_clip + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Has Clip + - name: has_snapshot + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Has Snapshot + - name: in_progress + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: In Progress + - name: include_thumbnails + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + default: 1 + title: Include Thumbnails + - name: favorites + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Favorites + - name: min_score + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Min Score + - name: max_score + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Max Score + - name: min_speed + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Min Speed + - name: max_speed + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Max Speed + - name: recognized_license_plate + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Recognized License Plate + - name: is_submitted + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Is Submitted + - name: min_length + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Min Length + - name: max_length + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Max Length + - name: event_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Event Id + - name: sort + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Sort + - name: timezone + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: utc + title: Timezone + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EventResponse' + title: Response Events Events Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /events/explore: + get: + tags: + - Events + summary: Get summary of objects + description: |- + **Access:** Any authenticated user. + + Gets a summary of objects from the database. + Returns a list of objects with a max of `limit` objects for each label. + operationId: events_explore_events_explore_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 10 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EventResponse' + title: Response Events Explore Events Explore Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /event_ids: + get: + tags: + - Events + summary: Get events by ids + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets events by a list of ids. + Returns a list of events. + operationId: event_ids_event_ids_get + parameters: + - name: ids + in: query + required: true + schema: + type: string + title: Ids + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EventResponse' + title: Response Event Ids Event Ids Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /events/search: + get: + tags: + - Events + summary: Search events + description: |- + **Access:** Any authenticated user. + + Searches for events in the database. + Returns a list of events. + operationId: events_search_events_search_get + parameters: + - name: query + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Query + - name: event_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Event Id + - name: search_type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: thumbnail + title: Search Type + - name: include_thumbnails + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + default: 1 + title: Include Thumbnails + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + default: 50 + title: Limit + - name: cameras + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Cameras + - name: labels + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Labels + - name: sub_labels + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Sub Labels + - name: attributes + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Attributes + - name: zones + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Zones + - name: after + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: After + - name: before + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Before + - name: time_range + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: 00:00,24:00 + title: Time Range + - name: has_clip + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Has Clip + - name: has_snapshot + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Has Snapshot + - name: is_submitted + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Is Submitted + - name: timezone + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: utc + title: Timezone + - name: min_score + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Min Score + - name: max_score + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Max Score + - name: min_speed + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Min Speed + - name: max_speed + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Max Speed + - name: recognized_license_plate + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: all + title: Recognized License Plate + - name: sort + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Sort + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /events/summary: + get: + tags: + - Events + summary: Events Summary + operationId: events_summary_events_summary_get + parameters: + - name: timezone + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + default: utc + title: Timezone + - name: has_clip + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Has Clip + - name: has_snapshot + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Has Snapshot + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /events/{event_id}: + get: + tags: + - Events + summary: Get event by id + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets an event by its id. + operationId: event_events__event_id__get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + delete: + tags: + - Events + summary: Delete event + description: |- + **Access:** Admin role required. + + Deletes an event from the database. + Returns a success message or an error if the event is not found. + operationId: delete_event_events__event_id__delete + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/retain: + post: + tags: + - Events + summary: Set event retain indefinitely. + description: |- + **Access:** Admin role required. + + Sets an event to retain indefinitely. + Returns a success message or an error if the event is not found. + NOTE: This is a legacy endpoint and is not supported in the frontend. + operationId: set_retain_events__event_id__retain_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Events + summary: Stop event from being retained indefinitely + description: |- + **Access:** Admin role required. + + Stops an event from being retained indefinitely. + Returns a success message or an error if the event is not found. + NOTE: This is a legacy endpoint and is not supported in the frontend. + operationId: delete_retain_events__event_id__retain_delete + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/plus: + post: + tags: + - Events + summary: Send event to Frigate+ + description: |- + **Access:** Admin role required. + + Sends an event to Frigate+. + Returns a success message or an error if the event is not found. + operationId: send_to_plus_events__event_id__plus_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitPlusBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventUploadPlusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/false_positive: + put: + tags: + - Events + summary: Submit false positive to Frigate+ + description: |- + **Access:** Admin role required. + + Submit an event as a false positive to Frigate+. + This endpoint is the same as the standard Frigate+ submission endpoint, + but is specifically for marking an event as a false positive. + operationId: false_positive_events__event_id__false_positive_put + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventUploadPlusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/sub_label: + post: + tags: + - Events + summary: Set event sub label + description: |- + **Access:** Admin role required. + + Sets an event's sub label. + Returns a success message or an error if the event is not found. + operationId: set_sub_label_events__event_id__sub_label_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsSubLabelBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/recognized_license_plate: + post: + tags: + - Events + summary: Set event license plate + description: |- + **Access:** Admin role required. + + Sets an event's license plate. + Returns a success message or an error if the event is not found. + operationId: set_plate_events__event_id__recognized_license_plate_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsLPRBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/attributes: + post: + tags: + - Events + summary: Set custom classification attributes + description: |- + **Access:** Admin role required. + + Sets an event's custom classification attributes for all attribute-type models that apply to the event's object type. + operationId: set_attributes_events__event_id__attributes_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsAttributesBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/description: + post: + tags: + - Events + summary: Set event description + description: |- + **Access:** Admin role required. + + Sets an event's description. + Returns a success message or an error if the event is not found. + operationId: set_description_events__event_id__description_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsDescriptionBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/description/regenerate: + put: + tags: + - Events + summary: Regenerate event description + description: |- + **Access:** Admin role required. + + Regenerates an event's description. + Returns a success message or an error if the event is not found. + operationId: + regenerate_description_events__event_id__description_regenerate_put + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + - name: source + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/RegenerateDescriptionEnum' + - type: 'null' + default: thumbnails + title: Source + - name: force + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + default: false + title: Force + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /description/generate: + post: + tags: + - Events + summary: Generate description embedding + description: |- + **Access:** Admin role required. + + Generates an embedding for an event's description. + Returns a success message or an error if the event is not found. + operationId: generate_description_embedding_description_generate_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsDescriptionBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/: + delete: + tags: + - Events + summary: Delete events + description: |- + **Access:** Admin role required. + + Deletes a list of events from the database. + Returns a success message or an error if the events are not found. + operationId: delete_events_events__delete + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsDeleteBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventMultiDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{camera_name}/{label}/create: + post: + tags: + - Events + summary: Create manual event + description: |- + **Access:** Admin role required. + + Creates a manual event in the database. + Returns a success message or an error if the event is not found. + NOTES: + - Creating a manual event does not trigger an update to /events MQTT topic. + - If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end. + - The review item is an alert unless the label is listed in the camera's review -> detections -> labels config. + operationId: create_event_events__camera_name___label__create_post + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: label + in: path + required: true + schema: + type: string + title: Label + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EventsCreateBody' + default: + score: 0.0 + duration: 30 + include_recording: true + draw: {} + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventCreateResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/end: + put: + tags: + - Events + summary: End manual event + description: |- + **Access:** Admin role required. + + Ends a manual event. + Returns a success message or an error if the event is not found. + NOTE: This should only be used for manual events. + operationId: end_event_events__event_id__end_put + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventsEndBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /trigger/embedding: + post: + tags: + - Events + summary: Create trigger embedding + description: |- + **Access:** Admin role required. + + Creates a trigger embedding for a specific trigger. + Returns a success message or an error if the trigger is not found. + operationId: create_trigger_embedding_trigger_embedding_post + parameters: + - name: camera_name + in: query + required: true + schema: + type: string + title: Camera Name + - name: name + in: query + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerEmbeddingBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + title: Response Create Trigger Embedding Trigger Embedding Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /trigger/embedding/{camera_name}/{name}: + put: + tags: + - Events + summary: Update trigger embedding + description: |- + **Access:** Admin role required. + + Updates a trigger embedding for a specific trigger. + Returns a success message or an error if the trigger is not found. + operationId: + update_trigger_embedding_trigger_embedding__camera_name___name__put + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerEmbeddingBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + title: Response Update Trigger Embedding Trigger Embedding + Camera Name Name Put + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Events + summary: Delete trigger embedding + description: |- + **Access:** Admin role required. + + Deletes a trigger embedding for a specific trigger. + Returns a success message or an error if the trigger is not found. + operationId: + delete_trigger_embedding_trigger_embedding__camera_name___name__delete + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + title: Response Delete Trigger Embedding Trigger Embedding + Camera Name Name Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /triggers/status/{camera_name}: + get: + tags: + - Events + summary: Get triggers status + description: |- + **Access:** Admin role required. + + Gets the status of all triggers for a specific camera. + Returns a success message or an error if the camera is not found. + operationId: get_triggers_status_triggers_status__camera_name__get + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + title: Response Get Triggers Status Triggers Status Camera Name + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /{camera_name}: + get: + tags: + - Media + summary: Mjpeg Feed + operationId: mjpeg_feed__camera_name__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: fps + in: query + required: false + schema: + type: integer + default: 3 + title: Fps + - name: height + in: query + required: false + schema: + type: integer + default: 360 + title: Height + - name: bbox + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Bbox + - name: timestamp + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Timestamp + - name: zones + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Zones + - name: mask + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Mask + - name: motion + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Motion + - name: regions + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Regions + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/ptz/info: + get: + tags: + - Media + summary: Camera Ptz Info + operationId: camera_ptz_info__camera_name__ptz_info_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/latest.{extension}: + get: + tags: + - Media + summary: Latest Frame + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline. + operationId: latest_frame__camera_name__latest__extension__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: extension + in: path + required: true + schema: + $ref: '#/components/schemas/Extension' + - name: bbox + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Bbox + - name: timestamp + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Timestamp + - name: zones + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Zones + - name: mask + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Mask + - name: motion + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Motion + - name: paths + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Paths + - name: regions + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Regions + - name: quality + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + default: 70 + title: Quality + - name: height + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Height + - name: store + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Store + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/recordings/{frame_time}/snapshot.{format}: + get: + tags: + - Media + summary: Get Snapshot From Recording + operationId: + get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: frame_time + in: path + required: true + schema: + type: number + title: Frame Time + - name: format + in: path + required: true + schema: + type: string + enum: + - png + - jpg + title: Format + - name: height + in: query + required: false + schema: + type: integer + title: Height + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/plus/{frame_time}: + post: + tags: + - Media + summary: Submit Recording Snapshot To Plus + operationId: + submit_recording_snapshot_to_plus__camera_name__plus__frame_time__post + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: frame_time + in: path + required: true + schema: + type: string + title: Frame Time + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4: + get: + tags: + - Media + summary: Recording Clip + description: |- + **Access:** Authenticated user with access to the referenced camera. + + For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files. + operationId: + recording_clip__camera_name__start__start_ts__end__end_ts__clip_mp4_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{camera_name}/start/{start_ts}/end/{end_ts}: + get: + tags: + - Media + summary: Vod Ts + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: vod_ts_vod__camera_name__start__start_ts__end__end_ts__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + - name: force_discontinuity + in: query + required: false + schema: + type: boolean + default: false + title: Force Discontinuity + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{year_month}/{day}/{hour}/{camera_name}: + get: + tags: + - Media + summary: Vod Hour No Timezone + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_hour_no_timezone_vod__year_month___day___hour___camera_name__get parameters: - name: year_month in: path @@ -1841,7 +5820,60 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' + title: Camera Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}: + get: + tags: + - Media + summary: Vod Hour + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_hour_vod__year_month___day___hour___camera_name___tz_name__get + parameters: + - name: year_month + in: path + required: true + schema: + type: string + title: Year Month + - name: day + in: path + required: true + schema: + type: integer + title: Day + - name: hour + in: path + required: true + schema: + type: integer + title: Hour + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' title: Camera Name - name: tz_name in: path @@ -1850,35 +5882,72 @@ paths: type: string title: Tz Name responses: - "200": + '200': description: Successful Response content: application/json: - schema: - type: array - items: - $ref: "#/components/schemas/PreviewModel" - title: >- - Response Preview Hour Preview Year Month Day Hour - Camera Name Tz Name Get - "422": + schema: {} + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/event/{event_id}: get: tags: - - Preview - summary: Get cached preview frame filenames - description: >- - Gets a list of cached preview frame filenames for a specific camera and - time range. - Returns an array of filenames for preview frames that fall within the specified time period, - sorted in chronological order. These are individual frame images cached for quick preview display. - operationId: >- - get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get + - Media + summary: Vod Event + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: vod_event_vod_event__event_id__get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + - name: padding + in: query + required: false + schema: + type: integer + description: Padding to apply to the vod. + default: 0 + title: Padding + description: Padding to apply to the vod. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/clip/{camera_name}/start/{start_ts}/end/{end_ts}: + get: + tags: + - Media + summary: Vod Clip + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for a timestamp range with HLS discontinuity enabled. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_clip_vod_clip__camera_name__start__start_ts__end__end_ts__get parameters: - name: camera_name in: path @@ -1886,7 +5955,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -1901,1738 +5970,160 @@ paths: type: number title: End Ts responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: array - items: - type: string - title: >- - Response Get Preview Frames From Cache Preview Camera Name - Start Start Ts End End Ts Frames Get - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /notifications/pubkey: - get: - tags: - - Notifications - summary: Get VAPID public key - description: |- - Gets the VAPID public key for the notifications. - Returns the public key or an error if notifications are not enabled. - operationId: get_vapid_pub_key_notifications_pubkey_get - responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - /notifications/register: - post: - tags: - - Notifications - summary: Register notifications - description: |- - Registers a notifications subscription. - Returns a success message or an error if the subscription is not provided. - operationId: register_notifications_notifications_register_post - requestBody: - content: - application/json: - schema: - type: object - title: Body - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /exports: - get: - tags: - - Export - summary: Get exports - description: |- - Gets all exports from the database for cameras the user has access to. - Returns a list of exports ordered by date (most recent first). - operationId: get_exports_exports_get - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/ExportModel" - title: Response Get Exports Exports Get - /export/{camera_name}/start/{start_time}/end/{end_time}: - post: - tags: - - Export - summary: Start recording export - description: |- - Starts an export of a recording for the specified time range. - The export can be from recordings or preview footage. Returns the export ID if - successful, or an error message if the camera is invalid or no recordings/previews - are found for the time range. - operationId: >- - export_recording_export__camera_name__start__start_time__end__end_time__post - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: start_time - in: path - required: true - schema: - type: number - title: Start Time - - name: end_time - in: path - required: true - schema: - type: number - title: End Time - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExportRecordingsBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/StartExportResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /export/{event_id}/rename: - patch: - tags: - - Export - summary: Rename export - description: |- - Renames an export. - NOTE: This changes the friendly name of the export, not the filename. - operationId: export_rename_export__event_id__rename_patch - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExportRenameBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /export/{event_id}: - delete: - tags: - - Export - summary: Delete export - operationId: export_delete_export__event_id__delete - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /exports/{export_id}: - get: - tags: - - Export - summary: Get a single export - description: |- - Gets a specific export by ID. The user must have access to the camera - associated with the export. - operationId: get_export_exports__export_id__get - parameters: - - name: export_id - in: path - required: true - schema: - type: string - title: Export Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/ExportModel" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events: - get: - tags: - - Events - summary: Get events - description: Returns a list of events. - operationId: events_events_get - parameters: - - name: camera - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Camera - - name: cameras - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Cameras - - name: label - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Label - - name: labels - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Labels - - name: sub_label - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Sub Label - - name: sub_labels - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Sub Labels - - name: zone - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Zone - - name: zones - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Zones - - name: limit - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - default: 100 - title: Limit - - name: after - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: After - - name: before - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Before - - name: time_range - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: 00:00,24:00 - title: Time Range - - name: has_clip - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Has Clip - - name: has_snapshot - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Has Snapshot - - name: in_progress - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: In Progress - - name: include_thumbnails - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - default: 1 - title: Include Thumbnails - - name: favorites - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Favorites - - name: min_score - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Min Score - - name: max_score - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Max Score - - name: min_speed - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Min Speed - - name: max_speed - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Max Speed - - name: recognized_license_plate - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Recognized License Plate - - name: is_submitted - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Is Submitted - - name: min_length - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Min Length - - name: max_length - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Max Length - - name: event_id - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Event Id - - name: sort - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Sort - - name: timezone - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: utc - title: Timezone - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/EventResponse" - title: Response Events Events Get - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/explore: - get: - tags: - - Events - summary: Get summary of objects - description: |- - Gets a summary of objects from the database. - Returns a list of objects with a max of `limit` objects for each label. - operationId: events_explore_events_explore_get - parameters: - - name: limit - in: query - required: false - schema: - type: integer - default: 10 - title: Limit - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/EventResponse" - title: Response Events Explore Events Explore Get - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /event_ids: - get: - tags: - - Events - summary: Get events by ids - description: |- - Gets events by a list of ids. - Returns a list of events. - operationId: event_ids_event_ids_get - parameters: - - name: ids - in: query - required: true - schema: - type: string - title: Ids - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/EventResponse" - title: Response Event Ids Event Ids Get - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/search: - get: - tags: - - Events - summary: Search events - description: |- - Searches for events in the database. - Returns a list of events. - operationId: events_search_events_search_get - parameters: - - name: query - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Query - - name: event_id - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Event Id - - name: search_type - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: thumbnail - title: Search Type - - name: include_thumbnails - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - default: 1 - title: Include Thumbnails - - name: limit - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - default: 50 - title: Limit - - name: cameras - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Cameras - - name: labels - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Labels - - name: zones - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Zones - - name: after - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: After - - name: before - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Before - - name: time_range - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: 00:00,24:00 - title: Time Range - - name: has_clip - in: query - required: false - schema: - anyOf: - - type: boolean - - type: "null" - title: Has Clip - - name: has_snapshot - in: query - required: false - schema: - anyOf: - - type: boolean - - type: "null" - title: Has Snapshot - - name: is_submitted - in: query - required: false - schema: - anyOf: - - type: boolean - - type: "null" - title: Is Submitted - - name: timezone - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: utc - title: Timezone - - name: min_score - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Min Score - - name: max_score - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Max Score - - name: min_speed - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Min Speed - - name: max_speed - in: query - required: false - schema: - anyOf: - - type: number - - type: "null" - title: Max Speed - - name: recognized_license_plate - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: all - title: Recognized License Plate - - name: sort - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Sort - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/summary: - get: - tags: - - Events - summary: Events Summary - operationId: events_summary_events_summary_get - parameters: - - name: timezone - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - default: utc - title: Timezone - - name: has_clip - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Has Clip - - name: has_snapshot - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Has Snapshot - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}: - get: - tags: - - Events - summary: Get event by id - description: Gets an event by its id. - operationId: event_events__event_id__get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/EventResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - delete: - tags: - - Events - summary: Delete event - description: |- - Deletes an event from the database. - Returns a success message or an error if the event is not found. - operationId: delete_event_events__event_id__delete - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/retain: - post: - tags: - - Events - summary: Set event retain indefinitely - description: |- - Sets an event to retain indefinitely. - Returns a success message or an error if the event is not found. - NOTE: This is a legacy endpoint and is not supported in the frontend. - operationId: set_retain_events__event_id__retain_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - delete: - tags: - - Events - summary: Stop event from being retained indefinitely - description: |- - Stops an event from being retained indefinitely. - Returns a success message or an error if the event is not found. - NOTE: This is a legacy endpoint and is not supported in the frontend. - operationId: delete_retain_events__event_id__retain_delete - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/plus: - post: - tags: - - Events - summary: Send event to Frigate+ - description: |- - Sends an event to Frigate+. - Returns a success message or an error if the event is not found. - operationId: send_to_plus_events__event_id__plus_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/SubmitPlusBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/EventUploadPlusResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/false_positive: - put: - tags: - - Events - summary: Submit false positive to Frigate+ - description: |- - Submit an event as a false positive to Frigate+. - This endpoint is the same as the standard Frigate+ submission endpoint, - but is specifically for marking an event as a false positive. - operationId: false_positive_events__event_id__false_positive_put - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/EventUploadPlusResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/sub_label: - post: - tags: - - Events - summary: Set event sub label - description: |- - Sets an event's sub label. - Returns a success message or an error if the event is not found. - operationId: set_sub_label_events__event_id__sub_label_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsSubLabelBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/recognized_license_plate: - post: - tags: - - Events - summary: Set event license plate - description: |- - Sets an event's license plate. - Returns a success message or an error if the event is not found. - operationId: set_plate_events__event_id__recognized_license_plate_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsLPRBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/attributes: - post: - tags: - - Events - summary: Set custom classification attributes - description: |- - Sets an event's custom classification attributes for all attribute-type - models that apply to the event's object type. - Returns a success message or an error if the event is not found. - operationId: set_attributes_events__event_id__attributes_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsAttributesBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/description: - post: - tags: - - Events - summary: Set event description - description: |- - Sets an event's description. - Returns a success message or an error if the event is not found. - operationId: set_description_events__event_id__description_post - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsDescriptionBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/description/regenerate: - put: - tags: - - Events - summary: Regenerate event description - description: |- - Regenerates an event's description. - Returns a success message or an error if the event is not found. - operationId: regenerate_description_events__event_id__description_regenerate_put - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: source - in: query - required: false - schema: - anyOf: - - $ref: "#/components/schemas/RegenerateDescriptionEnum" - - type: "null" - default: thumbnails - title: Source - - name: force - in: query - required: false - schema: - anyOf: - - type: boolean - - type: "null" - default: false - title: Force - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /description/generate: - post: - tags: - - Events - summary: Generate description embedding - description: |- - Generates an embedding for an event's description. - Returns a success message or an error if the event is not found. - operationId: generate_description_embedding_description_generate_post - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsDescriptionBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/: - delete: - tags: - - Events - summary: Delete events - description: |- - Deletes a list of events from the database. - Returns a success message or an error if the events are not found. - operationId: delete_events_events__delete - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsDeleteBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/EventMultiDeleteResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{camera_name}/{label}/create: - post: - tags: - - Events - summary: Create manual event - description: |- - Creates a manual event in the database. - Returns a success message or an error if the event is not found. - NOTES: - - Creating a manual event does not trigger an update to /events MQTT topic. - - If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end. - operationId: create_event_events__camera_name___label__create_post - parameters: - - name: camera_name - in: path - required: true - schema: - type: string - title: Camera Name - - name: label - in: path - required: true - schema: - type: string - title: Label - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/EventsCreateBody" - default: - score: 0 - duration: 30 - include_recording: true - draw: {} - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/EventCreateResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/end: - put: - tags: - - Events - summary: End manual event - description: |- - Ends a manual event. - Returns a success message or an error if the event is not found. - NOTE: This should only be used for manual events. - operationId: end_event_events__event_id__end_put - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EventsEndBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /trigger/embedding: - post: - tags: - - Events - summary: Create trigger embedding - description: |- - Creates a trigger embedding for a specific trigger. - Returns a success message or an error if the trigger is not found. - operationId: create_trigger_embedding_trigger_embedding_post - parameters: - - name: camera_name - in: query - required: true - schema: - type: string - title: Camera Name - - name: name - in: query - required: true - schema: - type: string - title: Name - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TriggerEmbeddingBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: object - title: Response Create Trigger Embedding Trigger Embedding Post - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /trigger/embedding/{camera_name}/{name}: - put: - tags: - - Events - summary: Update trigger embedding - description: |- - Updates a trigger embedding for a specific trigger. - Returns a success message or an error if the trigger is not found. - operationId: update_trigger_embedding_trigger_embedding__camera_name___name__put - parameters: - - name: camera_name - in: path - required: true - schema: - type: string - title: Camera Name - - name: name - in: path - required: true - schema: - type: string - title: Name - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TriggerEmbeddingBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: object - title: >- - Response Update Trigger Embedding Trigger Embedding Camera - Name Name Put - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - delete: - tags: - - Events - summary: Delete trigger embedding - description: |- - Deletes a trigger embedding for a specific trigger. - Returns a success message or an error if the trigger is not found. - operationId: delete_trigger_embedding_trigger_embedding__camera_name___name__delete - parameters: - - name: camera_name - in: path - required: true - schema: - type: string - title: Camera Name - - name: name - in: path - required: true - schema: - type: string - title: Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: object - title: >- - Response Delete Trigger Embedding Trigger Embedding Camera - Name Name Delete - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /triggers/status/{camera_name}: - get: - tags: - - Events - summary: Get triggers status - description: |- - Gets the status of all triggers for a specific camera. - Returns a success message or an error if the camera is not found. - operationId: get_triggers_status_triggers_status__camera_name__get - parameters: - - name: camera_name - in: path - required: true - schema: - type: string - title: Camera Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: - type: object - title: Response Get Triggers Status Triggers Status Camera Name Get - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /events/{event_id}/snapshot.jpg: get: tags: - Media - summary: Mjpeg Feed - operationId: mjpeg_feed__camera_name__get + summary: Event Snapshot + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns a snapshot image for the specified object id. + operationId: event_snapshot_events__event_id__snapshot_jpg_get parameters: - - name: camera_name + - name: event_id in: path required: true schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: fps + type: string + title: Event Id + - name: download in: query required: false schema: - type: integer - default: 3 - title: Fps + anyOf: + - type: boolean + - type: 'null' + default: false + title: Download + - name: timestamp + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Timestamp + - name: bbox + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Bbox + - name: crop + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Crop - name: height in: query required: false schema: - type: integer - default: 360 + anyOf: + - type: integer + - type: 'null' title: Height - - name: bbox - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Bbox - - name: timestamp - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Timestamp - - name: zones - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Zones - - name: mask - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Mask - - name: motion - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Motion - - name: regions - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Regions - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/ptz/info: - get: - tags: - - Media - summary: Camera Ptz Info - operationId: camera_ptz_info__camera_name__ptz_info_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/latest.{extension}: - get: - tags: - - Media - summary: Latest Frame - operationId: latest_frame__camera_name__latest__extension__get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: extension - in: path - required: true - schema: - $ref: "#/components/schemas/Extension" - - name: bbox - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Bbox - - name: timestamp - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Timestamp - - name: zones - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Zones - - name: mask - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Mask - - name: motion - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Motion - - name: paths - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Paths - - name: regions - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Regions - name: quality in: query required: false schema: anyOf: - type: integer - - type: "null" - default: 70 + - type: 'null' title: Quality - - name: height - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Height - - name: store - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Store responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/recordings/{frame_time}/snapshot.{format}: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /events/{event_id}/thumbnail.{extension}: get: tags: - Media - summary: Get Snapshot From Recording - operationId: >- - get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get + summary: Event Thumbnail + operationId: event_thumbnail_events__event_id__thumbnail__extension__get parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: frame_time - in: path - required: true - schema: - type: number - title: Frame Time - - name: format + - name: event_id in: path required: true schema: type: string - enum: - - png - - jpg - title: Format - - name: height + title: Event Id + - name: extension + in: path + required: true + schema: + $ref: '#/components/schemas/Extension' + - name: max_cache_age in: query required: false schema: type: integer - title: Height + description: Max cache age in seconds. Default 30 days in seconds. + default: 2592000 + title: Max Cache Age + description: Max cache age in seconds. Default 30 days in seconds. + - name: format + in: query + required: false + schema: + type: string + enum: + - ios + - android + default: ios + title: Format responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/plus/{frame_time}: - post: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/grid.jpg: + get: tags: - Media - summary: Submit Recording Snapshot To Plus - operationId: submit_recording_snapshot_to_plus__camera_name__plus__frame_time__post + summary: Grid Snapshot + operationId: grid_snapshot__camera_name__grid_jpg_get parameters: - name: camera_name in: path @@ -3640,44 +6131,723 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - - name: frame_time - in: path - required: true + - name: color + in: query + required: false schema: type: string - title: Frame Time + default: green + title: Color + - name: font_scale + in: query + required: false + schema: + type: number + default: 0.5 + title: Font Scale responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /recordings/storage: - get: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/region_grid: + delete: tags: - Media - summary: Get Recordings Storage Usage - operationId: get_recordings_storage_usage_recordings_storage_get + summary: Clear Region Grid + description: |- + **Access:** Admin role required. + + Clear the region grid for a camera. + operationId: clear_region_grid__camera_name__region_grid_delete + parameters: + - name: camera_name + in: path + required: true + schema: + type: string + title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - /recordings/summary: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/snapshot-clean.webp: get: tags: - Media + summary: Event Snapshot Clean + operationId: + event_snapshot_clean_events__event_id__snapshot_clean_webp_get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + - name: download + in: query + required: false + schema: + type: boolean + default: false + title: Download + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /events/{event_id}/clip.mp4: + get: + tags: + - Media + summary: Event Clip + operationId: event_clip_events__event_id__clip_mp4_get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + - name: padding + in: query + required: false + schema: + type: integer + description: Padding to apply to clip. + default: 0 + title: Padding + description: Padding to apply to clip. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{review_id}/clip.mp4: + get: + tags: + - Media + summary: Review Clip + operationId: review_clip_review__review_id__clip_mp4_get + parameters: + - name: review_id + in: path + required: true + schema: + type: string + title: Review Id + - name: padding + in: query + required: false + schema: + type: integer + description: Padding to apply to clip. + default: 0 + title: Padding + description: Padding to apply to clip. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /events/{event_id}/preview.gif: + get: + tags: + - Media + summary: Event Preview + operationId: event_preview_events__event_id__preview_gif_get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif: + get: + tags: + - Media + summary: Preview Gif + operationId: + preview_gif__camera_name__start__start_ts__end__end_ts__preview_gif_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + - name: max_cache_age + in: query + required: false + schema: + type: integer + description: Max cache age in seconds. Default 30 days in seconds. + default: 2592000 + title: Max Cache Age + description: Max cache age in seconds. Default 30 days in seconds. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4: + get: + tags: + - Media + summary: Preview Mp4 + operationId: + preview_mp4__camera_name__start__start_ts__end__end_ts__preview_mp4_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + - name: max_cache_age + in: query + required: false + schema: + type: integer + description: Max cache age in seconds. Default 7 days in seconds. + default: 604800 + title: Max Cache Age + description: Max cache age in seconds. Default 7 days in seconds. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{event_id}/preview: + get: + tags: + - Media + summary: Review Preview + operationId: review_preview_review__event_id__preview_get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + - name: format + in: query + required: false + schema: + type: string + enum: + - gif + - mp4 + default: gif + title: Format + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /preview/{file_name}/thumbnail.webp: + get: + tags: + - Media + summary: Preview Thumbnail + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Get a thumbnail from the cached preview frames. + operationId: preview_thumbnail_preview__file_name__thumbnail_webp_get + parameters: + - name: file_name + in: path + required: true + schema: + type: string + title: File Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /preview/{file_name}/thumbnail.jpg: + get: + tags: + - Media + summary: Preview Thumbnail + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Get a thumbnail from the cached preview frames. + operationId: preview_thumbnail_preview__file_name__thumbnail_jpg_get + parameters: + - name: file_name + in: path + required: true + schema: + type: string + title: File Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/{label}/thumbnail.jpg: + get: + tags: + - Media + summary: Label Thumbnail + operationId: label_thumbnail__camera_name___label__thumbnail_jpg_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: label + in: path + required: true + schema: + type: string + title: Label + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/best.jpg: + get: + tags: + - Media + summary: Label Thumbnail + operationId: label_thumbnail__camera_name___label__best_jpg_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: label + in: path + required: true + schema: + type: string + title: Label + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/clip.mp4: + get: + tags: + - Media + summary: Label Clip + operationId: label_clip__camera_name___label__clip_mp4_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: label + in: path + required: true + schema: + type: string + title: Label + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/snapshot.jpg: + get: + tags: + - Media + summary: Label Snapshot + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the snapshot image from the latest event for the given camera and label combo + operationId: label_snapshot__camera_name___label__snapshot_jpg_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: label + in: path + required: true + schema: + type: string + title: Label + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion: + post: + tags: + - Motion Search + summary: Start motion search job + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Starts an asynchronous search for significant motion changes within + a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id + that can be used to poll for results. + operationId: start_motion_search__camera_name__search_motion_post + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MotionSearchRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MotionSearchStartResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion/{job_id}: + get: + tags: + - Motion Search + summary: Get motion search job status + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the status and results (if complete) of a motion search job. + operationId: + get_motion_search_status_endpoint__camera_name__search_motion__job_id__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MotionSearchStatusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion/{job_id}/cancel: + post: + tags: + - Motion Search + summary: Cancel motion search job + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Cancels an active motion search job if it is still processing. + operationId: + cancel_motion_search_endpoint__camera_name__search_motion__job_id__cancel_post + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /recordings/storage: + get: + tags: + - Recordings + summary: Get Recordings Storage Usage + operationId: get_recordings_storage_usage_recordings_storage_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' + /recordings/summary: + get: + tags: + - Recordings summary: All Recordings Summary - description: Returns true/false by day indicating if recordings exist + description: |- + **Access:** Any authenticated user. + + Returns true/false by day indicating if recordings exist operationId: all_recordings_summary_recordings_summary_get parameters: - name: timezone @@ -3693,27 +6863,33 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /{camera_name}/recordings/summary: get: tags: - - Media + - Recordings summary: Recordings Summary - description: Returns hourly summary for recordings of given camera + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns hourly summary for recordings of given camera operationId: recordings_summary__camera_name__recordings_summary_get parameters: - name: camera_name @@ -3722,7 +6898,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: timezone in: query @@ -3732,25 +6908,29 @@ paths: default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /{camera_name}/recordings: get: tags: - - Media + - Recordings summary: Recordings - description: >- - Return specific camera recordings between the given 'after'/'end' times. - If not provided the last hour will be used + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used operationId: recordings__camera_name__recordings_get parameters: - name: camera_name @@ -3759,40 +6939,44 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: after in: query required: false schema: type: number - default: 1759932070.40171 title: After - name: before in: query required: false schema: type: number - default: 1759935670.40172 title: Before responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /recordings/unavailable: get: tags: - - Media + - Recordings summary: No Recordings - description: Get time ranges with no recordings. + description: |- + **Access:** Any authenticated user. + + Get time ranges with no recordings. operationId: no_recordings_recordings_unavailable_get parameters: - name: cameras @@ -3822,7 +7006,7 @@ paths: default: 30 title: Scale responses: - "200": + '200': description: Successful Response content: application/json: @@ -3831,814 +7015,193 @@ paths: items: type: object title: Response No Recordings Recordings Unavailable Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4: - get: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /recordings/start/{start}/end/{end}: + delete: tags: - - Media - summary: Recording Clip - description: >- - For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. - Safari does not reliably process progressive mp4 files. - operationId: recording_clip__camera_name__start__start_ts__end__end_ts__clip_mp4_get + - Recordings + summary: Delete recordings + description: |- + **Access:** Admin role required. + + Deletes recordings within the specified time range. + Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes. + operationId: delete_recordings_recordings_start__start__end__end__delete parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: start_ts + - name: start in: path required: true schema: type: number - title: Start Ts - - name: end_ts + description: Start timestamp (unix) + title: Start + description: Start timestamp (unix) + - name: end in: path required: true schema: type: number - title: End Ts - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /vod/{camera_name}/start/{start_ts}/end/{end_ts}: - get: - tags: - - Media - summary: Vod Ts - description: >- - Returns an HLS playlist for the specified timestamp-range on the - specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. - operationId: vod_ts_vod__camera_name__start__start_ts__end__end_ts__get - parameters: - - name: camera_name - in: path - required: true + description: End timestamp (unix) + title: End + description: End timestamp (unix) + - name: keep + in: query + required: false schema: anyOf: - type: string - - type: "null" - title: Camera Name - - name: start_ts - in: path - required: true - schema: - type: number - title: Start Ts - - name: end_ts - in: path - required: true - schema: - type: number - title: End Ts - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /vod/{year_month}/{day}/{hour}/{camera_name}: - get: - tags: - - Media - summary: Vod Hour No Timezone - description: >- - Returns an HLS playlist for the specified date-time on the specified - camera. Append /master.m3u8 or /index.m3u8 for HLS playback. - operationId: vod_hour_no_timezone_vod__year_month___day___hour___camera_name__get - parameters: - - name: year_month - in: path - required: true - schema: - type: string - title: Year Month - - name: day - in: path - required: true - schema: - type: integer - title: Day - - name: hour - in: path - required: true - schema: - type: integer - title: Hour - - name: camera_name - in: path - required: true + - type: 'null' + title: Keep + - name: cameras + in: query + required: false schema: anyOf: - type: string - - type: "null" - title: Camera Name + - type: 'null' + default: all + title: Cameras responses: - "200": + '200': description: Successful Response content: application/json: - schema: {} - "422": + schema: + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /debug_replay/start: + post: + tags: + - App + summary: Start debug replay + description: |- + **Access:** Admin role required. + + Start a debug replay session from camera recordings. Returns immediately while clip generation runs as a background job; subscribe to the 'debug_replay' job_state WS topic to track progress. + operationId: start_debug_replay_debug_replay_start_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartBody' + responses: + '202': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartResponse' + '400': + description: Invalid camera or time range + '404': + description: No recordings in the requested time range + '409': + description: A replay session is already active + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /debug_replay/start_from_export: + post: + tags: + - App + summary: Start debug replay from an export + description: |- + **Access:** Admin role required. + + Start a debug replay session covering an existing export's time range. The end time is derived from the export's video duration. + operationId: + start_debug_replay_from_export_debug_replay_start_from_export_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartFromExportBody' + responses: + '202': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartResponse' + '400': + description: Invalid export, time range, or no recordings + '404': + description: Export not found + '409': + description: A replay session is already active + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /debug_replay/status: get: tags: - - Media - summary: Vod Hour - description: >- - Returns an HLS playlist for the specified date-time (with timezone) on - the specified camera. Append /master.m3u8 or /index.m3u8 for HLS - playback. - operationId: vod_hour_vod__year_month___day___hour___camera_name___tz_name__get - parameters: - - name: year_month - in: path - required: true - schema: - type: string - title: Year Month - - name: day - in: path - required: true - schema: - type: integer - title: Day - - name: hour - in: path - required: true - schema: - type: integer - title: Hour - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: tz_name - in: path - required: true - schema: - type: string - title: Tz Name + - App + summary: Get debug replay status + description: |- + **Access:** Admin role required. + + Get the status of the current debug replay session. + operationId: get_debug_replay_status_debug_replay_status_get responses: - "200": + '200': description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /vod/event/{event_id}: - get: + $ref: '#/components/schemas/DebugReplayStatusResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin + /debug_replay/stop: + post: tags: - - Media - summary: Vod Event - description: >- - Returns an HLS playlist for the specified object. Append /master.m3u8 or - /index.m3u8 for HLS playback. - operationId: vod_event_vod_event__event_id__get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: padding - in: query - required: false - schema: - type: integer - description: Padding to apply to the vod. - default: 0 - title: Padding - description: Padding to apply to the vod. + - App + summary: Stop debug replay + description: |- + **Access:** Admin role required. + + Stop the active debug replay session and clean up all artifacts. + operationId: stop_debug_replay_debug_replay_stop_post responses: - "200": + '200': description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/snapshot.jpg: - get: - tags: - - Media - summary: Event Snapshot - description: >- - Returns a snapshot image for the specified object id. NOTE: The query - params only take affect while the event is in-progress. Once the event - has ended the snapshot configuration is used. - operationId: event_snapshot_events__event_id__snapshot_jpg_get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: download - in: query - required: false - schema: - anyOf: - - type: boolean - - type: "null" - default: false - title: Download - - name: timestamp - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Timestamp - - name: bbox - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Bbox - - name: crop - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Crop - - name: height - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - title: Height - - name: quality - in: query - required: false - schema: - anyOf: - - type: integer - - type: "null" - default: 70 - title: Quality - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/thumbnail.{extension}: - get: - tags: - - Media - summary: Event Thumbnail - operationId: event_thumbnail_events__event_id__thumbnail__extension__get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: extension - in: path - required: true - schema: - $ref: "#/components/schemas/Extension" - - name: max_cache_age - in: query - required: false - schema: - type: integer - description: Max cache age in seconds. Default 30 days in seconds. - default: 2592000 - title: Max Cache Age - description: Max cache age in seconds. Default 30 days in seconds. - - name: format - in: query - required: false - schema: - type: string - enum: - - ios - - android - default: ios - title: Format - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/grid.jpg: - get: - tags: - - Media - summary: Grid Snapshot - operationId: grid_snapshot__camera_name__grid_jpg_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: color - in: query - required: false - schema: - type: string - default: green - title: Color - - name: font_scale - in: query - required: false - schema: - type: number - default: 0.5 - title: Font Scale - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/snapshot-clean.webp: - get: - tags: - - Media - summary: Event Snapshot Clean - operationId: event_snapshot_clean_events__event_id__snapshot_clean_png_get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: download - in: query - required: false - schema: - type: boolean - default: false - title: Download - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/clip.mp4: - get: - tags: - - Media - summary: Event Clip - operationId: event_clip_events__event_id__clip_mp4_get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: padding - in: query - required: false - schema: - type: integer - description: Padding to apply to clip. - default: 0 - title: Padding - description: Padding to apply to clip. - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /events/{event_id}/preview.gif: - get: - tags: - - Media - summary: Event Preview - operationId: event_preview_events__event_id__preview_gif_get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif: - get: - tags: - - Media - summary: Preview Gif - operationId: preview_gif__camera_name__start__start_ts__end__end_ts__preview_gif_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: start_ts - in: path - required: true - schema: - type: number - title: Start Ts - - name: end_ts - in: path - required: true - schema: - type: number - title: End Ts - - name: max_cache_age - in: query - required: false - schema: - type: integer - description: Max cache age in seconds. Default 30 days in seconds. - default: 2592000 - title: Max Cache Age - description: Max cache age in seconds. Default 30 days in seconds. - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4: - get: - tags: - - Media - summary: Preview Mp4 - operationId: preview_mp4__camera_name__start__start_ts__end__end_ts__preview_mp4_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: start_ts - in: path - required: true - schema: - type: number - title: Start Ts - - name: end_ts - in: path - required: true - schema: - type: number - title: End Ts - - name: max_cache_age - in: query - required: false - schema: - type: integer - description: Max cache age in seconds. Default 7 days in seconds. - default: 604800 - title: Max Cache Age - description: Max cache age in seconds. Default 7 days in seconds. - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /review/{event_id}/preview: - get: - tags: - - Media - summary: Review Preview - operationId: review_preview_review__event_id__preview_get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - - name: format - in: query - required: false - schema: - type: string - enum: - - gif - - mp4 - default: gif - title: Format - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /preview/{file_name}/thumbnail.webp: - get: - tags: - - Media - summary: Preview Thumbnail - description: Get a thumbnail from the cached preview frames. - operationId: preview_thumbnail_preview__file_name__thumbnail_webp_get - parameters: - - name: file_name - in: path - required: true - schema: - type: string - title: File Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /preview/{file_name}/thumbnail.jpg: - get: - tags: - - Media - summary: Preview Thumbnail - description: Get a thumbnail from the cached preview frames. - operationId: preview_thumbnail_preview__file_name__thumbnail_jpg_get - parameters: - - name: file_name - in: path - required: true - schema: - type: string - title: File Name - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/{label}/thumbnail.jpg: - get: - tags: - - Media - summary: Label Thumbnail - operationId: label_thumbnail__camera_name___label__thumbnail_jpg_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: label - in: path - required: true - schema: - type: string - title: Label - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/{label}/best.jpg: - get: - tags: - - Media - summary: Label Thumbnail - operationId: label_thumbnail__camera_name___label__best_jpg_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: label - in: path - required: true - schema: - type: string - title: Label - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/{label}/clip.mp4: - get: - tags: - - Media - summary: Label Clip - operationId: label_clip__camera_name___label__clip_mp4_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: label - in: path - required: true - schema: - type: string - title: Label - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /{camera_name}/{label}/snapshot.jpg: - get: - tags: - - Media - summary: Label Snapshot - description: >- - Returns the snapshot image from the latest event for the given camera - and label combo - operationId: label_snapshot__camera_name___label__snapshot_jpg_get - parameters: - - name: camera_name - in: path - required: true - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name - - name: label - in: path - required: true - schema: - type: string - title: Label - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/DebugReplayStopResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin components: schemas: AppConfigSetBody: @@ -4650,13 +7213,17 @@ components: update_topic: anyOf: - type: string - - type: "null" + - type: 'null' title: Update Topic config_data: anyOf: - type: object - - type: "null" + - type: 'null' title: Config Data + skip_save: + type: boolean + title: Skip Save + default: false type: object title: AppConfigSetBody AppPostLoginBody: @@ -4683,7 +7250,7 @@ components: role: anyOf: - type: string - - type: "null" + - type: 'null' title: Role default: viewer type: object @@ -4696,6 +7263,11 @@ components: password: type: string title: Password + old_password: + anyOf: + - type: string + - type: 'null' + title: Old Password type: object required: - password @@ -4714,10 +7286,160 @@ components: event_id: type: string title: Event Id + description: ID of the event to transcribe audio for type: object required: - event_id title: AudioTranscriptionBody + BatchExportBody: + properties: + items: + items: + $ref: '#/components/schemas/BatchExportItem' + type: array + maxItems: 50 + minItems: 1 + title: Items + description: List of export items. Each item has its own camera and + time range. + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: 'null' + title: Export case ID + description: Existing export case ID to assign all exports to. + Attaching to an existing case is temporarily admin-only until + case-level ACLs exist. + new_case_name: + anyOf: + - type: string + maxLength: 100 + - type: 'null' + title: New case name + description: Name of a new export case to create when export_case_id + is omitted + new_case_description: + anyOf: + - type: string + - type: 'null' + title: New case description + description: Optional description for a newly created export case + type: object + required: + - items + title: BatchExportBody + BatchExportItem: + properties: + camera: + type: string + title: Camera name + start_time: + type: number + title: Start time + end_time: + type: number + title: End time + image_path: + anyOf: + - type: string + - type: 'null' + title: Existing thumbnail path + description: Optional existing image to use as the export thumbnail + friendly_name: + anyOf: + - type: string + maxLength: 256 + - type: 'null' + title: Friendly name + description: Optional friendly name for this specific export item + client_item_id: + anyOf: + - type: string + maxLength: 128 + - type: 'null' + title: Client item ID + description: Optional opaque client identifier echoed back in results + type: object + required: + - camera + - start_time + - end_time + title: BatchExportItem + BatchExportResponse: + properties: + export_case_id: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + description: Export case ID associated with the batch + export_ids: + items: + type: string + type: array + title: Export Ids + description: Export IDs successfully queued + results: + items: + $ref: '#/components/schemas/BatchExportResultModel' + type: array + title: Results + description: Per-item batch export results + type: object + required: + - export_ids + - results + title: BatchExportResponse + description: Response model for starting an export batch. + BatchExportResultModel: + properties: + camera: + type: string + title: Camera + description: Camera name for this export attempt + export_id: + anyOf: + - type: string + - type: 'null' + title: Export Id + description: The export ID when the export was successfully queued + success: + type: boolean + title: Success + description: Whether the export was successfully queued + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Queue status for this camera export + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: Validation or queueing error for this item, if any + item_index: + anyOf: + - type: integer + - type: 'null' + title: Item Index + description: Zero-based index of this result within the request items + list + client_item_id: + anyOf: + - type: string + - type: 'null' + title: Client Item Id + description: Opaque client-supplied item identifier echoed from the + request + type: object + required: + - camera + - success + title: BatchExportResultModel + description: Per-item result for a batch export request. Body_recognize_face_faces_recognize_post: properties: file: @@ -4738,6 +7460,99 @@ components: required: - file title: Body_register_face_faces__name__register_post + CameraSetBody: + properties: + value: + type: string + title: Value + description: The value to set for the feature + type: object + required: + - value + title: CameraSetBody + ChaptersEnum: + type: string + enum: + - none + - recording_segments + - review_items + title: ChaptersEnum + ChatCompletionRequest: + properties: + messages: + items: + $ref: '#/components/schemas/ChatMessage' + type: array + title: Messages + description: List of messages in the conversation + max_tool_iterations: + type: integer + maximum: 10.0 + minimum: 1.0 + title: Max Tool Iterations + description: 'Maximum number of tool call iterations (default: 5)' + default: 5 + stream: + type: boolean + title: Stream + description: If true, stream the final assistant response in the body + as newline-delimited JSON. + default: false + enable_thinking: + anyOf: + - type: boolean + - type: 'null' + title: Enable Thinking + description: Per-request thinking toggle. None means use the provider + default. Ignored by providers that do not expose a per-request + thinking switch. + type: object + required: + - messages + title: ChatCompletionRequest + description: Request for chat completion with tool calling. + ChatMessage: + properties: + role: + type: string + title: Role + description: "Message role: 'user', 'assistant', 'system', or 'tool'" + content: + anyOf: + - {} + - type: 'null' + title: Content + description: Message content. Usually a string, but may be a + multimodal content list (e.g. text + image_url) or null for + assistant turns that only request tool calls. + tool_call_id: + anyOf: + - type: string + - type: 'null' + title: Tool Call Id + description: For tool messages, the ID of the tool call + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: For tool messages, the tool name + tool_calls: + anyOf: + - items: + type: object + type: array + - type: 'null' + title: Tool Calls + description: For assistant messages replayed from prior turns, the + OpenAI-format tool calls the model previously requested. Replaying + these verbatim keeps the conversation prefix byte-for-byte identical + so the model server's prompt cache hits on follow-up turns. + type: object + required: + - role + title: ChatMessage + description: A single message in a chat conversation. DayReview: properties: day: @@ -4764,6 +7579,103 @@ components: - total_alert - total_detection title: DayReview + DebugReplayStartBody: + properties: + camera: + type: string + title: Source camera name + start_time: + type: number + title: Start timestamp + end_time: + type: number + title: End timestamp + type: object + required: + - camera + - start_time + - end_time + title: DebugReplayStartBody + description: Request body for starting a debug replay session. + DebugReplayStartFromExportBody: + properties: + export_id: + type: string + title: Export id + type: object + required: + - export_id + title: DebugReplayStartFromExportBody + description: Request body for starting a debug replay session from an + export. + DebugReplayStartResponse: + properties: + success: + type: boolean + title: Success + replay_camera: + type: string + title: Replay Camera + job_id: + type: string + title: Job Id + type: object + required: + - success + - replay_camera + - job_id + title: DebugReplayStartResponse + description: Response for starting a debug replay session. + DebugReplayStatusResponse: + properties: + active: + type: boolean + title: Active + replay_camera: + anyOf: + - type: string + - type: 'null' + title: Replay Camera + source_camera: + anyOf: + - type: string + - type: 'null' + title: Source Camera + start_time: + anyOf: + - type: number + - type: 'null' + title: Start Time + end_time: + anyOf: + - type: number + - type: 'null' + title: End Time + live_ready: + type: boolean + title: Live Ready + default: false + type: object + required: + - active + title: DebugReplayStatusResponse + description: |- + Response for debug replay status. + + Returns only session-presence fields. Startup progress and error + details flow through the job_state WebSocket topic via the + debug_replay job (see frigate.jobs.debug_replay); the + Replay page subscribes there with useJobStatus("debug_replay"). + DebugReplayStopResponse: + properties: + success: + type: boolean + title: Success + type: object + required: + - success + title: DebugReplayStopResponse + description: Response for stopping a debug replay session. DeleteFaceImagesBody: properties: ids: @@ -4825,7 +7737,7 @@ components: sub_label: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Label camera: type: string @@ -4836,12 +7748,12 @@ components: end_time: anyOf: - type: number - - type: "null" + - type: 'null' title: End Time false_positive: anyOf: - type: boolean - - type: "null" + - type: 'null' title: False Positive zones: items: @@ -4851,7 +7763,7 @@ components: thumbnail: anyOf: - type: string - - type: "null" + - type: 'null' title: Thumbnail has_clip: type: boolean @@ -4865,22 +7777,22 @@ components: plus_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Plus Id model_hash: anyOf: - type: string - - type: "null" + - type: 'null' title: Model Hash detector_type: anyOf: - type: string - - type: "null" + - type: 'null' title: Detector Type model_type: anyOf: - type: string - - type: "null" + - type: 'null' title: Model Type data: type: object @@ -4918,37 +7830,51 @@ components: - success - plus_id title: EventUploadPlusResponse + EventsAttributesBody: + properties: + attributes: + items: + type: string + type: array + title: Selected classification attributes for the event + type: object + title: EventsAttributesBody EventsCreateBody: properties: sub_label: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Label score: anyOf: - type: number - - type: "null" + - type: 'null' title: Score default: 0 duration: anyOf: - type: integer - - type: "null" + - type: 'null' title: Duration default: 30 include_recording: anyOf: - type: boolean - - type: "null" + - type: 'null' title: Include Recording default: true draw: anyOf: - type: object - - type: "null" + - type: 'null' title: Draw default: {} + pre_capture: + anyOf: + - type: integer + - type: 'null' + title: Pre Capture type: object title: EventsCreateBody EventsDeleteBody: @@ -4967,7 +7893,7 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' title: The description of the event type: object required: @@ -4978,7 +7904,7 @@ components: end_time: anyOf: - type: number - - type: "null" + - type: 'null' title: End Time type: object title: EventsEndBody @@ -4991,9 +7917,9 @@ components: recognizedLicensePlateScore: anyOf: - type: number - maximum: 1 - exclusiveMinimum: 0 - - type: "null" + maximum: 1.0 + exclusiveMinimum: 0.0 + - type: 'null' title: Score for recognized license plate type: object required: @@ -5008,31 +7934,206 @@ components: subLabelScore: anyOf: - type: number - maximum: 1 - exclusiveMinimum: 0 - - type: "null" + maximum: 1.0 + exclusiveMinimum: 0.0 + - type: 'null' title: Score for sub label camera: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera this object is detected on. type: object required: - subLabel title: EventsSubLabelBody - EventsAttributesBody: + ExportBulkDeleteBody: properties: - attributes: - type: object - title: Attributes - description: Object with model names as keys and attribute values - additionalProperties: + ids: + items: type: string + minLength: 1 + type: array + minItems: 1 + title: Ids type: object required: - - attributes - title: EventsAttributesBody + - ids + title: ExportBulkDeleteBody + description: Request body for bulk deleting exports. + ExportBulkReassignBody: + properties: + ids: + items: + type: string + minLength: 1 + type: array + minItems: 1 + title: Ids + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: 'null' + title: Export Case Id + description: Case ID to assign to, or null to unassign from current + case + type: object + required: + - ids + title: ExportBulkReassignBody + description: Request body for bulk reassigning exports to a case. + ExportCaseCreateBody: + properties: + name: + type: string + maxLength: 100 + title: Name + description: Friendly name of the export case + description: + anyOf: + - type: string + - type: 'null' + title: Description + description: Optional description of the export case + type: object + required: + - name + title: ExportCaseCreateBody + description: Request body for creating a new export case. + ExportCaseModel: + properties: + id: + type: string + title: Id + description: Unique identifier for the export case + name: + type: string + title: Name + description: Friendly name of the export case + description: + anyOf: + - type: string + - type: 'null' + title: Description + description: Optional description of the export case + created_at: + type: number + title: Created At + description: Unix timestamp when the export case was created + updated_at: + type: number + title: Updated At + description: Unix timestamp when the export case was last updated + type: object + required: + - id + - name + - created_at + - updated_at + title: ExportCaseModel + description: Model representing a single export case. + ExportCaseUpdateBody: + properties: + name: + anyOf: + - type: string + maxLength: 100 + - type: 'null' + title: Name + description: Updated friendly name of the export case + description: + anyOf: + - type: string + - type: 'null' + title: Description + description: Updated description of the export case + type: object + title: ExportCaseUpdateBody + description: Request body for updating an existing export case. + ExportJobModel: + properties: + id: + type: string + title: Id + description: Unique identifier for the export job + job_type: + type: string + title: Job Type + description: Job type + status: + type: string + title: Status + description: Current job status + camera: + type: string + title: Camera + description: Camera associated with this export job + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: Friendly name for the export + export_case_id: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + description: ID of the export case this export belongs to + request_start_time: + type: number + title: Request Start Time + description: Requested export start time + request_end_time: + type: number + title: Request End Time + description: Requested export end time + start_time: + anyOf: + - type: number + - type: 'null' + title: Start Time + description: Unix timestamp when execution started + end_time: + anyOf: + - type: number + - type: 'null' + title: End Time + description: Unix timestamp when execution completed + error_message: + anyOf: + - type: string + - type: 'null' + title: Error Message + description: Error message for failed jobs + results: + anyOf: + - type: object + - type: 'null' + title: Results + description: Result metadata for completed jobs + current_step: + type: string + title: Current Step + description: Current execution step (queued, preparing, encoding, + encoding_retry, finalizing) + default: queued + progress_percent: + type: number + title: Progress Percent + description: Progress percentage of the current step (0.0 - 100.0) + default: 0.0 + type: object + required: + - id + - job_type + - status + - camera + - request_start_time + - request_end_time + title: ExportJobModel + description: Model representing a queued or running export job. ExportModel: properties: id: @@ -5063,6 +8164,12 @@ components: type: boolean title: In Progress description: Whether the export is currently being processed + export_case_id: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + description: ID of the export case this export belongs to type: object required: - id @@ -5076,12 +8183,39 @@ components: description: Model representing a single export. ExportRecordingsBody: properties: - playback: - $ref: "#/components/schemas/PlaybackFactorEnum" - title: Playback factor - default: realtime source: - $ref: "#/components/schemas/PlaybackSourceEnum" + $ref: '#/components/schemas/PlaybackSourceEnum' + title: Playback source + default: recordings + name: + anyOf: + - type: string + maxLength: 256 + - type: 'null' + title: Friendly name + image_path: + type: string + title: Image Path + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: 'null' + title: Export case ID + description: ID of the export case to assign this export to + chapters: + anyOf: + - $ref: '#/components/schemas/ChaptersEnum' + - type: 'null' + title: Chapter mode + description: Optional chapter metadata to embed in the export. When + omitted, the camera's configured export chapter mode is used. + type: object + title: ExportRecordingsBody + ExportRecordingsCustomBody: + properties: + source: + $ref: '#/components/schemas/PlaybackSourceEnum' title: Playback source default: recordings name: @@ -5091,8 +8225,35 @@ components: image_path: type: string title: Image Path + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: 'null' + title: Export case ID + description: ID of the export case to assign this export to + ffmpeg_input_args: + anyOf: + - type: string + - type: 'null' + title: FFmpeg input arguments + description: Custom FFmpeg input arguments. If not provided, defaults + to timelapse input args. + ffmpeg_output_args: + anyOf: + - type: string + - type: 'null' + title: FFmpeg output arguments + description: Custom FFmpeg output arguments. If not provided, defaults + to timelapse output args. + cpu_fallback: + type: boolean + title: CPU Fallback + description: If true, retry export without hardware acceleration if + the initial export fails. + default: false type: object - title: ExportRecordingsBody + title: ExportRecordingsCustomBody ExportRenameBody: properties: name: @@ -5120,25 +8281,23 @@ components: score: anyOf: - type: number - - type: "null" + - type: 'null' title: Score description: Confidence score of the recognition (0-1) face_name: anyOf: - type: string - - type: "null" + - type: 'null' title: Face Name description: The recognized face name if successful type: object required: - success title: FaceRecognitionResponse - description: >- + description: |- Response model for face recognition endpoint. - - Returns the result of attempting to recognize a face from an uploaded - image. + Returns the result of attempting to recognize a face from an uploaded image. FacesResponse: additionalProperties: items: @@ -5158,6 +8317,82 @@ components: "john_doe": ["face1.webp", "face2.jpg"], "jane_smith": ["face3.png"] } + GenAIProbeBody: + properties: + provider: + $ref: '#/components/schemas/GenAIProviderEnum' + name: + anyOf: + - type: string + - type: 'null' + title: Name + api_key: + anyOf: + - type: string + - type: 'null' + title: Api Key + base_url: + anyOf: + - type: string + - type: 'null' + title: Base Url + provider_options: + type: object + title: Provider Options + type: object + required: + - provider + title: GenAIProbeBody + GenAIProviderEnum: + type: string + enum: + - openai + - azure_openai + - gemini + - ollama + - llamacpp + title: GenAIProviderEnum + GenerateObjectExamplesBody: + properties: + model_name: + type: string + title: Model Name + description: Name of the classification model + label: + type: string + title: Label + description: Object label to collect examples for (e.g., 'person', + 'car') + type: object + required: + - model_name + - label + title: GenerateObjectExamplesBody + GenerateStateExamplesBody: + properties: + model_name: + type: string + title: Model Name + description: Name of the classification model + cameras: + additionalProperties: + prefixItems: + - type: number + - type: number + - type: number + - type: number + type: array + maxItems: 4 + minItems: 4 + type: object + title: Cameras + description: Dictionary mapping camera names to normalized crop + coordinates in [x1, y1, x2, y2] format (values 0-1) + type: object + required: + - model_name + - cameras + title: GenerateStateExamplesBody GenericResponse: properties: success: @@ -5175,7 +8410,7 @@ components: properties: detail: items: - $ref: "#/components/schemas/ValidationError" + $ref: '#/components/schemas/ValidationError' type: array title: Detail type: object @@ -5201,12 +8436,206 @@ components: - total_alert - total_detection title: Last24HoursReview - PlaybackFactorEnum: - type: string - enum: - - realtime - - timelapse_25x - title: PlaybackFactorEnum + MediaSyncBody: + properties: + dry_run: + type: boolean + title: Dry Run + description: If True, only report orphans without deleting them + default: true + media_types: + items: + type: string + type: array + title: Media Types + description: "Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', + 'review_thumbnails', 'previews', 'exports', 'recordings'" + default: + - all + force: + type: boolean + title: Force + description: If True, bypass safety threshold checks + default: false + verbose: + type: boolean + title: Verbose + description: If True, write full orphan file list to disk + default: false + type: object + title: MediaSyncBody + MotionSearchMetricsResponse: + properties: + segments_scanned: + type: integer + title: Segments Scanned + default: 0 + segments_processed: + type: integer + title: Segments Processed + default: 0 + metadata_inactive_segments: + type: integer + title: Metadata Inactive Segments + default: 0 + heatmap_roi_skip_segments: + type: integer + title: Heatmap Roi Skip Segments + default: 0 + fallback_full_range_segments: + type: integer + title: Fallback Full Range Segments + default: 0 + frames_decoded: + type: integer + title: Frames Decoded + default: 0 + wall_time_seconds: + type: number + title: Wall Time Seconds + default: 0.0 + segments_with_errors: + type: integer + title: Segments With Errors + default: 0 + type: object + title: MotionSearchMetricsResponse + description: Metrics collected during motion search execution. + MotionSearchRequest: + properties: + start_time: + type: number + title: Start Time + description: Start timestamp for the search range + end_time: + type: number + title: End Time + description: End timestamp for the search range + polygon_points: + items: + items: + type: number + type: array + type: array + title: Polygon Points + description: List of [x, y] normalized coordinates (0-1) defining the + ROI polygon + threshold: + type: integer + maximum: 255.0 + minimum: 1.0 + title: Threshold + description: Pixel difference threshold (1-255) + default: 30 + min_area: + type: number + maximum: 100.0 + minimum: 0.1 + title: Min Area + description: Minimum change area as a percentage of the ROI + default: 5.0 + parallel: + type: boolean + title: Parallel + description: Enable parallel scanning across segments + default: false + max_results: + type: integer + maximum: 200.0 + minimum: 1.0 + title: Max Results + description: Maximum number of search results to return + default: 25 + type: object + required: + - start_time + - end_time + - polygon_points + title: MotionSearchRequest + description: Request body for motion search. + MotionSearchResult: + properties: + timestamp: + type: number + title: Timestamp + description: Timestamp where change was detected + change_percentage: + type: number + title: Change Percentage + description: Percentage of ROI area that changed + type: object + required: + - timestamp + - change_percentage + title: MotionSearchResult + description: A single search result with timestamp and change info. + MotionSearchStartResponse: + properties: + success: + type: boolean + title: Success + message: + type: string + title: Message + job_id: + type: string + title: Job Id + type: object + required: + - success + - message + - job_id + title: MotionSearchStartResponse + description: Response when motion search job starts. + MotionSearchStatusResponse: + properties: + success: + type: boolean + title: Success + message: + type: string + title: Message + status: + type: string + title: Status + results: + anyOf: + - items: + $ref: '#/components/schemas/MotionSearchResult' + type: array + - type: 'null' + title: Results + total_frames_processed: + anyOf: + - type: integer + - type: 'null' + title: Total Frames Processed + error_message: + anyOf: + - type: string + - type: 'null' + title: Error Message + metrics: + anyOf: + - $ref: '#/components/schemas/MotionSearchMetricsResponse' + - type: 'null' + scanning_timestamp: + anyOf: + - type: number + - type: 'null' + title: Scanning Timestamp + progress: + anyOf: + - type: number + - type: 'null' + title: Progress + type: object + required: + - success + - message + - status + title: MotionSearchStatusResponse + description: Response containing job status and results. PlaybackSourceEnum: type: string enum: @@ -5255,6 +8684,7 @@ components: new_name: type: string title: New Name + description: New name for the face type: object required: - new_name @@ -5285,6 +8715,10 @@ components: type: array minItems: 1 title: Ids + reviewed: + type: boolean + title: Reviewed + default: true type: object required: - ids @@ -5309,7 +8743,7 @@ components: type: boolean title: Has Been Reviewed severity: - $ref: "#/components/schemas/SeverityEnum" + $ref: '#/components/schemas/SeverityEnum' thumb_path: type: string title: Thumb Path @@ -5329,10 +8763,10 @@ components: ReviewSummaryResponse: properties: last24Hours: - $ref: "#/components/schemas/Last24HoursReview" + $ref: '#/components/schemas/Last24HoursReview' root: additionalProperties: - $ref: "#/components/schemas/DayReview" + $ref: '#/components/schemas/DayReview' type: object title: Root type: object @@ -5359,9 +8793,15 @@ components: export_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Export Id description: The export ID if successfully started + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Queue status for the export job type: object required: - success @@ -5376,17 +8816,31 @@ components: default: 1 type: object title: SubmitPlusBody + ToolExecuteRequest: + properties: + tool_name: + type: string + title: Tool Name + arguments: + type: object + title: Arguments + type: object + required: + - tool_name + - arguments + title: ToolExecuteRequest + description: Request model for tool execution. TriggerEmbeddingBody: properties: type: - $ref: "#/components/schemas/TriggerType" + $ref: '#/components/schemas/TriggerType' data: type: string title: Data threshold: type: number - maximum: 1 - minimum: 0 + maximum: 1.0 + minimum: 0.0 title: Threshold default: 0.5 type: object @@ -5400,6 +8854,36 @@ components: - thumbnail - description title: TriggerType + VLMMonitorRequest: + properties: + camera: + type: string + title: Camera + condition: + type: string + title: Condition + max_duration_minutes: + type: integer + title: Max Duration Minutes + default: 60 + labels: + items: + type: string + type: array + title: Labels + default: [] + zones: + items: + type: string + type: array + title: Zones + default: [] + type: object + required: + - camera + - condition + title: VLMMonitorRequest + description: Request model for starting a VLM watch job. ValidationError: properties: loc: @@ -5421,3 +8905,19 @@ components: - msg - type title: ValidationError + securitySchemes: + frigateAdminAuth: + type: apiKey + in: cookie + name: frigate_token + description: Authenticated session whose resolved role is 'admin'. The + session is established via the JWT cookie issued by POST /login, or via + proxy auth headers (remote-user / remote-role) when Frigate runs behind + an authenticating reverse proxy. + frigateUserAuth: + type: apiKey + in: cookie + name: frigate_token + description: Any authenticated session (role 'viewer' or higher), + established via the JWT cookie issued by POST /login, or via proxy auth + headers when Frigate runs behind an authenticating reverse proxy. diff --git a/frigate/__main__.py b/frigate/__main__.py index f3181e4946..df27b42a27 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -4,7 +4,6 @@ import multiprocessing as mp import signal import sys import threading -from typing import Union import ruamel.yaml from pydantic import ValidationError @@ -54,7 +53,7 @@ def main() -> None: print("*************************************************************\n") # Attempt to get the original config file for line number tracking config_path = find_config_file() - with open(config_path, "r") as f: + with open(config_path) as f: yaml_config = ruamel.yaml.YAML() yaml_config.preserve_quotes = True full_config = yaml_config.load(f) @@ -68,7 +67,7 @@ def main() -> None: try: for i, part in enumerate(error_path): - key: Union[int, str] = ( + key: int | str = ( int(part) if isinstance(part, str) and part.isdigit() else part ) diff --git a/frigate/api/app.py b/frigate/api/app.py index d954a74f31..0af3bc1aba 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -5,13 +5,14 @@ import copy import json import logging import os +import platform import traceback import urllib from datetime import datetime, timedelta from functools import reduce from io import StringIO from pathlib import Path as FilePath -from typing import Any, Dict, List, Optional +from typing import Any import aiofiles import ruamel.yaml @@ -19,6 +20,7 @@ from fastapi import APIRouter, Body, Path, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.params import Depends from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse +from filelock import FileLock, Timeout from markupsafe import escape from peewee import SQL, fn, operator from pydantic import ValidationError @@ -29,23 +31,47 @@ from frigate.api.auth import ( get_allowed_cameras_for_filter, require_role, ) +from frigate.api.config_util import ( + publish_camera_section_updates, + swap_runtime_config, +) from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters -from frigate.api.defs.request.app_body import AppConfigSetBody +from frigate.api.defs.request.app_body import ( + AppConfigSetBody, + 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, + start_media_sync_job, +) from frigate.models import Event, Timeline from frigate.stats.prometheus import get_metrics, update_metrics +from frigate.types import JobStatusTypesEnum from frigate.util.builtin import ( clean_camera_user_pass, + deep_merge, flatten_config_data, + load_labels, process_config_query_string, update_yaml_file_bulk, ) -from frigate.util.config import 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, process_logs, @@ -60,6 +86,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())] @@ -70,9 +104,7 @@ def is_healthy(): @router.get("/config/schema.json", dependencies=[Depends(allow_public())]) def config_schema(request: Request): - return Response( - content=request.app.frigate_config.schema_json(), media_type="application/json" - ) + return JSONResponse(content=get_config_schema(FrigateConfig)) @router.get( @@ -83,11 +115,46 @@ def version(): @router.get("/stats", dependencies=[Depends(allow_any_authenticated())]) -def stats(request: Request): - return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) +def stats( + request: Request, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + stats_data = request.app.stats_emitter.get_latest_stats() + + # Admins see the full snapshot + if request.headers.get("remote-role") == "admin": + return JSONResponse(content=stats_data) + + allowed_set = set(allowed_cameras) + + # Shallow-copy so we don't mutate the cached stats history entry. + filtered = {**stats_data} + + cameras = stats_data.get("cameras") + if cameras is not None: + filtered["cameras"] = { + name: data for name, data in cameras.items() if name in allowed_set + } + + bandwidth = stats_data.get("bandwidth_usages") + if bandwidth is not None: + filtered["bandwidth_usages"] = { + name: data for name, data in bandwidth.items() if name in allowed_set + } + + # cmdline can leak camera URLs/paths; strip but keep cpu/mem so + # client-side problem heuristics still work. + cpu_usages = stats_data.get("cpu_usages") + if cpu_usages is not None: + filtered["cpu_usages"] = { + pid: {k: v for k, v in usage.items() if k != "cmdline"} + for pid, usage in cpu_usages.items() + } + + return JSONResponse(content=filtered) -@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())]) +@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))]) def stats_history(request: Request, keys: str = None): if keys: keys = keys.split(",") @@ -101,7 +168,7 @@ def metrics(request: Request): # Retrieve the latest statistics and update the Prometheus metrics stats = request.app.stats_emitter.get_latest_stats() # query DB for count of events by camera, label - event_counts: List[Dict[str, Any]] = ( + event_counts: list[dict[str, Any]] = ( Event.select(Event.camera, Event.label, fn.Count()) .group_by(Event.camera, Event.label) .dicts() @@ -112,22 +179,146 @@ def metrics(request: Request): return Response(content=content, media_type=content_type) +@router.get( + "/genai/models", + dependencies=[Depends(allow_any_authenticated())], + summary="List available GenAI models", + description="Returns available models for each configured GenAI provider.", +) +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(request: Request, 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"}, + ) + + api_key = body.api_key + if api_key == REDACTED_CREDENTIAL_SENTINEL: + saved_cfg = ( + request.app.frigate_config.genai.get(body.name) if body.name else None + ) + api_key = saved_cfg.api_key if saved_cfg else None + + # 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=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 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 config: dict[str, dict[str, Any]] = config_obj.model_dump( mode="json", warnings="none", exclude_none=True ) + config["detectors"] = { + name: detector.model_dump(mode="json", warnings="none", exclude_none=True) + for name, detector in config_obj.detectors.items() + } - # remove the mqtt password - config["mqtt"].pop("password", None) + # remove environment_vars for non-admin users + if request.headers.get("remote-role") != "admin": + config.pop("environment_vars", None) - # remove the proxy secret - config["proxy"].pop("auth_secret", None) + # redact mqtt credentials + redact_credential(config["mqtt"], "password") + + # redact proxy secret + redact_credential(config["proxy"], "auth_secret") + + # redact genai api keys + for _genai_name, genai_cfg in config.get("genai", {}).items(): + if isinstance(genai_cfg, dict): + redact_credential(genai_cfg, "api_key") for camera_name, camera in request.app.frigate_config.cameras.items(): camera_dict = config["cameras"][camera_name] + # redact onvif credentials + onvif_dict = camera_dict.get("onvif", {}) + if onvif_dict: + redact_credential(onvif_dict, "password") + # clean paths for input in camera_dict.get("ffmpeg", {}).get("inputs", []): input["path"] = clean_camera_user_pass(input["path"]) @@ -141,6 +332,31 @@ def config(request: Request): for zone_name, zone in config_obj.cameras[camera_name].zones.items(): camera_dict["zones"][zone_name]["color"] = zone.color + # Re-dump profile overrides with exclude_unset so that only + # explicitly-set fields are returned (not Pydantic defaults). + # Without this, the frontend merges defaults (e.g. threshold=30) + # over the camera's actual base values (e.g. threshold=20). + if camera.profiles: + for profile_name, profile_config in camera.profiles.items(): + camera_dict.setdefault("profiles", {})[profile_name] = ( + profile_config.model_dump( + mode="json", warnings="none", exclude_unset=True + ) + ) + + # When a profile is active, the top-level camera sections contain + # profile-merged (effective) values. Include the original base + # configs so the frontend settings can display them separately. + if ( + config_obj.active_profile is not None + and request.app.profile_manager is not None + ): + base_sections = request.app.profile_manager.get_base_configs_for_api( + camera_name + ) + if base_sections: + camera_dict["base_config"] = base_sections + # remove go2rtc stream passwords go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( mode="json", warnings="none", exclude_none=True @@ -169,7 +385,7 @@ def config(request: Request): if model_path: model_json_path = FilePath(model_path).with_suffix(".json") try: - with open(model_json_path, "r") as f: + with open(model_json_path) as f: model_plus_data = json.load(f) config["model"]["plus"] = model_plus_data except FileNotFoundError: @@ -188,6 +404,75 @@ def config(request: Request): return JSONResponse(content=config) +@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) +def get_profiles(request: Request): + """List all available profiles and the currently active profile.""" + profile_manager = request.app.profile_manager + return JSONResponse(content=profile_manager.get_profile_info()) + + +@router.get("/profile/active", dependencies=[Depends(allow_any_authenticated())]) +def get_active_profile(request: Request): + """Get the currently active profile.""" + config_obj: FrigateConfig = request.app.frigate_config + return JSONResponse(content={"active_profile": config_obj.active_profile}) + + +@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())]) +def ffmpeg_presets(): + """Return available ffmpeg preset keys for config UI usage.""" + machine = platform.machine().lower() + is_arm64 = machine in ("aarch64", "arm64", "armv8", "armv7l") + + if is_arm64: + hwaccel_presets = [ + "preset-rpi-64-h264", + "preset-rpi-64-h265", + "preset-jetson-h264", + "preset-jetson-h265", + "preset-rkmpp", + "preset-vaapi", + ] + else: + hwaccel_presets = [ + "preset-vaapi", + "preset-intel-qsv-h264", + "preset-intel-qsv-h265", + "preset-nvidia", + ] + + input_presets = [ + "preset-http-jpeg-generic", + "preset-http-mjpeg-generic", + "preset-http-reolink", + "preset-rtmp-generic", + "preset-rtsp-generic", + "preset-rtsp-restream", + "preset-rtsp-restream-low-latency", + "preset-rtsp-udp", + "preset-rtsp-blue-iris", + ] + record_output_presets = [ + "preset-record-generic", + "preset-record-generic-audio-copy", + "preset-record-generic-audio-aac", + "preset-record-mjpeg", + "preset-record-jpeg", + "preset-record-ubiquiti", + ] + + return JSONResponse( + content={ + "hwaccel_args": hwaccel_presets, + "input_args": input_presets, + "output_args": { + "record": record_output_presets, + "detect": [], + }, + } + ) + + @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) def config_raw_paths(request: Request): """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" @@ -228,7 +513,7 @@ def config_raw(): status_code=404, ) - with open(config_file, "r") as f: + with open(config_file) as f: raw_config = f.read() f.close() @@ -362,108 +647,372 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) -@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) -def config_set(request: Request, body: AppConfigSetBody): - config_file = find_config_file() +def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: + """Substitute incoming `*:*` masked credentials with the in-memory ones. - with open(config_file, "r") as f: - old_raw_config = f.read() + The /config response masks ffmpeg input credentials, so the settings UI + sends the masked path back when sibling fields (e.g. hwaccel_args) are + edited. Without this we'd write `rtsp://*:*@host` into YAML and lose + the real credentials. Mutates `config_data` in place. + """ + cameras = config_data.get("cameras") + if not isinstance(cameras, dict): + return + for camera_name, camera_data in cameras.items(): + if not isinstance(camera_data, dict): + continue + inputs = camera_data.get("ffmpeg", {}).get("inputs") + if not isinstance(inputs, list): + continue + existing = config.cameras.get(camera_name) + if existing is None: + continue + existing_paths = [inp.path for inp in existing.ffmpeg.inputs] + for index, input_obj in enumerate(inputs): + if not isinstance(input_obj, dict): + continue + path = input_obj.get("path") + if not isinstance(path, str): + continue + if ("://*:*@" in path or "user=*&password=*" in path) and index < len( + existing_paths + ): + input_obj["path"] = existing_paths[index] + + +def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: + """Apply config changes in-memory only, without writing to YAML. + + Used for temporary config changes like debug replay camera tuning. + Updates the in-memory Pydantic config and publishes ZMQ updates, + bypassing YAML parsing entirely. + """ try: updates = {} - - # process query string parameters (takes precedence over body.config_data) - parsed_url = urllib.parse.urlparse(str(request.url)) - query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True) - - # Filter out empty keys but keep blank values for non-empty keys - query_string = {k: v for k, v in query_string.items() if k} - - if query_string: - updates = process_config_query_string(query_string) - elif body.config_data: + if body.config_data: + _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( - content=( - {"success": False, "message": "No configuration data provided"} - ), + content={"success": False, "message": "No configuration data provided"}, status_code=400, ) - # apply all updates in a single operation - update_yaml_file_bulk(config_file, updates) + config: FrigateConfig = request.app.frigate_config - # validate the updated config - with open(config_file, "r") as f: - new_raw_config = f.read() + # Group flat key paths into nested per-camera, per-section dicts + grouped: dict[str, dict[str, dict]] = {} + for key_path, value in updates.items(): + parts = key_path.split(".") + if len(parts) < 3 or parts[0] != "cameras": + continue - try: - config = FrigateConfig.parse(new_raw_config) - except Exception: - with open(config_file, "w") as f: - f.write(old_raw_config) - f.close() - logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") - return JSONResponse( - content=( - { + cam, section = parts[1], parts[2] + grouped.setdefault(cam, {}).setdefault(section, {}) + + # Build nested dict from remaining path (e.g. "filters.person.threshold") + target = grouped[cam][section] + for part in parts[3:-1]: + target = target.setdefault(part, {}) + if len(parts) > 3: + target[parts[-1]] = value + elif isinstance(value, dict): + grouped[cam][section] = deep_merge( + grouped[cam][section], value, override=True + ) + else: + grouped[cam][section] = value + + # Apply each section update + for cam_name, sections in grouped.items(): + camera_config = config.cameras.get(cam_name) + if not camera_config: + return JSONResponse( + content={ "success": False, - "message": "Error parsing config. Check logs for error message.", - } - ), - status_code=400, - ) - except Exception as e: - logging.error(f"Error updating config: {e}") - return JSONResponse( - content=({"success": False, "message": "Error updating config"}), - status_code=500, - ) + "message": f"Camera '{cam_name}' not found", + }, + status_code=400, + ) - if body.requires_restart == 0 or body.update_topic: - old_config: FrigateConfig = request.app.frigate_config - request.app.frigate_config = config + for section_name, update in sections.items(): + err = apply_section_update(camera_config, section_name, update) + if err is not None: + return JSONResponse( + content={"success": False, "message": err}, + status_code=400, + ) - if body.update_topic: - if body.update_topic.startswith("config/cameras/"): - _, _, camera, field = body.update_topic.split("/") - - if field == "add": - settings = config.cameras[camera] - elif field == "remove": - settings = old_config.cameras[camera] - else: - settings = config.get_nested_object(body.update_topic) + # Publish ZMQ updates so processing threads pick up changes + if body.update_topic and body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + settings = getattr(config.cameras.get(camera, None), field, None) + if settings is not None: request.app.config_publisher.publish_update( CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), settings, ) - else: - # Generic handling for global config updates - settings = config.get_nested_object(body.update_topic) - # Publish None for removal, actual config for add/update - request.app.config_publisher.publisher.publish( - body.update_topic, settings + # 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, + ) + except Exception as e: + logger.error(f"Error applying config in-memory: {e}") + return JSONResponse( + content={"success": False, "message": "Error applying config"}, + status_code=500, + ) + + +@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) +def config_set(request: Request, body: AppConfigSetBody): + config_file = find_config_file() + + if body.skip_save: + return _config_set_in_memory(request, body) + + lock = FileLock(f"{config_file}.lock", timeout=5) + + try: + with lock: + with open(config_file) as f: + old_raw_config = f.read() + + try: + updates = {} + + # process query string parameters (takes precedence over body.config_data) + parsed_url = urllib.parse.urlparse(str(request.url)) + query_string = urllib.parse.parse_qs( + parsed_url.query, keep_blank_values=True ) - return JSONResponse( - content=( - { - "success": True, - "message": "Config successfully updated, restart to apply", - } - ), - status_code=200, - ) + # Filter out empty keys but keep blank values for non-empty keys + query_string = {k: v for k, v in query_string.items() if k} + + if query_string: + updates = process_config_query_string(query_string) + elif body.config_data: + _restore_masked_camera_paths( + body.config_data, request.app.frigate_config + ) + 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( + content=( + { + "success": False, + "message": "No configuration data provided", + } + ), + status_code=400, + ) + + # apply all updates in a single operation + update_yaml_file_bulk(config_file, updates) + + # validate the updated config + with open(config_file) as f: + new_raw_config = f.read() + + try: + config = FrigateConfig.parse(new_raw_config) + except ValidationError as e: + with open(config_file, "w") as f: + f.write(old_raw_config) + f.close() + logger.error( + f"Config Validation Error:\n\n{str(traceback.format_exc())}" + ) + error_messages = [] + for err in e.errors(): + msg = err.get("msg", "") + # Strip pydantic "Value error, " prefix for cleaner display + if msg.startswith("Value error, "): + msg = msg[len("Value error, ") :] + error_messages.append(msg) + message = ( + "; ".join(error_messages) + if error_messages + else "Check logs for error message." + ) + return JSONResponse( + content=( + { + "success": False, + "message": f"Error saving config: {message}", + } + ), + status_code=400, + ) + except Exception: + with open(config_file, "w") as f: + f.write(old_raw_config) + f.close() + logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") + return JSONResponse( + content=( + { + "success": False, + "message": "Error parsing config. Check logs for error message.", + } + ), + status_code=400, + ) + except Exception as e: + logging.error(f"Error updating config: {e}") + return JSONResponse( + content=({"success": False, "message": "Error updating config"}), + status_code=500, + ) + + # drop runtime overrides for any fields the user just rewrote in + # yaml so a stale override doesn't silently win after restart + if request.app.dispatcher is not None: + request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys()) + + if body.requires_restart == 0 or body.update_topic: + old_config: FrigateConfig = request.app.frigate_config + swap_runtime_config(request.app, config) + + if body.update_topic: + if body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + + if camera == "*": + # Wildcard: fan out update to all cameras + enum_value = CameraConfigUpdateEnum[field] + for camera_name in config.cameras: + settings = config.get_nested_object( + f"config/cameras/{camera_name}/{field}" + ) + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(enum_value, camera_name), + settings, + ) + else: + if field == "add": + settings = config.cameras[camera] + elif field == "remove": + settings = old_config.cameras[camera] + else: + settings = config.get_nested_object(body.update_topic) + + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum[field], camera + ), + settings, + ) + else: + # Generic handling for global config updates + settings = config.get_nested_object(body.update_topic) + + # Publish None for removal, actual config for add/update + request.app.config_publisher.publisher.publish( + body.update_topic, settings + ) + + # a config/cameras/* topic publishes camera copies, a + # global topic the global object. FrigateConfig.parse + # folds some global sections down into every camera, + # and workers read both objects, so any such section + # needs its camera copies sent alongside the global + # publish above. + if body.update_topic == "config/birdseye": + publish_camera_section_updates( + request.app, config, CameraConfigUpdateEnum.birdseye + ) + + return JSONResponse( + content=( + { + "success": True, + "message": ( + "Config successfully updated" + if body.requires_restart == 0 + else "Config successfully updated, restart to apply" + ), + } + ), + status_code=200, + ) + except Timeout: + return JSONResponse( + content=( + { + "success": False, + "message": "Another process is currently updating the config. Please try again in a few seconds.", + } + ), + status_code=503, + ) @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())]) def vainfo(): - vainfo = vainfo_hwaccel() + # Use LibvaGpuSelector to pick an appropriate libva device (if available) + selected_gpu = "" + try: + selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or "" + except Exception: + selected_gpu = "" + + # If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`. + vainfo = vainfo_hwaccel(device_name=selected_gpu or None) return JSONResponse( content={ "return_code": vainfo.returncode, @@ -489,20 +1038,20 @@ def nvinfo(): @router.get( "/logs/{service}", tags=[Tags.logs], - dependencies=[Depends(allow_any_authenticated())], + dependencies=[Depends(require_role(["admin"]))], ) async def logs( service: str = Path(enum=["frigate", "nginx", "go2rtc"]), - download: Optional[str] = None, - stream: Optional[bool] = False, - start: Optional[int] = 0, - end: Optional[int] = None, + download: str | None = None, + stream: bool | None = False, + start: int | None = 0, + end: int | None = None, ): """Get logs for the requested service (frigate/nginx/go2rtc)""" def download_logs(service_location: str): try: - file = open(service_location, "r") + file = open(service_location) contents = file.read() file.close() return JSONResponse(jsonable_encoder(contents)) @@ -517,7 +1066,7 @@ async def logs( """Asynchronously stream log lines.""" buffer = "" try: - async with aiofiles.open(file_path, "r") as file: + async with aiofiles.open(file_path) as file: await file.seek(0, 2) while True: line = await file.readline() @@ -555,7 +1104,7 @@ async def logs( # For full logs initially try: - async with aiofiles.open(service_location, "r") as file: + async with aiofiles.open(service_location) as file: contents = await file.read() total_lines, log_lines = process_logs(contents, service, start, end) @@ -598,13 +1147,123 @@ def restart(): ) +@router.post( + "/media/sync", + dependencies=[Depends(require_role(["admin"]))], + summary="Start media sync job", + description="""Start an asynchronous media sync job to find and (optionally) remove orphaned media files. + Returns 202 with job details when queued, or 409 if a job is already running.""", +) +def sync_media(body: MediaSyncBody = Body(...)): + """Start async media sync job - remove orphaned files. + + Syncs specified media types: event snapshots, event thumbnails, review thumbnails, + previews, exports, and/or recordings. Job runs in background; use /media/sync/current + or /media/sync/status/{job_id} to check status. + + Args: + body: MediaSyncBody with dry_run flag and media_types list. + media_types can include: 'all', 'event_snapshots', 'event_thumbnails', + 'review_thumbnails', 'previews', 'exports', 'recordings' + + Returns: + 202 Accepted with job_id, or 409 Conflict if job already running. + """ + job_id = start_media_sync_job( + dry_run=body.dry_run, + media_types=body.media_types, + force=body.force, + verbose=body.verbose, + ) + + if job_id is None: + # A job is already running + current = get_current_media_sync_job() + return JSONResponse( + content={ + "error": "A media sync job is already running", + "current_job_id": current.id if current else None, + }, + status_code=409, + ) + + return JSONResponse( + content={ + "job": { + "job_type": "media_sync", + "status": JobStatusTypesEnum.queued, + "id": job_id, + } + }, + status_code=202, + ) + + +@router.get( + "/media/sync/current", + dependencies=[Depends(require_role(["admin"]))], + summary="Get current media sync job", + description="""Retrieve the current running media sync job, if any. Returns the job details + or null when no job is active.""", +) +def get_media_sync_current(): + """Get the current running media sync job, if any.""" + job = get_current_media_sync_job() + + if job is None: + return JSONResponse(content={"job": None}, status_code=200) + + return JSONResponse( + content={"job": job.to_dict()}, + status_code=200, + ) + + +@router.get( + "/media/sync/status/{job_id}", + dependencies=[Depends(require_role(["admin"]))], + summary="Get media sync job status", + description="""Get status and results for the specified media sync job id. Returns 200 with + job details including results, or 404 if the job is not found.""", +) +def get_media_sync_status(job_id: str): + """Get the status of a specific media sync job.""" + job = get_media_sync_job_by_id(job_id) + + if job is None: + return JSONResponse( + content={"error": "Job not found"}, + status_code=404, + ) + + return JSONResponse( + content={"job": job.to_dict()}, + status_code=200, + ) + + @router.get("/labels", dependencies=[Depends(allow_any_authenticated())]) -def get_labels(camera: str = ""): +def get_labels( + camera: str = "", + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): try: if camera: + if camera not in allowed_cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Access denied to camera '{camera}'", + }, + status_code=403, + ) events = Event.select(Event.label).where(Event.camera == camera).distinct() else: - events = Event.select(Event.label).distinct() + events = ( + Event.select(Event.label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception as e: logger.error(e) return JSONResponse( @@ -617,9 +1276,16 @@ def get_labels(camera: str = ""): @router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())]) -def get_sub_labels(split_joined: Optional[int] = None): +def get_sub_labels( + split_joined: int | None = None, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): try: - events = Event.select(Event.sub_label).distinct() + events = ( + Event.select(Event.sub_label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception: return JSONResponse( content=({"success": False, "message": "Failed to get sub_labels"}), @@ -647,6 +1313,12 @@ def get_sub_labels(split_joined: Optional[int] = None): return JSONResponse(content=sub_labels) +@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())]) +def get_audio_labels(): + labels = load_labels("/audio-labelmap.txt", prefill=521) + return JSONResponse(content=labels) + + @router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())]) def plusModels(request: Request, filterByCurrentModelDetector: bool = False): if not request.app.frigate_config.plus_api.is_active(): @@ -693,8 +1365,8 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False): "/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())] ) def get_recognized_license_plates( - split_joined: Optional[int] = None, - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + split_joined: int | None = None, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): try: query = ( @@ -735,8 +1407,8 @@ def get_recognized_license_plates( def timeline( camera: str = "all", limit: int = 100, - source_id: Optional[str] = None, - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + source_id: str | None = None, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): clauses = [] @@ -750,20 +1422,20 @@ def timeline( ] if camera != "all": - clauses.append((Timeline.camera == camera)) + clauses.append(Timeline.camera == camera) if source_id: source_ids = [sid.strip() for sid in source_id.split(",")] if len(source_ids) == 1: - clauses.append((Timeline.source_id == source_ids[0])) + clauses.append(Timeline.source_id == source_ids[0]) else: - clauses.append((Timeline.source_id.in_(source_ids))) + clauses.append(Timeline.source_id.in_(source_ids)) # Enforce per-camera access control - clauses.append((Timeline.camera << allowed_cameras)) + clauses.append(Timeline.camera << allowed_cameras) if len(clauses) == 0: - clauses.append((True)) + clauses.append(True) timeline = ( Timeline.select(*selected_columns) @@ -779,7 +1451,7 @@ def timeline( @router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())]) def hourly_timeline( params: AppTimelineHourlyQueryParameters = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): """Get hourly summary for timeline.""" cameras = params.cameras @@ -796,23 +1468,23 @@ def hourly_timeline( if cameras != "all": camera_list = cameras.split(",") - clauses.append((Timeline.camera << camera_list)) + clauses.append(Timeline.camera << camera_list) # Enforce per-camera access control - clauses.append((Timeline.camera << allowed_cameras)) + clauses.append(Timeline.camera << allowed_cameras) if labels != "all": label_list = labels.split(",") - clauses.append((Timeline.data["label"] << label_list)) + clauses.append(Timeline.data["label"] << label_list) if before: - clauses.append((Timeline.timestamp < before)) + clauses.append(Timeline.timestamp < before) if after: - clauses.append((Timeline.timestamp > after)) + clauses.append(Timeline.timestamp > after) if len(clauses) == 0: - clauses.append((True)) + clauses.append(True) timeline = ( Timeline.select( diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 79cd21ad15..fa51d6f328 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,7 +11,7 @@ import secrets import time from datetime import datetime from pathlib import Path -from typing import List, Optional +from urllib.parse import parse_qs, urlparse from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse @@ -26,12 +26,23 @@ from frigate.api.defs.request.app_body import ( AppPutRoleBody, ) from frigate.api.defs.tags import Tags +from frigate.api.media_auth import ( + check_camera_access, + deny_response_for_media_uri, + is_role_restricted, +) from frigate.config import AuthConfig, ProxyConfig from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.models import User logger = logging.getLogger(__name__) +# In-memory cache to track which clients we've logged for an anonymous access event. +# Keyed by a hashed value combining remote address + user-agent. The value is +# an expiration timestamp (float). +FIRST_LOAD_TTL_SECONDS = 60 * 60 * 24 * 7 # 7 days +_first_load_seen: dict[str, float] = {} + def require_admin_by_default(): """ @@ -41,7 +52,7 @@ def require_admin_by_default(): endpoints require admin access unless explicitly overridden with allow_public(), allow_any_authenticated(), or require_role(). - Port 5000 (internal) always has admin role set by the /auth endpoint, + Internal port always has admin role set by the /auth endpoint, so this check passes automatically for internal requests. Certain paths are exempted from the global admin check because they must @@ -58,6 +69,7 @@ def require_admin_by_default(): "/logout", # Authenticated user endpoints (allow_any_authenticated) "/profile", + "/profiles", # Public info endpoints (allow_public) "/", "/version", @@ -73,6 +85,7 @@ def require_admin_by_default(): "/sub_labels", "/plus/models", "/recognized_license_plates", + "/classification/attributes", "/timeline", "/timeline/hourly", "/recordings/storage", @@ -81,7 +94,9 @@ def require_admin_by_default(): "/go2rtc/streams", "/event_ids", "/events", + "/cases", "/exports", + "/jobs/export", } # Path prefixes that should be exempt (for paths with parameters) @@ -94,7 +109,9 @@ def require_admin_by_default(): "/go2rtc/streams/", # /go2rtc/streams/{camera} "/users/", # /users/{username}/password (has own auth) "/preview/", # /preview/{file}/thumbnail.jpg + "/cases/", # /cases/{case_id} "/exports/", # /exports/{export_id} + "/jobs/export/", # /jobs/export/{export_id} "/vod/", # /vod/{camera_name}/... "/notifications/", # /notifications/pubkey, /notifications/register ) @@ -129,7 +146,7 @@ def require_admin_by_default(): pass # For all other paths, require admin role - # Port 5000 (internal) requests have admin role set automatically + # Internal port requests have admin role set automatically role = request.headers.get("remote-role") if role == "admin": return @@ -142,6 +159,17 @@ def require_admin_by_default(): return admin_checker +def _is_authenticated(request: Request) -> bool: + """ + Helper to determine if a request is from an authenticated user. + + Returns True if the request has a valid authenticated user (not anonymous). + Internal port requests are considered anonymous despite having admin role. + """ + username = request.headers.get("remote-user") + return username is not None and username != "anonymous" + + def allow_public(): """ Override dependency to allow unauthenticated access to an endpoint. @@ -170,6 +198,7 @@ def allow_any_authenticated(): Rejects: - Requests with no remote-user header (did not pass through /auth endpoint) + - External port requests with anonymous user (auth disabled, no proxy auth) Example: @router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())]) @@ -178,8 +207,14 @@ def allow_any_authenticated(): async def auth_checker(request: Request): # Ensure a remote-user has been set by the /auth endpoint username = request.headers.get("remote-user") - if username is None: - raise HTTPException(status_code=401, detail="Authentication required") + + # Internal port requests have admin role and should be allowed + role = request.headers.get("remote-role") + + if role != "admin": + if username is None or not _is_authenticated(request): + raise HTTPException(status_code=401, detail="Authentication required") + return return auth_checker @@ -219,7 +254,14 @@ rateLimiter = RateLimiter() def get_remote_addr(request: Request): - route = list(reversed(request.headers.get("x-forwarded-for").split(","))) + # fall back to the direct TCP peer when no proxy chain is present + direct_addr = request.client.host if request.client else None + + forwarded_for = request.headers.get("x-forwarded-for") + if not forwarded_for: + return direct_addr or "127.0.0.1" + + route = list(reversed(forwarded_for.split(","))) logger.debug(f"IP Route: {[r for r in route]}") trusted_proxies = [] for proxy in request.app.frigate_config.auth.trusted_proxies: @@ -256,13 +298,17 @@ def get_remote_addr(request: Request): logger.debug(f"First untrusted IP: {str(ip)}") return str(ip) - # if there wasn't anything in the route, just return the default - remote_addr = None + # every hop in the route was trusted, so fall back to the direct peer + return direct_addr or "127.0.0.1" - if hasattr(request, "remote_addr"): - remote_addr = request.remote_addr - return remote_addr or "127.0.0.1" +def _cleanup_first_load_seen() -> None: + """Cleanup expired entries in the in-memory first-load cache.""" + now = time.time() + # Build list for removal to avoid mutating dict during iteration + expired = [k for k, exp in _first_load_seen.items() if exp <= now] + for k in expired: + del _first_load_seen[k] def get_jwt_secret() -> str: @@ -344,7 +390,7 @@ def verify_password(password, password_hash): return secrets.compare_digest(password_hash, compare_hash) -def validate_password_strength(password: str) -> tuple[bool, Optional[str]]: +def validate_password_strength(password: str) -> tuple[bool, str | None]: """ Validate password strength. @@ -370,13 +416,19 @@ def create_encoded_jwt(user, role, expiration, secret): ) -def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): +def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure): # TODO: ideally this would set secure as well, but that requires TLS + # SameSite is intentionally left unset (browsers default to Lax). Setting + # SameSite=Lax/Strict would stop the cookie from being sent in cross-origin + # iframes, breaking embedded views such as the Home Assistant Frigate card. + # CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which + # cross-origin pages cannot set without a CORS preflight that Frigate never + # grants (see check_csrf in api/fastapi_app.py). response.set_cookie( key=cookie_name, value=encoded_jwt, httponly=True, - expires=expiration, + max_age=max_age, secure=secure, ) @@ -393,7 +445,7 @@ async def get_current_user(request: Request): return {"username": username, "role": role} -def require_role(required_roles: List[str]): +def require_role(required_roles: list[str]): async def role_checker(request: Request): proxy_config: ProxyConfig = request.app.frigate_config.proxy config_roles = list(request.app.frigate_config.auth.roles.keys()) @@ -573,8 +625,14 @@ def auth(request: Request): success_response = Response("", status_code=202) # dont require auth if the request is on the internal port - # this header is set by Frigate's nginx proxy, so it cant be spoofed - if int(request.headers.get("x-server-port", default=0)) == 5000: + # this header is set by Frigate's nginx proxy, so it cant be spoofed. + # the port is the boot-time snapshot rather than the live config value: + # nginx's listeners are fixed at container start, so an in-memory config + # change must never move the port that is trusted here + if ( + int(request.headers.get("x-server-port", default=0)) + == request.app.auth_internal_port + ): success_response.headers["remote-user"] = "anonymous" success_response.headers["remote-role"] = "admin" return success_response @@ -589,6 +647,9 @@ def auth(request: Request): logger.debug("X-Proxy-Secret header does not match configured secret value") return fail_response + original_url = request.headers.get("x-original-url") + frigate_config = request.app.frigate_config + # if auth is disabled, just apply the proxy header map and return success if not auth_config.enabled: # pass the user header value from the upstream proxy if a mapping is specified @@ -605,6 +666,15 @@ def auth(request: Request): role = resolve_role(request.headers, proxy_config, config_roles_set) success_response.headers["remote-role"] = role + + deny_status = deny_response_for_media_uri(original_url, role, frigate_config) + if deny_status is not None: + return Response("", status_code=deny_status) + + deny_status = deny_response_for_go2rtc_stream(original_url, role, request) + if deny_status is not None: + return Response("", status_code=deny_status) + return success_response # now apply authentication @@ -693,12 +763,21 @@ def auth(request: Request): success_response, JWT_COOKIE_NAME, new_encoded_jwt, - new_expiration, + JWT_SESSION_LENGTH, JWT_COOKIE_SECURE, ) success_response.headers["remote-user"] = user success_response.headers["remote-role"] = role + + deny_status = deny_response_for_media_uri(original_url, role, frigate_config) + if deny_status is not None: + return Response("", status_code=deny_status) + + deny_status = deny_response_for_go2rtc_stream(original_url, role, request) + if deny_status is not None: + return Response("", status_code=deny_status) + return success_response except Exception as e: logger.error(f"Error parsing jwt: {e}") @@ -719,10 +798,30 @@ def profile(request: Request): roles_dict = request.app.frigate_config.auth.roles allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) - return JSONResponse( + response = JSONResponse( content={"username": username, "role": role, "allowed_cameras": allowed_cameras} ) + if username == "anonymous": + try: + remote_addr = get_remote_addr(request) + except Exception: + remote_addr = ( + request.client.host if hasattr(request, "client") else "unknown" + ) + + ua = request.headers.get("user-agent", "") + key_material = f"{remote_addr}|{ua}" + cache_key = hashlib.sha256(key_material.encode()).hexdigest() + + _cleanup_first_load_seen() + now = time.time() + if cache_key not in _first_load_seen: + _first_load_seen[cache_key] = now + FIRST_LOAD_TTL_SECONDS + logger.info(f"Anonymous user access from {remote_addr} ua={ua[:200]}") + + return response + @router.get( "/logout", @@ -748,6 +847,11 @@ limiter = Limiter(key_func=get_remote_addr) ) @limiter.limit(limit_value=rateLimiter.get_limit) def login(request: Request, body: AppPostLoginBody): + if not request.app.frigate_config.auth.enabled: + return JSONResponse( + content={"message": "Authentication is disabled"}, status_code=404 + ) + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length @@ -772,7 +876,11 @@ def login(request: Request, body: AppPostLoginBody): encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) set_jwt_cookie( - response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE + response, + JWT_COOKIE_NAME, + encoded_jwt, + JWT_SESSION_LENGTH, + JWT_COOKIE_SECURE, ) # Clear admin_first_time_login flag after successful admin login so the # UI stops showing the first-time login documentation link. @@ -864,6 +972,7 @@ def delete_user(request: Request, username: str): summary="Update user password", description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.", ) +@limiter.limit(limit_value=rateLimiter.get_limit) async def update_password( request: Request, username: str, @@ -877,10 +986,11 @@ async def update_password( current_username = current_user.get("username") current_role = current_user.get("role") - # viewers can only change their own password - if current_role == "viewer" and current_username != username: + # Only admins may target another account. This has to cover every non-admin + # role rather than just viewer, since custom roles are arbitrary names + if current_role != "admin" and current_username != username: raise HTTPException( - status_code=403, detail="Viewers can only update their own password" + status_code=403, detail="Users can only update their own password" ) HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations @@ -934,7 +1044,11 @@ async def update_password( ) # Set new JWT cookie on response set_jwt_cookie( - response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE + response, + JWT_COOKIE_NAME, + encoded_jwt, + JWT_SESSION_LENGTH, + JWT_COOKIE_SECURE, ) return response @@ -979,7 +1093,7 @@ async def update_role( async def require_camera_access( - camera_name: Optional[str] = None, + camera_name: str | None = None, request: Request = None, ): """Dependency to enforce camera access based on user role.""" @@ -1000,19 +1114,19 @@ async def require_camera_access( raise HTTPException(status_code=current_user.status_code, detail=detail) role = current_user["role"] - all_camera_names = set(request.app.frigate_config.cameras.keys()) - roles_dict = request.app.frigate_config.auth.roles - allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + frigate_config = request.app.frigate_config - # Admin or full access bypasses - if role == "admin" or not roles_dict.get(role): + if check_camera_access(role, camera_name, frigate_config): return - if camera_name not in allowed_cameras: - raise HTTPException( - status_code=403, - detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", - ) + all_camera_names = set(frigate_config.cameras.keys()) + allowed_cameras = User.get_allowed_cameras( + role, frigate_config.auth.roles, all_camera_names + ) + raise HTTPException( + status_code=403, + detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", + ) def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]: @@ -1029,8 +1143,68 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]: return owner_cameras +# nginx proxies these paths straight to go2rtc with authentication-only checks +# (see auth_request.conf). Each names the desired stream via the `src` query +# param, so the camera-level check must happen here in the `/auth` subrequest — +# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}` +# endpoint, not these proxied live-stream paths. +GO2RTC_STREAM_PROXY_PATHS = frozenset( + { + "/live/mse/api/ws", + "/live/webrtc/api/ws", + "/api/go2rtc/webrtc", + } +) + + +def deny_response_for_go2rtc_stream( + original_url: str | None, role: str | None, request: Request +) -> int | None: + """Block role-restricted users from go2rtc live streams they cannot access. + + Returns 403 when any `src` stream named in `original_url` resolves to a + camera outside the role's allow-list (or when no `src` is provided on a + stream-proxy path), otherwise None. Mirrors the resolution logic in + `require_go2rtc_stream_access` so substream names map to their owning + camera correctly. + """ + if not original_url: + return None + + parsed = urlparse(original_url) + if parsed.path not in GO2RTC_STREAM_PROXY_PATHS: + return None + + frigate_config = request.app.frigate_config + + # admin and full-access roles (no allow-list) bypass the camera check + if not role or not is_role_restricted(role, frigate_config): + return None + + sources = parse_qs(parsed.query).get("src", []) + if not sources: + # a stream-proxy request naming no stream has nothing legitimate to + # show a restricted user + return 403 + + allowed_cameras = set( + User.get_allowed_cameras( + role, + frigate_config.auth.roles, + set(frigate_config.cameras.keys()), + ) + ) + + # deny if any requested source resolves outside the allow-list + for src in sources: + if not (_get_stream_owner_cameras(request, src) & allowed_cameras): + return 403 + + return None + + async def require_go2rtc_stream_access( - stream_name: Optional[str] = None, + stream_name: str | None = None, request: Request = None, ): """Dependency to enforce go2rtc stream access based on owning camera access.""" @@ -1080,3 +1254,23 @@ async def get_allowed_cameras_for_filter(request: Request): all_camera_names = set(request.app.frigate_config.cameras.keys()) roles_dict = request.app.frigate_config.auth.roles return User.get_allowed_cameras(role, roles_dict, all_camera_names) + + +async def require_full_camera_access( + request: Request, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + """Dependency for endpoints returning data that spans every camera. + + Some responses cannot be meaningfully scoped to a subset of cameras, so + rather than filter them the endpoint is limited to callers who can already + see every camera. Admin and viewer always qualify; a custom role qualifies + only when its camera list covers all configured cameras. + """ + all_camera_names = set(request.app.frigate_config.cameras.keys()) + + if not all_camera_names.issubset(allowed_cameras): + raise HTTPException( + status_code=403, + detail="Access to all cameras is required for this endpoint", + ) diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 1dae5ae31d..bc0a318206 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -1,5 +1,6 @@ """Camera apis.""" +import asyncio import json import logging import re @@ -11,20 +12,38 @@ import httpx import requests from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import JSONResponse +from filelock import FileLock, Timeout from onvif import ONVIFCamera, ONVIFError +from ruamel.yaml import YAML from zeep.exceptions import Fault, TransportError from zeep.transports import AsyncTransport from frigate.api.auth import ( + _get_stream_owner_cameras, allow_any_authenticated, + get_current_user, require_go2rtc_stream_access, require_role, ) +from frigate.api.config_util import swap_runtime_config +from frigate.api.defs.request.app_body import CameraSetBody from frigate.api.defs.tags import Tags -from frigate.config.config import FrigateConfig -from frigate.util.builtin import clean_camera_user_pass +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) +from frigate.config.env import substitute_frigate_vars +from frigate.models import User +from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time +from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files +from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source +from frigate.util.services import ( + analyze_record_keyframes, + ffprobe_stream, + is_restricted_go2rtc_source, +) logger = logging.getLogger(__name__) @@ -55,8 +74,8 @@ def _is_valid_host(host: str) -> bool: @router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())]) -def go2rtc_streams(): - r = requests.get("http://127.0.0.1:1984/api/streams") +async def go2rtc_streams(request: Request): + r = await asyncio.to_thread(requests.get, "http://127.0.0.1:1984/api/streams") if not r.ok: logger.error("Failed to fetch streams from go2rtc") return JSONResponse( @@ -64,6 +83,24 @@ def go2rtc_streams(): status_code=500, ) stream_data = r.json() + + # Roles with an explicit camera list see only streams owned by an allowed + # camera. Admin and full-access roles (no list / empty list) see all streams. + current_user = await get_current_user(request) + if not isinstance(current_user, JSONResponse): + role = current_user["role"] + roles_dict = request.app.frigate_config.auth.roles + if role != "admin" and roles_dict.get(role): + all_camera_names = set(request.app.frigate_config.cameras.keys()) + allowed_cameras = set( + User.get_allowed_cameras(role, roles_dict, all_camera_names) + ) + stream_data = { + name: data + for name, data in stream_data.items() + if _get_stream_owner_cameras(request, name) & allowed_cameras + } + for data in stream_data.values(): for producer in data.get("producers") or []: producer["url"] = clean_camera_user_pass(producer.get("url", "")) @@ -127,7 +164,25 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): try: params = {"name": stream_name} if src: - params["src"] = src + try: + resolved_src = substitute_frigate_vars(src) + except KeyError: + resolved_src = src + + if is_restricted_go2rtc_source(resolved_src): + logger.warning( + "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + stream_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Restricted stream source type", + }, + status_code=400, + ) + + params["src"] = resolved_src r = requests.put( "http://127.0.0.1:1984/api/streams", @@ -325,6 +380,48 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False): return JSONResponse(content=output) +@router.get("/keyframe_analysis", dependencies=[Depends(require_role(["admin"]))]) +async def keyframe_analysis(request: Request, camera: str = ""): + """Probe a camera's record stream and classify its keyframe spacing. + + Detects smart/+ codecs and long/variable GOPs that degrade recording. + """ + config: FrigateConfig = request.app.frigate_config + + if camera not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"{camera} is not a valid camera."}, + status_code=404, + ) + + camera_config = config.cameras[camera] + + if not camera_config.enabled: + return JSONResponse( + content={"success": False, "message": f"{camera} is not enabled."}, + status_code=404, + ) + + # keyframe spacing only matters when this camera is recording + if not camera_config.record.enabled: + return JSONResponse(content={"severity": "record_disabled"}) + + # recording guarantees an input carries the record role; its index matches + # the "Stream N" numbering the ffprobe endpoint surfaces (same input order) + record_index, record_input = next( + (idx, i) + for idx, i in enumerate(camera_config.ffmpeg.inputs) + if "record" in i.roles + ) + + segment_time = get_record_segment_time(camera_config) + result = await analyze_record_keyframes( + config.ffmpeg, record_input.path, segment_time + ) + result["stream_index"] = record_index + return JSONResponse(content=result) + + @router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))]) def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10): """Get a snapshot from a stream URL using ffmpeg.""" @@ -492,6 +589,68 @@ def _extract_fps(r_frame_rate: str) -> float | None: return None +def _build_digest_transport(username: str, password: str) -> AsyncTransport: + """Build a zeep transport backed by an httpx client using HTTP digest auth.""" + auth = httpx.DigestAuth(username, password) + client = httpx.AsyncClient(auth=auth, timeout=10.0) + return AsyncTransport(client=client) + + +async def _connect_onvif_camera( + host: str, + port: int, + username: str, + password: str, + wsdl_base: str | None, + auth_type: str, +) -> ONVIFCamera: + """Connect to an ONVIF device, trying both WS-Security password encodings. + + Cameras disagree on whether the WS-Security UsernameToken should carry a + hashed PasswordDigest or a plaintext PasswordText. The wizard can't know + which a given camera expects, so we try PasswordDigest first (the common + case) and fall back to PasswordText when the device rejects the token. This + is independent of auth_type, which controls HTTP transport-level auth. + """ + first_error: Fault | None = None + + # encrypt=True -> PasswordDigest, encrypt=False -> PasswordText + for encrypt in (True, False): + onvif_camera = ONVIFCamera( + host, + port, + username or "", + password or "", + wsdl_dir=wsdl_base, + encrypt=encrypt, + ) + + try: + await onvif_camera.update_xaddrs() + except Fault as e: + # A SOAP fault here is how a camera signals the wrong password + # encoding, so retry with the other encoding before giving up. + logger.debug( + "ONVIF connect with %s rejected, trying alternate encoding", + "PasswordDigest" if encrypt else "PasswordText", + ) + if first_error is None: + first_error = e + continue + + if auth_type == "digest" and username and password: + transport = _build_digest_transport(username, password) + for service in ("devicemgmt", "media", "ptz"): + if hasattr(onvif_camera, service): + getattr(onvif_camera, service).zeep_client.transport = transport + logger.debug("Configured digest authentication") + + return onvif_camera + + # Both encodings failed authentication; surface the original fault. + raise first_error + + @router.get( "/onvif/probe", dependencies=[Depends(require_role(["admin"]))], @@ -568,34 +727,10 @@ async def onvif_probe( except Exception: wsdl_base = None - onvif_camera = ONVIFCamera( - host, port, username or "", password or "", wsdl_dir=wsdl_base + onvif_camera = await _connect_onvif_camera( + host, port, username, password, wsdl_base, auth_type ) - # Configure digest authentication if requested - if auth_type == "digest" and username and password: - # Create httpx client with digest auth - auth = httpx.DigestAuth(username, password) - client = httpx.AsyncClient(auth=auth, timeout=10.0) - - # Replace the transport in the zeep client - transport = AsyncTransport(client=client) - - # Update the xaddr before setting transport - await onvif_camera.update_xaddrs() - - # Replace transport in all services - if hasattr(onvif_camera, "devicemgmt"): - onvif_camera.devicemgmt.zeep_client.transport = transport - if hasattr(onvif_camera, "media"): - onvif_camera.media.zeep_client.transport = transport - if hasattr(onvif_camera, "ptz"): - onvif_camera.ptz.zeep_client.transport = transport - - logger.debug("Configured digest authentication") - else: - await onvif_camera.update_xaddrs() - # Get device information device_info = { "manufacturer": "Unknown", @@ -607,10 +742,9 @@ async def onvif_probe( # Update transport for device service if digest auth if auth_type == "digest" and username and password: - auth = httpx.DigestAuth(username, password) - client = httpx.AsyncClient(auth=auth, timeout=10.0) - transport = AsyncTransport(client=client) - device_service.zeep_client.transport = transport + device_service.zeep_client.transport = _build_digest_transport( + username, password + ) device_info_resp = await device_service.GetDeviceInformation() manufacturer = getattr(device_info_resp, "Manufacturer", None) or ( @@ -648,10 +782,9 @@ async def onvif_probe( # Update transport for media service if digest auth if auth_type == "digest" and username and password: - auth = httpx.DigestAuth(username, password) - client = httpx.AsyncClient(auth=auth, timeout=10.0) - transport = AsyncTransport(client=client) - media_service.zeep_client.transport = transport + media_service.zeep_client.transport = _build_digest_transport( + username, password + ) profiles = await media_service.GetProfiles() profiles_count = len(profiles) if profiles else 0 @@ -683,10 +816,9 @@ async def onvif_probe( # Update transport for PTZ service if digest auth if auth_type == "digest" and username and password: - auth = httpx.DigestAuth(username, password) - client = httpx.AsyncClient(auth=auth, timeout=10.0) - transport = AsyncTransport(client=client) - ptz_service.zeep_client.transport = transport + ptz_service.zeep_client.transport = _build_digest_transport( + username, password + ) # Check if PTZ service is available try: @@ -839,10 +971,9 @@ async def onvif_probe( # Update transport for media service if digest auth if auth_type == "digest" and username and password: - auth = httpx.DigestAuth(username, password) - client = httpx.AsyncClient(auth=auth, timeout=10.0) - transport = AsyncTransport(client=client) - media_service.zeep_client.transport = transport + media_service.zeep_client.transport = _build_digest_transport( + username, password + ) if profiles_count and media_service: for p in profiles or []: @@ -965,7 +1096,6 @@ async def onvif_probe( probe = ffprobe_stream( request.app.frigate_config.ffmpeg, test_uri, detailed=False ) - print(probe) ok = probe is not None and getattr(probe, "returncode", 1) == 0 tested_candidates.append( { @@ -1021,3 +1151,280 @@ async def onvif_probe( await onvif_camera.close() except Exception as e: logger.debug(f"Error closing ONVIF camera session: {e}") + + +@router.delete( + "/cameras/{camera_name}", + dependencies=[Depends(require_role(["admin"]))], +) +async def delete_camera( + request: Request, + camera_name: str, + delete_exports: bool = Query(default=False), +): + """Delete a camera and all its associated data. + + Removes the camera from config, stops processes, and cleans up + all database entries and media files. + + Args: + camera_name: Name of the camera to delete + delete_exports: Whether to also delete exports for this camera + """ + frigate_config: FrigateConfig = request.app.frigate_config + + if camera_name not in frigate_config.cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Camera {camera_name} not found", + }, + status_code=404, + ) + + old_camera_config = frigate_config.cameras[camera_name] + config_file = find_config_file() + lock = FileLock(f"{config_file}.lock", timeout=5) + + try: + with lock: + with open(config_file) as f: + old_raw_config = f.read() + + try: + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + + with open(config_file) as f: + data = yaml.load(f) + + # Remove camera from config + if "cameras" in data and camera_name in data["cameras"]: + del data["cameras"][camera_name] + + # Remove camera from auth roles + auth = data.get("auth", {}) + if auth and "roles" in auth: + empty_roles = [] + for role_name, cameras_list in auth["roles"].items(): + if ( + isinstance(cameras_list, list) + and camera_name in cameras_list + ): + cameras_list.remove(camera_name) + # Custom roles can't be empty; mark for removal + if not cameras_list and role_name not in ( + "admin", + "viewer", + ): + empty_roles.append(role_name) + for role_name in empty_roles: + del auth["roles"][role_name] + + with open(config_file, "w") as f: + yaml.dump(data, f) + + with open(config_file) as f: + new_raw_config = f.read() + + try: + config = FrigateConfig.parse(new_raw_config) + except Exception: + with open(config_file, "w") as f: + f.write(old_raw_config) + logger.exception( + "Config error after removing camera %s", + camera_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Error parsing config after camera removal", + }, + status_code=400, + ) + except Exception as e: + logger.error( + "Error updating config to remove camera %s: %s", camera_name, e + ) + return JSONResponse( + content={ + "success": False, + "message": "Error updating config", + }, + status_code=500, + ) + + # rebind every collaborator to the new config and re-layer runtime + # toggles for the surviving cameras, same as /api/config/set + swap_runtime_config(request.app, config) + + # drop the deleted camera's persisted overrides so a camera later + # added under the same name doesn't inherit them + if request.app.dispatcher is not None: + request.app.dispatcher.clear_runtime_state_for_camera(camera_name) + + # Publish removal to stop ffmpeg processes and clean up runtime state + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name), + old_camera_config, + ) + + except Timeout: + return JSONResponse( + content={ + "success": False, + "message": "Another process is currently updating the config", + }, + status_code=409, + ) + + # Clean up database entries + counts, export_paths = await asyncio.to_thread( + cleanup_camera_db, camera_name, delete_exports + ) + + # Clean up media files in background thread + await asyncio.to_thread( + cleanup_camera_files, camera_name, export_paths if delete_exports else None + ) + + # Best-effort go2rtc stream removal + try: + await asyncio.to_thread( + requests.delete, + "http://127.0.0.1:1984/api/streams", + params={"src": camera_name}, + timeout=5, + ) + except Exception: + logger.debug("Failed to remove go2rtc stream for %s", camera_name) + + return JSONResponse( + content={ + "success": True, + "message": f"Camera {camera_name} has been deleted", + "cleanup": counts, + }, + status_code=200, + ) + + +_SUB_COMMAND_FEATURES = {"motion_mask", "object_mask", "zone"} + + +@router.put( + "/camera/{camera_name}/set/{feature}", + dependencies=[Depends(require_role(["admin"]))], +) +@router.put( + "/camera/{camera_name}/set/{feature}/{sub_command}", + dependencies=[Depends(require_role(["admin"]))], +) +def camera_set( + request: Request, + camera_name: str, + feature: str, + body: CameraSetBody, + sub_command: str | None = None, +): + """Set a camera feature state. Use camera_name='*' to target all cameras. + + The value to set is sent in the request body as `{"value": ""}`. + + | Feature | Accepted values | + | --- | --- | + | `enabled` | `ON`, `OFF` | + | `detect` | `ON`, `OFF` | + | `motion` | `ON`, `OFF` | + | `recordings` | `ON`, `OFF` | + | `snapshots` | `ON`, `OFF` | + | `audio` | `ON`, `OFF` | + | `audio_transcription` | `ON`, `OFF` | + | `notifications` | `ON`, `OFF` | + | `review_alerts` | `ON`, `OFF` | + | `review_detections` | `ON`, `OFF` | + | `object_descriptions` | `ON`, `OFF` | + | `review_descriptions` | `ON`, `OFF` | + | `improve_contrast` | `ON`, `OFF` | + | `ptz_autotracker` | `ON`, `OFF` | + | `birdseye` | `ON`, `OFF` | + | `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` | + | `motion_contour_area` | integer | + | `motion_threshold` | integer | + | `motion_mask` | `ON`, `OFF` | + | `object_mask` | `ON`, `OFF` | + | `zone` | `ON`, `OFF` | + | `profile` | a profile name, or `none` to deactivate | + + `motion_mask`, `object_mask`, and `zone` require the `sub_command` path + parameter to be set to the name of the mask or zone. All other features + reject a sub-command. + + `profile` applies globally rather than per camera, so it requires + `camera_name` to be `*`. + + These features map to the equivalent MQTT topics, which document the + behavior of each value in more detail. + """ + dispatcher = request.app.dispatcher + frigate_config: FrigateConfig = request.app.frigate_config + + if feature == "profile": + if camera_name != "*": + return JSONResponse( + content={ + "success": False, + "message": "Profile feature requires camera_name='*'", + }, + status_code=400, + ) + dispatcher._receive("profile/set", body.value) + return JSONResponse(content={"success": True}) + + if feature not in dispatcher._camera_settings_handlers: + return JSONResponse( + content={"success": False, "message": f"Unknown feature: {feature}"}, + status_code=400, + ) + + if sub_command and feature not in _SUB_COMMAND_FEATURES: + return JSONResponse( + content={ + "success": False, + "message": f"Feature '{feature}' does not support sub-commands", + }, + status_code=400, + ) + + if not sub_command and feature in _SUB_COMMAND_FEATURES: + return JSONResponse( + content={ + "success": False, + "message": f"Feature '{feature}' requires a sub-command (e.g. mask or zone name)", + }, + status_code=400, + ) + + if camera_name == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera_name not in frigate_config.cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Camera '{camera_name}' not found", + }, + status_code=404, + ) + else: + cameras = [camera_name] + + for cam in cameras: + topic = ( + f"{cam}/{feature}/{sub_command}/set" + if sub_command + else f"{cam}/{feature}/set" + ) + dispatcher._receive(topic, body.value) + + return JSONResponse(content={"success": True}) diff --git a/frigate/api/chat.py b/frigate/api/chat.py new file mode 100644 index 0000000000..fa4510cf14 --- /dev/null +++ b/frigate/api/chat.py @@ -0,0 +1,1570 @@ +"""Chat and LLM tool calling APIs.""" + +import base64 +import json +import logging +import operator +import time +from datetime import datetime +from functools import reduce +from typing import Any, Literal + +import cv2 +from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel + +from frigate.api.auth import ( + allow_any_authenticated, + get_allowed_cameras_for_filter, + require_camera_access, +) +from frigate.api.chat_util import ( + chunk_content, + distance_to_score, + format_events_with_local_time, + fuse_scores, + hydrate_event, + parse_iso_to_timestamp, +) +from frigate.api.defs.query.events_query_parameters import EventsQueryParams +from frigate.api.defs.request.chat_body import ChatCompletionRequest +from frigate.api.defs.response.chat_response import ( + ChatCompletionResponse, + ChatMessageResponse, + ToolCall, +) +from frigate.api.defs.tags import Tags +from frigate.api.event import _build_attribute_filter_clause, events +from frigate.config import FrigateConfig +from frigate.config.classification import SemanticSearchModelEnum +from frigate.genai.prompts import ( + build_chat_system_prompt, + get_attribute_classifications, + get_tool_definitions, +) +from frigate.genai.utils import build_assistant_message_for_conversation +from frigate.jobs.vlm_watch import ( + get_vlm_watch_job, + start_vlm_watch_job, + stop_vlm_watch_job, +) +from frigate.models import Event + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.chat]) + + +class ToolExecuteRequest(BaseModel): + """Request model for tool execution.""" + + tool_name: str + arguments: dict[str, Any] + + +class VLMMonitorRequest(BaseModel): + """Request model for starting a VLM watch job.""" + + camera: str + condition: str + max_duration_minutes: int = 60 + labels: list[str] = [] + zones: list[str] = [] + + +@router.get( + "/chat/tools", + dependencies=[Depends(allow_any_authenticated())], + summary="Get available tools", + description="Returns OpenAI-compatible tool definitions for function calling.", +) +def get_tools(request: Request) -> JSONResponse: + """Get list of available tools for LLM function calling.""" + 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, + embeddings_language=_embeddings_language(config), + ) + return JSONResponse(content={"tools": tools}) + + +def _embeddings_language(config: FrigateConfig) -> Literal["english", "multi"]: + """Return the language capability of the configured embeddings model. + + JinaV1 is English-only; every other option (JinaV2 or a GenAI embeddings + provider) handles multiple languages. + """ + if config.semantic_search.model == SemanticSearchModelEnum.jinav1: + return "english" + + return "multi" + + +def _resolve_zones( + zones: list[str], + config: FrigateConfig, + target_cameras: list[str], +) -> list[str]: + """Map zone names to their canonical config keys, case-insensitively. + + LLMs frequently echo a user's casing ("Front Yard") instead of the + configured key ("front_yard"), or fall back to a zone's friendly name + ("Front Walkway") instead of its ID ("front_walk"). The downstream zone + filter is a SQLite GLOB over the JSON-encoded zones column, which stores + config keys and is case-sensitive — so an unnormalized name silently + returns zero matches. Build a lookup over the relevant cameras' configured + zones, keyed by both the config key and the friendly name, and substitute + when we find a match; unknown names pass through so behavior matches what + the model asked for. + """ + if not zones: + return zones + + lookup: dict[str, str] = {} + for camera_id in target_cameras: + camera_config = config.cameras.get(camera_id) + if camera_config is None: + continue + for zone_name, zone_config in camera_config.zones.items(): + lookup.setdefault(zone_name.lower(), zone_name) + lookup.setdefault( + zone_config.get_formatted_name(zone_name).lower(), zone_name + ) + + return [lookup.get(z.lower(), z) for z in zones] + + +async def _execute_search_objects( + request: Request, + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> JSONResponse: + """ + Execute the search_objects tool. + + Routes to the semantic path when the LLM supplied a `semantic_query` + and semantic search is enabled; otherwise delegates to the standard + events API logic. + """ + config = request.app.frigate_config + semantic_query = arguments.get("semantic_query") + if isinstance(semantic_query, str): + semantic_query = semantic_query.strip() or None + else: + semantic_query = None + + if semantic_query and getattr(config.semantic_search, "enabled", False): + return await _execute_search_objects_semantic( + request, arguments, allowed_cameras, semantic_query + ) + + # Parse after/before as server local time; convert to Unix timestamp + after = arguments.get("after") + before = arguments.get("before") + + def _parse_as_local_timestamp(s: str): + s = s.replace("Z", "").strip()[:19] + dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + return time.mktime(dt.timetuple()) + + if after: + try: + after = _parse_as_local_timestamp(after) + except (ValueError, AttributeError, TypeError): + logger.warning(f"Invalid 'after' timestamp format: {after}") + after = None + + if before: + try: + before = _parse_as_local_timestamp(before) + except (ValueError, AttributeError, TypeError): + logger.warning(f"Invalid 'before' timestamp format: {before}") + before = None + + # Convert zones array to comma-separated string if provided + zones = arguments.get("zones") + if isinstance(zones, list): + camera_arg = arguments.get("camera") + target_cameras = ( + [camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras + ) + zones = _resolve_zones(zones, config, target_cameras) + zones = ",".join(zones) + 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, + before=before, + limit=arguments.get("limit", 25), + ) + + try: + # Call the events endpoint function directly + # The events function is synchronous and takes params and allowed_cameras + response = events(query_params, allowed_cameras) + + # The response is already a JSONResponse with event data + # Return it as-is for the LLM + return response + except Exception as e: + logger.exception(f"Error executing search_objects: {e}") + return JSONResponse( + content={ + "success": False, + "message": "Error searching objects", + }, + status_code=500, + ) + + +async def _execute_search_objects_semantic( + request: Request, + arguments: dict[str, Any], + allowed_cameras: list[str], + semantic_query: str, +) -> JSONResponse: + """Search objects via fused thumbnail + description embeddings. + + Runs both visual and description vec searches against `semantic_query`, + intersects the candidates with the structured filters (camera, label, + sub_label, zones, time window) the LLM supplied, and ranks the survivors + by fused similarity. Mirrors the candidate-then-filter pattern used by + find_similar_objects since sqlite-vec's IN filter is unreliable. + """ + from peewee import fn + + config = request.app.frigate_config + context = request.app.embeddings + if context is None: + logger.warning( + "semantic_query supplied but embeddings context is unavailable; " + "returning empty results." + ) + return JSONResponse(content=[]) + + after = parse_iso_to_timestamp(arguments.get("after")) + before = parse_iso_to_timestamp(arguments.get("before")) + + camera_arg = arguments.get("camera") + if camera_arg and camera_arg != "all": + if camera_arg not in allowed_cameras: + return JSONResponse(content=[]) + cameras = [camera_arg] + else: + cameras = list(allowed_cameras) if allowed_cameras else [] + + if not cameras: + return JSONResponse(content=[]) + + label = arguments.get("label") + sub_label = arguments.get("sub_label") + attribute = arguments.get("attribute") + + zones = arguments.get("zones") + if isinstance(zones, list) and zones: + zones = _resolve_zones(zones, config, cameras) + else: + zones = None + + limit = int(arguments.get("limit", 25)) + limit = max(1, min(limit, 100)) + + visual_distances: dict[str, float] = {} + description_distances: dict[str, float] = {} + try: + rows = context.search_thumbnail(semantic_query) + visual_distances = {row[0]: row[1] for row in rows} + except Exception: + logger.exception( + "search_thumbnail failed for semantic_query: %s", semantic_query + ) + + try: + rows = context.search_description(semantic_query) + description_distances = {row[0]: row[1] for row in rows} + except Exception: + logger.exception( + "search_description failed for semantic_query: %s", semantic_query + ) + + vec_ids = set(visual_distances) | set(description_distances) + if not vec_ids: + return JSONResponse(content=[]) + + clauses = [Event.id.in_(list(vec_ids)), Event.camera.in_(cameras)] + if after is not None: + clauses.append(Event.start_time >= after) + if before is not None: + clauses.append(Event.start_time <= before) + if label: + clauses.append(Event.label == label) + 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)) + + eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))} + + scored: list[tuple[str, float]] = [] + for eid in eligible: + v_score = ( + distance_to_score(visual_distances[eid], context.thumb_stats) + if eid in visual_distances + else None + ) + d_score = ( + distance_to_score(description_distances[eid], context.desc_stats) + if eid in description_distances + else None + ) + fused = fuse_scores(v_score, d_score) + if fused is None: + continue + scored.append((eid, fused)) + + scored.sort(key=lambda pair: pair[1], reverse=True) + scored = scored[:limit] + + results = [hydrate_event(eligible[eid], score=score) for eid, score in scored] + return JSONResponse(content=results) + + +async def _execute_find_similar_objects( + request: Request, + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> dict[str, Any]: + """Execute the find_similar_objects tool. + + Returns a plain dict (not JSONResponse) so the chat loop can embed it + directly in tool-result messages. + """ + # 1. Semantic search enabled? + config = request.app.frigate_config + if not getattr(config.semantic_search, "enabled", False): + return { + "error": "semantic_search_disabled", + "message": ( + "Semantic search must be enabled to find similar objects. " + "Enable it in the Frigate config under semantic_search." + ), + } + + context = request.app.embeddings + if context is None: + return { + "error": "semantic_search_disabled", + "message": "Embeddings context is not available.", + } + + # 2. Anchor lookup. + event_id = arguments.get("event_id") + if not event_id: + return {"error": "missing_event_id", "message": "event_id is required."} + + try: + anchor = Event.get(Event.id == event_id) + except Event.DoesNotExist: + return { + "error": "anchor_not_found", + "message": f"Could not find event {event_id}.", + } + + # 3. Parse params. + after = parse_iso_to_timestamp(arguments.get("after")) + before = parse_iso_to_timestamp(arguments.get("before")) + + cameras = arguments.get("cameras") + if cameras: + # Respect RBAC: intersect with the user's allowed cameras. + cameras = [c for c in cameras if c in allowed_cameras] + else: + cameras = list(allowed_cameras) if allowed_cameras else None + + labels = arguments.get("labels") or [anchor.label] + sub_labels = arguments.get("sub_labels") + zones = arguments.get("zones") + + if zones: + zones = _resolve_zones( + zones, request.app.frigate_config, cameras or list(allowed_cameras) + ) + + similarity_mode = arguments.get("similarity_mode", "fused") + if similarity_mode not in ("visual", "semantic", "fused"): + similarity_mode = "fused" + + min_score = arguments.get("min_score") + limit = int(arguments.get("limit", 10)) + limit = max(1, min(limit, 50)) + + # 4. Run similarity searches. We deliberately do NOT pass event_ids into + # the vec queries — the IN filter on sqlite-vec is broken in the installed + # version (see frigate/embeddings/__init__.py). Mirror the pattern used by + # frigate/api/event.py events_search: fetch top-k globally, then intersect + # with the structured filters via Peewee. + visual_distances: dict[str, float] = {} + description_distances: dict[str, float] = {} + + try: + if similarity_mode in ("visual", "fused"): + rows = context.search_thumbnail(anchor) + visual_distances = {row[0]: row[1] for row in rows} + + if similarity_mode in ("semantic", "fused"): + query_text = ( + (anchor.data or {}).get("description") + or anchor.sub_label + or anchor.label + ) + rows = context.search_description(query_text) + description_distances = {row[0]: row[1] for row in rows} + except Exception: + logger.exception("Similarity search failed") + return { + "error": "similarity_search_failed", + "message": "Failed to run similarity search.", + } + + vec_ids = set(visual_distances) | set(description_distances) + vec_ids.discard(anchor.id) + # vec layer returns up to k=100 per modality; flag when we hit that ceiling + # so the LLM can mention there may be more matches beyond what we saw. + candidate_truncated = ( + len(visual_distances) >= 100 or len(description_distances) >= 100 + ) + + if not vec_ids: + return { + "anchor": hydrate_event(anchor), + "results": [], + "similarity_mode": similarity_mode, + "candidate_truncated": candidate_truncated, + } + + # 5. Apply structured filters, intersected with vec hits. + clauses = [Event.id.in_(list(vec_ids))] + if after is not None: + clauses.append(Event.start_time >= after) + if before is not None: + clauses.append(Event.start_time <= before) + if cameras: + clauses.append(Event.camera.in_(cameras)) + if labels: + clauses.append(Event.label.in_(labels)) + if sub_labels: + clauses.append(Event.sub_label.in_(sub_labels)) + if zones: + # Mirror the pattern used by frigate/api/event.py for JSON-array zone match. + zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones] + clauses.append(reduce(operator.or_, zone_clauses)) + + eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))} + + # 6. Fuse and rank. + scored: list[tuple[str, float]] = [] + for eid in eligible: + v_score = ( + distance_to_score(visual_distances[eid], context.thumb_stats) + if eid in visual_distances + else None + ) + d_score = ( + distance_to_score(description_distances[eid], context.desc_stats) + if eid in description_distances + else None + ) + fused = fuse_scores(v_score, d_score) + if fused is None: + continue + if min_score is not None and fused < min_score: + continue + scored.append((eid, fused)) + + scored.sort(key=lambda pair: pair[1], reverse=True) + scored = scored[:limit] + + results = [hydrate_event(eligible[eid], score=score) for eid, score in scored] + + return { + "anchor": hydrate_event(anchor), + "results": results, + "similarity_mode": similarity_mode, + "candidate_truncated": candidate_truncated, + } + + +@router.post( + "/chat/execute", + dependencies=[Depends(allow_any_authenticated())], + summary="Execute a tool", + description="Execute a tool function call from an LLM.", +) +async def execute_tool( + request: Request, + body: ToolExecuteRequest = Body(...), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +) -> JSONResponse: + """ + Execute a tool function call. + + This endpoint receives tool calls from LLMs and executes the corresponding + Frigate operations, returning results in a format the LLM can understand. + """ + tool_name = body.tool_name + arguments = body.arguments + + logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}") + + if tool_name == "search_objects": + return await _execute_search_objects(request, arguments, allowed_cameras) + + if tool_name == "find_similar_objects": + result = await _execute_find_similar_objects( + request, arguments, allowed_cameras + ) + status_code = 200 if "error" not in result else 400 + return JSONResponse(content=result, status_code=status_code) + + if tool_name == "set_camera_state": + result = await _execute_set_camera_state(request, arguments) + return JSONResponse( + content=result, status_code=200 if result.get("success") else 400 + ) + + return JSONResponse( + content={ + "success": False, + "message": f"Unknown tool: {tool_name}", + "tool": tool_name, + }, + status_code=400, + ) + + +async def _execute_get_live_context( + request: Request, + camera: str, + allowed_cameras: list[str], +) -> dict[str, Any]: + # 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: + return { + "error": f"Camera '{camera}' not found", + } + + try: + frame_processor = request.app.detected_frames_processor + camera_state = frame_processor.camera_states.get(camera) + + if camera_state is None: + return { + "error": f"Camera '{camera}' state not available", + } + + tracked_objects_dict = {} + with camera_state.current_frame_lock: + tracked_objects = camera_state.tracked_objects.copy() + frame_time = camera_state.current_frame_time + + for obj_id, tracked_obj in tracked_objects.items(): + obj_dict = tracked_obj.to_dict() + if obj_dict.get("frame_time") == frame_time: + tracked_objects_dict[obj_id] = { + "label": obj_dict.get("label"), + "zones": obj_dict.get("current_zones", []), + "sub_label": obj_dict.get("sub_label"), + "stationary": obj_dict.get("stationary", False), + } + + result: dict[str, Any] = { + "camera": camera, + "timestamp": frame_time, + "detections": list(tracked_objects_dict.values()), + } + + # Grab live frame when the chat model supports vision + image_url = await _get_live_frame_image_url(request, camera, allowed_cameras) + if image_url: + chat_client = request.app.genai_manager.chat_client + if chat_client is not None and chat_client.supports_vision: + # Pass image URL so it can be injected as a user message + # (images can't be in tool results) + result["_image_url"] = image_url + + return result + + except Exception as e: + logger.exception(f"Error executing get_live_context: {e}") + return { + "error": "Error getting live context", + } + + +async def _get_live_frame_image_url( + request: Request, + camera: str, + allowed_cameras: list[str], +) -> str | None: + """ + Fetch the current live frame for a camera as a base64 data URL. + + Returns None if the frame cannot be retrieved. Used by get_live_context + to attach the live image to the conversation. + """ + if ( + camera not in allowed_cameras + or camera not in request.app.frigate_config.cameras + ): + return None + try: + frame_processor = request.app.detected_frames_processor + if camera not in frame_processor.camera_states: + return None + frame = frame_processor.get_current_frame(camera, {}) + if frame is None: + return None + height, width = frame.shape[:2] + target_height = 480 + if height > target_height: + scale = target_height / height + frame = cv2.resize( + frame, + (int(width * scale), target_height), + interpolation=cv2.INTER_AREA, + ) + _, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) + b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8") + return f"data:image/jpeg;base64,{b64}" + except Exception as e: + logger.debug("Failed to get live frame for %s: %s", camera, e) + return None + + +async def _execute_set_camera_state( + request: Request, + arguments: dict[str, Any], +) -> dict[str, Any]: + role = request.headers.get("remote-role", "") + if "admin" not in [r.strip() for r in role.split(",")]: + return {"error": "Admin privileges required to change camera settings."} + + camera = arguments.get("camera", "").strip() + feature = arguments.get("feature", "").strip() + value = arguments.get("value", "").strip() + + if not camera or not feature or not value: + return {"error": "camera, feature, and value are all required."} + + dispatcher = request.app.dispatcher + frigate_config = request.app.frigate_config + + if feature == "profile": + if camera != "*": + return {"error": "Profile feature requires camera='*'."} + dispatcher._receive("profile/set", value) + return {"success": True, "camera": camera, "feature": feature, "value": value} + + if feature not in dispatcher._camera_settings_handlers: + return {"error": f"Unknown feature: {feature}"} + + if camera == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera not in frigate_config.cameras: + return {"error": f"Camera '{camera}' not found."} + else: + cameras = [camera] + + for cam in cameras: + dispatcher._receive(f"{cam}/{feature}/set", value) + + return {"success": True, "camera": camera, "feature": feature, "value": value} + + +async def _execute_tool_internal( + tool_name: str, + arguments: dict[str, Any], + request: Request, + allowed_cameras: list[str], +) -> dict[str, Any]: + """ + Internal helper to execute a tool and return the result as a dict. + + This is used by the chat completion endpoint to execute tools. + """ + if tool_name == "search_objects": + response = await _execute_search_objects(request, arguments, allowed_cameras) + try: + if hasattr(response, "body"): + body_str = response.body.decode("utf-8") + return json.loads(body_str) + elif hasattr(response, "content"): + return response.content + else: + return {} + except (json.JSONDecodeError, AttributeError) as e: + logger.warning(f"Failed to extract tool result: {e}") + return {"error": "Failed to parse tool result"} + elif tool_name == "find_similar_objects": + return await _execute_find_similar_objects(request, arguments, allowed_cameras) + elif tool_name == "set_camera_state": + return await _execute_set_camera_state(request, arguments) + elif tool_name == "get_live_context": + camera = arguments.get("camera") + if not camera: + logger.error( + "Tool get_live_context failed: camera parameter is required. " + "Arguments: %s", + json.dumps(arguments), + ) + 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) + elif tool_name == "stop_camera_watch": + return _execute_stop_camera_watch() + elif tool_name == "get_profile_status": + return _execute_get_profile_status(request) + elif tool_name == "get_recap": + return _execute_get_recap(arguments, allowed_cameras) + else: + logger.error( + "Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, " + "get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. " + "Arguments received: %s", + tool_name, + json.dumps(arguments), + ) + return {"error": f"Unknown tool: {tool_name}"} + + +async def _execute_start_camera_watch( + request: Request, + arguments: dict[str, Any], +) -> dict[str, Any]: + camera = arguments.get("camera", "").strip() + condition = arguments.get("condition", "").strip() + max_duration_minutes = int(arguments.get("max_duration_minutes", 60)) + labels = arguments.get("labels") or [] + zones = arguments.get("zones") or [] + + if not camera or not condition: + return {"error": "camera and condition are required."} + + config = request.app.frigate_config + if camera not in config.cameras: + return {"error": f"Camera '{camera}' not found."} + + await require_camera_access(camera, request=request) + + if zones: + zones = _resolve_zones(zones, config, [camera]) + + genai_manager = request.app.genai_manager + chat_client = genai_manager.chat_client + if chat_client is None or not chat_client.supports_vision: + return {"error": "VLM watch requires a chat model with vision support."} + + try: + job_id = start_vlm_watch_job( + camera=camera, + condition=condition, + max_duration_minutes=max_duration_minutes, + config=config, + frame_processor=request.app.detected_frames_processor, + genai_manager=genai_manager, + dispatcher=request.app.dispatcher, + labels=labels, + zones=zones, + ) + except RuntimeError as e: + logger.exception("Failed to start VLM watch job: %s", e) + return {"error": "Failed to start VLM watch job."} + + return { + "success": True, + "job_id": job_id, + "message": ( + f"Now watching '{camera}' for: {condition}. " + f"You'll receive a notification when the condition is met (timeout: {max_duration_minutes} min)." + ), + } + + +def _execute_stop_camera_watch() -> dict[str, Any]: + cancelled = stop_vlm_watch_job() + if cancelled: + return {"success": True, "message": "Watch job cancelled."} + return {"success": False, "message": "No active watch job to cancel."} + + +def _execute_get_profile_status(request: Request) -> dict[str, Any]: + """Return profile status including active profile and activation timestamps.""" + profile_manager = getattr(request.app, "profile_manager", None) + if profile_manager is None: + return {"error": "Profile manager is not available."} + + info = profile_manager.get_profile_info() + + # Convert timestamps to human-readable local times inline + last_activated = {} + for name, ts in info.get("last_activated", {}).items(): + try: + dt = datetime.fromtimestamp(ts) + last_activated[name] = dt.strftime("%Y-%m-%d %I:%M:%S %p") + except (TypeError, ValueError, OSError): + last_activated[name] = str(ts) + + return { + "active_profile": info.get("active_profile"), + "profiles": info.get("profiles", []), + "last_activated": last_activated, + } + + +def _execute_get_recap( + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> dict[str, Any]: + """Fetch review segments with GenAI metadata for a time period.""" + from functools import reduce + + from peewee import operator + + from frigate.models import ReviewSegment + + after_str = arguments.get("after") + before_str = arguments.get("before") + + def _parse_as_local_timestamp(s: str): + s = s.replace("Z", "").strip()[:19] + dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + return time.mktime(dt.timetuple()) + + try: + after = _parse_as_local_timestamp(after_str) + except (ValueError, AttributeError, TypeError): + return {"error": f"Invalid 'after' timestamp: {after_str}"} + + try: + before = _parse_as_local_timestamp(before_str) + except (ValueError, AttributeError, TypeError): + return {"error": f"Invalid 'before' timestamp: {before_str}"} + + cameras = arguments.get("cameras", "all") + if cameras != "all": + requested = set(cameras.split(",")) + camera_list = list(requested.intersection(allowed_cameras)) + if not camera_list: + return {"events": [], "message": "No accessible cameras matched."} + else: + camera_list = allowed_cameras + + clauses = [ + (ReviewSegment.start_time < before) + & ((ReviewSegment.end_time.is_null(True)) | (ReviewSegment.end_time > after)), + (ReviewSegment.camera << camera_list), + ] + + severity_filter = arguments.get("severity") + if severity_filter: + clauses.append(ReviewSegment.severity == severity_filter) + + try: + rows = ( + ReviewSegment.select( + ReviewSegment.camera, + ReviewSegment.start_time, + ReviewSegment.end_time, + ReviewSegment.severity, + ReviewSegment.data, + ) + .where(reduce(operator.and_, clauses)) + .order_by(ReviewSegment.start_time.asc()) + .limit(100) + .dicts() + .iterator() + ) + + events: list[dict[str, Any]] = [] + + for row in rows: + data = row.get("data") or {} + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + data = {} + + camera = row["camera"] + event: dict[str, Any] = { + "camera": camera.replace("_", " ").title(), + "severity": row.get("severity", "detection"), + } + + # Include GenAI metadata when available + metadata = data.get("metadata") + if metadata and isinstance(metadata, dict): + if metadata.get("title"): + event["title"] = metadata["title"] + if metadata.get("scene"): + event["description"] = metadata["scene"] + threat = metadata.get("potential_threat_level") + if threat is not None: + threat_labels = { + 0: "normal", + 1: "needs_review", + 2: "security_concern", + } + event["threat_level"] = threat_labels.get(threat, str(threat)) + + # Only include objects/zones/audio when there's no GenAI description + # to keep the payload concise — the description already covers these + if "description" not in event: + objects = data.get("objects", []) + if objects: + event["objects"] = objects + zones = data.get("zones", []) + if zones: + event["zones"] = zones + audio = data.get("audio", []) + if audio: + event["audio"] = audio + + start_ts = row.get("start_time") + end_ts = row.get("end_time") + if start_ts is not None: + try: + event["time"] = datetime.fromtimestamp(start_ts).strftime( + "%I:%M %p" + ) + except (TypeError, ValueError, OSError): + pass + if end_ts is not None and start_ts is not None: + try: + event["duration_seconds"] = round(end_ts - start_ts) + except (TypeError, ValueError): + pass + + events.append(event) + + if not events: + return { + "events": [], + "message": "No activity was found during this time period.", + } + + return {"events": events} + except Exception as e: + logger.exception("Error executing get_recap: %s", e) + return {"error": "Failed to fetch recap data."} + + +async def _execute_pending_tools( + pending_tool_calls: list[dict[str, Any]], + request: Request, + allowed_cameras: list[str], +) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]: + """ + Execute a list of tool calls. + + Returns: + (ToolCall list for API response, + tool result dicts for conversation, + extra messages to inject after tool results — e.g. user messages with images) + """ + tool_calls_out: list[ToolCall] = [] + tool_results: list[dict[str, Any]] = [] + extra_messages: list[dict[str, Any]] = [] + for tool_call in pending_tool_calls: + tool_name = tool_call["name"] + tool_args = tool_call.get("arguments") or {} + tool_call_id = tool_call["id"] + logger.debug( + f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}" + ) + try: + tool_result = await _execute_tool_internal( + tool_name, tool_args, request, allowed_cameras + ) + if isinstance(tool_result, dict) and tool_result.get("error"): + logger.error( + "Tool call %s (id: %s) returned error: %s. Arguments: %s", + tool_name, + tool_call_id, + tool_result.get("error"), + json.dumps(tool_args), + ) + if tool_name == "search_objects" and isinstance(tool_result, list): + tool_result = format_events_with_local_time(tool_result) + _keys = { + "id", + "camera", + "label", + "zones", + "start_time_local", + "end_time_local", + "sub_label", + "event_count", + } + tool_result = [ + {k: evt[k] for k in _keys if k in evt} + for evt in tool_result + if isinstance(evt, dict) + ] + + # Extract _image_url from get_live_context results — images can + # only be sent in user messages, not tool results + if isinstance(tool_result, dict) and "_image_url" in tool_result: + image_url = tool_result.pop("_image_url") + extra_messages.append( + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.", + }, + { + "type": "image_url", + "image_url": {"url": image_url}, + }, + ], + } + ) + + result_content = ( + json.dumps(tool_result) + if isinstance(tool_result, (dict, list)) + else (tool_result if isinstance(tool_result, str) else str(tool_result)) + ) + tool_calls_out.append( + ToolCall(name=tool_name, arguments=tool_args, response=result_content) + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content, + } + ) + except Exception as e: + logger.exception( + "Error executing tool %s (id: %s): %s. Arguments: %s", + tool_name, + tool_call_id, + e, + json.dumps(tool_args), + ) + error_content = json.dumps({"error": f"Tool execution failed: {str(e)}"}) + tool_calls_out.append( + ToolCall(name=tool_name, arguments=tool_args, response=error_content) + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": error_content, + } + ) + return (tool_calls_out, tool_results, extra_messages) + + +@router.post( + "/chat/completion", + dependencies=[Depends(allow_any_authenticated())], + summary="Chat completion with tool calling", + description=( + "Send a chat message to the configured GenAI provider with tool calling support. " + "The LLM can call Frigate tools to answer questions about your cameras and events." + ), +) +async def chat_completion( + request: Request, + body: ChatCompletionRequest = Body(...), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + """ + Chat completion endpoint with tool calling support. + + This endpoint: + 1. Gets the configured GenAI client + 2. Gets tool definitions + 3. Sends messages + tools to LLM + 4. Handles tool_calls if present + 5. Executes tools and sends results back to LLM + 6. Repeats until final answer + 7. Returns response to user + """ + genai_client = request.app.genai_manager.chat_client + if not genai_client: + return JSONResponse( + content={ + "error": "GenAI is not configured. Please configure a GenAI provider in your Frigate config.", + }, + status_code=400, + ) + + 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, + embeddings_language=_embeddings_language(config), + ) + conversation = [] + + # Build the system message only when the client hasn't already pinned one. + # The first turn has no system message; we generate it (with the current + # timestamp) and return the whole chain so the client persists it. Later + # turns send it back verbatim, freezing the timestamp so the prompt prefix + # stays byte-identical and the model server's prompt cache keeps hitting. + if not body.messages or body.messages[0].role != "system": + conversation.append( + { + "role": "system", + "content": build_chat_system_prompt( + config=config, + allowed_cameras=allowed_cameras, + semantic_search_enabled=semantic_search_enabled, + attribute_classifications=attribute_classifications, + ), + } + ) + + for msg in body.messages: + msg_dict = { + "role": msg.role, + "content": msg.content, + } + if msg.tool_call_id: + msg_dict["tool_call_id"] = msg.tool_call_id + if msg.name: + msg_dict["name"] = msg.name + if msg.tool_calls is not None: + msg_dict["tool_calls"] = msg.tool_calls + + conversation.append(msg_dict) + + tool_iterations = 0 + tool_calls: list[ToolCall] = [] + max_iterations = body.max_tool_iterations + + logger.debug( + f"Starting chat completion with {len(conversation)} message(s), " + f"{len(tools)} tool(s) available, max_iterations={max_iterations}" + ) + + # True LLM streaming when client supports it and stream requested + if body.stream and hasattr(genai_client, "chat_with_tools_stream"): + stream_iterations = 0 + + async def stream_body_llm(): + nonlocal conversation, stream_iterations + + def _emit_chain(extra: list[dict[str, Any]] | None = None): + # Return the full conversation (including the system message) so + # the client persists and replays it verbatim next turn. + chain = conversation + (extra or []) + return ( + json.dumps({"type": "messages", "messages": chain}).encode("utf-8") + + b"\n" + ) + + while stream_iterations < max_iterations: + if await request.is_disconnected(): + logger.debug("Client disconnected, stopping chat stream") + return + logger.debug( + f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) " + f"with {len(conversation)} message(s)" + ) + async for event in genai_client.chat_with_tools_stream( + messages=conversation, + tools=tools if tools else None, + tool_choice="auto", + enable_thinking=body.enable_thinking, + ): + if await request.is_disconnected(): + logger.debug("Client disconnected, stopping chat stream") + return + kind, value = event + if kind == "content_delta": + yield ( + json.dumps({"type": "content", "delta": value}).encode( + "utf-8" + ) + + b"\n" + ) + elif kind == "reasoning_delta": + yield ( + json.dumps({"type": "reasoning", "delta": value}).encode( + "utf-8" + ) + + b"\n" + ) + elif kind == "stats": + yield ( + json.dumps({"type": "stats", **value}).encode("utf-8") + + b"\n" + ) + elif kind == "message": + msg = value + if msg.get("finish_reason") == "error": + yield ( + json.dumps( + { + "type": "error", + "error": "An error occurred while processing your request.", + } + ).encode("utf-8") + + b"\n" + ) + return + pending = msg.get("tool_calls") + if pending: + stream_iterations += 1 + conversation.append( + build_assistant_message_for_conversation( + msg.get("content"), pending + ) + ) + if await request.is_disconnected(): + logger.debug( + "Client disconnected before tool execution" + ) + return + ( + _executed_calls, + tool_results, + extra_msgs, + ) = await _execute_pending_tools( + pending, request, allowed_cameras + ) + conversation.extend(tool_results) + conversation.extend(extra_msgs) + # Emit the running chain so the client can render tool + # calls live and replay them verbatim next turn. + yield _emit_chain() + break + else: + # Streaming never appends the final assistant message + # to the conversation, so add it to the chain. + yield _emit_chain( + extra=[ + { + "role": "assistant", + "content": msg.get("content"), + } + ] + ) + yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n") + return + else: + yield _emit_chain() + yield json.dumps({"type": "done"}).encode("utf-8") + b"\n" + + return StreamingResponse( + stream_body_llm(), + media_type="application/x-ndjson", + headers={"X-Accel-Buffering": "no"}, + ) + + try: + while tool_iterations < max_iterations: + logger.debug( + f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) " + f"with {len(conversation)} message(s) in conversation" + ) + response = genai_client.chat_with_tools( + messages=conversation, + tools=tools if tools else None, + tool_choice="auto", + enable_thinking=body.enable_thinking, + ) + + if response.get("finish_reason") == "error": + logger.error("GenAI client returned an error") + return JSONResponse( + content={ + "error": "An error occurred while processing your request.", + }, + status_code=500, + ) + + conversation.append( + build_assistant_message_for_conversation( + response.get("content"), response.get("tool_calls") + ) + ) + + pending_tool_calls = response.get("tool_calls") + if not pending_tool_calls: + logger.debug( + f"Chat completion finished with final answer (iterations: {tool_iterations})" + ) + final_content = response.get("content") or "" + + if body.stream: + final_reasoning = response.get("reasoning") + + chain = list(conversation) + + async def stream_body() -> Any: + yield ( + json.dumps({"type": "messages", "messages": chain}).encode( + "utf-8" + ) + + b"\n" + ) + # Emit the full reasoning trace up front when the + # underlying client did not stream it + if final_reasoning: + 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 ( + json.dumps({"type": "content", "delta": part}).encode( + "utf-8" + ) + + b"\n" + ) + yield json.dumps({"type": "done"}).encode("utf-8") + b"\n" + + return StreamingResponse( + stream_body(), + media_type="application/x-ndjson", + ) + + return JSONResponse( + content=ChatCompletionResponse( + message=ChatMessageResponse( + role="assistant", + content=final_content, + reasoning=response.get("reasoning"), + tool_calls=None, + ), + finish_reason=response.get("finish_reason", "stop"), + tool_iterations=tool_iterations, + tool_calls=tool_calls, + messages=list(conversation), + ).model_dump(), + ) + + tool_iterations += 1 + logger.debug( + f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): " + f"{len(pending_tool_calls)} tool(s) to execute" + ) + executed_calls, tool_results, extra_msgs = await _execute_pending_tools( + pending_tool_calls, request, allowed_cameras + ) + tool_calls.extend(executed_calls) + conversation.extend(tool_results) + conversation.extend(extra_msgs) + logger.debug( + f"Added {len(tool_results)} tool result(s) to conversation. " + f"Continuing with next LLM call..." + ) + + logger.warning( + f"Max tool iterations ({max_iterations}) reached. Returning partial response." + ) + return JSONResponse( + content=ChatCompletionResponse( + message=ChatMessageResponse( + role="assistant", + content="I reached the maximum number of tool call iterations. Please try rephrasing your question.", + tool_calls=None, + ), + finish_reason="length", + tool_iterations=tool_iterations, + tool_calls=tool_calls, + messages=list(conversation), + ).model_dump(), + ) + + except Exception as e: + logger.exception(f"Error in chat completion: {e}") + return JSONResponse( + content={ + "error": "An error occurred while processing your request.", + }, + status_code=500, + ) + + +# --------------------------------------------------------------------------- +# VLM Monitor endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/vlm/monitor", + dependencies=[Depends(allow_any_authenticated())], + summary="Start a VLM watch job", + description=( + "Start monitoring a camera with the vision provider. " + "The VLM analyzes live frames until the specified condition is met, " + "then sends a notification. Only one watch job can run at a time." + ), +) +async def start_vlm_monitor( + request: Request, + body: VLMMonitorRequest, +) -> JSONResponse: + config = request.app.frigate_config + genai_manager = request.app.genai_manager + + if body.camera not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"Camera '{body.camera}' not found."}, + status_code=404, + ) + + await require_camera_access(body.camera, request=request) + + chat_client = genai_manager.chat_client + if chat_client is None or not chat_client.supports_vision: + return JSONResponse( + content={ + "success": False, + "message": "VLM watch requires a chat model with vision support.", + }, + status_code=400, + ) + + try: + job_id = start_vlm_watch_job( + camera=body.camera, + condition=body.condition, + max_duration_minutes=body.max_duration_minutes, + config=config, + frame_processor=request.app.detected_frames_processor, + genai_manager=genai_manager, + dispatcher=request.app.dispatcher, + labels=body.labels, + zones=body.zones, + username=request.headers.get("remote-user", ""), + ) + except RuntimeError as e: + logger.exception("Failed to start VLM watch job: %s", e) + return JSONResponse( + content={"success": False, "message": "Failed to start VLM watch job."}, + status_code=409, + ) + + return JSONResponse( + content={"success": True, "job_id": job_id}, + status_code=201, + ) + + +@router.get( + "/vlm/monitor", + dependencies=[Depends(allow_any_authenticated())], + summary="Get current VLM watch job", + description="Returns the current (or most recently completed) VLM watch job.", +) +async def get_vlm_monitor(request: Request) -> JSONResponse: + job = get_vlm_watch_job() + if job is None: + return JSONResponse(content={"active": False}, status_code=200) + + role = request.headers.get("remote-role", "viewer") + username = request.headers.get("remote-user", "") + + # Admin and the job's creator always see the job. Other users only see it + # if they have access to the camera being watched; otherwise hide it. + if role != "admin" and username != job.username: + try: + await require_camera_access(job.camera, request=request) + except HTTPException: + return JSONResponse(content={"active": False}, status_code=200) + + return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200) + + +@router.delete( + "/vlm/monitor", + dependencies=[Depends(allow_any_authenticated())], + summary="Cancel the current VLM watch job", + description="Cancels the running watch job if one exists.", +) +async def cancel_vlm_monitor(request: Request) -> JSONResponse: + job = get_vlm_watch_job() + if job is None: + return JSONResponse( + content={"success": False, "message": "No active watch job to cancel."}, + status_code=404, + ) + + role = request.headers.get("remote-role", "viewer") + username = request.headers.get("remote-user", "") + + # Admin can cancel any job; other users can only cancel jobs they started. + if role != "admin" and username != job.username: + return JSONResponse( + content={ + "success": False, + "message": "Not authorized to cancel this watch job.", + }, + status_code=403, + ) + + cancelled = stop_vlm_watch_job() + if not cancelled: + return JSONResponse( + content={"success": False, "message": "No active watch job to cancel."}, + status_code=404, + ) + return JSONResponse(content={"success": True}, status_code=200) diff --git a/frigate/api/chat_util.py b/frigate/api/chat_util.py new file mode 100644 index 0000000000..a2f29c75b2 --- /dev/null +++ b/frigate/api/chat_util.py @@ -0,0 +1,136 @@ +"""Pure, stateless helpers used by the chat tool dispatchers. + +These were extracted from frigate/api/chat.py to keep that module focused on +route handlers, tool dispatchers, and streaming loop internals. Nothing in +this file touches the FastAPI request, the embeddings context, or the chat +loop state — all inputs and outputs are plain data. +""" + +import logging +import math +import time +from collections.abc import Generator +from datetime import datetime +from typing import Any + +from frigate.embeddings.util import ZScoreNormalization +from frigate.models import Event + +logger = logging.getLogger(__name__) + + +# Similarity fusion weights for find_similar_objects. +# Visual dominates because the feature's primary use case is "same specific object." +# If these change, update the test in test_chat_find_similar_objects.py. +VISUAL_WEIGHT = 0.65 +DESCRIPTION_WEIGHT = 0.35 + + +def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, None]: + """Yield content in word-aware chunks for streaming.""" + if not content: + return + words = content.split(" ") + current: list[str] = [] + current_len = 0 + for w in words: + current.append(w) + current_len += len(w) + 1 + if current_len >= chunk_size: + yield " ".join(current) + " " + current = [] + current_len = 0 + if current: + yield " ".join(current) + + +def format_events_with_local_time( + events_list: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Add human-readable local start/end times to each event for the LLM.""" + result = [] + for evt in events_list: + if not isinstance(evt, dict): + result.append(evt) + continue + copy_evt = dict(evt) + try: + start_ts = evt.get("start_time") + end_ts = evt.get("end_time") + if start_ts is not None: + dt_start = datetime.fromtimestamp(start_ts) + copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p") + if end_ts is not None: + dt_end = datetime.fromtimestamp(end_ts) + copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p") + except (TypeError, ValueError, OSError): + pass + result.append(copy_evt) + return result + + +def distance_to_score(distance: float, stats: ZScoreNormalization) -> float: + """Convert a cosine distance to a [0, 1] similarity score. + + Uses the existing ZScoreNormalization stats maintained by EmbeddingsContext + to normalize across deployments, then a bounded sigmoid. Lower distance -> + higher score. If stats are uninitialized (stddev == 0), returns a neutral + 0.5 so the fallback ordering by raw distance still dominates. + """ + if stats.stddev == 0: + return 0.5 + z = (distance - stats.mean) / stats.stddev + # Sigmoid on -z so that small distance (good) -> high score. + return 1.0 / (1.0 + math.exp(z)) + + +def fuse_scores( + visual_score: float | None, + description_score: float | None, +) -> float | None: + """Weighted fusion of visual and description similarity scores. + + If one side is missing (e.g., no description embedding for this event), + the other side's score is returned alone with no penalty. If both are + missing, returns None and the caller should drop the event. + """ + if visual_score is None and description_score is None: + return None + if visual_score is None: + return description_score + if description_score is None: + return visual_score + return VISUAL_WEIGHT * visual_score + DESCRIPTION_WEIGHT * description_score + + +def parse_iso_to_timestamp(value: str | None) -> float | None: + """Parse an ISO-8601 string as server-local time -> unix timestamp. + + Mirrors the parsing _execute_search_objects uses so both tools accept the + same format from the LLM. + """ + if value is None: + return None + try: + s = value.replace("Z", "").strip()[:19] + dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + return time.mktime(dt.timetuple()) + except (ValueError, AttributeError, TypeError): + logger.warning("Invalid timestamp format: %s", value) + return None + + +def hydrate_event(event: Event, score: float | None = None) -> dict[str, Any]: + """Convert an Event row into the dict shape returned by find_similar_objects.""" + data: dict[str, Any] = { + "id": event.id, + "camera": event.camera, + "label": event.label, + "sub_label": event.sub_label, + "start_time": event.start_time, + "end_time": event.end_time, + "zones": event.zones, + } + if score is not None: + data["score"] = score + return data diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 5e1087d17c..6caa745254 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -11,11 +11,10 @@ from typing import Any import cv2 from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import JSONResponse -from pathvalidate import sanitize_filename from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict -from frigate.api.auth import require_role +from frigate.api.auth import require_full_camera_access, require_role from frigate.api.defs.request.classification_body import ( AudioTranscriptionBody, DeleteFaceImagesBody, @@ -43,12 +42,21 @@ from frigate.util.classification import ( write_training_metadata, ) from frigate.util.file import get_event_snapshot +from frigate.util.path import safe_join, sanitize_path_component logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.classification]) +def invalid_name_response(value: str) -> JSONResponse: + """Response for a name that cannot be used as a path component.""" + return JSONResponse( + content={"success": False, "message": f"Invalid name: {value}"}, + status_code=400, + ) + + @router.get( "/faces", response_model=FacesResponse, @@ -98,9 +106,7 @@ def reclassify_face(request: Request, body: dict = None): ) json: dict[str, Any] = body or {} - training_file = os.path.join( - FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}" - ) + training_file = safe_join(FACE_DIR, "train", json.get("training_file", "")) if not training_file or not os.path.isfile(training_file): return JSONResponse( @@ -150,8 +156,10 @@ def train_face(request: Request, name: str, body: dict = None): ) json: dict[str, Any] = body or {} - training_file_name = sanitize_filename(json.get("training_file", "")) - training_file = os.path.join(FACE_DIR, f"train/{training_file_name}") + training_file_name = json.get("training_file", "") + training_file = ( + safe_join(FACE_DIR, "train", training_file_name) if training_file_name else None + ) event_id = json.get("event_id") if not training_file_name and not event_id: @@ -165,7 +173,9 @@ def train_face(request: Request, name: str, body: dict = None): status_code=400, ) - if training_file_name and not os.path.isfile(training_file): + if training_file_name and ( + training_file is None or not os.path.isfile(training_file) + ): return JSONResponse( content=( { @@ -176,9 +186,13 @@ def train_face(request: Request, name: str, body: dict = None): status_code=404, ) - sanitized_name = sanitize_filename(name) + sanitized_name = sanitize_path_component(name) + new_file_folder = safe_join(FACE_DIR, name) + + if sanitized_name is None or new_file_folder is None: + return invalid_name_response(name) + new_name = f"{sanitized_name}-{datetime.datetime.now().timestamp()}.webp" - new_file_folder = os.path.join(FACE_DIR, f"{sanitized_name}") os.makedirs(new_file_folder, exist_ok=True) @@ -261,9 +275,12 @@ async def create_face(request: Request, name: str): content={"message": "Face recognition is not enabled.", "success": False}, ) - os.makedirs( - os.path.join(FACE_DIR, sanitize_filename(name.replace(" ", "_"))), exist_ok=True - ) + face_folder = safe_join(FACE_DIR, name.replace(" ", "_")) + + if face_folder is None: + return invalid_name_response(name) + + os.makedirs(face_folder, exist_ok=True) return JSONResponse( status_code=200, content={"success": False, "message": "Successfully created face folder."}, @@ -280,15 +297,18 @@ async def create_face(request: Request, name: str): success response with details about the registration, or an error if face recognition is not enabled or the image cannot be processed.""", ) -async def register_face(request: Request, name: str, file: UploadFile): +def register_face(request: Request, name: str, file: UploadFile): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( status_code=400, content={"message": "Face recognition is not enabled.", "success": False}, ) + if sanitize_path_component(name) is None: + return invalid_name_response(name) + context: EmbeddingsContext = request.app.embeddings - result = None if context is None else context.register_face(name, await file.read()) + result = None if context is None else context.register_face(name, file.file.read()) if not isinstance(result, dict): return JSONResponse( @@ -313,7 +333,7 @@ async def register_face(request: Request, name: str, file: UploadFile): registered faces in the system. Returns the recognized face name and confidence score, or an error if face recognition is not enabled or the image cannot be processed.""", ) -async def recognize_face(request: Request, file: UploadFile): +def recognize_face(request: Request, file: UploadFile): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( status_code=400, @@ -321,7 +341,7 @@ async def recognize_face(request: Request, file: UploadFile): ) context: EmbeddingsContext = request.app.embeddings - result = context.recognize_face(await file.read()) + result = context.recognize_face(file.file.read()) if not isinstance(result, dict): return JSONResponse( @@ -338,6 +358,86 @@ async def recognize_face(request: Request, file: UploadFile): ) +@router.post( + "/faces/{name}/reclassify", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Reclassify a face image to a different name", + description="""Moves a single face image from one person's folder to another. + The image is moved and renamed, and the face classifier is cleared to + incorporate the change. Returns a success message or an error if the + image or target name is invalid.""", +) +def reclassify_face_image(request: Request, name: str, body: dict = None): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + json: dict[str, Any] = body or {} + image_id = sanitize_path_component(json.get("id", "")) + new_name = sanitize_path_component(json.get("new_name", "")) + + if not image_id or not new_name: + return JSONResponse( + content=( + { + "success": False, + "message": "Both 'id' and 'new_name' are required.", + } + ), + status_code=400, + ) + + if new_name == name: + return JSONResponse( + content=( + { + "success": False, + "message": "New name must differ from the current name.", + } + ), + status_code=400, + ) + + source_folder = safe_join(FACE_DIR, name) + target_folder = safe_join(FACE_DIR, new_name) + + if source_folder is None or target_folder is None: + return invalid_name_response(name) + + source_file = os.path.join(source_folder, image_id) + + if not os.path.isfile(source_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Image not found: {image_id}", + } + ), + status_code=404, + ) + + target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp" + + os.makedirs(target_folder, exist_ok=True) + shutil.move(source_file, os.path.join(target_folder, target_filename)) + + # Clean up empty source folder + if os.path.exists(source_folder) and not os.listdir(source_folder): + os.rmdir(source_folder) + + context: EmbeddingsContext = request.app.embeddings + context.clear_face_classifier() + + return JSONResponse( + content=({"success": True, "message": "Successfully reclassified face."}), + status_code=200, + ) + + @router.post( "/faces/{name}/delete", response_model=GenericResponse, @@ -354,8 +454,19 @@ def deregister_faces(request: Request, name: str, body: DeleteFaceImagesBody): content={"message": "Face recognition is not enabled.", "success": False}, ) + sanitized_name = sanitize_path_component(name) + + if sanitized_name is None: + return invalid_name_response(name) + + sanitized_ids = [ + component + for component in map(sanitize_path_component, body.ids) + if component is not None + ] + context: EmbeddingsContext = request.app.embeddings - context.delete_face_ids(name, map(lambda file: sanitize_filename(file), body.ids)) + context.delete_face_ids(sanitized_name, sanitized_ids) return JSONResponse( content=({"success": True, "message": "Successfully deleted faces."}), status_code=200, @@ -566,7 +677,11 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody): def get_classification_dataset(name: str): dataset_dict: dict[str, list[str]] = {} - dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset") + sanitized_name = sanitize_path_component(name) + dataset_dir = safe_join(CLIPS_DIR, name, "dataset") + + if sanitized_name is None or dataset_dir is None: + return invalid_name_response(name) if not os.path.exists(dataset_dir): return JSONResponse( @@ -588,8 +703,8 @@ def get_classification_dataset(name: str): dataset_dict[category_name].append(file) # Get training metadata - metadata = read_training_metadata(sanitize_filename(name)) - current_image_count = get_dataset_image_count(sanitize_filename(name)) + metadata = read_training_metadata(sanitized_name) + current_image_count = get_dataset_image_count(sanitized_name) if metadata is None: training_metadata = { @@ -626,6 +741,7 @@ def get_classification_dataset(name: str): @router.get( "/classification/attributes", + dependencies=[Depends(require_full_camera_access)], summary="Get custom classification attributes", description="""Returns custom classification attributes for a given object type. Only includes models with classification_type set to 'attribute'. @@ -653,8 +769,8 @@ def get_custom_attributes( if object_type is not None and object_type not in model_objects: continue - dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset") - if not os.path.exists(dataset_dir): + dataset_dir = safe_join(CLIPS_DIR, model_key, "dataset") + if dataset_dir is None or not os.path.exists(dataset_dir): continue attributes = [] @@ -684,7 +800,10 @@ def get_custom_attributes( The name must exist in the classification models. Returns a success message or an error if the name is invalid.""", ) def get_classification_images(name: str): - train_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "train") + train_dir = safe_join(CLIPS_DIR, name, "train") + + if train_dir is None: + return invalid_name_response(name) if not os.path.exists(train_dir): return JSONResponse(status_code=200, content=[]) @@ -755,15 +874,17 @@ def delete_classification_dataset_images( json: dict[str, Any] = body or {} list_of_ids = json.get("ids", "") - folder = os.path.join( - CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category) - ) + sanitized_name = sanitize_path_component(name) + folder = safe_join(CLIPS_DIR, name, "dataset", category) + + if sanitized_name is None or folder is None: + return invalid_name_response(name) deleted_count = 0 for id in list_of_ids: - file_path = os.path.join(folder, sanitize_filename(id)) + file_path = safe_join(folder, id) - if os.path.isfile(file_path): + if file_path and os.path.isfile(file_path): os.unlink(file_path) deleted_count += 1 @@ -774,7 +895,6 @@ def delete_classification_dataset_images( # This ensures the dataset is marked as changed after deletion # (even if the total count happens to be the same after adding and deleting) if deleted_count > 0: - sanitized_name = sanitize_filename(name) metadata = read_training_metadata(sanitized_name) if metadata: last_count = metadata.get("last_training_image_count", 0) @@ -787,6 +907,103 @@ def delete_classification_dataset_images( ) +@router.post( + "/classification/{name}/dataset/{category}/reclassify", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Reclassify a dataset image to a different category", + description="""Moves a single dataset image from one category to another. + The image is re-saved as PNG in the target category and removed from the source.""", +) +def reclassify_classification_image( + request: Request, name: str, category: str, body: dict = None +): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + json: dict[str, Any] = body or {} + image_id = sanitize_path_component(json.get("id", "")) + new_category = sanitize_path_component(json.get("new_category", "")) + + if not image_id or not new_category: + return JSONResponse( + content=( + { + "success": False, + "message": "Both 'id' and 'new_category' are required.", + } + ), + status_code=400, + ) + + if new_category == category: + return JSONResponse( + content=( + { + "success": False, + "message": "New category must differ from the current category.", + } + ), + status_code=400, + ) + + sanitized_name = sanitize_path_component(name) + source_folder = safe_join(CLIPS_DIR, name, "dataset", category) + target_folder = safe_join(CLIPS_DIR, name, "dataset", new_category) + + if sanitized_name is None or source_folder is None or target_folder is None: + return invalid_name_response(name) + + source_file = os.path.join(source_folder, image_id) + + if not os.path.isfile(source_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Image not found: {image_id}", + } + ), + status_code=404, + ) + + random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + timestamp = datetime.datetime.now().timestamp() + new_name = f"{new_category}-{timestamp}-{random_id}.png" + + os.makedirs(target_folder, exist_ok=True) + + img = cv2.imread(source_file) + cv2.imwrite(os.path.join(target_folder, new_name), img) + os.unlink(source_file) + + # Clean up empty source folder (unless it is "none") + if ( + os.path.exists(source_folder) + and not os.listdir(source_folder) + and category.lower() != "none" + ): + os.rmdir(source_folder) + + # Mark dataset as changed so UI knows retraining is needed + write_training_metadata(sanitized_name, 0) + + return JSONResponse( + content=({"success": True, "message": "Successfully reclassified image."}), + status_code=200, + ) + + @router.put( "/classification/{name}/dataset/{old_category}/rename", response_model=GenericResponse, @@ -812,7 +1029,7 @@ def rename_classification_category( ) json: dict[str, Any] = body or {} - new_category = sanitize_filename(json.get("new_category", "")) + new_category = sanitize_path_component(json.get("new_category", "")) if not new_category: return JSONResponse( @@ -825,12 +1042,12 @@ def rename_classification_category( status_code=400, ) - old_folder = os.path.join( - CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(old_category) - ) - new_folder = os.path.join( - CLIPS_DIR, sanitize_filename(name), "dataset", new_category - ) + sanitized_name = sanitize_path_component(name) + old_folder = safe_join(CLIPS_DIR, name, "dataset", old_category) + new_folder = safe_join(CLIPS_DIR, name, "dataset", new_category) + + if sanitized_name is None or old_folder is None or new_folder is None: + return invalid_name_response(name) if not os.path.exists(old_folder): return JSONResponse( @@ -859,7 +1076,6 @@ def rename_classification_category( # Mark dataset as ready to train by resetting training metadata # This ensures the dataset is marked as changed after renaming - sanitized_name = sanitize_filename(name) write_training_metadata(sanitized_name, 0) return JSONResponse( @@ -907,13 +1123,20 @@ def categorize_classification_image(request: Request, name: str, body: dict = No ) json: dict[str, Any] = body or {} - category = sanitize_filename(json.get("category", "")) - training_file_name = sanitize_filename(json.get("training_file", "")) - training_file = os.path.join( - CLIPS_DIR, sanitize_filename(name), "train", training_file_name + category = sanitize_path_component(json.get("category", "")) + training_file_name = json.get("training_file", "") + training_file = ( + safe_join(CLIPS_DIR, name, "train", training_file_name) + if training_file_name + else None ) - if training_file_name and not os.path.isfile(training_file): + if category is None: + return invalid_name_response(json.get("category", "")) + + if training_file_name and ( + training_file is None or not os.path.isfile(training_file) + ): return JSONResponse( content=( { @@ -927,9 +1150,10 @@ def categorize_classification_image(request: Request, name: str, body: dict = No random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) timestamp = datetime.datetime.now().timestamp() new_name = f"{category}-{timestamp}-{random_id}.png" - new_file_folder = os.path.join( - CLIPS_DIR, sanitize_filename(name), "dataset", category - ) + new_file_folder = safe_join(CLIPS_DIR, name, "dataset", category) + + if new_file_folder is None: + return invalid_name_response(name) os.makedirs(new_file_folder, exist_ok=True) @@ -967,9 +1191,10 @@ def create_classification_category(request: Request, name: str, category: str): status_code=404, ) - category_folder = os.path.join( - CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category) - ) + category_folder = safe_join(CLIPS_DIR, name, "dataset", category) + + if category_folder is None: + return invalid_name_response(category) os.makedirs(category_folder, exist_ok=True) @@ -1008,12 +1233,15 @@ def delete_classification_train_images(request: Request, name: str, body: dict = json: dict[str, Any] = body or {} list_of_ids = json.get("ids", "") - folder = os.path.join(CLIPS_DIR, sanitize_filename(name), "train") + folder = safe_join(CLIPS_DIR, name, "train") + + if folder is None: + return invalid_name_response(name) for id in list_of_ids: - file_path = os.path.join(folder, sanitize_filename(id)) + file_path = safe_join(folder, id) - if os.path.isfile(file_path): + if file_path and os.path.isfile(file_path): os.unlink(file_path) return JSONResponse( @@ -1030,7 +1258,11 @@ def delete_classification_train_images(request: Request, name: str, body: dict = ) async def generate_state_examples(request: Request, body: GenerateStateExamplesBody): """Generate examples for state classification.""" - model_name = sanitize_filename(body.model_name) + model_name = sanitize_path_component(body.model_name) + + if model_name is None: + return invalid_name_response(body.model_name) + cameras_normalized = { camera_name: tuple(crop) for camera_name, crop in body.cameras.items() @@ -1053,7 +1285,11 @@ async def generate_state_examples(request: Request, body: GenerateStateExamplesB ) async def generate_object_examples(request: Request, body: GenerateObjectExamplesBody): """Generate examples for object classification.""" - model_name = sanitize_filename(body.model_name) + model_name = sanitize_path_component(body.model_name) + + if model_name is None: + return invalid_name_response(body.model_name) + collect_object_classification_examples(model_name, body.label) return JSONResponse( @@ -1072,10 +1308,16 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample Returns a success message.""", ) def delete_classification_model(request: Request, name: str): - sanitized_name = sanitize_filename(name) + # This endpoint intentionally accepts models that are not in the config, so + # there is no allow list to fall back on. Both paths below are recursive + # deletes, so an unusable name has to be rejected outright. + data_dir = safe_join(CLIPS_DIR, name) + model_dir = safe_join(MODEL_CACHE_DIR, name) + + if data_dir is None or model_dir is None: + return invalid_name_response(name) # Delete the classification model's data directory in clips - data_dir = os.path.join(CLIPS_DIR, sanitized_name) if os.path.exists(data_dir): try: shutil.rmtree(data_dir) @@ -1084,7 +1326,6 @@ def delete_classification_model(request: Request, name: str): logger.debug(f"Failed to delete data directory for {name}: {e}") # Delete the classification model's files in model_cache - model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name) if os.path.exists(model_dir): try: shutil.rmtree(model_dir) diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py new file mode 100644 index 0000000000..0cb954af10 --- /dev/null +++ b/frigate/api/config_util.py @@ -0,0 +1,63 @@ +"""Shared helpers for applying a freshly parsed config to the running app.""" + +from fastapi import FastAPI + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) + + +def publish_camera_section_updates( + app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum +) -> None: + """Broadcast every camera's re-resolved value for a global section. + + Global sections are folded into each camera at parse time and the camera + copies are what workers read, so send them rather than leave a worker to + guess which cameras were inheriting. + """ + for camera_name, camera_config in config.cameras.items(): + settings = getattr(camera_config, update_type.name, None) + + if settings is None: + continue + + app.config_publisher.publish_update( + CameraConfigUpdateTopic(update_type, camera_name), settings + ) + + +def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: + """Point every long-lived collaborator at a newly parsed config object. + + Both /api/config/set and camera deletion re-parse yaml into a fresh + FrigateConfig and must rebind the same set of references, or the API and + the dispatcher drift onto different objects (the API reports one camera + state while the dispatcher acts on another). Runtime toggle overrides are + re-layered last: the swap rebuilt every camera from yaml, so without this a + camera the user turned off would silently come back on. + """ + app.frigate_config = config + + if app.config_holder is not None: + app.config_holder.set(config) + + app.genai_manager.update_config(config) + + if app.profile_manager is not None: + app.profile_manager.update_config(config) + + if app.stats_emitter is not None: + app.stats_emitter.config = config + + if app.dispatcher is not None: + app.dispatcher.config = config + + for comm in app.dispatcher.comms: + comm.config = config + + # workers still hold the live toggle values, so correct only the + # config object here rather than re-broadcasting every override + app.dispatcher.reapply_runtime_state_to_config() diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py new file mode 100644 index 0000000000..9973bad04c --- /dev/null +++ b/frigate/api/debug_replay.py @@ -0,0 +1,300 @@ +"""Debug replay API endpoints.""" + +import asyncio +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from peewee import DoesNotExist +from pydantic import BaseModel, Field + +from frigate.api.auth import require_role +from frigate.api.defs.tags import Tags +from frigate.jobs.debug_replay import ( + ExportDebugReplaySource, + NoRecordingsError, + RecordingDebugReplaySource, + start_debug_replay_job, +) +from frigate.models import Export +from frigate.util.services import get_video_properties + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.app]) + + +class DebugReplayStartBody(BaseModel): + """Request body for starting a debug replay session.""" + + camera: str = Field(title="Source camera name") + start_time: float = Field(title="Start timestamp") + end_time: float = Field(title="End timestamp") + + +class DebugReplayStartFromExportBody(BaseModel): + """Request body for starting a debug replay session from an export.""" + + export_id: str = Field(title="Export id") + + +class DebugReplayStartResponse(BaseModel): + """Response for starting a debug replay session.""" + + success: bool + replay_camera: str + job_id: str + + +class DebugReplayStatusResponse(BaseModel): + """Response for debug replay status. + + Returns only session-presence fields. Startup progress and error + details flow through the job_state WebSocket topic via the + debug_replay job (see frigate.jobs.debug_replay); the + Replay page subscribes there with useJobStatus("debug_replay"). + """ + + active: bool + replay_camera: str | None = None + source_camera: str | None = None + start_time: float | None = None + end_time: float | None = None + live_ready: bool = False + + +class DebugReplayStopResponse(BaseModel): + """Response for stopping a debug replay session.""" + + success: bool + + +@router.post( + "/debug_replay/start", + response_model=DebugReplayStartResponse, + status_code=202, + responses={ + 400: {"description": "Invalid camera or time range"}, + 404: {"description": "No recordings in the requested time range"}, + 409: {"description": "A replay session is already active"}, + }, + dependencies=[Depends(require_role(["admin"]))], + summary="Start debug replay", + description="Start a debug replay session from camera recordings. Returns " + "immediately while clip generation runs as a background job; subscribe " + "to the 'debug_replay' job_state WS topic to track progress.", +) +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: + job_id = await asyncio.to_thread( + start_debug_replay_job, + source=source, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + replay_manager=replay_manager, + ) + except RuntimeError: + return JSONResponse( + content={ + "success": False, + "message": "A replay session is already active", + }, + status_code=409, + ) + except NoRecordingsError: + return JSONResponse( + content={ + "success": False, + "message": "No recordings found in the selected time range", + }, + status_code=404, + ) + except ValueError: + logger.exception("Rejected debug replay start request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid debug replay parameters", + }, + status_code=400, + ) + + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_manager.replay_camera_name, + "job_id": job_id, + }, + status_code=202, + ) + + +@router.post( + "/debug_replay/start_from_export", + response_model=DebugReplayStartResponse, + status_code=202, + responses={ + 400: {"description": "Invalid export, time range, or no recordings"}, + 404: {"description": "Export not found"}, + 409: {"description": "A replay session is already active"}, + }, + dependencies=[Depends(require_role(["admin"]))], + summary="Start debug replay from an export", + description="Start a debug replay session covering an existing export's " + "time range. The end time is derived from the export's video duration.", +) +async def start_debug_replay_from_export( + request: Request, body: DebugReplayStartFromExportBody +): + """Start a debug replay session from an existing export.""" + try: + export: Export = Export.get(Export.id == body.export_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export not found"}, + status_code=404, + ) + + properties = await get_video_properties( + request.app.frigate_config.ffmpeg, export.video_path, get_duration=True + ) + duration = properties.get("duration", -1) + + if duration is None or duration <= 0: + return JSONResponse( + content={ + "success": False, + "message": "Could not determine export duration", + }, + status_code=400, + ) + + replay_manager = request.app.replay_manager + source = ExportDebugReplaySource(export=export, duration=float(duration)) + + try: + job_id = await asyncio.to_thread( + start_debug_replay_job, + source=source, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + replay_manager=replay_manager, + ) + except RuntimeError: + return JSONResponse( + content={ + "success": False, + "message": "A replay session is already active", + }, + status_code=409, + ) + except ValueError: + logger.exception("Rejected debug replay start request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid debug replay parameters", + }, + status_code=400, + ) + + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_manager.replay_camera_name, + "job_id": job_id, + }, + status_code=202, + ) + + +@router.get( + "/debug_replay/status", + response_model=DebugReplayStatusResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Get debug replay status", + description="Get the status of the current debug replay session.", +) +def get_debug_replay_status(request: Request): + """Get the current replay session status.""" + replay_manager = request.app.replay_manager + + live_ready = False + replay_camera = replay_manager.replay_camera_name + + if replay_manager.active and replay_camera: + frame_processor = request.app.detected_frames_processor + frame = ( + frame_processor.get_current_frame(replay_camera) + if frame_processor is not None + else None + ) + + if frame is not None: + frame_time = frame_processor.get_current_frame_time(replay_camera) + camera_config = request.app.frigate_config.cameras.get(replay_camera) + retry_interval = 10.0 + + if camera_config is not None: + retry_interval = float(camera_config.ffmpeg.retry_interval or 10) + + live_ready = datetime.now().timestamp() <= frame_time + retry_interval + + return DebugReplayStatusResponse( + active=replay_manager.active, + replay_camera=replay_camera, + source_camera=replay_manager.source_camera, + start_time=replay_manager.start_ts, + end_time=replay_manager.end_ts, + live_ready=live_ready, + ) + + +@router.post( + "/debug_replay/stop", + response_model=DebugReplayStopResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Stop debug replay", + description="Stop the active debug replay session and clean up all artifacts.", +) +async def stop_debug_replay(request: Request): + """Stop the active replay session.""" + replay_manager = request.app.replay_manager + + if not replay_manager.active: + return JSONResponse( + content={"success": False, "message": "No active replay session"}, + status_code=400, + ) + + try: + await asyncio.to_thread( + replay_manager.stop, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + ) + except (ValueError, RuntimeError, OSError) as e: + logger.error("Error stopping replay: %s", e) + return JSONResponse( + content={ + "success": False, + "message": "Failed to stop replay session due to an internal error.", + }, + status_code=500, + ) + + return DebugReplayStopResponse(success=True) diff --git a/frigate/api/defs/query/app_query_parameters.py b/frigate/api/defs/query/app_query_parameters.py index e182a6afda..626d39679b 100644 --- a/frigate/api/defs/query/app_query_parameters.py +++ b/frigate/api/defs/query/app_query_parameters.py @@ -1,12 +1,10 @@ -from typing import Optional - from pydantic import BaseModel class AppTimelineHourlyQueryParameters(BaseModel): - cameras: Optional[str] = "all" - labels: Optional[str] = "all" - after: Optional[float] = None - before: Optional[float] = None - limit: Optional[int] = 200 - timezone: Optional[str] = "utc" + cameras: str | None = "all" + labels: str | None = "all" + after: float | None = None + before: float | None = None + limit: int | None = 200 + timezone: str | None = "utc" diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 8e5a5391a9..06d0dfc3af 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -1,28 +1,26 @@ -from typing import Optional - from pydantic import BaseModel, Field DEFAULT_TIME_RANGE = "00:00,24:00" class EventsQueryParams(BaseModel): - camera: Optional[str] = "all" - cameras: Optional[str] = "all" - label: Optional[str] = "all" - labels: Optional[str] = "all" - sub_label: Optional[str] = "all" - sub_labels: Optional[str] = "all" - attributes: Optional[str] = "all" - zone: Optional[str] = "all" - zones: Optional[str] = "all" - limit: Optional[int] = 100 - after: Optional[float] = None - before: Optional[float] = None - time_range: Optional[str] = DEFAULT_TIME_RANGE - has_clip: Optional[int] = None - has_snapshot: Optional[int] = None - in_progress: Optional[int] = None - include_thumbnails: Optional[int] = Field( + camera: str | None = "all" + cameras: str | None = "all" + label: str | None = "all" + labels: str | None = "all" + sub_label: str | None = "all" + sub_labels: str | None = "all" + attributes: str | None = "all" + zone: str | None = "all" + zones: str | None = "all" + limit: int | None = 100 + after: float | None = None + before: float | None = None + time_range: str | None = DEFAULT_TIME_RANGE + has_clip: int | None = None + has_snapshot: int | None = None + in_progress: int | None = None + include_thumbnails: int | None = Field( 1, description=( "Deprecated. Thumbnail data is no longer included in the response. " @@ -30,25 +28,25 @@ class EventsQueryParams(BaseModel): ), deprecated=True, ) - favorites: Optional[int] = None - min_score: Optional[float] = None - max_score: Optional[float] = None - min_speed: Optional[float] = None - max_speed: Optional[float] = None - recognized_license_plate: Optional[str] = "all" - is_submitted: Optional[int] = None - min_length: Optional[float] = None - max_length: Optional[float] = None - event_id: Optional[str] = None - sort: Optional[str] = None - timezone: Optional[str] = "utc" + favorites: int | None = None + min_score: float | None = None + max_score: float | None = None + min_speed: float | None = None + max_speed: float | None = None + recognized_license_plate: str | None = "all" + is_submitted: int | None = None + min_length: float | None = None + max_length: float | None = None + event_id: str | None = None + sort: str | None = None + timezone: str | None = "utc" class EventsSearchQueryParams(BaseModel): - query: Optional[str] = None - event_id: Optional[str] = None - search_type: Optional[str] = "thumbnail" - include_thumbnails: Optional[int] = Field( + query: str | None = None + event_id: str | None = None + search_type: str | None = "thumbnail" + include_thumbnails: int | None = Field( 1, description=( "Deprecated. Thumbnail data is no longer included in the response. " @@ -56,28 +54,28 @@ class EventsSearchQueryParams(BaseModel): ), deprecated=True, ) - limit: Optional[int] = 50 - cameras: Optional[str] = "all" - labels: Optional[str] = "all" - sub_labels: Optional[str] = "all" - attributes: Optional[str] = "all" - zones: Optional[str] = "all" - after: Optional[float] = None - before: Optional[float] = None - time_range: Optional[str] = DEFAULT_TIME_RANGE - has_clip: Optional[bool] = None - has_snapshot: Optional[bool] = None - is_submitted: Optional[bool] = None - timezone: Optional[str] = "utc" - min_score: Optional[float] = None - max_score: Optional[float] = None - min_speed: Optional[float] = None - max_speed: Optional[float] = None - recognized_license_plate: Optional[str] = "all" - sort: Optional[str] = None + limit: int | None = 50 + cameras: str | None = "all" + labels: str | None = "all" + sub_labels: str | None = "all" + attributes: str | None = "all" + zones: str | None = "all" + after: float | None = None + before: float | None = None + time_range: str | None = DEFAULT_TIME_RANGE + has_clip: bool | None = None + has_snapshot: bool | None = None + is_submitted: bool | None = None + timezone: str | None = "utc" + min_score: float | None = None + max_score: float | None = None + min_speed: float | None = None + max_speed: float | None = None + recognized_license_plate: str | None = "all" + sort: str | None = None class EventsSummaryQueryParams(BaseModel): - timezone: Optional[str] = "utc" - has_clip: Optional[int] = None - has_snapshot: Optional[int] = None + timezone: str | None = "utc" + has_clip: int | None = None + has_snapshot: int | None = None diff --git a/frigate/api/defs/query/media_query_parameters.py b/frigate/api/defs/query/media_query_parameters.py index a16f0d53fd..f115685970 100644 --- a/frigate/api/defs/query/media_query_parameters.py +++ b/frigate/api/defs/query/media_query_parameters.py @@ -1,8 +1,6 @@ from enum import Enum -from typing import Optional, Union from pydantic import BaseModel -from pydantic.json_schema import SkipJsonSchema class Extension(str, Enum): @@ -18,45 +16,33 @@ class Extension(str, Enum): class MediaLatestFrameQueryParams(BaseModel): - bbox: Optional[int] = None - timestamp: Optional[int] = None - zones: Optional[int] = None - mask: Optional[int] = None - motion: Optional[int] = None - paths: Optional[int] = None - regions: Optional[int] = None - quality: Optional[int] = 70 - height: Optional[int] = None - store: Optional[int] = None + bbox: int | None = None + timestamp: int | None = None + zones: int | None = None + mask: int | None = None + motion: int | None = None + paths: int | None = None + regions: int | None = None + quality: int | None = 70 + height: int | None = None + store: int | None = None class MediaEventsSnapshotQueryParams(BaseModel): - download: Optional[bool] = False - timestamp: Optional[int] = None - bbox: Optional[int] = None - crop: Optional[int] = None - height: Optional[int] = None - quality: Optional[int] = 70 + download: bool | None = False + timestamp: int | None = None + bbox: int | None = None + crop: int | None = None + height: int | None = None + quality: int | None = None class MediaMjpegFeedQueryParams(BaseModel): fps: int = 3 height: int = 360 - bbox: Optional[int] = None - timestamp: Optional[int] = None - zones: Optional[int] = None - mask: Optional[int] = None - motion: Optional[int] = None - regions: Optional[int] = None - - -class MediaRecordingsSummaryQueryParams(BaseModel): - timezone: str = "utc" - cameras: Optional[str] = "all" - - -class MediaRecordingsAvailabilityQueryParams(BaseModel): - cameras: str = "all" - before: Union[float, SkipJsonSchema[None]] = None - after: Union[float, SkipJsonSchema[None]] = None - scale: int = 30 + bbox: int | None = None + timestamp: int | None = None + zones: int | None = None + mask: int | None = None + motion: int | None = None + regions: int | None = None diff --git a/frigate/api/defs/query/recordings_query_parameters.py b/frigate/api/defs/query/recordings_query_parameters.py new file mode 100644 index 0000000000..770da96bb8 --- /dev/null +++ b/frigate/api/defs/query/recordings_query_parameters.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from pydantic.json_schema import SkipJsonSchema + + +class MediaRecordingsSummaryQueryParams(BaseModel): + timezone: str = "utc" + cameras: str | None = "all" + + +class MediaRecordingsAvailabilityQueryParams(BaseModel): + cameras: str = "all" + before: float | SkipJsonSchema[None] = None + after: float | SkipJsonSchema[None] = None + scale: int = 30 + + +class RecordingsDeleteQueryParams(BaseModel): + keep: str | None = None + cameras: str | None = "all" diff --git a/frigate/api/defs/query/regenerate_query_parameters.py b/frigate/api/defs/query/regenerate_query_parameters.py index af50ada2c4..20d1016c41 100644 --- a/frigate/api/defs/query/regenerate_query_parameters.py +++ b/frigate/api/defs/query/regenerate_query_parameters.py @@ -1,13 +1,11 @@ -from typing import Optional - from pydantic import BaseModel, Field from frigate.events.types import RegenerateDescriptionEnum class RegenerateQueryParameters(BaseModel): - source: Optional[RegenerateDescriptionEnum] = RegenerateDescriptionEnum.thumbnails - force: Optional[bool] = Field( + source: RegenerateDescriptionEnum | None = RegenerateDescriptionEnum.thumbnails + force: bool | None = Field( default=False, description="Force (re)generating the description even if GenAI is disabled for this camera.", ) diff --git a/frigate/api/defs/query/review_query_parameters.py b/frigate/api/defs/query/review_query_parameters.py index 6d01d824d6..b16146a9bc 100644 --- a/frigate/api/defs/query/review_query_parameters.py +++ b/frigate/api/defs/query/review_query_parameters.py @@ -1,5 +1,3 @@ -from typing import Union - from pydantic import BaseModel from pydantic.json_schema import SkipJsonSchema @@ -10,11 +8,11 @@ class ReviewQueryParams(BaseModel): cameras: str = "all" labels: str = "all" zones: str = "all" - reviewed: Union[int, SkipJsonSchema[None]] = None - limit: Union[int, SkipJsonSchema[None]] = None - severity: Union[SeverityEnum, SkipJsonSchema[None]] = None - before: Union[float, SkipJsonSchema[None]] = None - after: Union[float, SkipJsonSchema[None]] = None + reviewed: int | SkipJsonSchema[None] = None + limit: int | SkipJsonSchema[None] = None + severity: SeverityEnum | SkipJsonSchema[None] = None + before: float | SkipJsonSchema[None] = None + after: float | SkipJsonSchema[None] = None class ReviewSummaryQueryParams(BaseModel): @@ -26,6 +24,6 @@ class ReviewSummaryQueryParams(BaseModel): class ReviewActivityMotionQueryParams(BaseModel): cameras: str = "all" - before: Union[float, SkipJsonSchema[None]] = None - after: Union[float, SkipJsonSchema[None]] = None + before: float | SkipJsonSchema[None] = None + after: float | SkipJsonSchema[None] = None scale: int = 30 diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index c4129d8da2..a331482703 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -1,23 +1,34 @@ -from typing import Any, Dict, Optional +from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field + +from frigate.config import GenAIProviderEnum class AppConfigSetBody(BaseModel): requires_restart: int = 1 update_topic: str | None = None - config_data: Optional[Dict[str, Any]] = None + config_data: dict[str, Any] | None = None + skip_save: bool = False + + +class GenAIProbeBody(BaseModel): + provider: GenAIProviderEnum + name: str | None = None + api_key: str | None = None + base_url: str | None = None + provider_options: dict[str, Any] = Field(default_factory=dict) class AppPutPasswordBody(BaseModel): password: str - old_password: Optional[str] = None + old_password: str | None = None class AppPostUsersBody(BaseModel): username: str password: str - role: Optional[str] = "viewer" + role: str | None = "viewer" class AppPostLoginBody(BaseModel): @@ -27,3 +38,24 @@ class AppPostLoginBody(BaseModel): class AppPutRoleBody(BaseModel): role: str + + +class CameraSetBody(BaseModel): + value: str = Field(..., description="The value to set for the feature") + + +class MediaSyncBody(BaseModel): + dry_run: bool = Field( + default=True, description="If True, only report orphans without deleting them" + ) + media_types: list[str] = Field( + default=["all"], + description="Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', 'recordings'", + ) + force: bool = Field( + default=False, description="If True, bypass safety threshold checks" + ) + verbose: bool = Field( + default=False, + description="If True, write full orphan file list to disk", + ) diff --git a/frigate/api/defs/request/batch_export_body.py b/frigate/api/defs/request/batch_export_body.py new file mode 100644 index 0000000000..24078bcc7c --- /dev/null +++ b/frigate/api/defs/request/batch_export_body.py @@ -0,0 +1,63 @@ +from pydantic import BaseModel, Field, model_validator + +MAX_BATCH_EXPORT_ITEMS = 50 + + +class BatchExportItem(BaseModel): + camera: str = Field(title="Camera name") + start_time: float = Field(title="Start time") + end_time: float = Field(title="End time") + image_path: str | None = Field( + default=None, + title="Existing thumbnail path", + description="Optional existing image to use as the export thumbnail", + ) + friendly_name: str | None = Field( + default=None, + title="Friendly name", + max_length=256, + description="Optional friendly name for this specific export item", + ) + client_item_id: str | None = Field( + default=None, + title="Client item ID", + max_length=128, + description="Optional opaque client identifier echoed back in results", + ) + + +class BatchExportBody(BaseModel): + items: list[BatchExportItem] = Field( + title="Items", + min_length=1, + max_length=MAX_BATCH_EXPORT_ITEMS, + description="List of export items. Each item has its own camera and time range.", + ) + export_case_id: str | None = Field( + default=None, + title="Export case ID", + max_length=30, + description=( + "Existing export case ID to assign all exports to. Attaching to an " + "existing case is temporarily admin-only until case-level ACLs exist." + ), + ) + new_case_name: str | None = Field( + default=None, + title="New case name", + max_length=100, + description="Name of a new export case to create when export_case_id is omitted", + ) + new_case_description: str | None = Field( + default=None, + title="New case description", + description="Optional description for a newly created export case", + ) + + @model_validator(mode="after") + def validate_case_target(self) -> "BatchExportBody": + for item in self.items: + if item.end_time <= item.start_time: + raise ValueError("end_time must be after start_time") + + return self diff --git a/frigate/api/defs/request/chat_body.py b/frigate/api/defs/request/chat_body.py new file mode 100644 index 0000000000..5ca674fbc8 --- /dev/null +++ b/frigate/api/defs/request/chat_body.py @@ -0,0 +1,61 @@ +"""Chat API request models.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ChatMessage(BaseModel): + """A single message in a chat conversation.""" + + role: str = Field( + description="Message role: 'user', 'assistant', 'system', or 'tool'" + ) + content: Any | None = Field( + default=None, + description=( + "Message content. Usually a string, but may be a multimodal content " + "list (e.g. text + image_url) or null for assistant turns that only " + "request tool calls." + ), + ) + tool_call_id: str | None = Field( + default=None, description="For tool messages, the ID of the tool call" + ) + name: str | None = Field( + default=None, description="For tool messages, the tool name" + ) + tool_calls: list[dict[str, Any]] | None = Field( + default=None, + description=( + "For assistant messages replayed from prior turns, the OpenAI-format " + "tool calls the model previously requested. Replaying these verbatim " + "keeps the conversation prefix byte-for-byte identical so the model " + "server's prompt cache hits on follow-up turns." + ), + ) + + +class ChatCompletionRequest(BaseModel): + """Request for chat completion with tool calling.""" + + messages: list[ChatMessage] = Field( + description="List of messages in the conversation" + ) + max_tool_iterations: int = Field( + default=5, + ge=1, + le=10, + description="Maximum number of tool call iterations (default: 5)", + ) + stream: bool = Field( + default=False, + description="If true, stream the final assistant response in the body as newline-delimited JSON.", + ) + enable_thinking: bool | None = 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." + ), + ) diff --git a/frigate/api/defs/request/classification_body.py b/frigate/api/defs/request/classification_body.py index fb6a7dd0fd..2aa90f4580 100644 --- a/frigate/api/defs/request/classification_body.py +++ b/frigate/api/defs/request/classification_body.py @@ -1,5 +1,3 @@ -from typing import Dict, List, Tuple - from pydantic import BaseModel, Field @@ -12,14 +10,14 @@ class AudioTranscriptionBody(BaseModel): class DeleteFaceImagesBody(BaseModel): - ids: List[str] = Field( + ids: list[str] = Field( description="List of image filenames to delete from the face folder" ) class GenerateStateExamplesBody(BaseModel): model_name: str = Field(description="Name of the classification model") - cameras: Dict[str, Tuple[float, float, float, float]] = Field( + cameras: dict[str, tuple[float, float, float, float]] = Field( description="Dictionary mapping camera names to normalized crop coordinates in [x1, y1, x2, y2] format (values 0-1)" ) diff --git a/frigate/api/defs/request/events_body.py b/frigate/api/defs/request/events_body.py index 50754e92ab..c7920ae897 100644 --- a/frigate/api/defs/request/events_body.py +++ b/frigate/api/defs/request/events_body.py @@ -1,5 +1,3 @@ -from typing import List, Optional, Union - from pydantic import BaseModel, Field from frigate.config.classification import TriggerType @@ -7,48 +5,47 @@ from frigate.config.classification import TriggerType class EventsSubLabelBody(BaseModel): subLabel: str = Field(title="Sub label", max_length=100) - subLabelScore: Optional[float] = Field( + subLabelScore: float | None = Field( title="Score for sub label", default=None, gt=0.0, le=1.0 ) - camera: Optional[str] = Field( - title="Camera this object is detected on.", default=None - ) + camera: str | None = Field(title="Camera this object is detected on.", default=None) class EventsLPRBody(BaseModel): recognizedLicensePlate: str = Field( title="Recognized License Plate", max_length=100 ) - recognizedLicensePlateScore: Optional[float] = Field( + recognizedLicensePlateScore: float | None = Field( title="Score for recognized license plate", default=None, gt=0.0, le=1.0 ) class EventsAttributesBody(BaseModel): - attributes: List[str] = Field( + attributes: list[str] = Field( title="Selected classification attributes for the event", default_factory=list, ) class EventsDescriptionBody(BaseModel): - description: Union[str, None] = Field(title="The description of the event") + description: str | None = Field(title="The description of the event") class EventsCreateBody(BaseModel): - sub_label: Optional[str] = None - score: Optional[float] = 0 - duration: Optional[int] = 30 - include_recording: Optional[bool] = True - draw: Optional[dict] = {} + sub_label: str | None = None + score: float | None = 0 + duration: int | None = 30 + include_recording: bool | None = True + draw: dict | None = {} + pre_capture: int | None = None class EventsEndBody(BaseModel): - end_time: Optional[float] = None + end_time: float | None = None class EventsDeleteBody(BaseModel): - event_ids: List[str] = Field(title="The event IDs to delete") + event_ids: list[str] = Field(title="The event IDs to delete") class SubmitPlusBody(BaseModel): diff --git a/frigate/api/defs/request/export_bulk_body.py b/frigate/api/defs/request/export_bulk_body.py new file mode 100644 index 0000000000..07283f81d4 --- /dev/null +++ b/frigate/api/defs/request/export_bulk_body.py @@ -0,0 +1,22 @@ +"""Request bodies for bulk export operations.""" + +from pydantic import BaseModel, Field, conlist, constr + + +class ExportBulkDeleteBody(BaseModel): + """Request body for bulk deleting exports.""" + + # List of export IDs with at least one element and each element with at least one char + ids: conlist(constr(min_length=1), min_length=1) + + +class ExportBulkReassignBody(BaseModel): + """Request body for bulk reassigning exports to a case.""" + + # List of export IDs with at least one element and each element with at least one char + ids: conlist(constr(min_length=1), min_length=1) + export_case_id: str | None = Field( + default=None, + max_length=30, + description="Case ID to assign to, or null to unassign from current case", + ) diff --git a/frigate/api/defs/request/export_case_body.py b/frigate/api/defs/request/export_case_body.py new file mode 100644 index 0000000000..c7b436f14b --- /dev/null +++ b/frigate/api/defs/request/export_case_body.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field + + +class ExportCaseCreateBody(BaseModel): + """Request body for creating a new export case.""" + + name: str = Field(max_length=100, description="Friendly name of the export case") + description: str | None = Field( + default=None, description="Optional description of the export case" + ) + + +class ExportCaseUpdateBody(BaseModel): + """Request body for updating an existing export case.""" + + name: str | None = Field( + default=None, + max_length=100, + description="Updated friendly name of the export case", + ) + description: str | None = Field( + default=None, description="Updated description of the export case" + ) diff --git a/frigate/api/defs/request/export_recordings_body.py b/frigate/api/defs/request/export_recordings_body.py index aef5aa9520..beb8f39962 100644 --- a/frigate/api/defs/request/export_recordings_body.py +++ b/frigate/api/defs/request/export_recordings_body.py @@ -1,29 +1,58 @@ -from typing import Optional, Union - from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema from frigate.record.export import ( ChaptersEnum, - PlaybackFactorEnum, PlaybackSourceEnum, ) class ExportRecordingsBody(BaseModel): - playback: PlaybackFactorEnum = Field( - default=PlaybackFactorEnum.realtime, title="Playback factor" - ) source: PlaybackSourceEnum = Field( default=PlaybackSourceEnum.recordings, title="Playback source" ) - name: Optional[str] = Field(title="Friendly name", default=None, max_length=256) - image_path: Union[str, SkipJsonSchema[None]] = None - chapters: Optional[ChaptersEnum] = Field( + name: str | None = Field(title="Friendly name", default=None, max_length=256) + image_path: str | SkipJsonSchema[None] = None + export_case_id: str | None = Field( + default=None, + title="Export case ID", + max_length=30, + description="ID of the export case to assign this export to", + ) + chapters: ChaptersEnum | None = Field( default=None, title="Chapter mode", description=( "Optional chapter metadata to embed in the export. When omitted, " - "no chapter track is added." + "the camera's configured export chapter mode is used." ), ) + + +class ExportRecordingsCustomBody(BaseModel): + source: PlaybackSourceEnum = Field( + default=PlaybackSourceEnum.recordings, title="Playback source" + ) + name: str = Field(title="Friendly name", default=None, max_length=256) + image_path: str | SkipJsonSchema[None] = None + export_case_id: str | None = Field( + default=None, + title="Export case ID", + max_length=30, + description="ID of the export case to assign this export to", + ) + ffmpeg_input_args: str | None = Field( + default=None, + title="FFmpeg input arguments", + description="Custom FFmpeg input arguments. If not provided, defaults to timelapse input args.", + ) + ffmpeg_output_args: str | None = Field( + default=None, + title="FFmpeg output arguments", + description="Custom FFmpeg output arguments. If not provided, defaults to timelapse output args.", + ) + cpu_fallback: bool = Field( + default=False, + title="CPU Fallback", + description="If true, retry export without hardware acceleration if the initial export fails.", + ) diff --git a/frigate/api/defs/response/chat_response.py b/frigate/api/defs/response/chat_response.py new file mode 100644 index 0000000000..3007c4c0f4 --- /dev/null +++ b/frigate/api/defs/response/chat_response.py @@ -0,0 +1,67 @@ +"""Chat API response models.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ToolCallInvocation(BaseModel): + """A tool call requested by the LLM (before execution).""" + + id: str = Field(description="Unique identifier for this tool call") + name: str = Field(description="Tool name to call") + arguments: dict[str, Any] = Field(description="Arguments for the tool call") + + +class ChatMessageResponse(BaseModel): + """A message in the chat response.""" + + role: str = Field(description="Message role") + content: str | None = Field( + default=None, description="Message content (None if tool calls present)" + ) + reasoning: str | None = Field( + default=None, + description="Separated reasoning/thinking trace if the model emitted one", + ) + tool_calls: list[ToolCallInvocation] | None = Field( + default=None, description="Tool calls if LLM wants to call tools" + ) + + +class ToolCall(BaseModel): + """A tool that was executed during the completion, with its response.""" + + name: str = Field(description="Tool name that was called") + arguments: dict[str, Any] = Field( + default_factory=dict, description="Arguments passed to the tool" + ) + response: str = Field( + default="", + description="The response or result returned from the tool execution", + ) + + +class ChatCompletionResponse(BaseModel): + """Response from chat completion.""" + + message: ChatMessageResponse = Field(description="The assistant's message") + finish_reason: str = Field( + description="Reason generation stopped: 'stop', 'tool_calls', 'length', 'error'" + ) + tool_iterations: int = Field( + default=0, description="Number of tool call iterations performed" + ) + tool_calls: list[ToolCall] = Field( + default_factory=list, + description="List of tool calls that were executed during this completion", + ) + messages: list[dict[str, Any]] = Field( + default_factory=list, + description=( + "The full conversation chain, including the system message. Persist " + "and replay this verbatim on the next request so the prompt prefix " + "stays byte-identical and the model server's prompt cache keeps " + "hitting." + ), + ) diff --git a/frigate/api/defs/response/classification_response.py b/frigate/api/defs/response/classification_response.py index 92d354f242..09cdfbc176 100644 --- a/frigate/api/defs/response/classification_response.py +++ b/frigate/api/defs/response/classification_response.py @@ -1,9 +1,7 @@ -from typing import Dict, List, Optional - from pydantic import BaseModel, Field, RootModel -class FacesResponse(RootModel[Dict[str, List[str]]]): +class FacesResponse(RootModel[dict[str, list[str]]]): """Response model for the get_faces endpoint. Returns a mapping of face names to lists of image filenames. @@ -17,7 +15,7 @@ class FacesResponse(RootModel[Dict[str, List[str]]]): } """ - root: Dict[str, List[str]] = Field( + root: dict[str, list[str]] = Field( default_factory=dict, description="Dictionary mapping face names to lists of image filenames", ) @@ -30,9 +28,9 @@ class FaceRecognitionResponse(BaseModel): """ success: bool = Field(description="Whether the face recognition was successful") - score: Optional[float] = Field( + score: float | None = Field( default=None, description="Confidence score of the recognition (0-1)" ) - face_name: Optional[str] = Field( + face_name: str | None = Field( default=None, description="The recognized face name if successful" ) diff --git a/frigate/api/defs/response/event_response.py b/frigate/api/defs/response/event_response.py index 083849706a..a366af8d1e 100644 --- a/frigate/api/defs/response/event_response.py +++ b/frigate/api/defs/response/event_response.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, ConfigDict @@ -6,20 +6,20 @@ from pydantic import BaseModel, ConfigDict class EventResponse(BaseModel): id: str label: str - sub_label: Optional[str] + sub_label: str | None camera: str start_time: float - end_time: Optional[float] - false_positive: Optional[bool] + end_time: float | None + false_positive: bool | None zones: list[str] - thumbnail: Optional[str] + thumbnail: str | None has_clip: bool has_snapshot: bool retain_indefinitely: bool - plus_id: Optional[str] - model_hash: Optional[str] - detector_type: Optional[str] - model_type: Optional[str] + plus_id: str | None + model_hash: str | None + detector_type: str | None + model_type: str | None data: dict[str, Any] model_config = ConfigDict(protected_namespaces=()) diff --git a/frigate/api/defs/response/export_case_response.py b/frigate/api/defs/response/export_case_response.py new file mode 100644 index 0000000000..199d5b6b76 --- /dev/null +++ b/frigate/api/defs/response/export_case_response.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field + + +class ExportCaseModel(BaseModel): + """Model representing a single export case.""" + + id: str = Field(description="Unique identifier for the export case") + name: str = Field(description="Friendly name of the export case") + description: str | None = Field( + default=None, description="Optional description of the export case" + ) + created_at: float = Field( + description="Unix timestamp when the export case was created" + ) + updated_at: float = Field( + description="Unix timestamp when the export case was last updated" + ) + + +ExportCasesResponse = list[ExportCaseModel] diff --git a/frigate/api/defs/response/export_response.py b/frigate/api/defs/response/export_response.py index 63a9e91a17..5e900599d3 100644 --- a/frigate/api/defs/response/export_response.py +++ b/frigate/api/defs/response/export_response.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any from pydantic import BaseModel, Field @@ -15,6 +15,9 @@ class ExportModel(BaseModel): in_progress: bool = Field( description="Whether the export is currently being processed" ) + export_case_id: str | None = Field( + default=None, description="ID of the export case this export belongs to" + ) class StartExportResponse(BaseModel): @@ -22,9 +25,99 @@ class StartExportResponse(BaseModel): success: bool = Field(description="Whether the export was started successfully") message: str = Field(description="Status or error message") - export_id: Optional[str] = Field( + export_id: str | None = Field( default=None, description="The export ID if successfully started" ) + status: str | None = Field( + default=None, + description="Queue status for the export job", + ) -ExportsResponse = List[ExportModel] +class BatchExportResultModel(BaseModel): + """Per-item result for a batch export request.""" + + camera: str = Field(description="Camera name for this export attempt") + export_id: str | None = Field( + default=None, + description="The export ID when the export was successfully queued", + ) + success: bool = Field(description="Whether the export was successfully queued") + status: str | None = Field( + default=None, + description="Queue status for this camera export", + ) + error: str | None = Field( + default=None, + description="Validation or queueing error for this item, if any", + ) + item_index: int | None = Field( + default=None, + description="Zero-based index of this result within the request items list", + ) + client_item_id: str | None = Field( + default=None, + description="Opaque client-supplied item identifier echoed from the request", + ) + + +class BatchExportResponse(BaseModel): + """Response model for starting an export batch.""" + + export_case_id: str | None = Field( + default=None, + description="Export case ID associated with the batch", + ) + export_ids: list[str] = Field(description="Export IDs successfully queued") + results: list[BatchExportResultModel] = Field( + description="Per-item batch export results" + ) + + +class ExportJobModel(BaseModel): + """Model representing a queued or running export job.""" + + id: str = Field(description="Unique identifier for the export job") + job_type: str = Field(description="Job type") + status: str = Field(description="Current job status") + camera: str = Field(description="Camera associated with this export job") + name: str | None = Field( + default=None, + description="Friendly name for the export", + ) + export_case_id: str | None = Field( + default=None, + description="ID of the export case this export belongs to", + ) + request_start_time: float = Field(description="Requested export start time") + request_end_time: float = Field(description="Requested export end time") + start_time: float | None = Field( + default=None, + description="Unix timestamp when execution started", + ) + end_time: float | None = Field( + default=None, + description="Unix timestamp when execution completed", + ) + error_message: str | None = Field( + default=None, + description="Error message for failed jobs", + ) + results: dict[str, Any] | None = Field( + default=None, + description="Result metadata for completed jobs", + ) + current_step: str = Field( + default="queued", + description="Current execution step (queued, preparing, encoding, encoding_retry, finalizing)", + ) + progress_percent: float = Field( + default=0.0, + description="Progress percentage of the current step (0.0 - 100.0)", + ) + + +ExportJobsResponse = list[ExportJobModel] + + +ExportsResponse = list[ExportModel] diff --git a/frigate/api/defs/response/preview_response.py b/frigate/api/defs/response/preview_response.py index d320a865da..70d2cac171 100644 --- a/frigate/api/defs/response/preview_response.py +++ b/frigate/api/defs/response/preview_response.py @@ -1,5 +1,3 @@ -from typing import List - from pydantic import BaseModel, Field @@ -13,5 +11,5 @@ class PreviewModel(BaseModel): end: float = Field(description="Unix timestamp when the preview ends") -PreviewsResponse = List[PreviewModel] -PreviewFramesResponse = List[str] +PreviewsResponse = list[PreviewModel] +PreviewFramesResponse = list[str] diff --git a/frigate/api/defs/response/review_response.py b/frigate/api/defs/response/review_response.py index b2fed3b1a6..a0b755bd60 100644 --- a/frigate/api/defs/response/review_response.py +++ b/frigate/api/defs/response/review_response.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Dict from pydantic import BaseModel, Json @@ -34,7 +33,7 @@ class DayReview(BaseModel): class ReviewSummaryResponse(BaseModel): last24Hours: Last24HoursReview - root: Dict[str, DayReview] + root: dict[str, DayReview] class ReviewActivityMotionResponse(BaseModel): diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index f804385d1f..c6f37b67f1 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -3,13 +3,16 @@ from enum import Enum class Tags(Enum): app = "App" + auth = "Auth" camera = "Camera" - preview = "Preview" + chat = "Chat" + events = "Events" + export = "Export" + classification = "Classification" logs = "Logs" media = "Media" + motion_search = "Motion Search" notifications = "Notifications" + preview = "Preview" + recordings = "Recordings" review = "Review" - export = "Export" - events = "Events" - classification = "Classification" - auth = "Auth" diff --git a/frigate/api/event.py b/frigate/api/event.py index c03cfb4314..1597c18856 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1,5 +1,6 @@ """Event apis.""" +import asyncio import base64 import datetime import json @@ -9,15 +10,12 @@ import random import string from functools import reduce from pathlib import Path -from typing import List from urllib.parse import unquote -import cv2 import numpy as np from fastapi import APIRouter, Request from fastapi.params import Depends from fastapi.responses import JSONResponse -from pathvalidate import sanitize_filename from peewee import JOIN, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict @@ -57,11 +55,12 @@ from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags from frigate.comms.event_metadata_updater import EventMetadataTypeEnum from frigate.config.classification import ObjectClassificationType -from frigate.const import CLIPS_DIR, TRIGGER_DIR +from frigate.const import CLIPS_DIR from frigate.embeddings import EmbeddingsContext from frigate.models import Event, ReviewSegment, Timeline, Trigger from frigate.track.object_processing import TrackedObject -from frigate.util.file import get_event_thumbnail_bytes +from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image +from frigate.util.path import get_trigger_thumbnail_path, safe_join from frigate.util.time import get_dst_transitions, get_tz_modifiers logger = logging.getLogger(__name__) @@ -97,7 +96,7 @@ def _build_attribute_filter_clause(attributes: str): ) def events( params: EventsQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): camera = params.camera cameras = params.cameras @@ -171,7 +170,7 @@ def events( ] if camera != "all": - clauses.append((Event.camera == camera)) + clauses.append(Event.camera == camera) if cameras != "all": requested = set(cameras.split(",")) @@ -181,11 +180,11 @@ def events( camera_list = list(filtered) else: camera_list = allowed_cameras - clauses.append((Event.camera << camera_list)) + clauses.append(Event.camera << camera_list) if labels != "all": label_list = labels.split(",") - clauses.append((Event.label << label_list)) + clauses.append(Event.label << label_list) if sub_labels != "all": # use matching so joined sub labels are included @@ -196,19 +195,24 @@ def events( if "None" in filtered_sub_labels: filtered_sub_labels.remove("None") - sub_label_clauses.append((Event.sub_label.is_null())) + sub_label_clauses.append(Event.sub_label.is_null()) for label in filtered_sub_labels: + lowered = label.lower() sub_label_clauses.append( - (Event.sub_label.cast("text") == label) - ) # include exact matches + fn.LOWER(Event.sub_label.cast("text")) == lowered + ) # include exact matches (case-insensitive) - # include this label when part of a list - sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) - sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) + # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII) + sub_label_clauses.append( + fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*" + ) + sub_label_clauses.append( + fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*" + ) sub_label_clause = reduce(operator.or_, sub_label_clauses) - clauses.append((sub_label_clause)) + clauses.append(sub_label_clause) if attributes != "all": # Custom classification results are stored as data[model_name] = result_value @@ -252,19 +256,19 @@ def events( if "None" in filtered_zones: filtered_zones.remove("None") - zone_clauses.append((Event.zones.length() == 0)) + zone_clauses.append(Event.zones.length() == 0) for zone in filtered_zones: - zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*')) + zone_clauses.append(Event.zones.cast("text") % f'*"{zone}"*') zone_clause = reduce(operator.or_, zone_clauses) - clauses.append((zone_clause)) + clauses.append(zone_clause) if after: - clauses.append((Event.start_time > after)) + clauses.append(Event.start_time > after) if before: - clauses.append((Event.start_time < before)) + clauses.append(Event.start_time < before) if time_range != DEFAULT_TIME_RANGE: # get timezone arg to ensure browser times are used @@ -284,62 +288,60 @@ def events( # should use or operator if time_after > time_before: clauses.append( - ( - reduce( - operator.or_, - [(start_hour_fun > time_after), (start_hour_fun < time_before)], - ) + reduce( + operator.or_, + [(start_hour_fun > time_after), (start_hour_fun < time_before)], ) ) # all other cases should be and operator else: - clauses.append((start_hour_fun > time_after)) - clauses.append((start_hour_fun < time_before)) + clauses.append(start_hour_fun > time_after) + clauses.append(start_hour_fun < time_before) if has_clip is not None: - clauses.append((Event.has_clip == has_clip)) + clauses.append(Event.has_clip == has_clip) if has_snapshot is not None: - clauses.append((Event.has_snapshot == has_snapshot)) + clauses.append(Event.has_snapshot == has_snapshot) if in_progress is not None: - clauses.append((Event.end_time.is_null(in_progress))) + clauses.append(Event.end_time.is_null(in_progress)) if include_thumbnails: selected_columns.append(Event.thumbnail) if favorites: - clauses.append((Event.retain_indefinitely == favorites)) + clauses.append(Event.retain_indefinitely == favorites) if max_score is not None: - clauses.append((Event.data["score"] <= max_score)) + clauses.append(Event.data["score"] <= max_score) if min_score is not None: - clauses.append((Event.data["score"] >= min_score)) + clauses.append(Event.data["score"] >= min_score) if max_speed is not None: - clauses.append((Event.data["average_estimated_speed"] <= max_speed)) + clauses.append(Event.data["average_estimated_speed"] <= max_speed) if min_speed is not None: - clauses.append((Event.data["average_estimated_speed"] >= min_speed)) + clauses.append(Event.data["average_estimated_speed"] >= min_speed) if min_length is not None: - clauses.append(((Event.end_time - Event.start_time) >= min_length)) + clauses.append((Event.end_time - Event.start_time) >= min_length) if max_length is not None: - clauses.append(((Event.end_time - Event.start_time) <= max_length)) + clauses.append((Event.end_time - Event.start_time) <= max_length) if is_submitted is not None: if is_submitted == 0: - clauses.append((Event.plus_id.is_null())) + clauses.append(Event.plus_id.is_null()) elif is_submitted > 0: - clauses.append((Event.plus_id != "")) + clauses.append(Event.plus_id != "") if event_id is not None: - clauses.append((Event.id == event_id)) + clauses.append(Event.id == event_id) if len(clauses) == 0: - clauses.append((True)) + clauses.append(True) if sort: if sort == "score_asc": @@ -382,7 +384,7 @@ def events( ) def events_explore( limit: int = 10, - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): # get distinct labels for all events distinct_labels = ( @@ -510,7 +512,7 @@ async def event_ids(ids: str, request: Request): def events_search( request: Request, params: EventsSearchQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): query = params.query search_type = params.search_type @@ -590,12 +592,12 @@ def events_search( filtered = requested.intersection(allowed_cameras) if not filtered: return JSONResponse(content=[]) - event_filters.append((Event.camera << list(filtered))) + event_filters.append(Event.camera << list(filtered)) else: - event_filters.append((Event.camera << allowed_cameras)) + event_filters.append(Event.camera << allowed_cameras) if labels != "all": - event_filters.append((Event.label << labels.split(","))) + event_filters.append(Event.label << labels.split(",")) if sub_labels != "all": # use matching so joined sub labels are included @@ -606,18 +608,23 @@ def events_search( if "None" in filtered_sub_labels: filtered_sub_labels.remove("None") - sub_label_clauses.append((Event.sub_label.is_null())) + sub_label_clauses.append(Event.sub_label.is_null()) for label in filtered_sub_labels: + lowered = label.lower() sub_label_clauses.append( - (Event.sub_label.cast("text") == label) - ) # include exact matches + fn.LOWER(Event.sub_label.cast("text")) == lowered + ) # include exact matches (case-insensitive) - # include this label when part of a list - sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) - sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) + # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII) + sub_label_clauses.append( + fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*" + ) + sub_label_clauses.append( + fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*" + ) - event_filters.append((reduce(operator.or_, sub_label_clauses))) + event_filters.append(reduce(operator.or_, sub_label_clauses)) if attributes != "all": # Custom classification results are stored as data[model_name] = result_value @@ -631,12 +638,12 @@ def events_search( if "None" in filtered_zones: filtered_zones.remove("None") - zone_clauses.append((Event.zones.length() == 0)) + zone_clauses.append(Event.zones.length() == 0) for zone in filtered_zones: - zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*')) + zone_clauses.append(Event.zones.cast("text") % f'*"{zone}"*') - event_filters.append((reduce(operator.or_, zone_clauses))) + event_filters.append(reduce(operator.or_, zone_clauses)) if recognized_license_plate != "all": filtered_recognized_license_plates = recognized_license_plate.split(",") @@ -664,43 +671,43 @@ def events_search( ) recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates) - event_filters.append((recognized_license_plate_clause)) + event_filters.append(recognized_license_plate_clause) if after: - event_filters.append((Event.start_time > after)) + event_filters.append(Event.start_time > after) if before: - event_filters.append((Event.start_time < before)) + event_filters.append(Event.start_time < before) if has_clip is not None: - event_filters.append((Event.has_clip == has_clip)) + event_filters.append(Event.has_clip == has_clip) if has_snapshot is not None: - event_filters.append((Event.has_snapshot == has_snapshot)) + event_filters.append(Event.has_snapshot == has_snapshot) if is_submitted is not None: if is_submitted == 0: - event_filters.append((Event.plus_id.is_null())) + event_filters.append(Event.plus_id.is_null()) elif is_submitted > 0: - event_filters.append((Event.plus_id != "")) + event_filters.append(Event.plus_id != "") if min_score is not None and max_score is not None: - event_filters.append((Event.data["score"].between(min_score, max_score))) + event_filters.append(Event.data["score"].between(min_score, max_score)) else: if min_score is not None: - event_filters.append((Event.data["score"] >= min_score)) + event_filters.append(Event.data["score"] >= min_score) if max_score is not None: - event_filters.append((Event.data["score"] <= max_score)) + event_filters.append(Event.data["score"] <= max_score) if min_speed is not None and max_speed is not None: event_filters.append( - (Event.data["average_estimated_speed"].between(min_speed, max_speed)) + Event.data["average_estimated_speed"].between(min_speed, max_speed) ) else: if min_speed is not None: - event_filters.append((Event.data["average_estimated_speed"] >= min_speed)) + event_filters.append(Event.data["average_estimated_speed"] >= min_speed) if max_speed is not None: - event_filters.append((Event.data["average_estimated_speed"] <= max_speed)) + event_filters.append(Event.data["average_estimated_speed"] <= max_speed) if time_range != DEFAULT_TIME_RANGE: tz_name = params.timezone @@ -718,17 +725,15 @@ def events_search( # should use or operator if time_after > time_before: event_filters.append( - ( - reduce( - operator.or_, - [(start_hour_fun > time_after), (start_hour_fun < time_before)], - ) + reduce( + operator.or_, + [(start_hour_fun > time_after), (start_hour_fun < time_before)], ) ) # all other cases should be and operator else: - event_filters.append((start_hour_fun > time_after)) - event_filters.append((start_hour_fun < time_before)) + event_filters.append(start_hour_fun > time_after) + event_filters.append(start_hour_fun < time_before) # Perform semantic search search_results = {} @@ -744,6 +749,15 @@ def events_search( status_code=404, ) + if search_event.camera not in allowed_cameras: + return JSONResponse( + content={ + "success": False, + "message": "Event not found", + }, + status_code=404, + ) + thumb_result = context.search_thumbnail(search_event) thumb_ids = {result[0]: result[1] for result in thumb_result} search_results = { @@ -875,7 +889,7 @@ def events_search( @router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())]) def events_summary( params: EventsSummaryQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): tz_name = params.timezone has_clip = params.has_clip @@ -884,13 +898,13 @@ def events_summary( clauses = [] if has_clip is not None: - clauses.append((Event.has_clip == has_clip)) + clauses.append(Event.has_clip == has_clip) if has_snapshot is not None: - clauses.append((Event.has_snapshot == has_snapshot)) + clauses.append(Event.has_snapshot == has_snapshot) if len(clauses) == 0: - clauses.append((True)) + clauses.append(True) time_range_query = ( Event.select( @@ -1081,30 +1095,8 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N content=({"success": False, "message": message}), status_code=400 ) - # load clean.webp or clean.png (legacy) try: - filename_webp = f"{event.camera}-{event.id}-clean.webp" - filename_png = f"{event.camera}-{event.id}-clean.png" - - image_path = None - if os.path.exists(os.path.join(CLIPS_DIR, filename_webp)): - image_path = os.path.join(CLIPS_DIR, filename_webp) - elif os.path.exists(os.path.join(CLIPS_DIR, filename_png)): - image_path = os.path.join(CLIPS_DIR, filename_png) - - if image_path is None: - logger.error(f"Unable to find clean snapshot for event: {event.id}") - return JSONResponse( - content=( - { - "success": False, - "message": "Unable to find clean snapshot for event", - } - ), - status_code=400, - ) - - image = cv2.imread(image_path) + image, is_clean_snapshot = load_event_snapshot_image(event, clean_only=True) except Exception: logger.error(f"Unable to load clean snapshot for event: {event.id}") return JSONResponse( @@ -1114,17 +1106,22 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N status_code=400, ) - if image is None or image.size == 0: - logger.error(f"Unable to load clean snapshot for event: {event.id}") + if not is_clean_snapshot or image is None or image.size == 0: + logger.error(f"Unable to find clean snapshot for event: {event.id}") return JSONResponse( content=( - {"success": False, "message": "Unable to load clean snapshot for event"} + { + "success": False, + "message": "Unable to find clean snapshot for event", + } ), status_code=400, ) try: - plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera) + plus_id = await asyncio.to_thread( + request.app.frigate_config.plus_api.upload_image, image, event.camera + ) except Exception as ex: logger.exception(ex) return JSONResponse( @@ -1140,7 +1137,8 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N box = event.data["box"] try: - request.app.frigate_config.plus_api.add_annotation( + await asyncio.to_thread( + request.app.frigate_config.plus_api.add_annotation, event.plus_id, box, event.label, @@ -1230,7 +1228,8 @@ async def false_positive(request: Request, event_id: str): ) try: - request.app.frigate_config.plus_api.add_false_positive( + await asyncio.to_thread( + request.app.frigate_config.plus_api.add_false_positive, event.plus_id, region, box, @@ -1453,10 +1452,10 @@ async def set_attributes( continue # Get available labels from dataset directory - dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset") + dataset_dir = safe_join(CLIPS_DIR, model_key, "dataset") available_labels = set() - if os.path.exists(dataset_dir): + if dataset_dir and os.path.exists(dataset_dir): for category_name in os.listdir(dataset_dir): category_dir = os.path.join(dataset_dir, category_name) if os.path.isdir(category_dir): @@ -1539,15 +1538,18 @@ async def set_description( event.data["description"] = new_description event.save() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: if len(new_description) > 0: - context.update_description( - event_id, - new_description, - ) + # If semantic search is enabled, update the index + if request.app.frigate_config.semantic_search.enabled: + context.update_description( + event_id, + new_description, + ) else: + # embeddings are always cleaned up so they don't outlive their description context.db.delete_embeddings_description(event_ids=[event_id]) response_message = ( @@ -1676,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict: event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + # embeddings are always cleaned up, even when semantic search is disabled, + # so that they don't outlive their events + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) @@ -1744,6 +1748,7 @@ async def delete_events(request: Request, body: EventsDeleteBody): NOTES: - Creating a manual event does not trigger an update to /events MQTT topic. - If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end. + - The review item is an alert unless the label is listed in the camera's review -> detections -> labels config. """, ) def create_event( @@ -1782,6 +1787,7 @@ def create_event( body.duration, "api", body.draw, + body.pre_capture, ), EventMetadataTypeEnum.manual_event_create.value, ) @@ -1953,18 +1959,13 @@ def create_trigger_embedding( if body.type == "thumbnail": # Save image to the triggers directory try: - os.makedirs( - os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)), - exist_ok=True, - ) - with open( - os.path.join( - TRIGGER_DIR, - sanitize_filename(camera_name), - f"{sanitize_filename(body.data)}.webp", - ), - "wb", - ) as f: + webp_path = get_trigger_thumbnail_path(camera_name, body.data) + + if webp_path is None: + raise ValueError(f"Invalid trigger thumbnail path for {body.data}") + + os.makedirs(os.path.dirname(webp_path), exist_ok=True) + with open(webp_path, "wb") as f: f.write(thumbnail) logger.debug( f"Writing thumbnail for trigger with data {body.data} in {camera_name}." @@ -2036,10 +2037,16 @@ def update_trigger_embedding( if body.type == "description": embedding = context.generate_description_embedding(body.data) elif body.type == "thumbnail": - webp_file = sanitize_filename(body.data) + ".webp" - webp_path = os.path.join( - TRIGGER_DIR, sanitize_filename(camera_name), webp_file - ) + webp_path = get_trigger_thumbnail_path(camera_name, body.data) + + if webp_path is None: + return JSONResponse( + content={ + "success": False, + "message": f"Invalid data for {body.type} trigger", + }, + status_code=400, + ) try: event: Event = Event.get(Event.id == body.data) @@ -2096,13 +2103,14 @@ def update_trigger_embedding( # Update existing trigger if trigger.data != body.data: # Delete old thumbnail only if data changes try: - os.remove( - os.path.join( - TRIGGER_DIR, - sanitize_filename(camera_name), - f"{trigger.data}.webp", + old_path = get_trigger_thumbnail_path(camera_name, trigger.data) + + if old_path is None: + raise ValueError( + f"Invalid trigger thumbnail path for {trigger.data}" ) - ) + + os.remove(old_path) logger.debug( f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." ) @@ -2136,12 +2144,13 @@ def update_trigger_embedding( if body.type == "thumbnail": # Save image to the triggers directory try: - camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)) - os.makedirs(camera_path, exist_ok=True) - with open( - os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"), - "wb", - ) as f: + thumbnail_path = get_trigger_thumbnail_path(camera_name, body.data) + + if thumbnail_path is None: + raise ValueError(f"Invalid trigger thumbnail path for {body.data}") + + os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) + with open(thumbnail_path, "wb") as f: f.write(thumbnail) logger.debug( f"Writing thumbnail for trigger with data {body.data} in {camera_name}." @@ -2212,11 +2221,12 @@ def delete_trigger_embedding( ) try: - os.remove( - os.path.join( - TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp" - ) - ) + thumbnail_path = get_trigger_thumbnail_path(camera_name, trigger.data) + + if thumbnail_path is None: + raise ValueError(f"Invalid trigger thumbnail path for {trigger.data}") + + os.remove(thumbnail_path) logger.debug( f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." ) diff --git a/frigate/api/export.py b/frigate/api/export.py index 8f916eaf17..817dbe7ba8 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -1,27 +1,55 @@ """Export apis.""" +import datetime import logging import random import string +import time +import zipfile +from collections import deque +from collections.abc import Iterator from pathlib import Path -from typing import List +from urllib.parse import quote import psutil -from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse -from pathvalidate import sanitize_filepath +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse, StreamingResponse +from pathvalidate import sanitize_filename from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.auth import ( allow_any_authenticated, get_allowed_cameras_for_filter, + get_current_user, require_camera_access, require_role, ) -from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody +from frigate.api.defs.request.batch_export_body import ( + BatchExportBody, + BatchExportItem, +) +from frigate.api.defs.request.export_bulk_body import ( + ExportBulkDeleteBody, + ExportBulkReassignBody, +) +from frigate.api.defs.request.export_case_body import ( + ExportCaseCreateBody, + ExportCaseUpdateBody, +) +from frigate.api.defs.request.export_recordings_body import ( + ExportRecordingsBody, + ExportRecordingsCustomBody, +) from frigate.api.defs.request.export_rename_body import ExportRenameBody +from frigate.api.defs.response.export_case_response import ( + ExportCaseModel, + ExportCasesResponse, +) from frigate.api.defs.response.export_response import ( + BatchExportResponse, + ExportJobModel, + ExportJobsResponse, ExportModel, ExportsResponse, StartExportResponse, @@ -29,12 +57,24 @@ from frigate.api.defs.response.export_response import ( from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR -from frigate.models import Export, Previews, Recordings -from frigate.record.export import ( - PlaybackFactorEnum, - PlaybackSourceEnum, - RecordingExporter, +from frigate.jobs.export import ( + ExportJob, + ExportQueueFullError, + available_export_queue_slots, + cancel_queued_export_jobs_for_case, + get_export_job, + list_active_export_jobs, + start_export_job, ) +from frigate.models import Export, ExportCase, Previews, Recordings +from frigate.record.export import ( + DEFAULT_TIME_LAPSE_FFMPEG_ARGS, + DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS, + ChaptersEnum, + PlaybackSourceEnum, + validate_ffmpeg_args, +) +from frigate.util.path import sanitize_contained_path from frigate.util.time import is_current_hour logger = logging.getLogger(__name__) @@ -42,6 +82,214 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.export]) +def _generate_id(length: int = 12) -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def _generate_export_id(camera_name: str) -> str: + return f"{camera_name}_{_generate_id(6)}" + + +def _create_export_case_record( + name: str, + description: str | None, +) -> ExportCase: + now = datetime.datetime.fromtimestamp(time.time()) + return ExportCase.create( + id=_generate_id(), + name=name, + description=description, + created_at=now, + updated_at=now, + ) + + +def _validate_camera_name(request: Request, camera_name: str) -> JSONResponse | None: + if camera_name and request.app.frigate_config.cameras.get(camera_name): + return None + + return JSONResponse( + content={"success": False, "message": f"{camera_name} is not a valid camera."}, + status_code=404, + ) + + +def _validate_export_case(export_case_id: str | None) -> JSONResponse | None: + if export_case_id is None: + return None + + try: + ExportCase.get(ExportCase.id == export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + return None + + +def _sanitize_existing_image( + image_path: str | None, +) -> tuple[str | None, JSONResponse | None]: + if not image_path: + return None, None + + existing_image = sanitize_contained_path(image_path, CLIPS_DIR) + + if existing_image is None: + return None, JSONResponse( + content={"success": False, "message": "Invalid image path"}, + status_code=400, + ) + + return existing_image, None + + +def _validate_export_source( + camera_name: str, + start_time: float, + end_time: float, + playback_source: PlaybackSourceEnum, +) -> str | None: + if playback_source == PlaybackSourceEnum.recordings: + recordings_count = ( + Recordings.select() + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ( + (start_time > Recordings.start_time) + & (end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .count() + ) + + if recordings_count <= 0: + return "No recordings found for time range" + + return None + + previews_count = ( + Previews.select() + .where( + Previews.start_time.between(start_time, end_time) + | Previews.end_time.between(start_time, end_time) + | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .count() + ) + + if not is_current_hour(start_time) and previews_count <= 0: + return "No previews found for time range" + + return None + + +def _get_item_recording_export_errors( + request: Request, + items: list[BatchExportItem], +) -> dict[int, str]: + """Return {item_index: error message} for items with invalid state. + + Checks camera configuration and recording presence per item. Groups by + camera and issues one query per unique camera covering that camera's + full requested range, then checks each item's range against the returned + rows in Python. This avoids O(N) DB round-trips on large batches. + """ + configured_cameras = request.app.frigate_config.cameras + errors: dict[int, str] = {} + + # Validate camera configuration first + item_ranges_by_camera: dict[str, list[tuple[int, float, float]]] = {} + for index, item in enumerate(items): + if not configured_cameras.get(item.camera): + errors[index] = f"{item.camera} is not a valid camera." + continue + item_ranges_by_camera.setdefault(item.camera, []).append( + (index, item.start_time, item.end_time) + ) + + if not item_ranges_by_camera: + return errors + + # For each camera, fetch recordings that cover the union of ranges + for camera_name, indexed_ranges in item_ranges_by_camera.items(): + min_start = min(r[1] for r in indexed_ranges) + max_end = max(r[2] for r in indexed_ranges) + + recording_ranges = list( + Recordings.select(Recordings.start_time, Recordings.end_time) + .where( + Recordings.camera == camera_name, + Recordings.start_time.between(min_start, max_end) + | Recordings.end_time.between(min_start, max_end) + | ( + (min_start > Recordings.start_time) + & (max_end < Recordings.end_time) + ), + ) + .iterator() + ) + + for index, start_time, end_time in indexed_ranges: + has_recording = any( + ( + start_time <= rec.start_time <= end_time + or start_time <= rec.end_time <= end_time + or (start_time > rec.start_time and end_time < rec.end_time) + ) + for rec in recording_ranges + ) + if not has_recording: + errors[index] = "No recordings found for time range" + + return errors + + +def _build_export_job( + camera_name: str, + start_time: float, + end_time: float, + friendly_name: str | None, + existing_image: str | None, + playback_source: PlaybackSourceEnum, + export_case_id: str | None, + ffmpeg_input_args: str | None = None, + ffmpeg_output_args: str | None = None, + cpu_fallback: bool = False, + chapters: ChaptersEnum | None = None, +) -> ExportJob: + return ExportJob( + id=_generate_export_id(camera_name), + camera=camera_name, + name=friendly_name, + image_path=existing_image, + export_case_id=export_case_id, + request_start_time=int(start_time), + request_end_time=int(end_time), + playback_source=playback_source.value, + ffmpeg_input_args=ffmpeg_input_args, + ffmpeg_output_args=ffmpeg_output_args, + cpu_fallback=cpu_fallback, + chapters=chapters, + ) + + +def _export_case_to_dict(case: ExportCase) -> dict[str, object]: + case_dict = model_to_dict(case) + + for field in ("created_at", "updated_at"): + value = case_dict.get(field) + if isinstance(value, datetime.datetime): + case_dict[field] = value.timestamp() + + return case_dict + + @router.get( "/exports", response_model=ExportsResponse, @@ -51,18 +299,507 @@ router = APIRouter(tags=[Tags.export]) Returns a list of exports ordered by date (most recent first).""", ) def get_exports( - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), + export_case_id: str | None = None, + cameras: str | None = Query(default="all"), + start_date: float | None = None, + end_date: float | None = None, ): - exports = ( - Export.select() - .where(Export.camera << allowed_cameras) - .order_by(Export.date.desc()) - .dicts() - .iterator() - ) + query = Export.select().where(Export.camera << allowed_cameras) + + if export_case_id is not None: + if export_case_id == "unassigned": + query = query.where(Export.export_case.is_null(True)) + else: + query = query.where(Export.export_case == export_case_id) + + if cameras and cameras != "all": + requested = set(cameras.split(",")) + filtered_cameras = list(requested.intersection(allowed_cameras)) + if not filtered_cameras: + return JSONResponse(content=[]) + query = query.where(Export.camera << filtered_cameras) + + if start_date is not None: + query = query.where(Export.date >= start_date) + + if end_date is not None: + query = query.where(Export.date <= end_date) + + exports = query.order_by(Export.date.desc()).dicts().iterator() return JSONResponse(content=[e for e in exports]) +@router.get( + "/cases", + response_model=ExportCasesResponse, + dependencies=[Depends(allow_any_authenticated())], + summary="Get export cases", + description="Gets all export cases from the database.", +) +def get_export_cases(): + cases = ExportCase.select().order_by(ExportCase.created_at.desc()).iterator() + return JSONResponse(content=[_export_case_to_dict(case) for case in cases]) + + +@router.post( + "/cases", + response_model=ExportCaseModel, + dependencies=[Depends(require_role(["admin"]))], + summary="Create export case", + description="Creates a new export case.", +) +def create_export_case(body: ExportCaseCreateBody): + case = _create_export_case_record(body.name, body.description) + return JSONResponse(content=_export_case_to_dict(case)) + + +@router.get( + "/cases/{case_id}", + response_model=ExportCaseModel, + dependencies=[Depends(allow_any_authenticated())], + summary="Get a single export case", + description="Gets a specific export case by ID.", +) +def get_export_case(case_id: str): + try: + case = ExportCase.get(ExportCase.id == case_id) + return JSONResponse(content=_export_case_to_dict(case)) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + +_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB + + +class _StreamingZipBuffer: + """File-like sink for ZipFile that exposes written bytes via drain(). + + ZipFile writes synchronously into this buffer; the generator drains the + queue between writes so StreamingResponse can yield bytes without + materializing the whole archive in memory. + """ + + def __init__(self) -> None: + self._queue: deque[bytes] = deque() + self._offset = 0 + + def write(self, data: bytes) -> int: + if data: + self._queue.append(bytes(data)) + self._offset += len(data) + return len(data) + + def tell(self) -> int: + return self._offset + + def flush(self) -> None: + pass + + def drain(self) -> Iterator[bytes]: + while self._queue: + yield self._queue.popleft() + + +def _unique_archive_name(export: Export, used: set[str]) -> str: + base = sanitize_filename(export.name) if export.name else None + if not base: + base = f"{export.camera}_{int(export.date)}" + + candidate = f"{base}.mp4" + counter = 1 + while candidate in used: + candidate = f"{base}_{counter}.mp4" + counter += 1 + + used.add(candidate) + return candidate + + +def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]: + """Yield bytes of a zip archive built from the given exports' mp4 files.""" + buffer = _StreamingZipBuffer() + used_names: set[str] = set() + + # ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win. + with zipfile.ZipFile( + buffer, + mode="w", + compression=zipfile.ZIP_STORED, + allowZip64=True, + ) as archive: + for export in exports: + source = Path(export.video_path) + if not source.exists(): + continue + + arcname = _unique_archive_name(export, used_names) + + with ( + archive.open(arcname, mode="w", force_zip64=True) as entry, + source.open("rb") as src, + ): + while True: + chunk = src.read(_ZIP_STREAM_CHUNK_SIZE) + if not chunk: + break + + entry.write(chunk) + yield from buffer.drain() + + yield from buffer.drain() + + yield from buffer.drain() + + +def _content_disposition(filename: str, ascii_fallback: str) -> str: + """Build an attachment Content-Disposition that survives non-ASCII names. + + Header values are encoded as latin-1, so a name outside that range cannot + go in filename at all. RFC 6266 handles this with a pair: a plain ASCII + filename for old clients, plus a percent-encoded UTF-8 filename* that + every current browser prefers. + """ + ascii_name = filename if filename.isascii() else ascii_fallback + + return ( + f'attachment; filename="{ascii_name}"; ' + f"filename*=UTF-8''{quote(filename, safe='')}" + ) + + +@router.get( + "/cases/{case_id}/download", + dependencies=[Depends(allow_any_authenticated())], + summary="Download export case as zip", + description="Streams a zip archive containing every completed export's mp4 for the given case.", +) +def download_export_case( + case_id: str, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + try: + case = ExportCase.get(ExportCase.id == case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + exports = list( + Export.select() + .where( + Export.export_case == case_id, + ~Export.in_progress, + Export.camera << allowed_cameras, + ) + .order_by(Export.date.asc()) + ) + + if not exports: + return JSONResponse( + content={"success": False, "message": "No exports available to download."}, + status_code=404, + ) + + archive_base = sanitize_filename(case.name) if case.name else "" + if not archive_base: + archive_base = case_id + + return StreamingResponse( + _stream_case_archive(exports), + media_type="application/zip", + headers={ + "Content-Disposition": _content_disposition( + f"{archive_base}.zip", f"{case_id}.zip" + ), + }, + ) + + +@router.patch( + "/cases/{case_id}", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Update export case", + description="Updates an existing export case.", +) +def update_export_case(case_id: str, body: ExportCaseUpdateBody): + try: + case = ExportCase.get(ExportCase.id == case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + if body.name is not None: + case.name = body.name + if body.description is not None: + case.description = body.description + + case.updated_at = datetime.datetime.fromtimestamp(time.time()) + + case.save() + + return JSONResponse( + content={"success": True, "message": "Successfully updated export case."} + ) + + +@router.delete( + "/cases/{case_id}", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Delete export case", + description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """, +) +def delete_export_case(case_id: str, request: Request, delete_exports: bool = False): + try: + case = ExportCase.get(ExportCase.id == case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + if delete_exports: + cancel_queued_export_jobs_for_case(request.app.frigate_config, case_id) + + exports = list(Export.select().where(Export.export_case == case_id)) + for export in exports: + Path(export.video_path).unlink(missing_ok=True) + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + export.delete_instance() + else: + # Unassign exports from this case but keep the exports themselves + Export.update(export_case=None).where(Export.export_case == case_id).execute() + + case.delete_instance() + + return JSONResponse( + content={"success": True, "message": "Successfully deleted export case."} + ) + + +@router.get( + "/jobs/export", + response_model=ExportJobsResponse, + dependencies=[Depends(allow_any_authenticated())], + summary="Get active export jobs", + description="Gets queued and running export jobs.", +) +def get_active_export_jobs( + request: Request, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + jobs = list_active_export_jobs(request.app.frigate_config) + return JSONResponse( + content=[job.to_dict() for job in jobs if job.camera in allowed_cameras] + ) + + +@router.get( + "/jobs/export/{export_id}", + response_model=ExportJobModel, + dependencies=[Depends(allow_any_authenticated())], + summary="Get export job status", + description="Gets queued, running, or completed status for a specific export job.", +) +async def get_export_job_status(export_id: str, request: Request): + job = get_export_job(request.app.frigate_config, export_id) + if job is None: + return JSONResponse( + content={"success": False, "message": "Job not found"}, + status_code=404, + ) + + await require_camera_access(job.camera, request=request) + + return JSONResponse(content=job.to_dict()) + + +@router.post( + "/exports/batch", + response_model=BatchExportResponse, + dependencies=[Depends(allow_any_authenticated())], + summary="Start recording export batch", + description=( + "Starts recording exports for a batch of items, each with its own camera " + "and time range, and assigns them to a single export case. Attaching to " + "an existing case is temporarily admin-only until case-level ACLs exist." + ), +) +def export_recordings_batch( + request: Request, + body: BatchExportBody, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), + current_user: dict = Depends(get_current_user), +): + if isinstance(current_user, JSONResponse): + return current_user + + # Stopgap: attaching to an existing case remains admin-only until + # case-level ACLs exist. Non-admins can still create a fresh case + # as a side effect of queueing items they already have camera access to. + if body.export_case_id is not None and current_user["role"] != "admin": + return JSONResponse( + content={ + "success": False, + "message": "Only admins can attach exports to an existing case.", + }, + status_code=403, + ) + + case_validation_error = _validate_export_case(body.export_case_id) + if case_validation_error is not None: + return case_validation_error + + # Fail-closed camera access: any item referencing an inaccessible + # camera rejects the whole request. The UI's review list is already + # filtered by camera access, so reaching this branch implies a stale + # session or a crafted request — reject loudly rather than silently + # dropping items. + allowed_camera_set = set(allowed_cameras) + for item in body.items: + if item.camera not in allowed_camera_set: + return JSONResponse( + content={ + "success": False, + "message": f"Cannot export from {item.camera}: access denied", + }, + status_code=403, + ) + + # Sanitize each item's image_path up front. A bad path in any item + # kills the whole request, consistent with single-export behavior. + sanitized_images: list[str | None] = [] + for item in body.items: + existing_image, image_validation_error = _sanitize_existing_image( + item.image_path + ) + if image_validation_error is not None: + return image_validation_error + sanitized_images.append(existing_image) + + item_errors = _get_item_recording_export_errors(request, body.items) + + queueable_indexes = [ + index for index in range(len(body.items)) if index not in item_errors + ] + + if not queueable_indexes: + return JSONResponse( + content={ + "success": False, + "message": ( + "No exports could be queued: no recordings found for the " + "requested ranges." + ), + }, + status_code=400, + ) + + # Preflight admission: reject the whole batch if we can't fit every + # queueable item. Prevents partial batches where the tail fails with + # "queue full" after we've already created a case. + if available_export_queue_slots(request.app.frigate_config) < len( + queueable_indexes + ): + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) + + export_case = None + export_case_id = body.export_case_id + if export_case_id is None and body.new_case_name: + export_case = _create_export_case_record( + body.new_case_name, + body.new_case_description, + ) + export_case_id = export_case.id + + export_ids: list[str] = [] + results: list[dict[str, str | None | bool | int]] = [] + for index, item in enumerate(body.items): + if index in item_errors: + results.append( + { + "camera": item.camera, + "export_id": None, + "success": False, + "status": None, + "error": item_errors[index], + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + continue + + export_job = _build_export_job( + item.camera, + item.start_time, + item.end_time, + item.friendly_name, + sanitized_images[index], + PlaybackSourceEnum.recordings, + export_case_id, + chapters=request.app.frigate_config.cameras[ + item.camera + ].record.export.chapters, + ) + try: + start_export_job(request.app.frigate_config, export_job) + except Exception: + logger.exception("Failed to queue export job %s", export_job.id) + results.append( + { + "camera": item.camera, + "export_id": None, + "success": False, + "status": None, + "error": "Failed to queue export job", + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + continue + + export_ids.append(export_job.id) + results.append( + { + "camera": item.camera, + "export_id": export_job.id, + "success": True, + "status": "queued", + "error": None, + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + + if export_case is not None and not export_ids: + export_case.delete_instance() + export_case_id = None + + return JSONResponse( + content={ + "export_case_id": export_case_id, + "export_ids": export_ids, + "results": results, + }, + status_code=202, + ) + + @router.post( "/export/{camera_name}/start/{start_time}/end/{end_time}", response_model=StartExportResponse, @@ -79,29 +816,22 @@ def export_recording( start_time: float, end_time: float, body: ExportRecordingsBody, + current_user: dict = Depends(get_current_user), ): - if not camera_name or not request.app.frigate_config.cameras.get(camera_name): - return JSONResponse( - content=( - {"success": False, "message": f"{camera_name} is not a valid camera."} - ), - status_code=404, - ) + if isinstance(current_user, JSONResponse): + return current_user + + camera_validation_error = _validate_camera_name(request, camera_name) + if camera_validation_error is not None: + return camera_validation_error - playback_factor = body.playback playback_source = body.source friendly_name = body.name + existing_image, image_validation_error = _sanitize_existing_image(body.image_path) + if image_validation_error is not None: + return image_validation_error - # sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path - # like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still - # escapes the directory once resolved. A valid snapshot path never uses "..". - if body.image_path and ".." in body.image_path: - return JSONResponse( - content=({"success": False, "message": "Invalid image path"}), - status_code=400, - ) - - existing_image = sanitize_filepath(body.image_path) if body.image_path else None + export_case_id = body.export_case_id # a chapters value in the request body overrides the camera's export config camera_config = request.app.frigate_config.cameras[camera_name] @@ -111,86 +841,66 @@ def export_recording( else camera_config.record.export.chapters ) - # Ensure that existing_image is a valid path - if existing_image and not existing_image.startswith(CLIPS_DIR): + # Attaching to an existing case requires admin. Single-export for + # cameras the user can access is otherwise non-admin; we only gate + # the case-attachment side effect. + if export_case_id is not None and current_user["role"] != "admin": return JSONResponse( - content=({"success": False, "message": "Invalid image path"}), + content={ + "success": False, + "message": "Only admins can attach exports to an existing case.", + }, + status_code=403, + ) + + case_validation_error = _validate_export_case(export_case_id) + if case_validation_error is not None: + return case_validation_error + + source_error = _validate_export_source( + camera_name, + start_time, + end_time, + playback_source, + ) + if source_error is not None: + return JSONResponse( + content={"success": False, "message": source_error}, status_code=400, ) - if playback_source == "recordings": - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ( - (start_time > Recordings.start_time) - & (end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == camera_name) - .count() - ) - - if recordings_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No recordings found for time range"} - ), - status_code=400, - ) - else: - previews_count = ( - Previews.select() - .where( - Previews.start_time.between(start_time, end_time) - | Previews.end_time.between(start_time, end_time) - | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) - ) - .where(Previews.camera == camera_name) - .count() - ) - - if not is_current_hour(start_time) and previews_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No previews found for time range"} - ), - status_code=400, - ) - - export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" - exporter = RecordingExporter( - request.app.frigate_config, - export_id, + export_job = _build_export_job( camera_name, + start_time, + end_time, friendly_name, existing_image, - int(start_time), - int(end_time), - ( - PlaybackFactorEnum[playback_factor] - if playback_factor in PlaybackFactorEnum.__members__.values() - else PlaybackFactorEnum.realtime - ), - ( - PlaybackSourceEnum[playback_source] - if playback_source in PlaybackSourceEnum.__members__.values() - else PlaybackSourceEnum.recordings - ), + playback_source, + export_case_id, chapters=chapters, ) - exporter.start() + try: + start_export_job(request.app.frigate_config, export_job) + except ExportQueueFullError: + logger.warning("Export queue is full; rejecting %s", export_job.id) + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) + return JSONResponse( content=( { "success": True, - "message": "Starting export of recording.", - "export_id": export_id, + "message": "Export queued.", + "export_id": export_job.id, + "status": "queued", } ), - status_code=200, + status_code=202, ) @@ -231,62 +941,117 @@ async def export_rename(event_id: str, body: ExportRenameBody, request: Request) ) -@router.delete( - "/export/{event_id}", - response_model=GenericResponse, - dependencies=[Depends(require_role(["admin"]))], - summary="Delete export", +@router.post( + "/export/custom/{camera_name}/start/{start_time}/end/{end_time}", + response_model=StartExportResponse, + dependencies=[Depends(require_camera_access)], + summary="Start custom recording export", + description="""Starts an export of a recording for the specified time range using custom FFmpeg arguments. + The export can be from recordings or preview footage. Returns the export ID if + successful, or an error message if the camera is invalid or no recordings/previews + are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided, + defaults to timelapse export settings.""", ) -async def export_delete(event_id: str, request: Request): - try: - export: Export = Export.get(Export.id == event_id) - await require_camera_access(export.camera, request=request) - except DoesNotExist: - return JSONResponse( - content=( - { - "success": False, - "message": "Export not found.", - } - ), - status_code=404, - ) +def export_recording_custom( + request: Request, + camera_name: str, + start_time: float, + end_time: float, + body: ExportRecordingsCustomBody, +): + camera_validation_error = _validate_camera_name(request, camera_name) + if camera_validation_error is not None: + return camera_validation_error - files_in_use = [] - for process in psutil.process_iter(): - try: - if process.name() != "ffmpeg": - continue - file_list = process.open_files() - if file_list: - for nt in file_list: - if nt.path.startswith(EXPORT_DIR): - files_in_use.append(nt.path.split("/")[-1]) - except psutil.Error: - continue + playback_source = body.source + friendly_name = body.name + existing_image, image_validation_error = _sanitize_existing_image(body.image_path) + if image_validation_error is not None: + return image_validation_error + ffmpeg_input_args = body.ffmpeg_input_args + ffmpeg_output_args = body.ffmpeg_output_args + cpu_fallback = body.cpu_fallback - if export.video_path.split("/")[-1] in files_in_use: + export_case_id = body.export_case_id + case_validation_error = _validate_export_case(export_case_id) + if case_validation_error is not None: + return case_validation_error + + source_error = _validate_export_source( + camera_name, + start_time, + end_time, + playback_source, + ) + if source_error is not None: return JSONResponse( - content=( - {"success": False, "message": "Can not delete in progress export."} - ), + content={"success": False, "message": source_error}, status_code=400, ) - Path(export.video_path).unlink(missing_ok=True) + # Validate user-provided ffmpeg args to prevent injection. + # Admin users are trusted and skip validation. + is_admin = request.headers.get("remote-role", "") == "admin" - if export.thumb_path: - Path(export.thumb_path).unlink(missing_ok=True) + if not is_admin: + for args_label, args_value in [ + ("input", ffmpeg_input_args), + ("output", ffmpeg_output_args), + ]: + if args_value is not None: + valid, message = validate_ffmpeg_args(args_value) + if not valid: + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid ffmpeg {args_label} arguments: {message}", + } + ), + status_code=400, + ) + + # Set default values if not provided (timelapse defaults) + if ffmpeg_input_args is None: + ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS + + if ffmpeg_output_args is None: + ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS + + export_job = _build_export_job( + camera_name, + start_time, + end_time, + friendly_name, + existing_image, + playback_source, + export_case_id, + ffmpeg_input_args, + ffmpeg_output_args, + cpu_fallback, + ) + try: + start_export_job(request.app.frigate_config, export_job) + except ExportQueueFullError: + logger.warning("Export queue is full; rejecting %s", export_job.id) + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) - export.delete_instance() return JSONResponse( content=( { "success": True, - "message": "Successfully deleted export.", + "message": "Export queued.", + "export_id": export_job.id, + "status": "queued", } ), - status_code=200, + status_code=202, ) @@ -308,3 +1073,102 @@ async def get_export(export_id: str, request: Request): content={"success": False, "message": "Export not found"}, status_code=404, ) + + +def _get_files_in_use() -> set[str]: + """Get set of export filenames currently in use by ffmpeg.""" + files_in_use: set[str] = set() + for process in psutil.process_iter(): + try: + if process.name() != "ffmpeg": + continue + file_list = process.open_files() + if file_list: + for nt in file_list: + if nt.path.startswith(EXPORT_DIR): + files_in_use.add(nt.path.split("/")[-1]) + except psutil.Error: + continue + return files_in_use + + +@router.post( + "/exports/delete", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Bulk delete exports", + description="Deletes one or more exports by ID. All IDs must exist and none can be in-progress.", +) +def bulk_delete_exports(body: ExportBulkDeleteBody): + exports = list(Export.select().where(Export.id << body.ids)) + + if len(exports) != len(body.ids): + return JSONResponse( + content={"success": False, "message": "One or more exports not found."}, + status_code=404, + ) + + files_in_use = _get_files_in_use() + + for export in exports: + if export.video_path.split("/")[-1] in files_in_use: + return JSONResponse( + content={ + "success": False, + "message": "Can not delete in-progress export.", + }, + status_code=400, + ) + + for export in exports: + Path(export.video_path).unlink(missing_ok=True) + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + + Export.delete().where(Export.id << body.ids).execute() + + return JSONResponse( + content={ + "success": True, + "message": f"Successfully deleted {len(exports)} export(s).", + }, + status_code=200, + ) + + +@router.post( + "/exports/reassign", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Bulk reassign exports to a case", + description="Assigns or unassigns one or more exports to/from a case. All IDs must exist.", +) +def bulk_reassign_exports(body: ExportBulkReassignBody): + exports = list(Export.select().where(Export.id << body.ids)) + + if len(exports) != len(body.ids): + return JSONResponse( + content={"success": False, "message": "One or more exports not found."}, + status_code=404, + ) + + if body.export_case_id is not None: + try: + ExportCase.get(ExportCase.id == body.export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found."}, + status_code=404, + ) + + Export.update(export_case=body.export_case_id).where( + Export.id << body.ids + ).execute() + + return JSONResponse( + content={ + "success": True, + "message": f"Successfully updated {len(exports)} export(s).", + }, + status_code=200, + ) diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 48c97dfaf7..387f0473fc 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -1,6 +1,6 @@ +import asyncio import logging import re -from typing import Optional from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse @@ -16,21 +16,30 @@ from frigate.api import app as main_app from frigate.api import ( auth, camera, + chat, classification, + debug_replay, event, export, media, + motion_search, notification, preview, + record, review, ) from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default +from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, ) from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.config.holder import ConfigHolder +from frigate.config.profile_manager import ProfileManager +from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog from frigate.embeddings import EmbeddingsContext +from frigate.genai import GenAIClientManager from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter from frigate.storage import StorageMaintainer @@ -55,14 +64,18 @@ class RemoteUserPlugin(Plugin): def create_fastapi_app( frigate_config: FrigateConfig, database: SqliteQueueDatabase, - embeddings: Optional[EmbeddingsContext], + embeddings: EmbeddingsContext | None, detected_frames_processor, storage_maintainer: StorageMaintainer, onvif: OnvifController, stats_emitter: StatsEmitter, event_metadata_updater: EventMetadataPublisher, config_publisher: CameraConfigUpdatePublisher, + replay_manager: DebugReplayManager, + dispatcher: Dispatcher | None = None, + profile_manager: ProfileManager | None = None, enforce_default_admin: bool = True, + config_holder: ConfigHolder | None = None, ): logger.info("Starting FastAPI app") app = FastAPI( @@ -105,6 +118,11 @@ def create_fastapi_app( @app.on_event("startup") async def startup(): logger.info("FastAPI started") + asyncio.create_task( + debug_replay_auto_stop_watchdog( + replay_manager, frigate_config, config_publisher + ) + ) # Rate limiter (used for login endpoint) if frigate_config.auth.failed_login_rate_limit is None: @@ -120,6 +138,7 @@ def create_fastapi_app( # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters app.include_router(auth.router) app.include_router(camera.router) + app.include_router(chat.router) app.include_router(classification.router) app.include_router(review.router) app.include_router(main_app.router) @@ -128,8 +147,14 @@ def create_fastapi_app( app.include_router(export.router) app.include_router(event.router) app.include_router(media.router) + app.include_router(motion_search.router) + app.include_router(record.router) + app.include_router(debug_replay.router) # App Properties app.frigate_config = frigate_config + # snapshot the port nginx bound at startup, the live config can be swapped + app.auth_internal_port = frigate_config.networking.listen.internal_port + app.genai_manager = GenAIClientManager(frigate_config) app.embeddings = embeddings app.detected_frames_processor = detected_frames_processor app.storage_maintainer = storage_maintainer @@ -138,6 +163,10 @@ def create_fastapi_app( app.stats_emitter = stats_emitter app.event_metadata_updater = event_metadata_updater app.config_publisher = config_publisher + app.replay_manager = replay_manager + app.dispatcher = dispatcher + app.profile_manager = profile_manager + app.config_holder = config_holder if frigate_config.auth.enabled: secret = get_jwt_secret() diff --git a/frigate/api/media.py b/frigate/api/media.py index 8e1d00ade3..c6fa90c068 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -7,10 +7,9 @@ import math import os import subprocess as sp import time -from datetime import datetime, timedelta, timezone -from functools import reduce +from datetime import UTC, datetime, timedelta from pathlib import Path as FilePath -from typing import Any, List +from typing import Any from urllib.parse import unquote import cv2 @@ -19,39 +18,42 @@ import pytz from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pathvalidate import sanitize_filename -from peewee import DoesNotExist, fn, operator +from peewee import DoesNotExist, fn from tzlocal import get_localzone_name from frigate.api.auth import ( allow_any_authenticated, - get_allowed_cameras_for_filter, require_camera_access, + require_role, ) from frigate.api.defs.query.media_query_parameters import ( Extension, MediaEventsSnapshotQueryParams, MediaLatestFrameQueryParams, MediaMjpegFeedQueryParams, - MediaRecordingsAvailabilityQueryParams, - MediaRecordingsSummaryQueryParams, ) from frigate.api.defs.tags import Tags from frigate.camera.state import CameraState from frigate.config import FrigateConfig +from frigate.config.camera.snapshots import SnapshotsConfig from frigate.const import ( CACHE_DIR, - CLIPS_DIR, INSTALL_DIR, MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, - RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment +from frigate.output.preview import get_most_recent_preview_frame from frigate.track.object_processing import TrackedObjectProcessor -from frigate.util.file import get_event_thumbnail_bytes -from frigate.util.image import get_image_from_recording +from frigate.util.file import ( + get_event_snapshot_bytes, + get_event_snapshot_path, + get_event_thumbnail_bytes, + load_event_snapshot_image, +) +from frigate.util.image import get_image_from_recording, get_image_quality_params from frigate.util.media import get_keyframe_before -from frigate.util.time import get_dst_transitions +from frigate.util.object import create_empty_regions_grid logger = logging.getLogger(__name__) @@ -129,6 +131,24 @@ def imagestream( ) +def _resolve_snapshot_settings( + snapshot_config: SnapshotsConfig, params: MediaEventsSnapshotQueryParams +) -> dict[str, Any]: + return { + "timestamp": snapshot_config.timestamp + if params.timestamp is None + else bool(params.timestamp), + "bounding_box": snapshot_config.bounding_box + if params.bbox is None + else bool(params.bbox), + "crop": snapshot_config.crop if params.crop is None else bool(params.crop), + "height": snapshot_config.height if params.height is None else params.height, + "quality": snapshot_config.quality + if params.quality is None + else params.quality, + } + + @router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)]) async def camera_ptz_info(request: Request, camera_name: str): if camera_name in request.app.frigate_config.cameras: @@ -146,7 +166,9 @@ async def camera_ptz_info(request: Request, camera_name: str): @router.get( - "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)] + "/{camera_name}/latest.{extension}", + dependencies=[Depends(require_camera_access)], + description="Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline.", ) async def latest_frame( request: Request, @@ -164,36 +186,44 @@ async def latest_frame( "paths": params.paths, "regions": params.regions, } - quality = params.quality + quality_params = get_image_quality_params(extension.value, params.quality) - if extension == Extension.png: - quality_params = None - elif extension == Extension.webp: - quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality] - else: # jpg or jpeg - quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality] - - if camera_name in request.app.frigate_config.cameras: + camera_config = request.app.frigate_config.cameras.get(camera_name) + if camera_config is not None: frame = frame_processor.get_current_frame(camera_name, draw_options) - retry_interval = float( - request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval - or 10 - ) + retry_interval = float(camera_config.ffmpeg.retry_interval or 10) + is_offline = False if frame is None or datetime.now().timestamp() > ( frame_processor.get_current_frame_time(camera_name) + retry_interval ): - if request.app.camera_error_image is None: - error_image = glob.glob( - os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") - ) + last_frame_time = frame_processor.get_current_frame_time(camera_name) + preview_path = get_most_recent_preview_frame( + camera_name, before=last_frame_time + ) - if len(error_image) > 0: - request.app.camera_error_image = cv2.imread( - error_image[0], cv2.IMREAD_UNCHANGED + if preview_path: + logger.debug(f"Using most recent preview frame for {camera_name}") + frame = cv2.imread(preview_path, cv2.IMREAD_UNCHANGED) + + if frame is not None: + is_offline = True + + if frame is None or not is_offline: + logger.debug( + f"No live or preview frame available for {camera_name}. Using error image." + ) + if request.app.camera_error_image is None: + error_image = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") ) - frame = request.app.camera_error_image + if len(error_image) > 0: + request.app.camera_error_image = cv2.imread( + error_image[0], cv2.IMREAD_UNCHANGED + ) + + frame = request.app.camera_error_image height = int(params.height or str(frame.shape[0])) width = int(height * frame.shape[1] / frame.shape[0]) @@ -215,14 +245,18 @@ async def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) _, img = cv2.imencode(f".{extension.value}", frame, quality_params) + + headers = { + "Cache-Control": "no-store" if not params.store else "private, max-age=60", + } + + if is_offline: + headers["X-Frigate-Offline"] = "true" + return Response( content=img.tobytes(), media_type=extension.get_mime_type(), - headers={ - "Cache-Control": "no-store" - if not params.store - else "private, max-age=60", - }, + headers=headers, ) elif ( camera_name == "birdseye" @@ -281,10 +315,8 @@ async def get_snapshot_from_recording( Recordings.start_time, ) .where( - ( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + (frame_time >= Recordings.start_time) + & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) .order_by(Recordings.start_time.desc()) @@ -302,10 +334,8 @@ async def get_snapshot_from_recording( Recordings.start_time, ) .where( - ( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + (frame_time >= Recordings.start_time) + & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) .order_by(Recordings.start_time.desc()) @@ -365,10 +395,7 @@ async def submit_recording_snapshot_to_plus( Recordings.start_time, ) .where( - ( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + (frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) .order_by(Recordings.start_time.desc()) @@ -414,333 +441,6 @@ async def submit_recording_snapshot_to_plus( ) -@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())]) -def get_recordings_storage_usage(request: Request): - recording_stats = request.app.stats_emitter.get_latest_stats()["service"][ - "storage" - ][RECORD_DIR] - - if not recording_stats: - return JSONResponse({}) - - total_mb = recording_stats["total"] - - camera_usages: dict[str, dict] = ( - request.app.storage_maintainer.calculate_camera_usages() - ) - - for camera_name in camera_usages.keys(): - if camera_usages.get(camera_name, {}).get("usage"): - camera_usages[camera_name]["usage_percent"] = ( - camera_usages.get(camera_name, {}).get("usage", 0) / total_mb - ) * 100 - - return JSONResponse(content=camera_usages) - - -@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())]) -def all_recordings_summary( - request: Request, - params: MediaRecordingsSummaryQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), -): - """Returns true/false by day indicating if recordings exist""" - - cameras = params.cameras - if cameras != "all": - requested = set(unquote(cameras).split(",")) - filtered = requested.intersection(allowed_cameras) - if not filtered: - return JSONResponse(content={}) - camera_list = list(filtered) - else: - camera_list = allowed_cameras - - time_range_query = ( - Recordings.select( - fn.MIN(Recordings.start_time).alias("min_time"), - fn.MAX(Recordings.start_time).alias("max_time"), - ) - .where(Recordings.camera << camera_list) - .dicts() - .get() - ) - - min_time = time_range_query.get("min_time") - max_time = time_range_query.get("max_time") - - if min_time is None or max_time is None: - return JSONResponse(content={}) - - dst_periods = get_dst_transitions(params.timezone, min_time, max_time) - - days: dict[str, bool] = {} - - for period_start, period_end, period_offset in dst_periods: - hours_offset = int(period_offset / 60 / 60) - minutes_offset = int(period_offset / 60 - hours_offset * 60) - period_hour_modifier = f"{hours_offset} hour" - period_minute_modifier = f"{minutes_offset} minute" - - period_query = ( - Recordings.select( - fn.strftime( - "%Y-%m-%d", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ).alias("day") - ) - .where( - (Recordings.camera << camera_list) - & (Recordings.end_time >= period_start) - & (Recordings.start_time <= period_end) - ) - .group_by( - fn.strftime( - "%Y-%m-%d", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ) - ) - .order_by(Recordings.start_time.desc()) - .namedtuples() - ) - - for g in period_query: - days[g.day] = True - - return JSONResponse(content=dict(sorted(days.items()))) - - -@router.get( - "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] -) -async def recordings_summary(camera_name: str, timezone: str = "utc"): - """Returns hourly summary for recordings of given camera""" - - time_range_query = ( - Recordings.select( - fn.MIN(Recordings.start_time).alias("min_time"), - fn.MAX(Recordings.start_time).alias("max_time"), - ) - .where(Recordings.camera == camera_name) - .dicts() - .get() - ) - - min_time = time_range_query.get("min_time") - max_time = time_range_query.get("max_time") - - days: dict[str, dict] = {} - - if min_time is None or max_time is None: - return JSONResponse(content=list(days.values())) - - dst_periods = get_dst_transitions(timezone, min_time, max_time) - - for period_start, period_end, period_offset in dst_periods: - hours_offset = int(period_offset / 60 / 60) - minutes_offset = int(period_offset / 60 - hours_offset * 60) - period_hour_modifier = f"{hours_offset} hour" - period_minute_modifier = f"{minutes_offset} minute" - - recording_groups = ( - Recordings.select( - fn.strftime( - "%Y-%m-%d %H", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ).alias("hour"), - fn.SUM(Recordings.duration).alias("duration"), - fn.SUM(Recordings.motion).alias("motion"), - fn.SUM(Recordings.objects).alias("objects"), - ) - .where( - (Recordings.camera == camera_name) - & (Recordings.end_time >= period_start) - & (Recordings.start_time <= period_end) - ) - .group_by((Recordings.start_time + period_offset).cast("int") / 3600) - .order_by(Recordings.start_time.desc()) - .namedtuples() - ) - - event_groups = ( - Event.select( - fn.strftime( - "%Y-%m-%d %H", - fn.datetime( - Event.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ).alias("hour"), - fn.COUNT(Event.id).alias("count"), - ) - .where(Event.camera == camera_name, Event.has_clip) - .where( - (Event.start_time >= period_start) & (Event.start_time <= period_end) - ) - .group_by((Event.start_time + period_offset).cast("int") / 3600) - .namedtuples() - ) - - event_map = {g.hour: g.count for g in event_groups} - - for recording_group in recording_groups: - parts = recording_group.hour.split() - hour = parts[1] - day = parts[0] - events_count = event_map.get(recording_group.hour, 0) - hour_data = { - "hour": hour, - "events": events_count, - "motion": recording_group.motion, - "objects": recording_group.objects, - "duration": round(recording_group.duration), - } - if day in days: - # merge counts if already present (edge-case at DST boundary) - days[day]["events"] += events_count or 0 - days[day]["hours"].append(hour_data) - else: - days[day] = { - "events": events_count or 0, - "hours": [hour_data], - "day": day, - } - - return JSONResponse(content=list(days.values())) - - -@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) -async def recordings( - camera_name: str, - after: float = (datetime.now() - timedelta(hours=1)).timestamp(), - before: float = datetime.now().timestamp(), -): - """Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used""" - recordings = ( - Recordings.select( - Recordings.id, - Recordings.start_time, - Recordings.end_time, - Recordings.segment_size, - Recordings.motion, - Recordings.objects, - Recordings.duration, - ) - .where( - Recordings.camera == camera_name, - Recordings.end_time >= after, - Recordings.start_time <= before, - ) - .order_by(Recordings.start_time) - .dicts() - .iterator() - ) - - return JSONResponse(content=list(recordings)) - - -@router.get( - "/recordings/unavailable", - response_model=list[dict], - dependencies=[Depends(allow_any_authenticated())], -) -async def no_recordings( - request: Request, - params: MediaRecordingsAvailabilityQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), -): - """Get time ranges with no recordings.""" - cameras = params.cameras - if cameras != "all": - requested = set(unquote(cameras).split(",")) - filtered = requested.intersection(allowed_cameras) - if not filtered: - return JSONResponse(content=[]) - cameras = ",".join(filtered) - else: - cameras = allowed_cameras - - before = params.before or datetime.datetime.now().timestamp() - after = ( - params.after - or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp() - ) - scale = params.scale - - clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)] - if cameras != "all": - camera_list = cameras.split(",") - clauses.append((Recordings.camera << camera_list)) - else: - camera_list = allowed_cameras - - # Get recording start times - data: list[Recordings] = ( - Recordings.select(Recordings.start_time, Recordings.end_time) - .where(reduce(operator.and_, clauses)) - .order_by(Recordings.start_time.asc()) - .dicts() - .iterator() - ) - - # Convert recordings to list of (start, end) tuples - recordings = [(r["start_time"], r["end_time"]) for r in data] - - # Iterate through time segments and check if each has any recording - no_recording_segments = [] - current = after - current_gap_start = None - - while current < before: - segment_end = min(current + scale, before) - - # Check if this segment overlaps with any recording - has_recording = any( - rec_start < segment_end and rec_end > current - for rec_start, rec_end in recordings - ) - - if not has_recording: - # This segment has no recordings - if current_gap_start is None: - current_gap_start = current # Start a new gap - else: - # This segment has recordings - if current_gap_start is not None: - # End the current gap and append it - no_recording_segments.append( - {"start_time": int(current_gap_start), "end_time": int(current)} - ) - current_gap_start = None - - current = segment_end - - # Append the last gap if it exists - if current_gap_start is not None: - no_recording_segments.append( - {"start_time": int(current_gap_start), "end_time": int(before)} - ) - - return JSONResponse(content=no_recording_segments) - - @router.get( "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", dependencies=[Depends(require_camera_access)], @@ -1013,7 +713,7 @@ async def vod_hour( ): parts = year_month.split("-") start_date = ( - datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=timezone.utc) + datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC) - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) @@ -1081,7 +781,7 @@ async def vod_clip( @router.get( "/events/{event_id}/snapshot.jpg", - description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.", + description="Returns a snapshot image for the specified object id.", ) async def event_snapshot( request: Request, @@ -1090,6 +790,7 @@ async def event_snapshot( ): event_complete = False jpg_bytes = None + frame_time = 0 try: event = Event.get(Event.id == event_id, Event.end_time != None) event_complete = True @@ -1099,11 +800,22 @@ async def event_snapshot( content={"success": False, "message": "Snapshot not available"}, status_code=404, ) - # read snapshot from disk - with open( - os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), "rb" - ) as image_file: - jpg_bytes = image_file.read() + snapshot_settings = _resolve_snapshot_settings( + request.app.frigate_config.cameras[event.camera].snapshots, params + ) + jpg_bytes, frame_time = get_event_snapshot_bytes( + event, + ext="jpg", + timestamp=snapshot_settings["timestamp"], + bounding_box=snapshot_settings["bounding_box"], + crop=snapshot_settings["crop"], + height=snapshot_settings["height"], + quality=snapshot_settings["quality"], + timestamp_style=request.app.frigate_config.cameras[ + event.camera + ].timestamp_style, + colormap=request.app.frigate_config.model.colormap, + ) except DoesNotExist: # see if the object is currently being tracked try: @@ -1114,13 +826,16 @@ async def event_snapshot( if event_id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(event_id) if tracked_obj is not None: - jpg_bytes = tracked_obj.get_img_bytes( + snapshot_settings = _resolve_snapshot_settings( + camera_state.camera_config.snapshots, params + ) + jpg_bytes, frame_time = tracked_obj.get_img_bytes( ext="jpg", - timestamp=params.timestamp, - bounding_box=params.bbox, - crop=params.crop, - height=params.height, - quality=params.quality, + timestamp=snapshot_settings["timestamp"], + bounding_box=snapshot_settings["bounding_box"], + crop=snapshot_settings["crop"], + height=snapshot_settings["height"], + quality=snapshot_settings["quality"], ) await require_camera_access(camera_state.name, request=request) except Exception: @@ -1143,6 +858,7 @@ async def event_snapshot( headers = { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=31536000" if event_complete else "no-store", + "X-Frame-Time": str(frame_time), } if params.download: @@ -1187,6 +903,7 @@ async def event_thumbnail( if event_id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(event_id) if tracked_obj is not None: + await require_camera_access(camera_state.name, request=request) thumbnail_bytes = tracked_obj.get_thumbnail(extension.value) except Exception: return JSONResponse( @@ -1356,20 +1073,53 @@ def grid_snapshot( ) +@router.delete( + "/{camera_name}/region_grid", dependencies=[Depends(require_role(["admin"]))] +) +def clear_region_grid(request: Request, camera_name: str): + """Clear the region grid for a camera.""" + if camera_name not in request.app.frigate_config.cameras: + return JSONResponse( + content={"success": False, "message": "Camera not found"}, + status_code=404, + ) + + # store an empty grid instead of deleting the row so the grid is + # rebuilt from newly tracked objects and not from all past history + region = { + Regions.camera: camera_name, + Regions.grid: create_empty_regions_grid(), + Regions.last_update: datetime.now().timestamp(), + } + ( + Regions.insert(region) + .on_conflict( + conflict_target=[Regions.camera], + update=region, + ) + .execute() + ) + return JSONResponse( + content={"success": True, "message": "Region grid cleared"}, + ) + + @router.get( "/events/{event_id}/snapshot-clean.webp", ) async def event_snapshot_clean(request: Request, event_id: str, download: bool = False): webp_bytes = None + event_complete = False try: event = Event.get(Event.id == event_id) + event_complete = event.end_time is not None await require_camera_access(event.camera, request=request) snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots if not (snapshot_config.enabled and event.has_snapshot): return JSONResponse( content={ "success": False, - "message": "Snapshots and clean_copy must be enabled in the config", + "message": "Snapshots must be enabled in the config", }, status_code=404, ) @@ -1401,54 +1151,10 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool = ) if webp_bytes is None: try: - # webp - clean_snapshot_path_webp = os.path.join( - CLIPS_DIR, f"{event.camera}-{event.id}-clean.webp" + image_path, is_clean_snapshot = get_event_snapshot_path( + event, clean_only=True ) - # png (legacy) - clean_snapshot_path_png = os.path.join( - CLIPS_DIR, f"{event.camera}-{event.id}-clean.png" - ) - - if os.path.exists(clean_snapshot_path_webp): - with open(clean_snapshot_path_webp, "rb") as image_file: - webp_bytes = image_file.read() - elif os.path.exists(clean_snapshot_path_png): - # convert png to webp and save for future use - png_image = cv2.imread(clean_snapshot_path_png, cv2.IMREAD_UNCHANGED) - if png_image is None: - return JSONResponse( - content={ - "success": False, - "message": "Invalid png snapshot", - }, - status_code=400, - ) - - ret, webp_data = cv2.imencode( - ".webp", png_image, [int(cv2.IMWRITE_WEBP_QUALITY), 60] - ) - if not ret: - return JSONResponse( - content={ - "success": False, - "message": "Unable to convert png to webp", - }, - status_code=400, - ) - - webp_bytes = webp_data.tobytes() - - # save the converted webp for future requests - try: - with open(clean_snapshot_path_webp, "wb") as f: - f.write(webp_bytes) - except Exception as e: - logger.warning( - f"Failed to save converted webp for event {event.id}: {e}" - ) - # continue since we now have the data to return - else: + if not is_clean_snapshot or image_path is None: return JSONResponse( content={ "success": False, @@ -1456,6 +1162,34 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool = }, status_code=404, ) + + if image_path.endswith(".webp"): + with open(image_path, "rb") as image_file: + webp_bytes = image_file.read() + else: + image = load_event_snapshot_image(event, clean_only=True)[0] + if image is None: + return JSONResponse( + content={ + "success": False, + "message": "Unable to load clean snapshot for event", + }, + status_code=400, + ) + + ret, webp_data = cv2.imencode( + ".webp", image, get_image_quality_params("webp", None) + ) + if not ret: + return JSONResponse( + content={ + "success": False, + "message": "Unable to convert snapshot to webp", + }, + status_code=400, + ) + + webp_bytes = webp_data.tobytes() except Exception: logger.error(f"Unable to load clean snapshot for event: {event.id}") return JSONResponse( @@ -1468,7 +1202,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool = headers = { "Content-Type": "image/webp", - "Cache-Control": "private, max-age=31536000", + "Cache-Control": "private, max-age=31536000" if event_complete else "no-cache", } if download: @@ -1515,6 +1249,33 @@ async def event_clip( ) +@router.get( + "/review/{review_id}/clip.mp4", +) +async def review_clip( + request: Request, + review_id: str, + padding: int = Query(0, description="Padding to apply to clip."), +): + try: + review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Review not found"}, status_code=404 + ) + + await require_camera_access(review.camera, request=request) + + end_ts = ( + datetime.now().timestamp() + if review.end_time is None + else review.end_time + padding + ) + return await recording_clip( + request, review.camera, review.start_time - padding, end_ts + ) + + @router.get( "/events/{event_id}/preview.gif", ) @@ -1619,15 +1380,27 @@ async def preview_gif( else: # need to generate from existing images preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" - end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" + + if not os.path.isdir(preview_dir): + return JSONResponse( + content={"success": False, "message": "Preview not found"}, + status_code=404, + ) + + file_start = f"preview_{camera_name}-" + start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue @@ -1796,15 +1569,27 @@ async def preview_mp4( else: # need to generate from existing images preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" - end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" + + if not os.path.isdir(preview_dir): + return JSONResponse( + content={"success": False, "message": "Preview not found"}, + status_code=404, + ) + + file_start = f"preview_{camera_name}-" + start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue diff --git a/frigate/api/media_auth.py b/frigate/api/media_auth.py new file mode 100644 index 0000000000..0630e3604f --- /dev/null +++ b/frigate/api/media_auth.py @@ -0,0 +1,290 @@ +"""URI-aware authorization for nginx-served static media. + +The `/auth` endpoint (used as nginx `auth_request` target) calls into this +module to classify the requested URI from the `X-Original-URL` header and, for +camera-scoped resources, decide whether the current role may access them. + +Without this, `auth_request` only verifies the JWT — every authenticated user +could read clips, recordings, and exports for *any* camera, bypassing the +per-camera authorization the regular API enforces via `require_camera_access`. +""" + +from __future__ import annotations + +import logging +import os +from enum import Enum +from urllib.parse import unquote, urlparse + +from peewee import DoesNotExist + +from frigate.config import FrigateConfig +from frigate.const import EXPORT_DIR +from frigate.models import Export, User + +logger = logging.getLogger(__name__) + + +class MediaAuthResolution(str, Enum): + """Classification of an `X-Original-URL` path for media-auth purposes.""" + + CAMERA = "camera" + ADMIN_ONLY = "admin_only" + LISTING_MULTI_CAMERA = "listing_multi_camera" + LISTING_NEUTRAL = "listing_neutral" + # Under a recognized media root (/clips, /recordings, /exports) but + # unclassifiable (unknown subtree, no matching DB row, DB error). + # Restricted users are denied; admins/full-access roles are allowed + # (nginx will likely return 404 if the file genuinely doesn't exist). + UNRESOLVED_MEDIA = "unresolved_media" + # Not a media URI at all (e.g. /api/events, /login). + UNKNOWN = "unknown" + + +def extract_path(original_url: str | None) -> str | None: + """Return the decoded path component of nginx's `X-Original-URL` header. + + nginx forwards the *raw* request URI (with `..` segments intact) via + `$request_uri`. nginx normalizes the path before serving the file, so a + request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4` + would (1) parse as the allowed camera in our auth check, (2) be served + as the forbidden camera by nginx. To close the bypass we reject any URI + whose path contains `.` or `..` segments outright. + """ + if not original_url: + return None + + parsed = urlparse(original_url) + raw_path = parsed.path or original_url + decoded = unquote(raw_path) + if not decoded: + return None + + if not decoded.startswith("/"): + decoded = "/" + decoded + + segments = decoded.split("/") + if ".." in segments or "." in segments: + return None + + return decoded + + +def resolve_media_uri( + uri: str, frigate_config: FrigateConfig | None = None +) -> tuple[MediaAuthResolution, str | None]: + """Classify a URI and return the owning camera if applicable. + + `frigate_config` is used to disambiguate clip/review filenames whose + camera name contains hyphens by matching against the longest configured + camera-name prefix. + """ + if not uri: + return MediaAuthResolution.UNKNOWN, None + + parts = [p for p in uri.split("/") if p] + if not parts: + return MediaAuthResolution.UNKNOWN, None + + root = parts[0] + if root == "recordings": + return _resolve_recording(parts) + if root == "clips": + return _resolve_clip(parts, frigate_config) + if root == "exports": + return _resolve_export(parts) + + return MediaAuthResolution.UNKNOWN, None + + +def _resolve_recording( + parts: list[str], +) -> tuple[MediaAuthResolution, str | None]: + # /recordings → neutral + # /recordings/{date} → neutral + # /recordings/{date}/{hour} → multi-camera listing + # /recordings/{date}/{hour}/{cam}/... → camera + if len(parts) <= 2: + return MediaAuthResolution.LISTING_NEUTRAL, None + if len(parts) == 3: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + return MediaAuthResolution.CAMERA, parts[3] + + +def _resolve_clip( + parts: list[str], frigate_config: FrigateConfig | None +) -> tuple[MediaAuthResolution, str | None]: + # /clips → multi-camera listing + # /clips/thumbs/{cam}/... → camera + # /clips/previews/{cam}/... → camera + # /clips/review/thumb-{cam}-{review_id}.webp → camera (parsed) + # /clips/faces/... → admin-only + # /clips/genai-requests/... → admin-only + # /clips/preview_restart_cache/... → admin-only + # /clips/{model}/train|dataset/... → admin-only + # /clips/{cam}-{event_id}[-clean].{ext} → camera (parsed) + # other /clips/{subdir}/... → unresolved (deny restricted) + if len(parts) == 1: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + + second = parts[1] + + if second in ("thumbs", "previews"): + if len(parts) == 2: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + return MediaAuthResolution.CAMERA, parts[2] + + if second == "review": + if len(parts) == 2: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + camera = _camera_from_thumb_filename(parts[2], frigate_config) + if camera: + return MediaAuthResolution.CAMERA, camera + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + if second in ("faces", "genai-requests", "preview_restart_cache"): + return MediaAuthResolution.ADMIN_ONLY, None + + if len(parts) >= 3 and parts[2] in ("train", "dataset"): + return MediaAuthResolution.ADMIN_ONLY, None + + if len(parts) == 2: + camera = _camera_from_clip_filename(second, frigate_config) + if camera: + return MediaAuthResolution.CAMERA, camera + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + +def _longest_prefix_camera( + stem: str, frigate_config: FrigateConfig | None +) -> str | None: + if frigate_config is None: + return None + for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True): + if stem.startswith(cam + "-"): + return cam + return None + + +def _camera_from_clip_filename( + filename: str, frigate_config: FrigateConfig | None +) -> str | None: + """Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against + configured camera names. Longest-prefix wins so camera names containing + hyphens (e.g. `front-door`) resolve correctly. + """ + dot = filename.rfind(".") + stem = filename[:dot] if dot > 0 else filename + return _longest_prefix_camera(stem, frigate_config) + + +def _camera_from_thumb_filename( + filename: str, frigate_config: FrigateConfig | None +) -> str | None: + """Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`.""" + if not filename.startswith("thumb-"): + return None + dot = filename.rfind(".") + stem = filename[len("thumb-") : dot] if dot > 0 else filename[len("thumb-") :] + return _longest_prefix_camera(stem, frigate_config) + + +def _resolve_export( + parts: list[str], +) -> tuple[MediaAuthResolution, str | None]: + # /exports → multi-camera listing + # /exports/{filename}.mp4 → camera (DB lookup by exact path) + if len(parts) == 1: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + if len(parts) != 2: + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + filename = parts[1] + full_path = os.path.join(EXPORT_DIR, filename) + try: + export = Export.get(Export.video_path == full_path) + return MediaAuthResolution.CAMERA, export.camera + except DoesNotExist: + return MediaAuthResolution.UNRESOLVED_MEDIA, None + except Exception as e: + logger.warning("Export DB lookup failed for %s: %s", filename, e) + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + +def check_camera_access(role: str, camera: str, frigate_config: FrigateConfig) -> bool: + """Return True iff `role` may access `camera`. + + Mirrors the gating logic in `require_camera_access`: admin and any role + without a non-empty allow-list bypass the check. + """ + if role == "admin": + return True + + roles_dict = frigate_config.auth.roles + if not roles_dict.get(role): + return True + + all_camera_names = set(frigate_config.cameras.keys()) + allowed = User.get_allowed_cameras(role, roles_dict, all_camera_names) + return camera in allowed + + +def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool: + """True if `role` has a non-empty allow-list (i.e. not full-access).""" + if role == "admin": + return False + return bool(frigate_config.auth.roles.get(role)) + + +def deny_response_for_media_uri( + original_url: str | None, role: str | None, frigate_config: FrigateConfig +) -> int | None: + """Decide whether the current role should be blocked from `original_url`. + + Returns an HTTP status code (403) when access should be denied, or `None` + when the request is allowed. + """ + if not original_url: + return None + + path = extract_path(original_url) + + # `extract_path` returns None for URIs containing `.` or `..` segments. + # For media-root URIs that's a traversal attempt — deny outright. For + # non-media URIs, pass through (nginx / the backend handle them). + if path is None: + raw = urlparse(original_url).path or original_url + decoded = unquote(raw) + first = decoded.lstrip("/").split("/", 1)[0] if decoded else "" + if first in ("clips", "recordings", "exports"): + return 403 + return None + + resolution, camera = resolve_media_uri(path, frigate_config) + if resolution == MediaAuthResolution.UNKNOWN: + return None + + if not role or role == "admin": + return None + + if not is_role_restricted(role, frigate_config): + return None + + if resolution == MediaAuthResolution.LISTING_NEUTRAL: + return None + + if resolution in ( + MediaAuthResolution.LISTING_MULTI_CAMERA, + MediaAuthResolution.ADMIN_ONLY, + MediaAuthResolution.UNRESOLVED_MEDIA, + ): + return 403 + + if resolution == MediaAuthResolution.CAMERA: + if camera and check_camera_access(role, camera, frigate_config): + return None + return 403 + + return 403 diff --git a/frigate/api/motion_search.py b/frigate/api/motion_search.py new file mode 100644 index 0000000000..d8449bde98 --- /dev/null +++ b/frigate/api/motion_search.py @@ -0,0 +1,290 @@ +"""Motion search API for detecting changes within a region of interest.""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from frigate.api.auth import require_camera_access +from frigate.api.defs.tags import Tags +from frigate.jobs.motion_search import ( + cancel_motion_search_job, + get_motion_search_job, + start_motion_search_job, +) +from frigate.types import JobStatusTypesEnum + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.motion_search]) + + +class MotionSearchRequest(BaseModel): + """Request body for motion search.""" + + start_time: float = Field(description="Start timestamp for the search range") + end_time: float = Field(description="End timestamp for the search range") + polygon_points: list[list[float]] = Field( + description="List of [x, y] normalized coordinates (0-1) defining the ROI polygon" + ) + threshold: int = Field( + default=30, + ge=1, + le=255, + description="Pixel difference threshold (1-255)", + ) + min_area: float = Field( + default=5.0, + ge=0.1, + le=100.0, + description="Minimum change area as a percentage of the ROI", + ) + parallel: bool = Field( + default=False, + description="Enable parallel scanning across segments", + ) + max_results: int = Field( + default=25, + ge=1, + le=200, + description="Maximum number of search results to return", + ) + + +class MotionSearchResult(BaseModel): + """A single search result with timestamp and change info.""" + + timestamp: float = Field(description="Timestamp where change was detected") + change_percentage: float = Field(description="Percentage of ROI area that changed") + + +class MotionSearchMetricsResponse(BaseModel): + """Metrics collected during motion search execution.""" + + segments_scanned: int = 0 + segments_processed: int = 0 + metadata_inactive_segments: int = 0 + heatmap_roi_skip_segments: int = 0 + fallback_full_range_segments: int = 0 + frames_decoded: int = 0 + wall_time_seconds: float = 0.0 + segments_with_errors: int = 0 + + +class MotionSearchStartResponse(BaseModel): + """Response when motion search job starts.""" + + success: bool + message: str + job_id: str + + +class MotionSearchStatusResponse(BaseModel): + """Response containing job status and results.""" + + success: bool + message: str + status: str # "queued", "running", "success", "failed", or "cancelled" + results: list[MotionSearchResult] | None = None + total_frames_processed: int | None = None + error_message: str | None = None + metrics: MotionSearchMetricsResponse | None = None + scanning_timestamp: float | None = None + progress: float | None = None + + +@router.post( + "/{camera_name}/search/motion", + response_model=MotionSearchStartResponse, + dependencies=[Depends(require_camera_access)], + summary="Start motion search job", + description="""Starts an asynchronous search for significant motion changes within + a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id + that can be used to poll for results.""", +) +async def start_motion_search( + request: Request, + camera_name: str, + body: MotionSearchRequest, +): + """Start an async motion search job.""" + config = request.app.frigate_config + + if camera_name not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"Camera {camera_name} not found"}, + status_code=404, + ) + + # Validate polygon has at least 3 points + if len(body.polygon_points) < 3: + return JSONResponse( + content={ + "success": False, + "message": "Polygon must have at least 3 points", + }, + status_code=400, + ) + + # Validate time range + if body.start_time >= body.end_time: + return JSONResponse( + content={ + "success": False, + "message": "Start time must be before end time", + }, + status_code=400, + ) + + # Start the job using the jobs module + job_id = start_motion_search_job( + config=config, + camera_name=camera_name, + start_time=body.start_time, + end_time=body.end_time, + polygon_points=body.polygon_points, + threshold=body.threshold, + min_area=body.min_area, + parallel=body.parallel, + max_results=body.max_results, + ) + + return JSONResponse( + content={ + "success": True, + "message": "Search job started", + "job_id": job_id, + } + ) + + +@router.get( + "/{camera_name}/search/motion/{job_id}", + response_model=MotionSearchStatusResponse, + dependencies=[Depends(require_camera_access)], + summary="Get motion search job status", + description="Returns the status and results (if complete) of a motion search job.", +) +async def get_motion_search_status_endpoint( + request: Request, + camera_name: str, + job_id: str, +): + """Get the status of a motion search job.""" + config = request.app.frigate_config + + if camera_name not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"Camera {camera_name} not found"}, + status_code=404, + ) + + job = get_motion_search_job(job_id) + if not job or job.camera != camera_name: + return JSONResponse( + content={"success": False, "message": "Job not found"}, + status_code=404, + ) + + api_status = job.status + + # Build response content + response_content: dict[str, Any] = { + "success": api_status != JobStatusTypesEnum.failed, + "status": api_status, + } + + if api_status == JobStatusTypesEnum.failed: + response_content["message"] = job.error_message or "Search failed" + response_content["error_message"] = job.error_message + elif api_status == JobStatusTypesEnum.cancelled: + response_content["message"] = "Search cancelled" + response_content["total_frames_processed"] = job.total_frames_processed + elif api_status == JobStatusTypesEnum.success: + response_content["message"] = "Search complete" + if job.results: + response_content["results"] = job.results.get("results", []) + response_content["total_frames_processed"] = job.results.get( + "total_frames_processed", job.total_frames_processed + ) + else: + response_content["results"] = [] + response_content["total_frames_processed"] = job.total_frames_processed + else: + response_content["message"] = "Job processing" + response_content["total_frames_processed"] = job.total_frames_processed + # Include partial results if available (streaming) + if job.results: + response_content["results"] = job.results.get("results", []) + response_content["total_frames_processed"] = job.results.get( + "total_frames_processed", job.total_frames_processed + ) + + # Include metrics if available + if job.metrics: + response_content["metrics"] = job.metrics.to_dict() + + response_content["scanning_timestamp"] = job.scanning_timestamp + response_content["progress"] = job.progress + + return JSONResponse(content=response_content) + + +@router.post( + "/{camera_name}/search/motion/{job_id}/cancel", + dependencies=[Depends(require_camera_access)], + summary="Cancel motion search job", + description="Cancels an active motion search job if it is still processing.", +) +async def cancel_motion_search_endpoint( + request: Request, + camera_name: str, + job_id: str, +): + """Cancel an active motion search job.""" + config = request.app.frigate_config + + if camera_name not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"Camera {camera_name} not found"}, + status_code=404, + ) + + job = get_motion_search_job(job_id) + if not job or job.camera != camera_name: + return JSONResponse( + content={"success": False, "message": "Job not found"}, + status_code=404, + ) + + # Check if already finished + api_status = job.status + if api_status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running): + return JSONResponse( + content={ + "success": True, + "message": "Job already finished", + "status": api_status, + } + ) + + # Request cancellation + cancelled = cancel_motion_search_job(job_id) + if cancelled: + return JSONResponse( + content={ + "success": True, + "message": "Search cancelled", + "status": "cancelled", + } + ) + + return JSONResponse( + content={ + "success": False, + "message": "Failed to cancel job", + }, + status_code=500, + ) diff --git a/frigate/api/notification.py b/frigate/api/notification.py index 502e76dbd2..04749fc260 100644 --- a/frigate/api/notification.py +++ b/frigate/api/notification.py @@ -1,8 +1,10 @@ """Notification apis.""" +import ipaddress import logging import os from typing import Any +from urllib.parse import urlparse from cryptography.hazmat.primitives import serialization from fastapi import APIRouter, Depends, Request @@ -19,6 +21,95 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.notifications]) +# Push endpoints are opaque URLs but stay well under this in practice +MAX_ENDPOINT_LENGTH = 2048 + +# Suffixes that only ever resolve on the local network +INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa") + + +def _validate_push_endpoint(endpoint: Any) -> str | None: + """Return a reason the endpoint is unusable, or None when it is valid. + + Subscriptions are issued by the browser vendor's push service, so a valid + endpoint is always a public https URL. Anything else is either a broken + registration or an attempt to aim the notification sender somewhere it + should not reach. + """ + if not isinstance(endpoint, str) or not endpoint: + return "endpoint must be a url" + + if len(endpoint) > MAX_ENDPOINT_LENGTH: + return "endpoint is too long" + + try: + parsed = urlparse(endpoint) + port = parsed.port + except ValueError: + return "endpoint is not a valid url" + + if parsed.scheme != "https": + return "endpoint must use https" + + if parsed.username or parsed.password: + return "endpoint must not include credentials" + + if port is not None and port != 443: + return "endpoint must use the default https port" + + hostname = parsed.hostname + + if not hostname: + return "endpoint must include a hostname" + + try: + address = ipaddress.ip_address(hostname) + except ValueError: + address = None + + if address is not None: + # A push service is never reachable at an address only this network can + # route, so anything non-global is a misconfiguration at best + if not address.is_global: + return "endpoint must not use a private address" + elif hostname == "localhost" or "." not in hostname: + return "endpoint must use a fully qualified hostname" + elif hostname.endswith(INTERNAL_HOST_SUFFIXES): + return "endpoint must not use an internal hostname" + + # The subscription token lives in the path, and webpush.py assumes there is + # a separator after the host when it builds the VAPID audience + if len(parsed.path) <= 1: + return "endpoint must include a subscription path" + + return None + + +def _validate_subscription(sub: Any) -> str | None: + """Return a reason the subscription is unusable, or None when it is valid.""" + if not isinstance(sub, dict): + return "subscription must be an object" + + reason = _validate_push_endpoint(sub.get("endpoint")) + + if reason: + return reason + + keys = sub.get("keys") + + if not isinstance(keys, dict): + return "subscription must include keys" + + # WebPusher raises on a missing key, which would break every send for the + # user rather than just this registration + for name in ("p256dh", "auth"): + value = keys.get(name) + + if not isinstance(value, str) or not value: + return f"subscription keys must include {name}" + + return None + @router.get( "/notifications/pubkey", @@ -71,6 +162,17 @@ def register_notifications(request: Request, body: dict = None): status_code=400, ) + reason = _validate_subscription(sub) + + if reason: + logger.warning( + "Rejected notification registration for %s: %s", username, reason + ) + return JSONResponse( + content={"success": False, "message": f"Invalid subscription: {reason}"}, + status_code=400, + ) + try: User.update(notification_tokens=User.notification_tokens.append(sub)).where( User.username == username diff --git a/frigate/api/preview.py b/frigate/api/preview.py index a8fef2044f..1047591434 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -1,8 +1,10 @@ """Preview apis.""" +import bisect import logging import os -from datetime import datetime, timedelta, timezone +import threading +from datetime import UTC, datetime, timedelta import pytz from fastapi import APIRouter, Depends, HTTPException @@ -123,7 +125,7 @@ def preview_hour( """Get all mp4 previews relevant for time period given the timezone""" parts = year_month.split("-") start_date = ( - datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) + datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC) - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) @@ -133,6 +135,32 @@ def preview_hour( return preview_ts(camera_name, start_ts, end_ts, allowed_cameras) +# cache one sorted listing of the shared preview_frames dir +_preview_listing_lock = threading.Lock() +_preview_listing_cache: tuple[float, list[str]] = (-1.0, []) + + +def _get_preview_frame_listing(preview_dir: str) -> list[str]: + """Return the sorted preview_frames listing, cached until the dir changes.""" + global _preview_listing_cache + + # mtime bumps when a frame is added or removed, invalidating the cache + mtime = os.stat(preview_dir).st_mtime + cached_mtime, files = _preview_listing_cache + if mtime == cached_mtime: + return files + + with _preview_listing_lock: + # another thread may have refreshed the cache while we waited + cached_mtime, files = _preview_listing_cache + if mtime == cached_mtime: + return files + + files = sorted(entry.name for entry in os.scandir(preview_dir)) + _preview_listing_cache = (mtime, files) + return files + + @router.get( "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames", response_model=PreviewFramesResponse, @@ -145,22 +173,19 @@ def preview_hour( def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" - end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" - selected_previews = [] + file_start = f"preview_{camera_name}-" + start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue + files = _get_preview_frame_listing(preview_dir) - if file < start_file: - continue - - if file > end_file: - break - - selected_previews.append(file) + # a camera's frames form a contiguous slice of the sorted listing; + # bisect locates it without scanning the whole directory + left = bisect.bisect_left(files, start_file) + right = bisect.bisect_right(files, end_file) + selected_previews = [ + file for file in files[left:right] if file.startswith(file_start) + ] return JSONResponse( content=selected_previews, diff --git a/frigate/api/record.py b/frigate/api/record.py new file mode 100644 index 0000000000..5db257f482 --- /dev/null +++ b/frigate/api/record.py @@ -0,0 +1,469 @@ +"""Recording APIs.""" + +import datetime as dt +import logging +from datetime import datetime, timedelta +from functools import reduce +from pathlib import Path +from urllib.parse import unquote + +from fastapi import APIRouter, Depends, Request +from fastapi import Path as PathParam +from fastapi.responses import JSONResponse +from peewee import fn, operator + +from frigate.api.auth import ( + allow_any_authenticated, + get_allowed_cameras_for_filter, + require_camera_access, + require_role, +) +from frigate.api.defs.query.recordings_query_parameters import ( + MediaRecordingsAvailabilityQueryParams, + MediaRecordingsSummaryQueryParams, + RecordingsDeleteQueryParams, +) +from frigate.api.defs.response.generic_response import GenericResponse +from frigate.api.defs.tags import Tags +from frigate.const import RECORD_DIR +from frigate.models import Event, Recordings +from frigate.util.time import get_dst_transitions + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.recordings]) + + +@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))]) +def get_recordings_storage_usage(request: Request): + recording_stats = request.app.stats_emitter.get_latest_stats()["service"][ + "storage" + ][RECORD_DIR] + + if not recording_stats: + return JSONResponse({}) + + total_mb = recording_stats["total"] + + camera_usages: dict[str, dict] = ( + request.app.storage_maintainer.calculate_camera_usages() + ) + + for camera_name in camera_usages.keys(): + if camera_usages.get(camera_name, {}).get("usage"): + camera_usages[camera_name]["usage_percent"] = ( + camera_usages.get(camera_name, {}).get("usage", 0) / total_mb + ) * 100 + + return JSONResponse(content=camera_usages) + + +@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())]) +def all_recordings_summary( + request: Request, + params: MediaRecordingsSummaryQueryParams = Depends(), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + """Returns true/false by day indicating if recordings exist""" + + cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content={}) + camera_list = list(filtered) + else: + camera_list = allowed_cameras + + time_range_query = ( + Recordings.select( + fn.MIN(Recordings.start_time).alias("min_time"), + fn.MAX(Recordings.start_time).alias("max_time"), + ) + .where(Recordings.camera << camera_list) + .dicts() + .get() + ) + + min_time = time_range_query.get("min_time") + max_time = time_range_query.get("max_time") + + if min_time is None or max_time is None: + return JSONResponse(content={}) + + dst_periods = get_dst_transitions(params.timezone, min_time, max_time) + + days: dict[str, bool] = {} + + for period_start, period_end, period_offset in dst_periods: + day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int") + + period_query = ( + Recordings.select(day_expr.alias("day_idx")) + .where( + (Recordings.camera << camera_list) + & (Recordings.end_time >= period_start) + & (Recordings.start_time <= period_end) + ) + .distinct() + .namedtuples() + ) + + for g in period_query: + day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat() + days[day_str] = True + + return JSONResponse(content=dict(sorted(days.items()))) + + +@router.get( + "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] +) +async def recordings_summary(camera_name: str, timezone: str = "utc"): + """Returns hourly summary for recordings of given camera""" + + time_range_query = ( + Recordings.select( + fn.MIN(Recordings.start_time).alias("min_time"), + fn.MAX(Recordings.start_time).alias("max_time"), + ) + .where(Recordings.camera == camera_name) + .dicts() + .get() + ) + + min_time = time_range_query.get("min_time") + max_time = time_range_query.get("max_time") + + days: dict[str, dict] = {} + + if min_time is None or max_time is None: + return JSONResponse(content=list(days.values())) + + dst_periods = get_dst_transitions(timezone, min_time, max_time) + + for period_start, period_end, period_offset in dst_periods: + hours_offset = int(period_offset / 60 / 60) + minutes_offset = int(period_offset / 60 - hours_offset * 60) + period_hour_modifier = f"{hours_offset} hour" + period_minute_modifier = f"{minutes_offset} minute" + + recording_groups = ( + Recordings.select( + fn.strftime( + "%Y-%m-%d %H", + fn.datetime( + Recordings.start_time, + "unixepoch", + period_hour_modifier, + period_minute_modifier, + ), + ).alias("hour"), + fn.SUM(Recordings.duration).alias("duration"), + fn.SUM(Recordings.motion).alias("motion"), + fn.SUM(Recordings.objects).alias("objects"), + ) + .where( + (Recordings.camera == camera_name) + & (Recordings.end_time >= period_start) + & (Recordings.start_time <= period_end) + ) + .group_by((Recordings.start_time + period_offset).cast("int") / 3600) + .order_by(Recordings.start_time.desc()) + .namedtuples() + ) + + event_groups = ( + Event.select( + fn.strftime( + "%Y-%m-%d %H", + fn.datetime( + Event.start_time, + "unixepoch", + period_hour_modifier, + period_minute_modifier, + ), + ).alias("hour"), + fn.COUNT(Event.id).alias("count"), + ) + .where(Event.camera == camera_name, Event.has_clip) + .where( + (Event.start_time >= period_start) & (Event.start_time <= period_end) + ) + .group_by((Event.start_time + period_offset).cast("int") / 3600) + .namedtuples() + ) + + event_map = {g.hour: g.count for g in event_groups} + + for recording_group in recording_groups: + parts = recording_group.hour.split() + hour = parts[1] + day = parts[0] + events_count = event_map.get(recording_group.hour, 0) + hour_data = { + "hour": hour, + "events": events_count, + "motion": recording_group.motion, + "objects": recording_group.objects, + "duration": round(recording_group.duration), + } + if day in days: + # merge counts if already present (edge-case at DST boundary) + days[day]["events"] += events_count or 0 + days[day]["hours"].append(hour_data) + else: + days[day] = { + "events": events_count or 0, + "hours": [hour_data], + "day": day, + } + + return JSONResponse(content=list(days.values())) + + +@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) +async def recordings( + camera_name: str, + after: float = (datetime.now() - timedelta(hours=1)).timestamp(), + before: float = datetime.now().timestamp(), +): + """Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used""" + recordings = ( + Recordings.select( + Recordings.id, + Recordings.start_time, + Recordings.end_time, + Recordings.segment_size, + Recordings.motion, + Recordings.objects, + Recordings.motion_heatmap, + Recordings.duration, + ) + .where( + Recordings.camera == camera_name, + Recordings.end_time >= after, + Recordings.start_time <= before, + ) + .order_by(Recordings.start_time) + .dicts() + .iterator() + ) + + return JSONResponse(content=list(recordings)) + + +@router.get( + "/recordings/unavailable", + response_model=list[dict], + dependencies=[Depends(allow_any_authenticated())], +) +async def no_recordings( + request: Request, + params: MediaRecordingsAvailabilityQueryParams = Depends(), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + """Get time ranges with no recordings.""" + cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + camera_list = list(requested.intersection(allowed_cameras)) + else: + camera_list = list(allowed_cameras) + + if not camera_list: + return JSONResponse(content=[]) + + before = params.before or datetime.datetime.now().timestamp() + after = ( + params.after + or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp() + ) + scale = params.scale + + clauses = [ + (Recordings.end_time >= after) & (Recordings.start_time <= before), + (Recordings.camera << camera_list), + ] + + # Get recording start times + data: list[Recordings] = ( + Recordings.select(Recordings.start_time, Recordings.end_time) + .where(reduce(operator.and_, clauses)) + .order_by(Recordings.start_time.asc()) + .dicts() + .iterator() + ) + + # Convert recordings to list of (start, end) tuples, ordered by start_time + recordings = [(r["start_time"], r["end_time"]) for r in data] + + # Merge overlapping/adjacent recordings into covered intervals. The query + # orders by start_time, so a single pass merges them + covered: list[tuple[float, float]] = [] + for rec_start, rec_end in recordings: + if covered and rec_start <= covered[-1][1]: + covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end)) + else: + covered.append((rec_start, rec_end)) + + # Iterate through time segments and check if each has any recording + no_recording_segments = [] + current = after + current_gap_start = None + idx = 0 + covered_count = len(covered) + + while current < before: + segment_end = min(current + scale, before) + + # Advance past covered intervals that end before this segment begins; + # they cannot overlap this or any later segment. + while idx < covered_count and covered[idx][1] <= current: + idx += 1 + + # A covered interval overlaps the segment when it starts before the + # segment ends (its end is already known to be > current). + has_recording = idx < covered_count and covered[idx][0] < segment_end + + if not has_recording: + # This segment has no recordings + if current_gap_start is None: + current_gap_start = current # Start a new gap + else: + # This segment has recordings + if current_gap_start is not None: + # End the current gap and append it + no_recording_segments.append( + {"start_time": int(current_gap_start), "end_time": int(current)} + ) + current_gap_start = None + + current = segment_end + + # Append the last gap if it exists + if current_gap_start is not None: + no_recording_segments.append( + {"start_time": int(current_gap_start), "end_time": int(before)} + ) + + return JSONResponse(content=no_recording_segments) + + +@router.delete( + "/recordings/start/{start}/end/{end}", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Delete recordings", + description="""Deletes recordings within the specified time range. + Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes. + """, +) +async def delete_recordings( + start: float = PathParam(..., description="Start timestamp (unix)"), + end: float = PathParam(..., description="End timestamp (unix)"), + params: RecordingsDeleteQueryParams = Depends(), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + """Delete recordings in the specified time range.""" + if start >= end: + return JSONResponse( + content={ + "success": False, + "message": "Start time must be less than end time.", + }, + status_code=400, + ) + + cameras = params.cameras + + if cameras != "all": + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + + if not filtered: + return JSONResponse( + content={ + "success": False, + "message": "No valid cameras found in the request.", + }, + status_code=400, + ) + + camera_list = list(filtered) + else: + camera_list = allowed_cameras + + # Parse keep parameter + keep_set = set() + + if params.keep: + keep_set = set(params.keep.split(",")) + + # Build query to find overlapping recordings + clauses = [ + ( + Recordings.start_time.between(start, end) + | Recordings.end_time.between(start, end) + | ((start > Recordings.start_time) & (end < Recordings.end_time)) + ), + (Recordings.camera << camera_list), + ] + + keep_clauses = [] + + if "motion" in keep_set: + keep_clauses.append(Recordings.motion.is_null(False) & (Recordings.motion > 0)) + + if "object" in keep_set: + keep_clauses.append( + Recordings.objects.is_null(False) & (Recordings.objects > 0) + ) + + if "audio" in keep_set: + keep_clauses.append(Recordings.dBFS.is_null(False)) + + if keep_clauses: + keep_condition = reduce(operator.or_, keep_clauses) + clauses.append(~keep_condition) + + recordings_to_delete = ( + Recordings.select(Recordings.id, Recordings.path) + .where(reduce(operator.and_, clauses)) + .dicts() + .iterator() + ) + + recording_ids = [] + deleted_count = 0 + error_count = 0 + + for recording in recordings_to_delete: + recording_ids.append(recording["id"]) + + try: + Path(recording["path"]).unlink(missing_ok=True) + deleted_count += 1 + except Exception as e: + logger.error(f"Failed to delete recording file {recording['path']}: {e}") + error_count += 1 + + if recording_ids: + max_deletes = 100000 + recording_ids_list = list(recording_ids) + + for i in range(0, len(recording_ids_list), max_deletes): + Recordings.delete().where( + Recordings.id << recording_ids_list[i : i + max_deletes] + ).execute() + + message = f"Successfully deleted {deleted_count} recording(s)." + + if error_count > 0: + message += f" {error_count} file deletion error(s) occurred." + + return JSONResponse( + content={"success": True, "message": message}, + status_code=200, + ) diff --git a/frigate/api/review.py b/frigate/api/review.py index 76619dcb2d..2194c7c2fb 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -4,7 +4,6 @@ import datetime import logging from functools import reduce from pathlib import Path -from typing import List import pandas as pd from fastapi import APIRouter, Request @@ -18,6 +17,7 @@ from frigate.api.auth import ( get_allowed_cameras_for_filter, get_current_user, require_camera_access, + require_full_camera_access, require_role, ) from frigate.api.defs.query.review_query_parameters import ( @@ -33,7 +33,6 @@ from frigate.api.defs.response.review_response import ( ReviewSummaryResponse, ) from frigate.api.defs.tags import Tags -from frigate.config import FrigateConfig from frigate.embeddings import EmbeddingsContext from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum @@ -52,7 +51,7 @@ router = APIRouter(tags=[Tags.review]) async def review( params: ReviewQueryParams = Depends(), current_user: dict = Depends(get_current_user), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): if isinstance(current_user, JSONResponse): return current_user @@ -84,7 +83,7 @@ async def review( camera_list = list(filtered) else: camera_list = allowed_cameras - clauses.append((ReviewSegment.camera << camera_list)) + clauses.append(ReviewSegment.camera << camera_list) if labels != "all": # use matching so segments with multiple labels @@ -107,12 +106,12 @@ async def review( for zone in filtered_zones: zone_clauses.append( - (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') + ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*' ) clauses.append(reduce(operator.or_, zone_clauses)) if severity: - clauses.append((ReviewSegment.severity == severity)) + clauses.append(ReviewSegment.severity == severity) # Join with UserReviewStatus to get per-user review status review_query = ( @@ -205,7 +204,7 @@ async def review_ids(request: Request, ids: str): async def review_summary( params: ReviewSummaryQueryParams = Depends(), current_user: dict = Depends(get_current_user), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): if isinstance(current_user, JSONResponse): return current_user @@ -228,7 +227,7 @@ async def review_summary( camera_list = list(filtered) else: camera_list = allowed_cameras - clauses.append((ReviewSegment.camera << camera_list)) + clauses.append(ReviewSegment.camera << camera_list) if labels != "all": # use matching so segments with multiple labels @@ -329,7 +328,7 @@ async def review_summary( camera_list = list(filtered) else: camera_list = allowed_cameras - clauses.append((ReviewSegment.camera << camera_list)) + clauses.append(ReviewSegment.camera << camera_list) if labels != "all": # use matching so segments with multiple labels @@ -585,7 +584,7 @@ def delete_reviews(body: ReviewModifyMultipleBody): ) def motion_activity( params: ReviewActivityMotionQueryParams = Depends(), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): """Get motion and audio activity.""" cameras = params.cameras @@ -598,7 +597,7 @@ def motion_activity( scale = params.scale clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] - clauses.append((Recordings.motion > 0)) + clauses.append(Recordings.motion > 0) if cameras != "all": requested = set(cameras.split(",")) @@ -606,9 +605,10 @@ def motion_activity( if not filtered: return JSONResponse(content=[]) camera_list = list(filtered) - clauses.append((Recordings.camera << camera_list)) else: - clauses.append((Recordings.camera << allowed_cameras)) + camera_list = list(allowed_cameras) + + clauses.append(Recordings.camera << camera_list) data: list[Recordings] = ( Recordings.select( @@ -636,14 +636,12 @@ def motion_activity( df.set_index(["start_time"], inplace=True) # normalize data - motion = ( - df["motion"] - .resample(f"{scale}s") - .apply(lambda x: max(x, key=abs, default=0.0)) - .fillna(0.0) - .to_frame() - ) - cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x))) + motion = df["motion"].resample(f"{scale}s").max().fillna(0.0).to_frame() + + if len(camera_list) == 1: + cameras = df["camera"].resample(f"{scale}s").first().fillna("") + else: + cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x))) df = motion.join(cameras) length = df.shape[0] @@ -659,6 +657,11 @@ def motion_activity( else: df.iloc[i : i + chunk, 0] = 0.0 + # Drop resample gap-fill buckets. The resample above emits a row for every + # {scale}s bucket spanning the range, and buckets with no recording get a + # motion of 0 (from fillna) and an empty camera (from joining an empty set). + df = df[df["camera"] != ""] + # change types for output df.index = df.index.astype(int) // (10**9) normalized = df.reset_index().to_dict("records") @@ -707,6 +710,7 @@ async def get_review(request: Request, review_id: str): dependencies=[Depends(allow_any_authenticated())], ) async def set_not_reviewed( + request: Request, review_id: str, current_user: dict = Depends(get_current_user), ): @@ -725,6 +729,8 @@ async def set_not_reviewed( status_code=404, ) + await require_camera_access(review.camera, request=request) + try: user_review = UserReviewStatus.get( UserReviewStatus.user_id == user_id, @@ -741,15 +747,16 @@ async def set_not_reviewed( ) +# Intentionally not camera scoped, as the summary correlates each flagged event +# with overlapping activity on other cameras. Restricted to callers who can +# already see every camera, so the unscoped query discloses nothing. @router.post( "/review/summarize/start/{start_ts}/end/{end_ts}", - dependencies=[Depends(allow_any_authenticated())], + dependencies=[Depends(require_full_camera_access)], description="Use GenAI to summarize review items over a period of time.", ) def generate_review_summary(request: Request, start_ts: float, end_ts: float): - config: FrigateConfig = request.app.frigate_config - - if not config.genai.provider: + if not request.app.genai_manager.description_client: return JSONResponse( content=( { diff --git a/frigate/app.py b/frigate/app.py index fac7a08d95..9f8192a31d 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -4,11 +4,11 @@ import multiprocessing as mp import os import secrets import shutil +from collections.abc import Callable from multiprocessing import Queue from multiprocessing.managers import DictProxy, SyncManager from multiprocessing.synchronize import Event as MpEvent from pathlib import Path -from typing import Optional import psutil import uvicorn @@ -30,6 +30,8 @@ from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.config import FrigateConfig +from frigate.config.holder import ConfigHolder +from frigate.config.profile_manager import ProfileManager from frigate.const import ( CACHE_DIR, CLIPS_DIR, @@ -43,10 +45,16 @@ from frigate.const import ( ) from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase +from frigate.debug_replay import ( + DebugReplayManager, + cleanup_replay_cameras, +) from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.events.audio import AudioProcessor from frigate.events.cleanup import EventCleanup from frigate.events.maintainer import EventProcessor +from frigate.jobs.export import reap_stale_exports +from frigate.jobs.motion_search import stop_all_motion_search_jobs from frigate.log import _stop_logging from frigate.models import ( Event, @@ -75,6 +83,7 @@ from frigate.timeline import TimelineProcessor from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.builtin import empty_and_close_queue from frigate.util.image import UntrackedSharedMemory +from frigate.util.process import FrigateProcess from frigate.util.services import set_file_limit from frigate.version import VERSION from frigate.watchdog import FrigateWatchdog @@ -87,33 +96,32 @@ class FrigateApp: self, config: FrigateConfig, manager: SyncManager, stop_event: MpEvent ) -> None: self.metrics_manager = manager - self.audio_process: Optional[mp.Process] = None + self.audio_process: mp.Process | None = None self.stop_event = stop_event self.detection_queue: Queue = mp.Queue() self.detectors: dict[str, ObjectDetectProcess] = {} self.detection_shms: list[mp.shared_memory.SharedMemory] = [] self.log_queue: Queue = mp.Queue() self.camera_metrics: DictProxy = self.metrics_manager.dict() - self.embeddings_metrics: DataProcessorMetrics | None = ( - DataProcessorMetrics( - self.metrics_manager, list(config.classification.custom.keys()) - ) - if ( - config.semantic_search.enabled - or any( - c.objects.genai.enabled or c.review.genai.enabled - for c in config.cameras.values() - ) - or config.lpr.enabled - or config.face_recognition.enabled - or len(config.classification.custom) > 0 - ) - else None + + self.embeddings_metrics = DataProcessorMetrics( + self.metrics_manager, list(config.classification.custom.keys()) ) self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} - self.embeddings: Optional[EmbeddingsContext] = None - self.config = config + self.embeddings: EmbeddingsContext | None = None + self.config_holder = ConfigHolder(config) + + @property + def config(self) -> FrigateConfig: + """The current config, not the one Frigate booted with. + + Read through the holder so the deferred watchdog factories below build + a replacement process from the config as it is now. There is no setter + on purpose: a plain attribute would let a caller pin this back to a + single object and reintroduce the staleness. + """ + return self.config_holder.config def ensure_dirs(self) -> None: dirs = [ @@ -135,10 +143,13 @@ class FrigateApp: for d in dirs: if not os.path.exists(d) and not os.path.islink(d): logger.info(f"Creating directory: {d}") - os.makedirs(d) + os.makedirs(d, exist_ok=True) else: logger.debug(f"Skipping directory: {d}") + def init_debug_replay_manager(self) -> None: + self.replay_manager = DebugReplayManager() + def init_camera_metrics(self) -> None: # create camera_metrics for camera_name in self.config.cameras.keys(): @@ -177,17 +188,6 @@ class FrigateApp: except PermissionError: logger.error("Unable to write to /config to save DB state") - def cleanup_timeline_db(db: SqliteExtDatabase) -> None: - db.execute_sql( - "DELETE FROM timeline WHERE source_id NOT IN (SELECT id FROM event);" - ) - - try: - with open(f"{CONFIG_DIR}/.timeline", "w") as f: - f.write(str(datetime.datetime.now().timestamp())) - except PermissionError: - logger.error("Unable to write to /config to save DB state") - # Migrate DB schema migrate_db = SqliteExtDatabase(self.config.database.path) @@ -204,11 +204,6 @@ class FrigateApp: router.run() - # this is a temporary check to clean up user DB from beta - # will be removed before final release - if not os.path.exists(f"{CONFIG_DIR}/.timeline"): - cleanup_timeline_db(migrate_db) - # check if vacuum needs to be run if os.path.exists(f"{CONFIG_DIR}/.vacuum"): with open(f"{CONFIG_DIR}/.vacuum") as f: @@ -274,7 +269,7 @@ class FrigateApp: 10 * len([c for c in self.config.cameras.values() if c.enabled_in_config]), ), - load_vec_extension=self.config.semantic_search.enabled, + load_vec_extension=True, ) models = [ Event, @@ -341,6 +336,12 @@ class FrigateApp: comms, ) + def init_profile_manager(self) -> None: + self.profile_manager = ProfileManager( + self.config, self.inter_config_updater, self.dispatcher + ) + self.dispatcher.profile_manager = self.profile_manager + def start_detectors(self) -> None: for name in self.config.cameras.keys(): try: @@ -419,18 +420,11 @@ class FrigateApp: self.camera_maintainer.start() def start_audio_processor(self) -> None: - audio_cameras = [ - c - for c in self.config.cameras.values() - if c.enabled and c.audio.enabled_in_config - ] - - if audio_cameras: - self.audio_process = AudioProcessor( - self.config, audio_cameras, self.camera_metrics, self.stop_event - ) - self.audio_process.start() - self.processes["audio_detector"] = self.audio_process.pid or 0 + self.audio_process = AudioProcessor( + self.config, self.camera_metrics, self.stop_event + ) + self.audio_process.start() + self.processes["audio_detector"] = self.audio_process.pid or 0 def start_timeline_processor(self) -> None: self.timeline_processor = TimelineProcessor( @@ -474,6 +468,47 @@ class FrigateApp: def start_watchdog(self) -> None: self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event) + + # (attribute on self, key in self.processes, factory) + specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [ + ( + "embedding_process", + "embeddings", + lambda: EmbeddingProcess( + self.config, self.embeddings_metrics, self.stop_event + ), + ), + ( + "recording_process", + "recording", + lambda: RecordProcess(self.config, self.stop_event), + ), + ( + "review_segment_process", + "review_segment", + lambda: ReviewProcess(self.config, self.stop_event), + ), + ( + "output_processor", + "output", + lambda: OutputProcess(self.config, self.stop_event), + ), + ] + + for attr, key, factory in specs: + if not hasattr(self, attr): + continue + + def on_restart( + proc: FrigateProcess, _attr: str = attr, _key: str = key + ) -> None: + setattr(self, _attr, proc) + self.processes[_key] = proc.pid or 0 + + self.frigate_watchdog.register( + key, getattr(self, attr), factory, on_restart + ) + self.frigate_watchdog.start() def init_auth(self) -> None: @@ -531,6 +566,7 @@ class FrigateApp: set_file_limit() # Start frigate services. + self.init_debug_replay_manager() self.init_camera_metrics() self.init_queues() self.init_database() @@ -541,9 +577,26 @@ class FrigateApp: self.init_embeddings_manager() self.bind_database() self.check_db_data_migrations() + + # Clean up any stale replay camera artifacts (filesystem + DB) + cleanup_replay_cameras() + + # Reap any Export rows still marked in_progress from a previous + # session (crash, kill, broken migration). Runs synchronously before + # uvicorn binds so no API request can observe a stale row. + reap_stale_exports() + self.init_inter_process_communicator() self.start_detectors() self.init_dispatcher() + self.init_profile_manager() + + # workers get a copy of the config and can miss the broadcast below, so + # apply both layers here. must stay after init_profile_manager(), which + # snapshots the base config that profile deactivation resets to + self.profile_manager.restore_persisted_profile_to_config() + self.dispatcher.reapply_runtime_state_to_config() + self.init_embeddings_client() self.start_video_output_processor() self.start_ptz_autotracker() @@ -558,6 +611,11 @@ class FrigateApp: self.start_record_cleanup() self.start_watchdog() + # publish for the recording/review/embeddings processes, which start + # before the config can be corrected, and for the retained MQTT states + self.profile_manager.restore_persisted_profile() + self.dispatcher.restore_runtime_state() + self.init_auth() try: @@ -572,6 +630,10 @@ class FrigateApp: self.stats_emitter, self.event_metadata_updater, self.inter_config_updater, + self.replay_manager, + self.dispatcher, + self.profile_manager, + config_holder=self.config_holder, ), host="127.0.0.1", port=5001, @@ -586,6 +648,9 @@ class FrigateApp: # used by the docker healthcheck Path("/dev/shm/.frigate-is-stopping").touch() + # Cancel any running motion search jobs before setting stop_event + stop_all_motion_search_jobs() + self.stop_event.set() # set an end_time on entries without an end_time before exiting @@ -637,6 +702,7 @@ class FrigateApp: self.record_cleanup.join() self.stats_emitter.join() self.frigate_watchdog.join() + self.camera_maintainer.join() self.db.stop() # Save embeddings stats to disk diff --git a/frigate/camera/__init__.py b/frigate/camera/__init__.py index 77b1fd4246..85831653e1 100644 --- a/frigate/camera/__init__.py +++ b/frigate/camera/__init__.py @@ -1,24 +1,27 @@ import multiprocessing as mp -from multiprocessing.managers import SyncManager +import queue +from multiprocessing.managers import SyncManager, ValueProxy from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event class CameraMetrics: - camera_fps: Synchronized - detection_fps: Synchronized - detection_frame: Synchronized - process_fps: Synchronized - skipped_fps: Synchronized - read_start: Synchronized - audio_rms: Synchronized - audio_dBFS: Synchronized + camera_fps: ValueProxy[float] + detection_fps: ValueProxy[float] + detection_frame: ValueProxy[float] + process_fps: ValueProxy[float] + skipped_fps: ValueProxy[float] + read_start: ValueProxy[float] + audio_rms: ValueProxy[float] + audio_dBFS: ValueProxy[float] - frame_queue: mp.Queue + frame_queue: queue.Queue - process_pid: Synchronized - capture_process_pid: Synchronized - ffmpeg_pid: Synchronized + process_pid: ValueProxy[int] + capture_process_pid: ValueProxy[int] + ffmpeg_pid: ValueProxy[int] + reconnects_last_hour: ValueProxy[int] + stalls_last_hour: ValueProxy[int] def __init__(self, manager: SyncManager): self.camera_fps = manager.Value("d", 0) @@ -35,6 +38,8 @@ class CameraMetrics: self.process_pid = manager.Value("i", 0) self.capture_process_pid = manager.Value("i", 0) self.ffmpeg_pid = manager.Value("i", 0) + self.reconnects_last_hour = manager.Value("i", 0) + self.stalls_last_hour = manager.Value("i", 0) class PTZMetrics: @@ -52,14 +57,14 @@ class PTZMetrics: reset: Event def __init__(self, *, autotracker_enabled: bool): - self.autotracker_enabled = mp.Value("i", autotracker_enabled) + self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment] - self.start_time = mp.Value("d", 0) - self.stop_time = mp.Value("d", 0) - self.frame_time = mp.Value("d", 0) - self.zoom_level = mp.Value("d", 0) - self.max_zoom = mp.Value("d", 0) - self.min_zoom = mp.Value("d", 0) + self.start_time = mp.Value("d", 0) # type: ignore[assignment] + self.stop_time = mp.Value("d", 0) # type: ignore[assignment] + self.frame_time = mp.Value("d", 0) # type: ignore[assignment] + self.zoom_level = mp.Value("d", 0) # type: ignore[assignment] + self.max_zoom = mp.Value("d", 0) # type: ignore[assignment] + self.min_zoom = mp.Value("d", 0) # type: ignore[assignment] self.tracking_active = mp.Event() self.motor_stopped = mp.Event() diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index c2dfa891da..bd3474b1ab 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -6,13 +6,18 @@ import logging import random import string from collections import Counter -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataTypeEnum, ) from frigate.config import CameraConfig, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) logger = logging.getLogger(__name__) @@ -29,6 +34,11 @@ class CameraActivityManager: self.zone_all_object_counts: dict[str, Counter] = {} self.zone_active_object_counts: dict[str, Counter] = {} self.all_zone_labels: dict[str, set[str]] = {} + self.config_subscriber = CameraConfigUpdateSubscriber( + config, + config.cameras, + [CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects], + ) for camera_config in config.cameras.values(): if not camera_config.enabled_in_config: @@ -37,6 +47,9 @@ class CameraActivityManager: self.__init_camera(camera_config) def __init_camera(self, camera_config: CameraConfig) -> None: + if camera_config.name is None: + return + self.last_camera_activity[camera_config.name] = {} self.camera_all_object_counts[camera_config.name] = Counter() self.camera_active_object_counts[camera_config.name] = Counter() @@ -53,10 +66,46 @@ class CameraActivityManager: else camera_config.objects.track ) + def __rebuild_zone_labels(self) -> None: + """Rebuild zone label tracking after a runtime zones/objects change.""" + new_zone_labels: dict[str, set[str]] = {} + + for camera_config in self.config.cameras.values(): + if not camera_config.enabled_in_config or camera_config.name is None: + continue + + for zone, zone_config in camera_config.zones.items(): + new_zone_labels.setdefault(zone, set()).update( + zone_config.objects + if zone_config.objects + else camera_config.objects.track + ) + + # drop counters for zones that no longer exist + for zone in list(self.zone_all_object_counts.keys()): + if zone not in new_zone_labels: + self.zone_all_object_counts.pop(zone, None) + self.zone_active_object_counts.pop(zone, None) + + # ensure counters exist for new zones so the first count is published + for zone in new_zone_labels: + self.zone_all_object_counts.setdefault(zone, Counter()) + self.zone_active_object_counts.setdefault(zone, Counter()) + + self.all_zone_labels = new_zone_labels + def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None: + updated_topics = self.config_subscriber.check_for_updates() + + if "zones" in updated_topics or "objects" in updated_topics: + self.__rebuild_zone_labels() + all_objects: list[dict[str, Any]] = [] for camera in new_activity.keys(): + if camera not in self.config.cameras: + continue + # handle cameras that were added dynamically if camera not in self.camera_all_object_counts: self.__init_camera(self.config.cameras[camera]) @@ -111,7 +160,7 @@ class CameraActivityManager: self.last_camera_activity = new_activity def compare_camera_activity( - self, camera: str, new_activity: dict[str, Any] + self, camera: str, new_activity: list[dict[str, Any]] ) -> None: all_objects = Counter( obj["label"].replace("-verified", "") for obj in new_activity @@ -124,7 +173,11 @@ class CameraActivityManager: any_changed = False # run through each object and check what topics need to be updated - for label in self.config.cameras[camera].objects.track: + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return + + for label in camera_config.objects.track: if label in self.config.model.non_logo_attributes: continue @@ -151,6 +204,9 @@ class CameraActivityManager: self.publish(f"{camera}/all", sum(list(all_objects.values()))) self.publish(f"{camera}/all/active", sum(list(active_objects.values()))) + def stop(self) -> None: + self.config_subscriber.stop() + class AudioActivityManager: def __init__( @@ -168,12 +224,18 @@ class AudioActivityManager: self.__init_camera(camera_config) def __init_camera(self, camera_config: CameraConfig) -> None: + if camera_config.name is None: + return + self.current_audio_detections[camera_config.name] = {} def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None: now = datetime.datetime.now().timestamp() for camera in new_activity.keys(): + if camera not in self.config.cameras: + continue + # handle cameras that were added dynamically if camera not in self.current_audio_detections: self.__init_camera(self.config.cameras[camera]) @@ -192,8 +254,12 @@ class AudioActivityManager: def compare_audio_activity( self, camera: str, new_detections: list[tuple[str, float]], now: float - ) -> None: - max_not_heard = self.config.cameras[camera].audio.max_not_heard + ) -> bool: + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return False + + max_not_heard = camera_config.audio.max_not_heard current = self.current_audio_detections[camera] any_changed = False @@ -222,6 +288,7 @@ class AudioActivityManager: None, "audio", {}, + None, ), EventMetadataTypeEnum.manual_event_create.value, ) diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index 815e650e90..9f63ead2da 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -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,13 +51,26 @@ class CameraMaintainer(threading.Thread): [ CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.refresh, ], ) self.shm_count = self.__calculate_shm_frame_count() self.camera_processes: dict[str, mp.Process] = {} self.capture_processes: dict[str, mp.Process] = {} + self.camera_stop_events: dict[str, MpEvent] = {} self.metrics_manager = metrics_manager + def __ensure_camera_stop_event(self, camera: str) -> MpEvent: + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is None: + camera_stop_event = mp.Event() + self.camera_stop_events[camera] = camera_stop_event + else: + camera_stop_event.clear() + + return camera_stop_event + def __init_historical_regions(self) -> None: # delete region grids for removed or renamed cameras cameras = list(self.config.cameras.keys()) @@ -90,7 +104,7 @@ class CameraMaintainer(threading.Thread): f"recommend increasing it to at least {shm_stats['min_shm']}MB." ) - return shm_stats["shm_frame_count"] + return int(shm_stats["shm_frame_count"]) def __start_camera_processor( self, name: str, config: CameraConfig, runtime: bool = False @@ -99,9 +113,13 @@ class CameraMaintainer(threading.Thread): logger.info(f"Camera processor not started for disabled camera {name}") return + camera_stop_event = self.__ensure_camera_stop_event(name) + if runtime: self.camera_metrics[name] = CameraMetrics(self.metrics_manager) - self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) + self.ptz_metrics[name] = PTZMetrics( + autotracker_enabled=config.onvif.autotracking.enabled + ) self.region_grids[name] = get_camera_regions_grid( name, config.detect, @@ -135,13 +153,13 @@ class CameraMaintainer(threading.Thread): self.camera_metrics[name], self.ptz_metrics[name], self.region_grids[name], - self.stop_event, + camera_stop_event, self.config.logger, ) - self.camera_processes[config.name] = camera_process + self.camera_processes[name] = camera_process camera_process.start() - self.camera_metrics[config.name].process_pid.value = camera_process.pid - logger.info(f"Camera processor started for {config.name}: {camera_process.pid}") + self.camera_metrics[name].process_pid.value = camera_process.pid + logger.info(f"Camera processor started for {name}: {camera_process.pid}") def __start_camera_capture( self, name: str, config: CameraConfig, runtime: bool = False @@ -150,6 +168,8 @@ class CameraMaintainer(threading.Thread): logger.info(f"Capture process not started for disabled camera {name}") return + camera_stop_event = self.__ensure_camera_stop_event(name) + # pre-create shms count = 10 if runtime else self.shm_count for i in range(count): @@ -160,7 +180,7 @@ class CameraMaintainer(threading.Thread): config, count, self.camera_metrics[name], - self.stop_event, + camera_stop_event, self.config.logger, ) capture_process.daemon = True @@ -170,22 +190,59 @@ class CameraMaintainer(threading.Thread): logger.info(f"Capture process started for {name}: {capture_process.pid}") def __stop_camera_capture_process(self, camera: str) -> None: - capture_process = self.capture_processes[camera] + capture_process = self.capture_processes.get(camera) if capture_process is not None: logger.info(f"Waiting for capture process for {camera} to stop") - capture_process.terminate() - capture_process.join() + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is not None: + camera_stop_event.set() + + capture_process.join(timeout=10) + if capture_process.is_alive(): + logger.warning( + f"Capture process for {camera} didn't exit, forcing termination" + ) + 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[camera] + camera_process = self.camera_processes.get(camera) if camera_process is not None: logger.info(f"Waiting for process for {camera} to stop") - camera_process.terminate() - camera_process.join() + camera_stop_event = self.camera_stop_events.get(camera) + + if camera_stop_event is not None: + camera_stop_event.set() + + camera_process.join(timeout=10) + if camera_process.is_alive(): + logger.warning(f"Process for {camera} didn't exit, forcing termination") + camera_process.terminate() + camera_process.join() logger.info(f"Closing frame queue for {camera}") empty_and_close_queue(self.camera_metrics[camera].frame_queue) - def run(self): + def run(self) -> None: self.__init_historical_regions() # start camera processes @@ -199,6 +256,12 @@ class CameraMaintainer(threading.Thread): for update_type, updated_cameras in updates.items(): if update_type == CameraConfigUpdateEnum.add.name: for camera in updated_cameras: + if ( + camera in self.camera_processes + or camera in self.capture_processes + ): + continue + self.__start_camera_processor( camera, self.update_subscriber.camera_configs[camera], @@ -210,15 +273,55 @@ class CameraMaintainer(threading.Thread): runtime=True, ) elif update_type == CameraConfigUpdateEnum.remove.name: - self.__stop_camera_capture_process(camera) - self.__stop_camera_process(camera) + 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.camera_processes.keys(): + for camera in self.capture_processes.keys(): self.__stop_camera_capture_process(camera) # ensure the camera processors are done - for camera in self.capture_processes.keys(): + for camera in self.camera_processes.keys(): self.__stop_camera_process(camera) self.update_subscriber.stop() diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 97c7153880..c94aa5654d 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -5,7 +5,8 @@ import logging import os import threading from collections import defaultdict -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import cv2 import numpy as np @@ -31,29 +32,57 @@ logger = logging.getLogger(__name__) class CameraState: def __init__( self, - name, + name: str, config: FrigateConfig, frame_manager: SharedMemoryFrameManager, ptz_autotracker_thread: PtzAutoTrackerThread, - ): + ) -> None: self.name = name self.config = config self.camera_config = config.cameras[name] self.frame_manager = frame_manager self.best_objects: dict[str, TrackedObject] = {} self.tracked_objects: dict[str, TrackedObject] = {} - self.frame_cache = {} - self.zone_objects = defaultdict(list) + 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 = [] - self.regions = [] - self.previous_frame_id = None - self.callbacks = defaultdict(list) + self.motion_boxes: list[tuple[int, int, int, int]] = [] + self.regions: list[tuple[int, int, int, int]] = [] + self.previous_frame_id: str | None = None + self.callbacks: defaultdict[str, list[Callable]] = defaultdict(list) self.ptz_autotracker_thread = ptz_autotracker_thread self.prev_enabled = self.camera_config.enabled + # Minimum object area thresholds for fast-tracking updates to secondary + # face/LPR pipelines when using a model without built-in detection. + self.face_recognition_min_obj_area: int = 0 + self.lpr_min_obj_area: int = 0 + self.lp_objects = { + label + for label, attributes in config.model.attributes_map.items() + if "license_plate" in attributes + } + + if ( + self.camera_config.face_recognition.enabled + and "face" not in config.objects.all_objects + ): + # A face is roughly 1/8 of person box area; use a conservative + # multiplier so fast-tracking starts slightly before the optimal zone + self.face_recognition_min_obj_area = ( + self.camera_config.face_recognition.min_area * 6 + ) + + if ( + self.camera_config.lpr.enabled + and "license_plate" not in self.camera_config.objects.track + ): + # A plate is a smaller fraction of a vehicle box; use ~20x multiplier + self.lpr_min_obj_area = self.camera_config.lpr.min_area * 20 + def get_current_frame(self, draw_options: dict[str, Any] = {}) -> np.ndarray: with self.current_frame_lock: frame_copy = np.copy(self._current_frame) @@ -62,10 +91,10 @@ class CameraState: motion_boxes = self.motion_boxes.copy() regions = self.regions.copy() - frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) + frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) # type: ignore[assignment] # draw on the frame if draw_options.get("mask"): - mask_overlay = np.where(self.camera_config.motion.mask == [0]) + mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) # type: ignore[attr-defined] frame_copy[mask_overlay] = [0, 0, 0] if draw_options.get("bounding_boxes"): @@ -87,9 +116,9 @@ class CameraState: # draw thicker box around ptz autotracked object if ( self.camera_config.onvif.autotracking.enabled - and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[ + and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get( self.name - ] + ) and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ self.name ] @@ -97,7 +126,7 @@ class CameraState: and obj["id"] == self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ self.name - ].obj_data["id"] + ].obj_data["id"] # type: ignore[attr-defined] and obj["frame_time"] == frame_time ): thickness = 5 @@ -109,10 +138,12 @@ class CameraState: if ( self.camera_config.onvif.autotracking.zooming != ZoomingModeEnum.disabled + and self.camera_config.detect.width is not None + and self.camera_config.detect.height is not None ): max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[ self.name - ]["max_target_box"] + ]["max_target_box"] # type: ignore[index] side_length = max_target_box * ( max( self.camera_config.detect.width, @@ -197,6 +228,10 @@ class CameraState: if draw_options.get("zones"): for name, zone in self.camera_config.zones.items(): + # skip disabled zones + if not zone.enabled: + continue + thickness = ( 8 if any( @@ -217,14 +252,14 @@ class CameraState: ) if draw_options.get("timestamp"): - color = self.camera_config.timestamp_style.color + ts_color = self.camera_config.timestamp_style.color draw_timestamp( frame_copy, frame_time, self.camera_config.timestamp_style.format, font_effect=self.camera_config.timestamp_style.effect, font_thickness=self.camera_config.timestamp_style.thickness, - font_color=(color.blue, color.green, color.red), + font_color=(ts_color.blue, ts_color.green, ts_color.red), position=self.camera_config.timestamp_style.position, ) @@ -269,12 +304,48 @@ class CameraState: return frame_copy - def finished(self, obj_id): + def finished(self, obj_id: str) -> None: del self.tracked_objects[obj_id] - def on(self, event_type: str, callback: Callable): + 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, @@ -282,7 +353,10 @@ class CameraState: current_detections: dict[str, dict[str, Any]], 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 ) @@ -304,14 +378,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), - "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 = { @@ -352,7 +430,8 @@ class CameraState: if thumb_update and current_frame is not None: # ensure this frame is stored in the cache if ( - updated_obj.thumbnail_data["frame_time"] == frame_time + updated_obj.thumbnail_data is not None + and updated_obj.thumbnail_data["frame_time"] == frame_time and frame_time not in self.frame_cache ): logger.debug( @@ -365,13 +444,30 @@ class CameraState: updated_obj.last_updated = frame_time - # if it has been more than 5 seconds since the last thumb update - # and the last update is greater than the last publish or - # the object has changed significantly or - # the object moved enough to update the path + # Determine the staleness threshold for publishing updates. + # Fast-track to 1s for objects in the optimal size range for + # secondary face/LPR recognition that don't yet have a sub_label. + obj_area = updated_obj.obj_data.get("area", 0) + obj_label = updated_obj.obj_data.get("label") + publish_threshold = 5 + + if ( + obj_label == "person" + and self.face_recognition_min_obj_area > 0 + and obj_area >= self.face_recognition_min_obj_area + and updated_obj.obj_data.get("sub_label") is None + ) or ( + obj_label in self.lp_objects + and self.lpr_min_obj_area > 0 + and obj_area >= self.lpr_min_obj_area + and updated_obj.obj_data.get("sub_label") is None + and updated_obj.obj_data.get("recognized_license_plate") is None + ): + publish_threshold = 1 + if ( ( - frame_time - updated_obj.last_published > 5 + frame_time - updated_obj.last_published > publish_threshold and updated_obj.last_updated > updated_obj.last_published ) or significant_update @@ -382,6 +478,18 @@ class CameraState: c(self.name, updated_obj, frame_name) updated_obj.last_published = frame_time + # send MQTT snapshot when object first enters a required zone, + # since the initial snapshot at creation time is blocked before + # zone evaluation has run + if updated_obj.new_zone_entered and not updated_obj.false_positive: + mqtt_required = self.camera_config.mqtt.required_zones + if mqtt_required and set(updated_obj.entered_zones) & set( + mqtt_required + ): + object_type = updated_obj.obj_data["label"] + self.send_mqtt_snapshot(updated_obj, object_type) + updated_obj.new_zone_entered = False + for id in removed_ids: # publish events to mqtt removed_obj = tracked_objects[id] @@ -393,7 +501,7 @@ class CameraState: # TODO: can i switch to looking this up and only changing when an event ends? # maintain best objects - camera_activity: dict[str, list[Any]] = { + camera_activity: dict[str, Any] = { "motion": len(motion_boxes) > 0, "objects": [], } @@ -407,10 +515,7 @@ class CameraState: sub_label = None if obj.obj_data.get("sub_label"): - if ( - obj.obj_data.get("sub_label")[0] - in self.config.model.all_attributes - ): + if obj.obj_data["sub_label"][0] in self.config.model.all_attributes: label = obj.obj_data["sub_label"][0] else: label = f"{object_type}-verified" @@ -445,14 +550,19 @@ class CameraState: # if the object is a higher score than the current best score # or the current object is older than desired, use the new object if ( - is_better_thumbnail( - object_type, + current_best.thumbnail_data is not None + and obj.thumbnail_data is not None + and is_better_thumbnail( + obj.thumbnail_attributes, current_best.thumbnail_data, obj.thumbnail_data, self.camera_config.frame_shape, ) - or (now - current_best.thumbnail_data["frame_time"]) - > self.camera_config.best_image_timeout + or ( + current_best.thumbnail_data is not None + and (now - current_best.thumbnail_data["frame_time"]) + > self.camera_config.best_image_timeout + ) ): self.send_mqtt_snapshot(obj, object_type) else: @@ -468,7 +578,9 @@ class CameraState: if obj.thumbnail_data is not None } current_best_frames = { - obj.thumbnail_data["frame_time"] for obj in self.best_objects.values() + obj.thumbnail_data["frame_time"] + for obj in self.best_objects.values() + if obj.thumbnail_data is not None } thumb_frames_to_delete = [ t @@ -528,53 +640,24 @@ class CameraState: ) -> None: img_frame = frame if frame is not None else self.get_current_frame() - # write clean snapshot if enabled - if self.camera_config.snapshots.clean_copy: - ret, webp = cv2.imencode( - ".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80] - ) + ret, webp = cv2.imencode( + ".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80] + ) - if ret: - with open( - os.path.join( - CLIPS_DIR, - f"{self.camera_config.name}-{event_id}-clean.webp", - ), - "wb", - ) as p: - p.write(webp.tobytes()) - - # write jpg snapshot with optional annotations - if draw.get("boxes") and isinstance(draw.get("boxes"), list): - for box in draw.get("boxes"): - x = int(box["box"][0] * self.camera_config.detect.width) - y = int(box["box"][1] * self.camera_config.detect.height) - width = int(box["box"][2] * self.camera_config.detect.width) - height = int(box["box"][3] * self.camera_config.detect.height) - - draw_box_with_label( - img_frame, - x, - y, - x + width, - y + height, - label, - f"{box.get('score', '-')}% {int(width * height)}", - thickness=2, - color=box.get("color", (255, 0, 0)), - ) - - ret, jpg = cv2.imencode(".jpg", img_frame) - with open( - os.path.join(CLIPS_DIR, f"{self.camera_config.name}-{event_id}.jpg"), - "wb", - ) as j: - j.write(jpg.tobytes()) + if ret: + with open( + os.path.join( + CLIPS_DIR, + f"{self.name}-{event_id}-clean.webp", + ), + "wb", + ) as p: + p.write(webp.tobytes()) # create thumbnail with max height of 175 and save width = int(175 * img_frame.shape[1] / img_frame.shape[0]) thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA) - thumb_path = os.path.join(THUMB_DIR, self.camera_config.name) + thumb_path = os.path.join(THUMB_DIR, self.name) os.makedirs(thumb_path, exist_ok=True) cv2.imwrite(os.path.join(thumb_path, f"{event_id}.webp"), thumb) diff --git a/frigate/comms/base_communicator.py b/frigate/comms/base_communicator.py index 5dfbf1115e..090552a5bd 100644 --- a/frigate/comms/base_communicator.py +++ b/frigate/comms/base_communicator.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import Any, Callable +from collections.abc import Callable +from typing import Any class Communicator(ABC): diff --git a/frigate/comms/config_updater.py b/frigate/comms/config_updater.py index 447089a949..4552abc111 100644 --- a/frigate/comms/config_updater.py +++ b/frigate/comms/config_updater.py @@ -26,8 +26,8 @@ class ConfigPublisher: def stop(self) -> None: self.stop_event.set() - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class ConfigSubscriber: @@ -55,5 +55,5 @@ class ConfigSubscriber: return (None, None) def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 6e45ac1756..ec16f9744e 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -3,11 +3,13 @@ import datetime import json import logging -from typing import Any, Callable, Optional, cast +from collections.abc import Callable, Iterable +from typing import Any, cast from frigate.camera import PTZMetrics from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager from frigate.comms.base_communicator import Communicator +from frigate.comms.runtime_state import RuntimeStatePersistence from frigate.comms.webpush import WebPushClient from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.config.camera.updater import ( @@ -15,6 +17,8 @@ from frigate.config.camera.updater import ( CameraConfigUpdatePublisher, CameraConfigUpdateTopic, ) +from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig +from frigate.config.profile_manager import ProfileManager from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, EXPIRE_AUDIO_ACTIVITY, @@ -28,6 +32,7 @@ from frigate.const import ( UPDATE_CAMERA_ACTIVITY, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_EVENT_DESCRIPTION, + UPDATE_JOB_STATE, UPDATE_MODEL_STATE, UPDATE_REVIEW_DESCRIPTION, UPSERT_REVIEW_SEGMENT, @@ -60,9 +65,11 @@ class Dispatcher: self.camera_activity = CameraActivityManager(config, self.publish) self.audio_activity = AudioActivityManager(config, self.publish) self.model_state: dict[str, ModelStatusTypesEnum] = {} + self.job_state: dict[str, dict[str, Any]] = {} # {job_type: job_data} self.embeddings_reindex: dict[str, Any] = {} self.birdseye_layout: dict[str, Any] = {} self.audio_transcription_state: str = "idle" + self._runtime_state = RuntimeStatePersistence() self._camera_settings_handlers: dict[str, Callable] = { "audio": self._on_audio_command, "audio_transcription": self._on_audio_transcription_command, @@ -82,10 +89,15 @@ class Dispatcher: "review_detections": self._on_detections_command, "object_descriptions": self._on_object_description_command, "review_descriptions": self._on_review_description_command, + "motion_mask": self._on_motion_mask_command, + "object_mask": self._on_object_mask_command, + "zone": self._on_zone_command, } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, + "profile": self._on_profile_command, } + self.profile_manager: ProfileManager | None = None for comm in self.comms: comm.subscribe(self._receive) @@ -93,16 +105,41 @@ class Dispatcher: self.web_push_client = next( (comm for comm in communicators if isinstance(comm, WebPushClient)), None ) + if self.web_push_client is not None: + self.web_push_client.set_suspension_broadcaster(self.publish) - def _receive(self, topic: str, payload: Any) -> Optional[Any]: + def _receive(self, topic: str, payload: Any) -> Any | None: """Handle receiving of payload from communicators.""" def handle_camera_command( - command_type: str, camera_name: str, command: str, payload: str + command_type: str, + camera_name: str, + command: str, + payload: str, + sub_command: str | None = None, ) -> None: + if camera_name not in self.config.cameras: + return + try: if command_type == "set": - self._camera_settings_handlers[command](camera_name, payload) + # Commands that require a sub-command (mask/zone name) + sub_command_required = { + "motion_mask", + "object_mask", + "zone", + } + if sub_command: + self._camera_settings_handlers[command]( + camera_name, sub_command, payload + ) + elif command in sub_command_required: + logger.error( + "Command %s requires a sub-command (mask/zone name)", + command, + ) + else: + self._camera_settings_handlers[command](camera_name, payload) elif command_type == "ptz": self._on_ptz_command(camera_name, payload) except KeyError: @@ -116,6 +153,9 @@ class Dispatcher: def handle_request_region_grid() -> Any: camera = payload + if camera not in self.config.cameras: + return None + grid = get_camera_regions_grid( camera, self.config.cameras[camera].detect, @@ -180,6 +220,19 @@ class Dispatcher: def handle_model_state() -> None: self.publish("model_state", json.dumps(self.model_state.copy())) + def handle_update_job_state() -> None: + if payload and isinstance(payload, dict): + job_type = payload.get("job_type") + if job_type: + self.job_state[job_type] = payload + self.publish( + "job_state", + json.dumps(self.job_state), + ) + + def handle_job_state() -> None: + self.publish("job_state", json.dumps(self.job_state.copy())) + def handle_update_audio_transcription_state() -> None: if payload: self.audio_transcription_state = payload @@ -215,7 +268,11 @@ class Dispatcher: self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) def handle_on_connect() -> None: - camera_status = self.camera_activity.last_camera_activity.copy() + camera_status = { + camera: status + for camera, status in self.camera_activity.last_camera_activity.copy().items() + if camera in self.config.cameras + } audio_detections = self.audio_activity.current_audio_detections.copy() cameras_with_status = camera_status.keys() @@ -260,6 +317,11 @@ class Dispatcher: ) self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) self.publish("audio_detections", json.dumps(audio_detections)) + self.publish( + "profile/state", + self.config.active_profile or "none", + retain=True, + ) def handle_notification_test() -> None: self.publish("notification_test", "Test notification") @@ -277,6 +339,7 @@ class Dispatcher: UPDATE_EVENT_DESCRIPTION: handle_update_event_description, UPDATE_REVIEW_DESCRIPTION: handle_update_review_description, UPDATE_MODEL_STATE: handle_update_model_state, + UPDATE_JOB_STATE: handle_update_job_state, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout, UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state, @@ -284,6 +347,7 @@ class Dispatcher: "restart": handle_restart, "embeddingsReindexProgress": handle_embeddings_reindex_progress, "modelState": handle_model_state, + "jobState": handle_job_state, "audioTranscriptionState": handle_audio_transcription_state, "birdseyeLayout": handle_birdseye_layout, "onConnect": handle_on_connect, @@ -297,6 +361,14 @@ class Dispatcher: camera_name = parts[-3] command = parts[-2] handle_camera_command("set", camera_name, command, payload) + elif len(parts) == 4 and topic.endswith("set"): + # example /cam_name/motion_mask/mask_name/set payload=ON|OFF + camera_name = parts[-4] + command = parts[-3] + sub_command = parts[-2] + handle_camera_command( + "set", camera_name, command, payload, sub_command + ) elif len(parts) == 2 and topic.endswith("set"): command = parts[-2] self._global_settings_handlers[command](payload) @@ -308,7 +380,8 @@ class Dispatcher: # example /cam_name/notifications/suspend payload=duration camera_name = parts[-3] command = parts[-2] - self._on_camera_notification_suspend(camera_name, payload) + if camera_name in self.config.cameras: + self._on_camera_notification_suspend(camera_name, payload) except IndexError: logger.error( f"Received invalid {topic.split('/')[-1]} command: {topic}" @@ -326,9 +399,141 @@ class Dispatcher: comm.publish(topic, payload, retain) def stop(self) -> None: + self.camera_activity.stop() + for comm in self.comms: comm.stop() + def apply_runtime_state(self) -> dict[str, dict[str, bool]]: + """Replay persisted runtime overrides through the camera settings handlers. + + Routing through the handlers (rather than mutating config directly) is + deliberate: they publish the ``config_updater`` broadcast and the + retained MQTT state as a side effect, so worker processes and the UI + converge on the replayed value. Unknown cameras and topics are skipped; + handler exceptions are logged and replay continues for the rest. + + Returns: + The entries handed to a handler without raising, keyed by camera + then topic. A handler can still refuse the value internally (an ON + payload for a camera that is not enabled_in_config, for example), + so this is not proof the override took effect. + """ + state = self._runtime_state.load() + applied: dict[str, dict[str, bool]] = {} + + for camera_name, features in state.items(): + if camera_name not in self.config.cameras: + continue + + for topic, value in features.items(): + handler = self._camera_settings_handlers.get(topic) + + if handler is None: + continue + + payload = "ON" if value else "OFF" + + try: + handler(camera_name, payload) + except Exception: + logger.exception( + "Failed to apply runtime state %s.%s=%s", + camera_name, + topic, + payload, + ) + continue + + applied.setdefault(camera_name, {})[topic] = value + + return applied + + def restore_runtime_state(self) -> None: + """Replay persisted runtime overrides once Frigate startup completes. + + Called after every ``config_updater`` subscriber is up so the resulting + broadcasts are not dropped by ZMQ PUB/SUB. + """ + for camera_name, features in self.apply_runtime_state().items(): + for topic, value in features.items(): + logger.info( + "Restored runtime state: %s.%s=%s", + camera_name, + topic, + "ON" if value else "OFF", + ) + + def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: + """Clear stored runtime overrides for YAML keys that were just rewritten. + + Called by ``/api/config/set`` after a successful YAML save so an + explicit settings-UI save isn't silently overridden by an older + runtime toggle on the next restart. + """ + self._runtime_state.clear_for_yaml_keys(dotted_keys) + + def clear_runtime_state(self) -> None: + """Wipe every stored runtime override. + + Called when a profile is activated or deactivated. A profile switch + changes the layer below the runtime overrides, so the stored + "steady state" is no longer valid and must be reset; otherwise a + subsequent restart would replay stale overrides on top of the new + profile-derived in-memory state. + """ + self._runtime_state.clear_all() + + def clear_runtime_state_for_camera(self, camera: str) -> None: + """Drop all persisted runtime overrides for a deleted camera. + + Called by camera deletion so a camera later added under the same name + does not inherit the removed camera's stale toggles. + """ + self._runtime_state.clear_camera(camera) + + def reapply_runtime_state_to_config(self) -> None: + """Re-apply persisted runtime overrides to the swapped-in config object. + + After config/set (or a camera delete) parses fresh yaml and swaps the + config, the worker processes still hold the live toggle values and the + overrides are already on disk, so only the in-process config object is + out of date. Unlike apply_runtime_state (used at startup, where workers + must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects + the config the API and dispatcher read. + + The field mutations and gates mirror the _on_*_command handlers; keep + the two in sync if a tracked toggle is added or its gate changes. + """ + state = self._runtime_state.load() + + for camera_name, features in state.items(): + camera = self.config.cameras.get(camera_name) + + if camera is None: + continue + + for topic, value in features.items(): + if topic == "enabled": + if value and not camera.enabled_in_config: + continue + camera.enabled = value + elif topic == "detect": + camera.detect.enabled = value + # detection requires motion, mirror the handler coupling + if value and not camera.motion.enabled: + camera.motion.enabled = True + elif topic == "snapshots": + camera.snapshots.enabled = value + elif topic == "recordings": + if value and not camera.record.enabled_in_config: + continue + camera.record.enabled = value + elif topic == "audio": + if value and not camera.audio.enabled_in_config: + continue + camera.audio.enabled = value + def _on_detect_command(self, camera_name: str, payload: str) -> None: """Callback for detect topic.""" detect_settings = self.config.cameras[camera_name].detect @@ -360,6 +565,7 @@ class Dispatcher: CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name), detect_settings, ) + self._runtime_state.set(camera_name, "detect", detect_settings.enabled) self.publish(f"{camera_name}/detect/state", payload, retain=True) def _on_enabled_command(self, camera_name: str, payload: str) -> None: @@ -384,6 +590,7 @@ class Dispatcher: CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name), camera_settings.enabled, ) + self._runtime_state.set(camera_name, "enabled", camera_settings.enabled) self.publish(f"{camera_name}/enabled/state", payload, retain=True) def _on_motion_command(self, camera_name: str, payload: str) -> None: @@ -457,6 +664,10 @@ class Dispatcher: self.ptz_metrics[camera_name].start_time.value = 0 ptz_autotracker_settings.enabled = False + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera_name), + ptz_autotracker_settings, + ) self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True) def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None: @@ -507,6 +718,22 @@ class Dispatcher: ) self.publish("notifications/state", payload, retain=True) + def _on_profile_command(self, payload: str) -> None: + """Callback for profile/set topic.""" + if self.profile_manager is None: + logger.error("Profile manager not initialized") + return + + profile_name = ( + payload.strip() if payload.strip() not in ("", "none", "None") else None + ) + err = self.profile_manager.activate_profile(profile_name) + if err: + logger.error("Failed to activate profile: %s", err) + return + + self.publish("profile/state", payload.strip() or "none", retain=True) + def _on_audio_command(self, camera_name: str, payload: str) -> None: """Callback for audio topic.""" audio_settings = self.config.cameras[camera_name].audio @@ -530,6 +757,7 @@ class Dispatcher: CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name), audio_settings, ) + self._runtime_state.set(camera_name, "audio", audio_settings.enabled) self.publish(f"{camera_name}/audio/state", payload, retain=True) def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None: @@ -586,6 +814,7 @@ class Dispatcher: CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name), record_settings, ) + self._runtime_state.set(camera_name, "recordings", record_settings.enabled) self.publish(f"{camera_name}/recordings/state", payload, retain=True) def _on_snapshots_command(self, camera_name: str, payload: str) -> None: @@ -605,6 +834,7 @@ class Dispatcher: CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name), snapshots_settings, ) + self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled) self.publish(f"{camera_name}/snapshots/state", payload, retain=True) def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None: @@ -841,3 +1071,149 @@ class Dispatcher: genai_settings, ) self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True) + + def _on_motion_mask_command( + self, camera_name: str, mask_name: str, payload: str + ) -> None: + """Callback for motion mask topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for motion mask {mask_name}: {payload}") + return + + motion_settings = self.config.cameras[camera_name].motion + + if mask_name not in motion_settings.mask: + logger.error(f"Unknown motion mask: {mask_name}") + return + + mask = motion_settings.mask[mask_name] + + if not mask: + logger.error(f"Motion mask {mask_name} is None") + return + + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + + mask.enabled = payload == "ON" + + # Recreate RuntimeMotionConfig to update rasterized_mask + motion_settings = RuntimeMotionConfig( + frame_shape=self.config.cameras[camera_name].frame_shape, + **motion_settings.model_dump(exclude_unset=True), + ) + + # Update the dispatcher's own config + self.config.cameras[camera_name].motion = motion_settings + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) + self.publish( + f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True + ) + + def _on_object_mask_command( + self, camera_name: str, mask_name: str, payload: str + ) -> None: + """Callback for object mask topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for object mask {mask_name}: {payload}") + return + + object_settings = self.config.cameras[camera_name].objects + + # Check if this is a global mask + mask_found = False + if mask_name in object_settings.mask: + mask = object_settings.mask[mask_name] + if mask: + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + mask.enabled = payload == "ON" + mask_found = True + + # Check if this is a per-object filter mask + for object_name, filter_config in object_settings.filters.items(): + if mask_name in filter_config.mask: + mask = filter_config.mask[mask_name] + if mask: + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + mask.enabled = payload == "ON" + mask_found = True + + if not mask_found: + logger.error(f"Unknown object mask: {mask_name}") + return + + # Recreate RuntimeFilterConfig for each object filter to update rasterized_mask + for object_name, filter_config in object_settings.filters.items(): + # Merge global object masks with per-object filter masks + merged_mask = dict(filter_config.mask) # Copy filter-specific masks + + # Add global object masks if they exist + if object_settings.mask: + for global_mask_id, global_mask_config in object_settings.mask.items(): + # Use a global prefix to avoid key collisions + global_mask_id_prefixed = f"global_{global_mask_id}" + merged_mask[global_mask_id_prefixed] = global_mask_config + + object_settings.filters[object_name] = RuntimeFilterConfig( + frame_shape=self.config.cameras[camera_name].frame_shape, + mask=merged_mask, + **filter_config.model_dump( + exclude_unset=True, exclude={"mask", "raw_mask"} + ), + ) + + # Update the dispatcher's own config + self.config.cameras[camera_name].objects = object_settings + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name), + object_settings, + ) + self.publish( + f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True + ) + + def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None: + """Callback for zone topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for zone {zone_name}: {payload}") + return + + camera_config = self.config.cameras[camera_name] + + if zone_name not in camera_config.zones: + logger.error(f"Unknown zone: {zone_name}") + return + + if payload == "ON": + if not camera_config.zones[zone_name].enabled_in_config: + logger.error( + f"Zone {zone_name} must be enabled in the config to be turned on via MQTT." + ) + return + + camera_config.zones[zone_name].enabled = payload == "ON" + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name), + camera_config.zones, + ) + self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True) diff --git a/frigate/comms/embeddings_updater.py b/frigate/comms/embeddings_updater.py index f7fd9c2bf3..cd83709f0b 100644 --- a/frigate/comms/embeddings_updater.py +++ b/frigate/comms/embeddings_updater.py @@ -1,8 +1,9 @@ """Facilitates communication between processes.""" import logging +from collections.abc import Callable from enum import Enum -from typing import Any, Callable +from typing import Any import zmq diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index e4aad9107d..6897b12ebf 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -3,8 +3,9 @@ import logging import multiprocessing as mp import threading +from collections.abc import Callable from multiprocessing.synchronize import Event as MpEvent -from typing import Any, Callable +from typing import Any import zmq @@ -61,8 +62,8 @@ class InterProcessCommunicator(Communicator): def stop(self) -> None: self.stop_event.set() self.reader_thread.join() - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class InterProcessRequestor: @@ -82,5 +83,5 @@ class InterProcessRequestor: return "" def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 68ae698d9f..b74d4284f9 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -1,6 +1,7 @@ import logging import threading -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion @@ -38,8 +39,21 @@ class MqttClient(Communicator): ) def stop(self) -> None: + self.publish("available", "stopped", retain=True) self.client.disconnect() + def _notifications_enabled_in_config(self) -> bool: + """Whether notifications are configured globally or on any camera. + + Notifications can be enabled per camera with the global config left + disabled, so the global topics must consider both (matching how + app.py decides to create the WebPushClient). + """ + return self.config.notifications.enabled_in_config or any( + cam.enabled and cam.notifications.enabled_in_config + for cam in self.config.cameras.values() + ) + def _set_initial_topics(self) -> None: """Set initial state topics.""" for camera_name, camera in self.config.cameras.items(): @@ -63,6 +77,11 @@ class MqttClient(Communicator): "ON" if camera.audio.enabled_in_config else "OFF", retain=True, ) + self.publish( + f"{camera_name}/audio_transcription/state", + "ON" if camera.audio_transcription.live_enabled else "OFF", + retain=True, + ) self.publish( f"{camera_name}/detect/state", "ON" if camera.detect.enabled else "OFF", @@ -133,13 +152,41 @@ class MqttClient(Communicator): retain=True, ) - if self.config.notifications.enabled_in_config: + for mask_name, motion_mask in camera.motion.mask.items(): + if motion_mask: + self.publish( + f"{camera_name}/motion_mask/{mask_name}/state", + "ON" if motion_mask.enabled else "OFF", + retain=True, + ) + + for mask_name, object_mask in camera.objects.mask.items(): + if object_mask: + self.publish( + f"{camera_name}/object_mask/{mask_name}/state", + "ON" if object_mask.enabled else "OFF", + retain=True, + ) + + for zone_name, zone in camera.zones.items(): + self.publish( + f"{camera_name}/zone/{zone_name}/state", + "ON" if zone.enabled else "OFF", + retain=True, + ) + + if self._notifications_enabled_in_config(): self.publish( "notifications/state", "ON" if self.config.notifications.enabled else "OFF", retain=True, ) + self.publish( + "profile/state", + self.config.active_profile or "none", + retain=True, + ) self.publish("available", "online", retain=True) def on_mqtt_command( @@ -173,8 +220,8 @@ class MqttClient(Communicator): logger.error("Unable to connect to MQTT server: MQTT Not authorized") else: logger.error( - "Unable to connect to MQTT server: Connection refused. Error code: " - + reason_code.getName() + "Unable to connect to MQTT server: Connection refused. Error code: %s", + reason_code.getName(), ) self.connected = True @@ -216,6 +263,7 @@ class MqttClient(Communicator): "snapshots", "detect", "audio", + "audio_transcription", "motion", "improve_contrast", "ptz_autotracker", @@ -227,6 +275,7 @@ class MqttClient(Communicator): "review_detections", "object_descriptions", "review_descriptions", + "notifications", ] for name in self.config.cameras.keys(): @@ -236,18 +285,47 @@ class MqttClient(Communicator): self.on_mqtt_command, ) + # notifications suspend doesn't follow the /set topic pattern + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend", + self.on_mqtt_command, + ) + if self.config.cameras[name].onvif.host: self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/{name}/ptz", self.on_mqtt_command, ) - if self.config.notifications.enabled_in_config: + for mask_name in self.config.cameras[name].motion.mask.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set", + self.on_mqtt_command, + ) + + for mask_name in self.config.cameras[name].objects.mask.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set", + self.on_mqtt_command, + ) + + for zone_name in self.config.cameras[name].zones.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set", + self.on_mqtt_command, + ) + + if self._notifications_enabled_in_config(): self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/notifications/set", self.on_mqtt_command, ) + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/profile/set", + self.on_mqtt_command, + ) + self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command ) diff --git a/frigate/comms/runtime_state.py b/frigate/comms/runtime_state.py new file mode 100644 index 0000000000..be1b850e92 --- /dev/null +++ b/frigate/comms/runtime_state.py @@ -0,0 +1,182 @@ +"""Persistence layer for dispatcher runtime state overrides.""" + +import json +import logging +import os +from collections.abc import Iterable +from typing import Any + +from filelock import FileLock, Timeout + +from frigate.util.config import find_config_file + +logger = logging.getLogger(__name__) + + +class RuntimeStatePersistence: + """Persist last-known runtime states for dispatcher toggles. + + Stores boolean overrides applied to camera-level toggles by the dispatcher. + Overrides are replayed at startup on top of the YAML-derived in-memory + config, so changes made via MQTT or the live-view UI survive a restart. + """ + + # Maps dispatcher topic name -> YAML key suffix under cameras. + TRACKED_TOPICS: dict[str, str] = { + "enabled": "enabled", + "detect": "detect.enabled", + "snapshots": "snapshots.enabled", + "recordings": "record.enabled", + "audio": "audio.enabled", + } + + _SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()} + + def __init__(self) -> None: + self._path = os.path.join( + os.path.dirname(find_config_file()), ".runtime_state.json" + ) + self._lock_path = f"{self._path}.lock" + self._lock_timeout = 5 + + def load(self) -> dict[str, dict[str, bool]]: + """Return {camera: {topic: bool}} or {} if missing/corrupt.""" + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + data = self._read_locked() + except Timeout: + logger.error("Timed out acquiring runtime state lock for load") + return {} + cameras = data.get("cameras", {}) + if not isinstance(cameras, dict): + return {} + # Filter out malformed camera entries so callers can trust the shape. + return { + name: features + for name, features in cameras.items() + if isinstance(features, dict) + } + + def set(self, camera: str, topic: str, value: bool) -> None: + """Persist a single (camera, topic, value). No-op if topic untracked.""" + if topic not in self.TRACKED_TOPICS: + return + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + data = self._read_locked() + cameras = data.setdefault("cameras", {}) + if not isinstance(cameras, dict): + cameras = {} + data["cameras"] = cameras + cam = cameras.setdefault(camera, {}) + if not isinstance(cam, dict): + cam = {} + cameras[camera] = cam + cam[topic] = bool(value) + self._write_locked(data) + except Timeout: + logger.error("Timed out persisting runtime state for %s/%s", camera, topic) + except OSError: + logger.exception("Failed to persist runtime state for %s/%s", camera, topic) + + def clear_all(self) -> None: + """Wipe every stored runtime override. + + Called when the "layer below" changes in a way that invalidates all + runtime overrides for the current session (currently: profile + activation or deactivation). + """ + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + if not os.path.exists(self._path): + return + self._write_locked({"cameras": {}}) + except Timeout: + logger.error("Timed out clearing runtime state") + except OSError: + logger.exception("Failed to clear runtime state") + + def clear_camera(self, camera: str) -> None: + """Drop every stored override for a single camera. + + Called when a camera is deleted so a camera later added under the same + name does not inherit the removed camera's stale toggles. + """ + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + data = self._read_locked() + cameras = data.get("cameras") + if not isinstance(cameras, dict) or camera not in cameras: + return + del cameras[camera] + self._write_locked(data) + except Timeout: + logger.error("Timed out clearing runtime state for camera") + except OSError: + logger.exception("Failed to clear runtime state for camera") + + def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: + """Remove stored entries whose YAML key was just rewritten. + + Each dotted key must be of the form ``cameras..``. + Keys that don't match a tracked topic are ignored. + """ + to_remove: list[tuple[str, str]] = [] + for key in dotted_keys: + parts = key.split(".") + if len(parts) < 3 or parts[0] != "cameras": + continue + camera = parts[1] + suffix = ".".join(parts[2:]) + topic = self._SUFFIX_TO_TOPIC.get(suffix) + if topic is not None: + to_remove.append((camera, topic)) + + if not to_remove: + return + + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + data = self._read_locked() + cameras = data.get("cameras") + if not isinstance(cameras, dict): + return + changed = False + for camera, topic in to_remove: + cam = cameras.get(camera) + if isinstance(cam, dict) and topic in cam: + del cam[topic] + changed = True + if not cam: + del cameras[camera] + if changed: + self._write_locked(data) + except Timeout: + logger.error("Timed out clearing runtime state for YAML keys") + except OSError: + logger.exception("Failed to clear runtime state for YAML keys") + + def _read_locked(self) -> dict[str, Any]: + """Read the JSON file while the FileLock is held. + + Returns ``{}`` on a missing or corrupt file so the caller can write a + fresh structure on the next mutation. + """ + if not os.path.exists(self._path): + return {} + try: + with open(self._path) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + logger.exception( + "Failed to read runtime state file %s; starting fresh", self._path + ) + return {} + return data if isinstance(data, dict) else {} + + def _write_locked(self, data: dict[str, Any]) -> None: + """Atomically write the JSON file while the FileLock is held.""" + tmp_path = f"{self._path}.tmp" + with open(tmp_path, "w") as f: + json.dump(data, f, indent=2, sort_keys=True) + os.replace(tmp_path, self._path) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index 30de43a687..34a0e57508 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -6,9 +6,10 @@ import logging import os import queue import threading +from collections.abc import Callable from dataclasses import dataclass from multiprocessing.synchronize import Event as MpEvent -from typing import Any, Callable +from typing import Any from py_vapid import Vapid01 from pywebpush import WebPusher @@ -54,6 +55,7 @@ class WebPushClient(Communicator): c.name: 0 # type: ignore[misc] for c in self.config.cameras.values() } + self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None self.last_camera_notification_time: dict[str, float] = { c.name: 0 # type: ignore[misc] for c in self.config.cameras.values() @@ -65,6 +67,10 @@ class WebPushClient(Communicator): target=self._process_notifications, daemon=True ) self.notification_thread.start() + self.suspension_thread = threading.Thread( + target=self._process_suspensions, daemon=True + ) + self.suspension_thread.start() if not self.config.notifications.email: logger.warning("Email must be provided for push notifications to be sent.") @@ -83,7 +89,9 @@ class WebPushClient(Communicator): # notification and auth config updater self.global_config_subscriber = ConfigSubscriber("config/") self.config_subscriber = CameraConfigUpdateSubscriber( - self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications] + self.config, + self.config.cameras, + [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.notifications], ) self._refresh_user_cameras() @@ -163,6 +171,27 @@ class WebPushClient(Communicator): def is_camera_suspended(self, camera: str) -> bool: return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera] + def set_suspension_broadcaster( + self, broadcaster: Callable[[str, Any, bool], None] + ) -> None: + """Register the callback used to broadcast suspension state changes.""" + self.suspension_broadcaster = broadcaster + + def _process_suspensions(self) -> None: + while not self.stop_event.wait(1): + self._clear_expired_suspensions() + + def _clear_expired_suspensions(self) -> None: + """Reset and broadcast cameras whose suspension window has elapsed.""" + now = datetime.datetime.now().timestamp() + for camera, suspended_until in list(self.suspended_cameras.items()): + if suspended_until and now > suspended_until: + self.unsuspend_notifications(camera) + if self.suspension_broadcaster is not None: + self.suspension_broadcaster( + f"{camera}/notifications/suspended", "0", True + ) + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Wrapper for publishing when client is in valid state.""" # check for updated global config (notifications, auth) @@ -186,6 +215,8 @@ class WebPushClient(Communicator): self.suspended_cameras[camera] = 0 self.last_camera_notification_time[camera] = 0 + self._refresh_user_cameras() + if topic == "reviews": decoded = json.loads(payload) camera = decoded["before"]["camera"] @@ -217,6 +248,15 @@ class WebPushClient(Communicator): logger.debug(f"Notifications for {camera} are currently suspended.") return self.send_trigger(decoded) + elif topic == "camera_monitoring": + decoded = json.loads(payload) + camera = decoded["camera"] + if not self.config.cameras[camera].notifications.enabled: + return + if self.is_camera_suspended(camera): + logger.debug(f"Notifications for {camera} are currently suspended.") + return + self.send_camera_monitoring(decoded) elif topic == "notification_test": if not self.config.notifications.enabled and not any( cam.notifications.enabled for cam in self.config.cameras.values() @@ -381,6 +421,7 @@ class WebPushClient(Communicator): # Don't notify if message is an update and important fields don't have an update if ( state == "update" + and payload["before"]["severity"] == payload["after"]["severity"] and len(payload["before"]["data"]["objects"]) == len(payload["after"]["data"]["objects"]) and len(payload["before"]["data"]["zones"]) @@ -420,7 +461,10 @@ class WebPushClient(Communicator): else: title = base_title - message = payload["after"]["data"]["metadata"]["shortSummary"] + if payload["after"]["data"]["metadata"].get("shortSummary"): + message = payload["after"]["data"]["metadata"]["shortSummary"] + else: + message = f"Detected on {camera_name}" else: zone_names = payload["after"]["data"]["zones"] formatted_zone_names = [] @@ -525,6 +569,38 @@ class WebPushClient(Communicator): self.cleanup_registrations() + def send_camera_monitoring(self, payload: dict[str, Any]) -> None: + camera: str = payload["camera"] + camera_name: str = getattr( + self.config.cameras[camera], "friendly_name", None + ) or titlecase(camera.replace("_", " ")) + + self.check_registrations() + + text: str = payload.get("message") or payload.get("reasoning", "") + title = f"{camera_name}: Monitoring Alert" + message = (text[:197] + "...") if len(text) > 200 else text + + logger.debug(f"Sending camera monitoring push notification for {camera_name}") + + for user in self.web_pushers: + if not self._user_has_camera_access(user, camera): + logger.debug( + "Skipping notification for user %s - no access to camera %s", + user, + camera, + ) + continue + + self.send_push_notification( + user=user, + payload=payload, + title=title, + message=message, + ) + + self.cleanup_registrations() + def stop(self) -> None: logger.info("Closing notification queue") self.notification_thread.join() diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 9af231da30..ccb5d42890 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -4,7 +4,8 @@ import errno import json import logging import threading -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from wsgiref.simple_server import make_server from ws4py.server.wsgirefserver import ( @@ -22,7 +23,6 @@ from frigate.const import ( EXPIRE_AUDIO_ACTIVITY, INSERT_MANY_RECORDINGS, INSERT_PREVIEW, - NOTIFICATION_TEST, REQUEST_REGION_GRID, UPDATE_AUDIO_ACTIVITY, UPDATE_AUDIO_TRANSCRIPTION_STATE, @@ -35,6 +35,7 @@ from frigate.const import ( UPSERT_REVIEW_SEGMENT, ) from frigate.models import User +from frigate.output.ws_auth import ws_has_camera_access logger = logging.getLogger(__name__) @@ -55,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset( UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_BIRDSEYE_LAYOUT, UPDATE_AUDIO_TRANSCRIPTION_STATE, - NOTIFICATION_TEST, } ) @@ -67,6 +67,7 @@ _WS_VIEWER_TOPICS = frozenset( "audioTranscriptionState", "birdseyeLayout", "embeddingsReindexProgress", + "jobState", } ) @@ -129,6 +130,321 @@ def _check_ws_authorization( return False +# ---- 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: """ @@ -216,6 +532,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( { @@ -228,14 +548,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: diff --git a/frigate/comms/zmq_proxy.py b/frigate/comms/zmq_proxy.py index 29329ec595..4a4a0492a0 100644 --- a/frigate/comms/zmq_proxy.py +++ b/frigate/comms/zmq_proxy.py @@ -43,7 +43,7 @@ class ZmqProxy: def stop(self) -> None: # destroying the context will tell the proxy to stop - self.context.destroy() + self.context.destroy(linger=0) self.runner.join() @@ -66,8 +66,8 @@ class Publisher(Generic[T]): self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}") def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) class Subscriber(Generic[T]): @@ -96,8 +96,8 @@ class Subscriber(Generic[T]): return self._return_object("", None) def stop(self) -> None: - self.socket.close() - self.context.destroy() + self.socket.close(linger=0) + self.context.destroy(linger=0) def _return_object(self, topic: str, payload: T | None) -> T | None: return payload diff --git a/frigate/config/__init__.py b/frigate/config/__init__.py index c6ff535b05..88f7b79f9a 100644 --- a/frigate/config/__init__.py +++ b/frigate/config/__init__.py @@ -8,6 +8,7 @@ from .config import * # noqa: F403 from .database import * # noqa: F403 from .logger import * # noqa: F403 from .mqtt import * # noqa: F403 +from .network import * # noqa: F403 from .proxy import * # noqa: F403 from .telemetry import * # noqa: F403 from .tls import * # noqa: F403 diff --git a/frigate/config/auth.py b/frigate/config/auth.py index 6935350a0c..04beeb7757 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -1,5 +1,3 @@ -from typing import Dict, List, Optional - from pydantic import Field, field_validator, model_validator from .base import FrigateBaseModel @@ -8,39 +6,63 @@ __all__ = ["AuthConfig"] class AuthConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable authentication") + enabled: bool = Field( + default=True, + title="Enable authentication", + description="Enable native authentication for the Frigate UI.", + ) reset_admin_password: bool = Field( - default=False, title="Reset the admin password on startup" + default=False, + title="Reset admin password", + description="If true, reset the admin user's password on startup and print the new password in logs.", ) cookie_name: str = Field( - default="frigate_token", title="Name for jwt token cookie", pattern=r"^[a-z_]+$" + default="frigate_token", + title="JWT cookie name", + description="Name of the cookie used to store the JWT token for native authentication.", + pattern=r"^[a-z_]+$", + ) + cookie_secure: bool = Field( + default=False, + title="Secure cookie flag", + description="Set the secure flag on the auth cookie; should be true when using TLS.", ) - cookie_secure: bool = Field(default=False, title="Set secure flag on cookie") session_length: int = Field( - default=86400, title="Session length for jwt session tokens", ge=60 + default=86400, + title="Session length", + description="Session duration in seconds for JWT-based sessions.", + ge=60, ) refresh_time: int = Field( default=1800, - title="Refresh the session if it is going to expire in this many seconds", + title="Session refresh window", + description="When a session is within this many seconds of expiring, refresh it back to full length.", ge=30, ) - failed_login_rate_limit: Optional[str] = Field( + failed_login_rate_limit: str | None = Field( default=None, - title="Rate limits for failed login attempts.", + title="Failed login limits", + description="Rate limiting rules for failed login attempts to reduce brute-force attacks.", ) trusted_proxies: list[str] = Field( default=[], - title="Trusted proxies for determining IP address to rate limit", + title="Trusted proxies", + description="List of trusted proxy IPs used when determining client IP for rate limiting.", ) # As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256 - hash_iterations: int = Field(default=600000, title="Password hash iterations") - roles: Dict[str, List[str]] = Field( - default_factory=dict, - title="Role to camera mappings. Empty list grants access to all cameras.", + hash_iterations: int = Field( + default=600000, + title="Hash iterations", + description="Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", ) - admin_first_time_login: Optional[bool] = Field( + roles: dict[str, list[str]] = Field( + default_factory=dict, + title="Role mappings", + description="Map roles to camera lists. An empty list grants access to all cameras for the role.", + ) + admin_first_time_login: bool | None = Field( default=False, - title="Internal field to expose first-time admin login flag to the UI", + title="First-time admin flag", description=( "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. " ), @@ -48,7 +70,7 @@ class AuthConfig(FrigateBaseModel): @field_validator("roles") @classmethod - def validate_roles(cls, v: Dict[str, List[str]]) -> Dict[str, List[str]]: + def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]: # Ensure role names are valid (alphanumeric with underscores) for role in v.keys(): if not role.replace("_", "").isalnum(): diff --git a/frigate/config/camera/audio.py b/frigate/config/camera/audio.py index 3734455a2e..813c2988db 100644 --- a/frigate/config/camera/audio.py +++ b/frigate/config/camera/audio.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import Field from frigate.const import AUDIO_MIN_CONFIDENCE @@ -9,7 +7,7 @@ from ..base import FrigateBaseModel __all__ = ["AudioConfig", "AudioFilterConfig"] -DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"] +DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "speech", "yell"] class AudioFilterConfig(FrigateBaseModel): @@ -17,25 +15,45 @@ class AudioFilterConfig(FrigateBaseModel): default=0.8, ge=AUDIO_MIN_CONFIDENCE, lt=1.0, - title="Minimum detection confidence threshold for audio to be counted.", + title="Minimum audio confidence", + description="Minimum confidence threshold for the audio event to be counted.", ) class AudioConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable audio events.") + enabled: bool = Field( + default=False, + title="Enable audio detection", + description="Enable or disable audio event detection for all cameras; can be overridden per-camera.", + ) max_not_heard: int = Field( - default=30, title="Seconds of not hearing the type of audio to end the event." + default=30, + title="End timeout", + description="Amount of seconds without the configured audio type before the audio event is ended.", ) min_volume: int = Field( - default=500, title="Min volume required to run audio detection." + default=500, + title="Minimum volume", + description="Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", ) listen: list[str] = Field( - default=DEFAULT_LISTEN_AUDIO, title="Audio to listen for." + default=DEFAULT_LISTEN_AUDIO, + title="Listen types", + description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", ) - filters: Optional[dict[str, AudioFilterConfig]] = Field( - None, title="Audio filters." + filters: dict[str, AudioFilterConfig] | None = Field( + None, + title="Audio filters", + description="Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", ) - enabled_in_config: Optional[bool] = Field( - None, title="Keep track of original state of audio detection." + enabled_in_config: bool | None = Field( + None, + title="Original audio state", + description="Indicates whether audio detection was originally enabled in the static config file.", + ) + num_threads: int = Field( + default=2, + title="Detection threads", + description="Number of threads to use for audio detection processing.", + ge=1, ) - num_threads: int = Field(default=2, title="Number of detection threads", ge=1) diff --git a/frigate/config/camera/birdseye.py b/frigate/config/camera/birdseye.py index 1e6f0f3354..b51c73d3c6 100644 --- a/frigate/config/camera/birdseye.py +++ b/frigate/config/camera/birdseye.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Optional from pydantic import BaseModel, Field @@ -29,45 +28,88 @@ class BirdseyeModeEnum(str, Enum): class BirdseyeLayoutConfig(FrigateBaseModel): scaling_factor: float = Field( - default=2.0, title="Birdseye Scaling Factor", ge=1.0, le=5.0 + default=2.0, + title="Scaling factor", + description="Scaling factor used by the layout calculator (range 1.0 to 5.0).", + ge=1.0, + le=5.0, + ) + max_cameras: int | None = Field( + default=None, + title="Max cameras", + description="Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", ) - max_cameras: Optional[int] = Field(default=None, title="Max cameras") class BirdseyeConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable birdseye view.") + enabled: bool = Field( + default=True, + title="Enable Birdseye", + description="Enable or disable the Birdseye view feature.", + ) mode: BirdseyeModeEnum = Field( - default=BirdseyeModeEnum.objects, title="Tracking mode." + default=BirdseyeModeEnum.objects, + title="Tracking mode", + description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", ) - restream: bool = Field(default=False, title="Restream birdseye via RTSP.") - width: int = Field(default=1280, title="Birdseye width.") - height: int = Field(default=720, title="Birdseye height.") + restream: bool = Field( + default=False, + title="Restream RTSP", + description="Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", + ) + width: int = Field( + default=1280, + title="Width", + description="Output width (pixels) of the composed Birdseye frame.", + ) + height: int = Field( + default=720, + title="Height", + description="Output height (pixels) of the composed Birdseye frame.", + ) quality: int = Field( default=8, - title="Encoding quality.", + title="Encoding quality", + description="Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", ge=1, le=31, ) inactivity_threshold: int = Field( - default=30, title="Birdseye Inactivity Threshold", gt=0 + default=30, + title="Inactivity threshold", + description="Seconds of inactivity after which a camera will stop being shown in Birdseye.", + gt=0, ) layout: BirdseyeLayoutConfig = Field( - default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config" + default_factory=BirdseyeLayoutConfig, + title="Layout", + description="Layout options for the Birdseye composition.", ) idle_heartbeat_fps: float = Field( default=0.0, ge=0.0, le=10.0, - title="Idle heartbeat FPS (0 disables, max 10)", + title="Idle heartbeat FPS", + description="Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", ) # uses BaseModel because some global attributes are not available at the camera level class BirdseyeCameraConfig(BaseModel): - enabled: bool = Field(default=True, title="Enable birdseye view for camera.") + enabled: bool = Field( + default=True, + title="Enable Birdseye", + description="Enable or disable the Birdseye view feature.", + ) mode: BirdseyeModeEnum = Field( - default=BirdseyeModeEnum.objects, title="Tracking mode for camera." + default=BirdseyeModeEnum.objects, + title="Tracking mode", + description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", ) - order: int = Field(default=0, title="Position of the camera in the birdseye view.") + order: int = Field( + default=0, + title="Position", + description="Numeric position controlling the camera's ordering in the Birdseye layout.", + ) diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 0f2b1c8be4..b9d2cef727 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -1,6 +1,5 @@ import os from enum import Enum -from typing import Optional from pydantic import Field, PrivateAttr, model_validator @@ -34,6 +33,7 @@ from .mqtt import CameraMqttConfig from .notification import NotificationConfig from .objects import ObjectConfig from .onvif import OnvifConfig +from .profile import CameraProfileConfig from .record import RecordConfig from .review import ReviewConfig from .snapshots import SnapshotsConfig @@ -50,10 +50,17 @@ class CameraTypeEnum(str, Enum): class CameraConfig(FrigateBaseModel): - name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME) + name: str | None = Field( + None, + title="Camera name", + description="Camera name is required", + pattern=REGEX_CAMERA_NAME, + ) - friendly_name: Optional[str] = Field( - None, title="Camera friendly name used in the Frigate UI." + friendly_name: str | None = Field( + None, + title="Friendly name", + description="Camera friendly name used in the Frigate UI", ) @model_validator(mode="before") @@ -63,80 +70,135 @@ class CameraConfig(FrigateBaseModel): pass return values - enabled: bool = Field(default=True, title="Enable camera.") + enabled: bool = Field(default=True, title="Enabled", description="Enabled") # Options with global fallback audio: AudioConfig = Field( - default_factory=AudioConfig, title="Audio events configuration." + default_factory=AudioConfig, + title="Audio detection", + description="Settings for audio-based event detection for this camera.", ) audio_transcription: CameraAudioTranscriptionConfig = Field( default_factory=CameraAudioTranscriptionConfig, - title="Audio transcription config.", + title="Audio transcription", + description="Settings for live and speech audio transcription used for events and live captions.", ) birdseye: BirdseyeCameraConfig = Field( - default_factory=BirdseyeCameraConfig, title="Birdseye camera configuration." + default_factory=BirdseyeCameraConfig, + title="Birdseye", + description="Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", ) detect: DetectConfig = Field( - default_factory=DetectConfig, title="Object detection configuration." + default_factory=DetectConfig, + title="Object Detection", + description="Settings for the detection/detect role used to run object detection and initialize trackers.", ) face_recognition: CameraFaceRecognitionConfig = Field( - default_factory=CameraFaceRecognitionConfig, title="Face recognition config." + default_factory=CameraFaceRecognitionConfig, + title="Face recognition", + description="Settings for face detection and recognition for this camera.", + ) + ffmpeg: CameraFfmpegConfig = Field( + title="Streams (FFmpeg)", + description="Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", ) - ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.") live: CameraLiveConfig = Field( - default_factory=CameraLiveConfig, title="Live playback settings." + default_factory=CameraLiveConfig, + title="Live playback", + description="Settings used by the Web UI to control live stream selection, resolution and quality.", ) lpr: CameraLicensePlateRecognitionConfig = Field( - default_factory=CameraLicensePlateRecognitionConfig, title="LPR config." + default_factory=CameraLicensePlateRecognitionConfig, + title="License Plate Recognition", + description="License plate recognition settings including detection thresholds, formatting, and known plates.", + ) + motion: MotionConfig = Field( + None, + title="Motion detection", + description="Default motion detection settings for this camera.", ) - motion: MotionConfig = Field(None, title="Motion detection configuration.") objects: ObjectConfig = Field( - default_factory=ObjectConfig, title="Object configuration." + default_factory=ObjectConfig, + title="Objects", + description="Object tracking defaults including which labels to track and per-object filters.", ) record: RecordConfig = Field( - default_factory=RecordConfig, title="Record configuration." + default_factory=RecordConfig, + title="Recording", + description="Recording and retention settings for this camera.", ) review: ReviewConfig = Field( - default_factory=ReviewConfig, title="Review configuration." + default_factory=ReviewConfig, + title="Review", + description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", ) semantic_search: CameraSemanticSearchConfig = Field( default_factory=CameraSemanticSearchConfig, - title="Semantic search configuration.", + title="Semantic Search", + description="Settings for semantic search which builds and queries object embeddings to find similar items.", ) snapshots: SnapshotsConfig = Field( - default_factory=SnapshotsConfig, title="Snapshot configuration." + default_factory=SnapshotsConfig, + title="Snapshots", + description="Settings for API-generated snapshots of tracked objects for this camera.", ) timestamp_style: TimestampStyleConfig = Field( - default_factory=TimestampStyleConfig, title="Timestamp style configuration." + default_factory=TimestampStyleConfig, + title="Timestamp style", + description="Styling options for timestamps applied to snapshots and Debug view.", ) # Options without global fallback best_image_timeout: int = Field( default=60, - title="How long to wait for the image with the highest confidence score.", + title="Best image timeout", + description="How long to wait for the image with the highest confidence score.", ) mqtt: CameraMqttConfig = Field( - default_factory=CameraMqttConfig, title="MQTT configuration." + default_factory=CameraMqttConfig, + title="MQTT", + description="MQTT image publishing settings.", ) notifications: NotificationConfig = Field( - default_factory=NotificationConfig, title="Notifications configuration." + default_factory=NotificationConfig, + title="Notifications", + description="Settings to enable and control notifications for this camera.", ) onvif: OnvifConfig = Field( - default_factory=OnvifConfig, title="Camera Onvif Configuration." + default_factory=OnvifConfig, + title="ONVIF", + description="ONVIF connection and PTZ autotracking settings for this camera.", + ) + type: CameraTypeEnum = Field( + default=CameraTypeEnum.generic, + title="Camera type", + description="Camera Type", ) - type: CameraTypeEnum = Field(default=CameraTypeEnum.generic, title="Camera Type") ui: CameraUiConfig = Field( - default_factory=CameraUiConfig, title="Camera UI Modifications." + default_factory=CameraUiConfig, + title="Camera UI", + description="Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", ) - webui_url: Optional[str] = Field( + webui_url: str | None = Field( None, - title="URL to visit the camera directly from system page", + title="Camera URL", + description="URL to visit the camera directly from system page", + ) + + profiles: dict[str, CameraProfileConfig] = Field( + default_factory=dict, + title="Profiles", + description="Named config profiles with partial overrides that can be activated at runtime.", ) zones: dict[str, ZoneConfig] = Field( - default_factory=dict, title="Zone configuration." + default_factory=dict, + title="Zones", + description="Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of camera." + enabled_in_config: bool | None = Field( + default=None, + title="Original camera state", + description="Keep track of original state of camera.", ) _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr() @@ -186,6 +248,14 @@ class CameraConfig(FrigateBaseModel): def create_ffmpeg_cmds(self): if "_ffmpeg_cmds" in self: return + self._build_ffmpeg_cmds() + + def recreate_ffmpeg_cmds(self): + """Force regeneration of ffmpeg commands from current config.""" + self._build_ffmpeg_cmds() + + def _build_ffmpeg_cmds(self): + """Build ffmpeg commands from the current ffmpeg config.""" ffmpeg_cmds = [] for ffmpeg_input in self.ffmpeg.inputs: ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index 1926f32542..d093ed986e 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -1,6 +1,4 @@ -from typing import Optional - -from pydantic import Field +from pydantic import Field, model_validator from ..base import FrigateBaseModel @@ -8,56 +6,91 @@ __all__ = ["DetectConfig", "StationaryConfig", "StationaryMaxFramesConfig"] class StationaryMaxFramesConfig(FrigateBaseModel): - default: Optional[int] = Field(default=None, title="Default max frames.", ge=1) + default: int | None = Field( + default=None, + title="Default max frames", + description="Default maximum frames to track a stationary object before stopping.", + ge=1, + ) objects: dict[str, int] = Field( - default_factory=dict, title="Object specific max frames." + default_factory=dict, + title="Object max frames", + description="Per-object overrides for maximum frames to track stationary objects.", ) class StationaryConfig(FrigateBaseModel): - interval: Optional[int] = Field( + interval: int | None = Field( default=None, - title="Frame interval for checking stationary objects.", + title="Stationary interval", + description="How often (in frames) to run a detection check to confirm a stationary object.", gt=0, ) - threshold: Optional[int] = Field( + threshold: int | None = Field( default=None, - title="Number of frames without a position change for an object to be considered stationary", + title="Stationary threshold", + description="Number of frames with no position change required to mark an object as stationary.", ge=1, ) max_frames: StationaryMaxFramesConfig = Field( default_factory=StationaryMaxFramesConfig, - title="Max frames for stationary objects.", + title="Max frames", + description="Limits how long stationary objects are tracked before being discarded.", ) classifier: bool = Field( default=True, - title="Enable visual classifier for determing if objects with jittery bounding boxes are stationary.", + title="Enable visual classifier", + description="Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", ) class DetectConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Detection Enabled.") - height: Optional[int] = Field( - default=None, title="Height of the stream for the detect role." + enabled: bool = Field( + default=False, + title="Enable object detection", + description="Enable or disable object detection for all cameras; can be overridden per-camera.", ) - width: Optional[int] = Field( - default=None, title="Width of the stream for the detect role." + height: int | None = Field( + default=None, + title="Detect height", + description="Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", + ) + width: int | None = Field( + default=None, + title="Detect width", + description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", ) fps: int = Field( - default=5, title="Number of frames per second to process through detection." + default=5, + title="Detect FPS", + description="Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", ) - min_initialized: Optional[int] = Field( + min_initialized: int | None = Field( default=None, - title="Minimum number of consecutive hits for an object to be initialized by the tracker.", + title="Minimum initialization frames", + description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", + ge=2, ) - max_disappeared: Optional[int] = Field( + max_disappeared: int | None = Field( default=None, - title="Maximum number of frames the object can disappear before detection ends.", + title="Maximum disappeared frames", + description="Number of frames without a detection before a tracked object is considered gone.", ) stationary: StationaryConfig = Field( default_factory=StationaryConfig, - title="Stationary objects config.", + title="Stationary objects config", + description="Settings to detect and manage objects that remain stationary for a period of time.", ) annotation_offset: int = Field( - default=0, title="Milliseconds to offset detect annotations by." + default=0, + title="Annotation offset", + description="Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", ) + + @model_validator(mode="after") + def validate_dimensions(self) -> "DetectConfig": + if (self.width is None) != (self.height is None): + raise ValueError( + "detect -> both width and height must be specified together, or both omitted" + ) + return self diff --git a/frigate/config/camera/ffmpeg.py b/frigate/config/camera/ffmpeg.py index 2c1e4cdcab..ad7cbc8aa1 100644 --- a/frigate/config/camera/ffmpeg.py +++ b/frigate/config/camera/ffmpeg.py @@ -1,9 +1,8 @@ from enum import Enum -from typing import Union from pydantic import Field, field_validator -from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS +from frigate.util.config import resolve_ffmpeg_path from ..base import FrigateBaseModel from ..env import EnvString @@ -33,59 +32,68 @@ DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [ class FfmpegOutputArgsConfig(FrigateBaseModel): - detect: Union[str, list[str]] = Field( + detect: str | list[str] = Field( default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT, - title="Detect role FFmpeg output arguments.", + title="Detect output arguments", + description="Default output arguments for detect role streams.", ) - record: Union[str, list[str]] = Field( + record: str | list[str] = Field( default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT, - title="Record role FFmpeg output arguments.", + title="Record output arguments", + description="Default output arguments for record role streams.", ) class FfmpegConfig(FrigateBaseModel): - path: str = Field(default="default", title="FFmpeg path") - global_args: Union[str, list[str]] = Field( - default=FFMPEG_GLOBAL_ARGS_DEFAULT, title="Global FFmpeg arguments." + path: str = Field( + default="default", + title="FFmpeg path", + description='Path to the FFmpeg binary to use or a version alias ("7.0" or "8.0").', ) - hwaccel_args: Union[str, list[str]] = Field( - default="auto", title="FFmpeg hardware acceleration arguments." + global_args: str | list[str] = Field( + default=FFMPEG_GLOBAL_ARGS_DEFAULT, + title="FFmpeg global arguments", + description="Global arguments passed to FFmpeg processes.", ) - input_args: Union[str, list[str]] = Field( - default=FFMPEG_INPUT_ARGS_DEFAULT, title="FFmpeg input arguments." + hwaccel_args: str | list[str] = Field( + default="auto", + title="Hardware acceleration arguments", + description="Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", + ) + input_args: str | list[str] = Field( + default=FFMPEG_INPUT_ARGS_DEFAULT, + title="Input arguments", + description="Input arguments applied to FFmpeg input streams.", ) output_args: FfmpegOutputArgsConfig = Field( default_factory=FfmpegOutputArgsConfig, - title="FFmpeg output arguments per role.", + title="Output arguments", + description="Default output arguments used for different FFmpeg roles such as detect and record.", ) retry_interval: float = Field( default=10.0, - title="Time in seconds to wait before FFmpeg retries connecting to the camera.", + title="FFmpeg retry time", + description="Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", gt=0.0, ) apple_compatibility: bool = Field( default=False, - title="Set tag on HEVC (H.265) recording stream to improve compatibility with Apple players.", + title="Apple compatibility", + description="Enable HEVC tagging for better Apple player compatibility when recording H.265.", + ) + gpu: int = Field( + default=0, + title="GPU index", + description="Default GPU index used for hardware acceleration if available.", ) - gpu: int = Field(default=0, title="GPU index to use for hardware acceleration.") @property def ffmpeg_path(self) -> str: - if self.path == "default": - return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" - elif self.path in INCLUDED_FFMPEG_VERSIONS: - return f"/usr/lib/ffmpeg/{self.path}/bin/ffmpeg" - else: - return f"{self.path}/bin/ffmpeg" + return resolve_ffmpeg_path(self.path, "ffmpeg") @property def ffprobe_path(self) -> str: - if self.path == "default": - return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe" - elif self.path in INCLUDED_FFMPEG_VERSIONS: - return f"/usr/lib/ffmpeg/{self.path}/bin/ffprobe" - else: - return f"{self.path}/bin/ffprobe" + return resolve_ffmpeg_path(self.path, "ffprobe") class CameraRoleEnum(str, Enum): @@ -95,21 +103,36 @@ class CameraRoleEnum(str, Enum): class CameraInput(FrigateBaseModel): - path: EnvString = Field(title="Camera input path.") - roles: list[CameraRoleEnum] = Field(title="Roles assigned to this input.") - global_args: Union[str, list[str]] = Field( - default_factory=list, title="FFmpeg global arguments." + path: EnvString = Field( + title="Input path", + description="Camera input stream URL or path.", ) - hwaccel_args: Union[str, list[str]] = Field( - default_factory=list, title="FFmpeg hardware acceleration arguments." + roles: list[CameraRoleEnum] = Field( + title="Input roles", + description="Roles for this input stream.", ) - input_args: Union[str, list[str]] = Field( - default_factory=list, title="FFmpeg input arguments." + global_args: str | list[str] = Field( + default_factory=list, + title="FFmpeg global arguments", + description="FFmpeg global arguments for this input stream.", + ) + hwaccel_args: str | list[str] = Field( + default_factory=list, + title="Hardware acceleration arguments", + description="Hardware acceleration arguments for this input stream.", + ) + input_args: str | list[str] = Field( + default_factory=list, + title="Input arguments", + description="Input arguments specific to this stream.", ) class CameraFfmpegConfig(FfmpegConfig): - inputs: list[CameraInput] = Field(title="Camera inputs.") + inputs: list[CameraInput] = Field( + title="Camera inputs", + description="List of input stream definitions (paths and roles) for this camera.", + ) @field_validator("inputs") @classmethod diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index a4d9199af4..82358252f8 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -1,12 +1,12 @@ from enum import Enum -from typing import Any, Optional +from typing import Any from pydantic import Field from ..base import FrigateBaseModel from ..env import EnvString -__all__ = ["GenAIConfig", "GenAIProviderEnum"] +__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"] class GenAIProviderEnum(str, Enum): @@ -14,18 +14,55 @@ class GenAIProviderEnum(str, Enum): azure_openai = "azure_openai" gemini = "gemini" ollama = "ollama" + llamacpp = "llamacpp" + + +class GenAIRoleEnum(str, Enum): + chat = "chat" + descriptions = "descriptions" + embeddings = "embeddings" class GenAIConfig(FrigateBaseModel): """Primary GenAI Config to define GenAI Provider.""" - api_key: Optional[EnvString] = Field(default=None, title="Provider API key.") - base_url: Optional[str] = Field(default=None, title="Provider base url.") - model: str = Field(default="gpt-4o", title="GenAI model.") - provider: GenAIProviderEnum | None = Field(default=None, title="GenAI provider.") + api_key: EnvString | None = Field( + default=None, + title="API key", + description="API key required by some providers (can also be set via environment variables).", + ) + base_url: str | None = Field( + default=None, + title="Base URL", + description="Base URL for self-hosted or compatible providers (for example an Ollama instance).", + ) + model: str = Field( + default="", + title="Model", + description="The model to use from the provider for generating descriptions or summaries.", + ) + provider: GenAIProviderEnum = Field( + title="Provider", + description="The GenAI provider to use (for example: ollama, gemini, openai).", + ) + roles: list[GenAIRoleEnum] = Field( + default_factory=lambda: [ + GenAIRoleEnum.embeddings, + GenAIRoleEnum.descriptions, + GenAIRoleEnum.chat, + ], + title="Roles", + description="GenAI roles (chat, descriptions, embeddings); one provider per role.", + ) provider_options: dict[str, Any] = Field( - default={}, title="GenAI Provider extra options." + default={}, + title="Provider options", + description="Additional provider-specific options to pass to the GenAI client.", + json_schema_extra={"additionalProperties": {}}, ) runtime_options: dict[str, Any] = Field( - default={}, title="Options to pass during inference calls." + default={}, + title="Runtime options", + description="Runtime options passed to the provider for each inference call.", + json_schema_extra={"additionalProperties": {}}, ) diff --git a/frigate/config/camera/live.py b/frigate/config/camera/live.py index 13ae2d04f3..90a7c8e4c1 100644 --- a/frigate/config/camera/live.py +++ b/frigate/config/camera/live.py @@ -1,5 +1,3 @@ -from typing import Dict - from pydantic import Field from ..base import FrigateBaseModel @@ -8,9 +6,20 @@ __all__ = ["CameraLiveConfig"] class CameraLiveConfig(FrigateBaseModel): - streams: Dict[str, str] = Field( + streams: dict[str, str] = Field( default_factory=list, - title="Friendly names and restream names to use for live view.", + title="Live stream names", + description="Mapping of configured stream names to restream/go2rtc names used for live playback.", + ) + height: int = Field( + default=720, + title="Live height", + description="Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", + ) + quality: int = Field( + default=8, + ge=1, + le=31, + title="Live quality", + description="Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", ) - height: int = Field(default=720, title="Live camera view height") - quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality") diff --git a/frigate/config/camera/mask.py b/frigate/config/camera/mask.py new file mode 100644 index 0000000000..ecfdb418b4 --- /dev/null +++ b/frigate/config/camera/mask.py @@ -0,0 +1,85 @@ +"""Mask configuration for motion and object masks.""" + +from typing import Any + +from pydantic import Field, field_serializer + +from ..base import FrigateBaseModel + +__all__ = ["MotionMaskConfig", "ObjectMaskConfig"] + + +class MotionMaskConfig(FrigateBaseModel): + """Configuration for a single motion mask.""" + + friendly_name: str | None = Field( + default=None, + title="Friendly name", + description="A friendly name for this motion mask used in the Frigate UI", + ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this motion mask", + ) + coordinates: str | list[str] = Field( + default="", + title="Coordinates", + description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", + ) + raw_coordinates: str | list[str] = "" + enabled_in_config: bool | None = Field( + default=None, title="Keep track of original state of motion mask." + ) + + def get_formatted_name(self, mask_id: str) -> str: + """Return the friendly name if set, otherwise return a formatted version of the mask ID.""" + if self.friendly_name: + return self.friendly_name + return mask_id.replace("_", " ").title() + + @field_serializer("coordinates", when_used="json") + def serialize_coordinates(self, value: Any, info): + return self.raw_coordinates if self.raw_coordinates else value + + @field_serializer("raw_coordinates", when_used="json") + def serialize_raw_coordinates(self, value: Any, info): + return None + + +class ObjectMaskConfig(FrigateBaseModel): + """Configuration for a single object mask.""" + + friendly_name: str | None = Field( + default=None, + title="Friendly name", + description="A friendly name for this object mask used in the Frigate UI", + ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this object mask", + ) + coordinates: str | list[str] = Field( + default="", + title="Coordinates", + description="Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", + ) + raw_coordinates: str | list[str] = "" + enabled_in_config: bool | None = Field( + default=None, title="Keep track of original state of object mask." + ) + + @field_serializer("coordinates", when_used="json") + def serialize_coordinates(self, value: Any, info): + return self.raw_coordinates if self.raw_coordinates else value + + @field_serializer("raw_coordinates", when_used="json") + def serialize_raw_coordinates(self, value: Any, info): + return None + + def get_formatted_name(self, mask_id: str) -> str: + """Return the friendly name if set, otherwise return a formatted version of the mask ID.""" + if self.friendly_name: + return self.friendly_name + return mask_id.replace("_", " ").title() diff --git a/frigate/config/camera/motion.py b/frigate/config/camera/motion.py index 65c03f7317..c44c1453f6 100644 --- a/frigate/config/camera/motion.py +++ b/frigate/config/camera/motion.py @@ -1,43 +1,89 @@ -from typing import Any, Optional, Union +from typing import Any from pydantic import Field, field_serializer from ..base import FrigateBaseModel +from .mask import MotionMaskConfig __all__ = ["MotionConfig"] class MotionConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable motion on all cameras.") + enabled: bool = Field( + default=True, + title="Enable motion detection", + description="Enable or disable motion detection for all cameras; can be overridden per-camera.", + ) threshold: int = Field( default=30, - title="Motion detection threshold (1-255).", + title="Motion threshold", + description="Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", ge=1, le=255, ) lightning_threshold: float = Field( - default=0.8, title="Lightning detection threshold (0.3-1.0).", ge=0.3, le=1.0 + default=0.8, + title="Lightning threshold", + description="Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", + ge=0.3, + le=1.0, ) - improve_contrast: bool = Field(default=True, title="Improve Contrast") - contour_area: Optional[int] = Field(default=10, title="Contour Area") - delta_alpha: float = Field(default=0.2, title="Delta Alpha") - frame_alpha: float = Field(default=0.01, title="Frame Alpha") - frame_height: Optional[int] = Field(default=100, title="Frame Height") - mask: Union[str, list[str]] = Field( - default="", title="Coordinates polygon for the motion mask." + skip_motion_threshold: float | None = Field( + default=None, + title="Skip motion threshold", + description="If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto‑tracking an object. The trade‑off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", + ge=0.0, + le=1.0, + ) + improve_contrast: bool = Field( + default=True, + title="Improve contrast", + description="Apply contrast improvement to frames before motion analysis to help detection.", + ) + contour_area: int | None = Field( + default=10, + title="Contour area", + description="Minimum contour area in pixels required for a motion contour to be counted.", + ) + delta_alpha: float = Field( + default=0.2, + title="Delta alpha", + description="Alpha blending factor used in frame differencing for motion calculation.", + ) + frame_alpha: float = Field( + default=0.01, + title="Frame alpha", + description="Alpha value used when blending frames for motion preprocessing.", + ) + frame_height: int | None = Field( + default=100, + title="Frame height", + description="Height in pixels to scale frames to when computing motion.", + ) + mask: dict[str, MotionMaskConfig | None] = Field( + default_factory=dict, + title="Mask coordinates", + description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", ) mqtt_off_delay: int = Field( default=30, - title="Delay for updating MQTT with no motion detected.", + title="MQTT off delay", + description="Seconds to wait after last motion before publishing an MQTT 'off' state.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of motion detection." + enabled_in_config: bool | None = Field( + default=None, + title="Original motion state", + description="Indicates whether motion detection was enabled in the original static configuration.", + ) + raw_mask: dict[str, MotionMaskConfig | None] = Field( + default_factory=dict, exclude=True ) - raw_mask: Union[str, list[str]] = "" @field_serializer("mask", when_used="json") def serialize_mask(self, value: Any, info): - return self.raw_mask + if self.raw_mask: + return self.raw_mask + return value @field_serializer("raw_mask", when_used="json") def serialize_raw_mask(self, value: Any, info): diff --git a/frigate/config/camera/mqtt.py b/frigate/config/camera/mqtt.py index 132fee059b..5f8da1a732 100644 --- a/frigate/config/camera/mqtt.py +++ b/frigate/config/camera/mqtt.py @@ -6,18 +6,40 @@ __all__ = ["CameraMqttConfig"] class CameraMqttConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Send image over MQTT.") - timestamp: bool = Field(default=True, title="Add timestamp to MQTT image.") - bounding_box: bool = Field(default=True, title="Add bounding box to MQTT image.") - crop: bool = Field(default=True, title="Crop MQTT image to detected object.") - height: int = Field(default=270, title="MQTT image height.") + enabled: bool = Field( + default=True, + title="Send image", + description="Enable publishing image snapshots for objects to MQTT topics for this camera.", + ) + timestamp: bool = Field( + default=True, + title="Add timestamp", + description="Overlay a timestamp on images published to MQTT.", + ) + bounding_box: bool = Field( + default=True, + title="Add bounding box", + description="Draw bounding boxes on images published over MQTT.", + ) + crop: bool = Field( + default=True, + title="Crop image", + description="Crop images published to MQTT to the detected object's bounding box.", + ) + height: int = Field( + default=270, + title="Image height", + description="Height (pixels) to resize images published over MQTT.", + ) required_zones: list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to send the image.", + title="Required zones", + description="Zones that an object must enter for an MQTT image to be published.", ) quality: int = Field( default=70, - title="Quality of the encoded jpeg (0-100).", + title="JPEG quality", + description="JPEG quality for images published to MQTT (0-100).", ge=0, le=100, ) diff --git a/frigate/config/camera/notification.py b/frigate/config/camera/notification.py index ce1ac8223c..7f5968193f 100644 --- a/frigate/config/camera/notification.py +++ b/frigate/config/camera/notification.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import Field from ..base import FrigateBaseModel @@ -8,11 +6,24 @@ __all__ = ["NotificationConfig"] class NotificationConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable notifications") - email: Optional[str] = Field(default=None, title="Email required for push.") + enabled: bool = Field( + default=False, + title="Enable notifications", + description="Enable or disable notifications for all cameras; can be overridden per-camera.", + ) + email: str | None = Field( + default=None, + title="Notification email", + description="Email address used for push notifications or required by certain notification providers.", + ) cooldown: int = Field( - default=0, ge=0, title="Cooldown period for notifications (time in seconds)." + default=0, + ge=0, + title="Cooldown period", + description="Cooldown (seconds) between notifications to avoid spamming recipients.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of notifications." + enabled_in_config: bool | None = Field( + default=None, + title="Original notifications state", + description="Indicates whether notifications were enabled in the original static configuration.", ) diff --git a/frigate/config/camera/objects.py b/frigate/config/camera/objects.py index 7b6317dd03..6b6759edf1 100644 --- a/frigate/config/camera/objects.py +++ b/frigate/config/camera/objects.py @@ -1,8 +1,9 @@ -from typing import Any, Optional, Union +from typing import Any from pydantic import Field, PrivateAttr, field_serializer, field_validator from ..base import FrigateBaseModel +from .mask import ObjectMaskConfig __all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"] @@ -11,38 +12,50 @@ DEFAULT_TRACKED_OBJECTS = ["person"] class FilterConfig(FrigateBaseModel): - min_area: Union[int, float] = Field( + min_area: int | float = Field( default=0, - title="Minimum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", + title="Minimum object area", + description="Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", ) - max_area: Union[int, float] = Field( + max_area: int | float = Field( default=24000000, - title="Maximum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", + title="Maximum object area", + description="Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", ) min_ratio: float = Field( default=0, - title="Minimum ratio of bounding box's width/height for object to be counted.", + title="Minimum aspect ratio", + description="Minimum width/height ratio required for the bounding box to qualify.", ) max_ratio: float = Field( default=24000000, - title="Maximum ratio of bounding box's width/height for object to be counted.", + title="Maximum aspect ratio", + description="Maximum width/height ratio allowed for the bounding box to qualify.", ) threshold: float = Field( default=0.7, - title="Average detection confidence threshold for object to be counted.", + title="Confidence threshold", + description="Average detection confidence threshold required for the object to be considered a true positive.", ) min_score: float = Field( - default=0.5, title="Minimum detection confidence for object to be counted." + default=0.5, + title="Minimum confidence", + description="Minimum single-frame detection confidence required for the object to be counted.", ) - mask: Optional[Union[str, list[str]]] = Field( - default=None, - title="Detection area polygon mask for this filter configuration.", + mask: dict[str, ObjectMaskConfig | None] = Field( + default_factory=dict, + title="Filter mask", + description="Polygon coordinates defining where this filter applies within the frame.", + ) + raw_mask: dict[str, ObjectMaskConfig | None] = Field( + default_factory=dict, exclude=True ) - raw_mask: Union[str, list[str]] = "" @field_serializer("mask", when_used="json") def serialize_mask(self, value: Any, info): - return self.raw_mask + if self.raw_mask: + return self.raw_mask + return value @field_serializer("raw_mask", when_used="json") def serialize_raw_mask(self, value: Any, info): @@ -51,46 +64,64 @@ class FilterConfig(FrigateBaseModel): class GenAIObjectTriggerConfig(FrigateBaseModel): tracked_object_end: bool = Field( - default=True, title="Send once the object is no longer tracked." + default=True, + title="Send on end", + description="Send a request to GenAI when the tracked object ends.", ) - after_significant_updates: Optional[int] = Field( + after_significant_updates: int | None = Field( default=None, - title="Send an early request to generative AI when X frames accumulated.", + title="Early GenAI trigger", + description="Send a request to GenAI after a specified number of significant updates for the tracked object.", ge=1, ) class GenAIObjectConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable GenAI for camera.") + enabled: bool = Field( + default=False, + title="Enable GenAI", + description="Enable GenAI generation of descriptions for tracked objects by default.", + ) use_snapshot: bool = Field( - default=False, title="Use snapshots for generating descriptions." + default=False, + title="Use snapshots", + description="Use object snapshots instead of thumbnails for GenAI description generation.", ) prompt: str = Field( default="Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", - title="Default caption prompt.", + title="Caption prompt", + description="Default prompt template used when generating descriptions with GenAI.", ) object_prompts: dict[str, str] = Field( - default_factory=dict, title="Object specific prompts." + default_factory=dict, + title="Object prompts", + description="Per-object prompts to customize GenAI outputs for specific labels.", ) - objects: Union[str, list[str]] = Field( + objects: str | list[str] = Field( default_factory=list, - title="List of objects to run generative AI for.", + title="GenAI objects", + description="List of object labels to send to GenAI by default.", ) - required_zones: Union[str, list[str]] = Field( + required_zones: str | list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to run generative AI.", + title="Required zones", + description="Zones that must be entered for objects to qualify for GenAI description generation.", ) debug_save_thumbnails: bool = Field( default=False, - title="Save thumbnails sent to generative AI for debugging purposes.", + title="Save thumbnails", + description="Save thumbnails sent to GenAI for debugging and review.", ) send_triggers: GenAIObjectTriggerConfig = Field( default_factory=GenAIObjectTriggerConfig, - title="What triggers to use to send frames to generative AI for a tracked object.", + title="GenAI triggers", + description="Defines when frames should be sent to GenAI (on end, after updates, etc.).", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of generative AI." + enabled_in_config: bool | None = Field( + default=None, + title="Original GenAI state", + description="Indicates whether GenAI was enabled in the original static config.", ) @field_validator("required_zones", mode="before") @@ -103,14 +134,28 @@ class GenAIObjectConfig(FrigateBaseModel): class ObjectConfig(FrigateBaseModel): - track: list[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") + track: list[str] = Field( + default=DEFAULT_TRACKED_OBJECTS, + title="Objects to track", + description="List of object labels to track for all cameras; can be overridden per-camera.", + ) filters: dict[str, FilterConfig] = Field( - default_factory=dict, title="Object filters." + default_factory=dict, + title="Object filters", + description="Filters applied to detected objects to reduce false positives (area, ratio, confidence).", + ) + mask: dict[str, ObjectMaskConfig | None] = Field( + default_factory=dict, + title="Object mask", + description="Mask polygon used to prevent object detection in specified areas.", + ) + raw_mask: dict[str, ObjectMaskConfig | None] = Field( + default_factory=dict, exclude=True ) - mask: Union[str, list[str]] = Field(default="", title="Object mask.") genai: GenAIObjectConfig = Field( default_factory=GenAIObjectConfig, - title="Config for using genai to analyze objects.", + title="GenAI object config", + description="GenAI options for describing tracked objects and sending frames for generation.", ) _all_objects: list[str] = PrivateAttr() @@ -129,3 +174,13 @@ class ObjectConfig(FrigateBaseModel): enabled_labels.update(camera.objects.track) self._all_objects = list(enabled_labels) + + @field_serializer("mask", when_used="json") + def serialize_mask(self, value: Any, info): + if self.raw_mask: + return self.raw_mask + return value + + @field_serializer("raw_mask", when_used="json") + def serialize_raw_mask(self, value: Any, info): + return None diff --git a/frigate/config/camera/onvif.py b/frigate/config/camera/onvif.py index fd35fa5377..01c9432819 100644 --- a/frigate/config/camera/onvif.py +++ b/frigate/config/camera/onvif.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Optional, Union from pydantic import Field, field_validator @@ -17,37 +16,57 @@ class ZoomingModeEnum(str, Enum): class PtzAutotrackConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable PTZ object autotracking.") + enabled: bool = Field( + default=False, + title="Enable Autotracking", + description="Enable or disable automatic PTZ camera tracking of detected objects.", + ) calibrate_on_startup: bool = Field( - default=False, title="Perform a camera calibration when Frigate starts." + default=False, + title="Calibrate on start", + description="Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", ) zooming: ZoomingModeEnum = Field( - default=ZoomingModeEnum.disabled, title="Autotracker zooming mode." + default=ZoomingModeEnum.disabled, + title="Zoom mode", + description="Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", ) zoom_factor: float = Field( default=0.3, - title="Zooming factor (0.1-0.75).", + title="Zoom factor", + description="Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", ge=0.1, le=0.75, ) - track: list[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") + track: list[str] = Field( + default=DEFAULT_TRACKED_OBJECTS, + title="Tracked objects", + description="List of object types that should trigger autotracking.", + ) required_zones: list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to begin autotracking.", + title="Required zones", + description="Objects must enter one of these zones before autotracking begins.", ) return_preset: str = Field( default="home", - title="Name of camera preset to return to when object tracking is over.", + title="Return preset", + description="ONVIF preset name configured in camera firmware to return to after tracking ends.", ) timeout: int = Field( - default=10, title="Seconds to delay before returning to preset." + default=10, + title="Return timeout", + description="Wait this many seconds after losing tracking before returning camera to preset position.", ) - movement_weights: Optional[Union[str, list[str]]] = Field( + movement_weights: str | list[str] | None = Field( default_factory=list, - title="Internal value used for PTZ movements based on the speed of your camera's motor.", + title="Movement weights", + description="Calibration values automatically generated by camera calibration. Do not modify manually.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of autotracking." + enabled_in_config: bool | None = Field( + default=None, + title="Original autotrack state", + description="Internal field to track whether autotracking was enabled in configuration.", ) @field_validator("movement_weights", mode="before") @@ -72,16 +91,43 @@ class PtzAutotrackConfig(FrigateBaseModel): class OnvifConfig(FrigateBaseModel): - host: EnvString = Field(default="", title="Onvif Host") - port: int = Field(default=8000, title="Onvif Port") - user: Optional[EnvString] = Field(default=None, title="Onvif Username") - password: Optional[EnvString] = Field(default=None, title="Onvif Password") - tls_insecure: bool = Field(default=False, title="Onvif Disable TLS verification") + host: EnvString = Field( + default="", + title="ONVIF host", + description="Host (and optional scheme) for the ONVIF service for this camera.", + ) + port: int = Field( + default=8000, + title="ONVIF port", + description="Port number for the ONVIF service.", + ) + user: EnvString | None = Field( + default=None, + title="ONVIF username", + description="Username for ONVIF authentication; some devices require admin user for ONVIF.", + ) + password: EnvString | None = Field( + default=None, + title="ONVIF password", + description="Password for ONVIF authentication.", + ) + tls_insecure: bool = Field( + default=False, + title="Disable TLS verify", + description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", + ) + profile: str | None = Field( + default=None, + title="ONVIF profile", + description="Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", + ) autotracking: PtzAutotrackConfig = Field( default_factory=PtzAutotrackConfig, - title="PTZ auto tracking config.", + title="Autotracking", + description="Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", ) ignore_time_mismatch: bool = Field( default=False, - title="Onvif Ignore Time Synchronization Mismatch Between Camera and Server", + title="Ignore time mismatch", + description="Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", ) diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py new file mode 100644 index 0000000000..e3014aa4e6 --- /dev/null +++ b/frigate/config/camera/profile.py @@ -0,0 +1,42 @@ +"""Camera profile configuration for named config overrides.""" + +from ..base import FrigateBaseModel +from ..classification import ( + CameraFaceRecognitionConfig, + CameraLicensePlateRecognitionConfig, +) +from .audio import AudioConfig +from .birdseye import BirdseyeCameraConfig +from .detect import DetectConfig +from .motion import MotionConfig +from .notification import NotificationConfig +from .objects import ObjectConfig +from .record import RecordConfig +from .review import ReviewConfig +from .snapshots import SnapshotsConfig +from .zone import ZoneConfig + +__all__ = ["CameraProfileConfig"] + + +class CameraProfileConfig(FrigateBaseModel): + """A named profile containing partial camera config overrides. + + Sections set to None inherit from the camera's base config. + Sections that are defined get Pydantic-validated, then only + explicitly-set fields are used as overrides via exclude_unset. + """ + + enabled: bool | None = None + audio: AudioConfig | None = None + birdseye: BirdseyeCameraConfig | None = None + detect: DetectConfig | None = None + face_recognition: CameraFaceRecognitionConfig | None = None + lpr: CameraLicensePlateRecognitionConfig | None = None + motion: MotionConfig | None = None + notifications: NotificationConfig | None = None + objects: ObjectConfig | None = None + record: RecordConfig | None = None + review: ReviewConfig | None = None + snapshots: SnapshotsConfig | None = None + zones: dict[str, ZoneConfig] | None = None diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 4c8f873568..44a71c9cb9 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Optional from pydantic import Field @@ -20,11 +19,14 @@ __all__ = [ "RetainModeEnum", ] -DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" - class RecordRetainConfig(FrigateBaseModel): - days: float = Field(default=0, ge=0, title="Default retention period.") + days: float = Field( + default=0, + ge=0, + title="Retention days", + description="Days to retain recordings.", + ) class RetainModeEnum(str, Enum): @@ -34,22 +36,37 @@ class RetainModeEnum(str, Enum): class ReviewRetainConfig(FrigateBaseModel): - days: float = Field(default=10, ge=0, title="Default retention period.") - mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.") + days: float = Field( + default=10, + ge=0, + title="Retention days", + description="Number of days to retain recordings of detection events.", + ) + mode: RetainModeEnum = Field( + default=RetainModeEnum.motion, + title="Retention mode", + description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", + ) class EventsConfig(FrigateBaseModel): pre_capture: int = Field( default=5, - title="Seconds to retain before event starts.", + title="Pre-capture seconds", + description="Number of seconds before the detection event to include in the recording.", le=MAX_PRE_CAPTURE, ge=0, ) post_capture: int = Field( - default=5, ge=0, title="Seconds to retain after event ends." + default=5, + ge=0, + title="Post-capture seconds", + description="Number of seconds after the detection event to include in the recording.", ) retain: ReviewRetainConfig = Field( - default_factory=ReviewRetainConfig, title="Event retention settings." + default_factory=ReviewRetainConfig, + title="Event retention", + description="Retention settings for recordings of detection events.", ) @@ -63,55 +80,81 @@ class RecordQualityEnum(str, Enum): class RecordPreviewConfig(FrigateBaseModel): quality: RecordQualityEnum = Field( - default=RecordQualityEnum.medium, title="Quality of recording preview." + default=RecordQualityEnum.medium, + title="Preview quality", + description="Preview quality level (very_low, low, medium, high, very_high).", ) class ChaptersEnum(str, Enum): none = "none" recording_segments = "recording_segments" + review_items = "review_items" class RecordExportConfig(FrigateBaseModel): - timelapse_args: str = Field( - default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args" + hwaccel_args: str | list[str] = Field( + default="auto", + title="Export hwaccel args", + description="Hardware acceleration args to use for export/transcode operations.", + ) + max_concurrent: int = Field( + default=3, + ge=1, + title="Maximum concurrent exports", + description="Maximum number of export jobs to process at the same time.", ) chapters: ChaptersEnum = Field( - default=ChaptersEnum.none, + default=ChaptersEnum.review_items, title="Chapter metadata to embed in exported recordings", ) class RecordConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable record on all cameras.") - sync_recordings: bool = Field( - default=False, title="Sync recordings with disk on startup and once a day." + enabled: bool = Field( + default=False, + title="Enable recording", + description="Enable or disable recording for all cameras; can be overridden per-camera.", ) expire_interval: int = Field( default=60, - title="Number of minutes to wait between cleanup runs.", + title="Record cleanup interval", + description="Minutes between cleanup passes that remove expired recording segments.", ) continuous: RecordRetainConfig = Field( default_factory=RecordRetainConfig, - title="Continuous recording retention settings.", + title="Continuous retention", + description="Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", ) motion: RecordRetainConfig = Field( - default_factory=RecordRetainConfig, title="Motion recording retention settings." + default_factory=RecordRetainConfig, + title="Motion retention", + description="Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", ) detections: EventsConfig = Field( - default_factory=EventsConfig, title="Detection specific retention settings." + default_factory=EventsConfig, + title="Detection retention", + description="Recording retention settings for detection events including pre/post capture durations.", ) alerts: EventsConfig = Field( - default_factory=EventsConfig, title="Alert specific retention settings." + default_factory=EventsConfig, + title="Alert retention", + description="Recording retention settings for alert events including pre/post capture durations.", ) export: RecordExportConfig = Field( - default_factory=RecordExportConfig, title="Recording Export Config" + default_factory=RecordExportConfig, + title="Export config", + description="Settings used when exporting recordings such as timelapse and hardware acceleration.", ) preview: RecordPreviewConfig = Field( - default_factory=RecordPreviewConfig, title="Recording Preview Config" + default_factory=RecordPreviewConfig, + title="Preview config", + description="Settings controlling the quality of recording previews shown in the UI.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of recording." + enabled_in_config: bool | None = Field( + default=None, + title="Original recording state", + description="Indicates whether recording was enabled in the original static configuration.", ) @property diff --git a/frigate/config/camera/review.py b/frigate/config/camera/review.py index 6e55b6242f..f267f46ea2 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Optional, Union from pydantic import Field, field_validator @@ -21,22 +20,32 @@ DEFAULT_ALERT_OBJECTS = ["person", "car"] class AlertsConfig(FrigateBaseModel): """Configure alerts""" - enabled: bool = Field(default=True, title="Enable alerts.") + enabled: bool = Field( + default=True, + title="Enable alerts", + description="Enable or disable alert generation for all cameras; can be overridden per-camera.", + ) labels: list[str] = Field( - default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." + default=DEFAULT_ALERT_OBJECTS, + title="Alert labels", + description="List of object labels that qualify as alerts (for example: car, person).", ) - required_zones: Union[str, list[str]] = Field( + required_zones: str | list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to save the event as an alert.", + title="Required zones", + description="Zones that an object must enter to be considered an alert; leave empty to allow any zone.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of alerts." + enabled_in_config: bool | None = Field( + default=None, + title="Original alerts state", + description="Tracks whether alerts were originally enabled in the static configuration.", ) cutoff_time: int = Field( default=40, - title="Time to cutoff alerts after no alert-causing activity has occurred.", + title="Alerts cutoff time", + description="Seconds to wait after no alert-causing activity before cutting off an alert.", ) @field_validator("required_zones", mode="before") @@ -51,22 +60,32 @@ class AlertsConfig(FrigateBaseModel): class DetectionsConfig(FrigateBaseModel): """Configure detections""" - enabled: bool = Field(default=True, title="Enable detections.") - - labels: Optional[list[str]] = Field( - default=None, title="Labels to create detections for." + enabled: bool = Field( + default=True, + title="Enable detections", + description="Enable or disable detection events for all cameras; can be overridden per-camera.", ) - required_zones: Union[str, list[str]] = Field( + + labels: list[str] | None = Field( + default=None, + title="Detection labels", + description="List of object labels that qualify as detection events.", + ) + required_zones: str | list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to save the event as a detection.", + title="Required zones", + description="Zones that an object must enter to be considered a detection; leave empty to allow any zone.", ) cutoff_time: int = Field( default=30, - title="Time to cutoff detection after no detection-causing activity has occurred.", + title="Detections cutoff time", + description="Seconds to wait after no detection-causing activity before cutting off a detection.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of detections." + enabled_in_config: bool | None = Field( + default=None, + title="Original detections state", + description="Tracks whether detections were originally enabled in the static configuration.", ) @field_validator("required_zones", mode="before") @@ -81,27 +100,42 @@ class DetectionsConfig(FrigateBaseModel): class GenAIReviewConfig(FrigateBaseModel): enabled: bool = Field( default=False, - title="Enable GenAI descriptions for review items.", + title="Enable GenAI descriptions", + description="Enable or disable GenAI-generated descriptions and summaries for review items.", + ) + alerts: bool = Field( + default=True, + title="Enable GenAI for alerts", + description="Use GenAI to generate descriptions for alert items.", + ) + detections: bool = Field( + default=False, + title="Enable GenAI for detections", + description="Use GenAI to generate descriptions for detection items.", ) - alerts: bool = Field(default=True, title="Enable GenAI for alerts.") - detections: bool = Field(default=False, title="Enable GenAI for detections.") image_source: ImageSourceEnum = Field( default=ImageSourceEnum.preview, - title="Image source for review descriptions.", + title="Review image source", + description="Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", ) additional_concerns: list[str] = Field( default=[], - title="Additional concerns that GenAI should make note of on this camera.", + title="Additional concerns", + description="A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", ) debug_save_thumbnails: bool = Field( default=False, - title="Save thumbnails sent to generative AI for debugging purposes.", + title="Save thumbnails", + description="Save thumbnails that are sent to the GenAI provider for debugging and review.", ) - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of generative AI." + enabled_in_config: bool | None = Field( + default=None, + title="Original GenAI state", + description="Tracks whether GenAI review was originally enabled in the static configuration.", ) preferred_language: str | None = Field( - title="Preferred language for GenAI Response", + title="Preferred language", + description="Preferred language to request from the GenAI provider for generated responses.", default=None, ) activity_context_prompt: str = Field( @@ -139,19 +173,24 @@ Evaluate in this order: 3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1) The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""", - title="Custom activity context prompt defining normal and suspicious activity patterns for this property.", + title="Activity context prompt", + description="Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", ) class ReviewConfig(FrigateBaseModel): - """Configure reviews""" - alerts: AlertsConfig = Field( - default_factory=AlertsConfig, title="Review alerts config." + default_factory=AlertsConfig, + title="Alerts config", + description="Settings for which tracked objects generate alerts and how alerts are retained.", ) detections: DetectionsConfig = Field( - default_factory=DetectionsConfig, title="Review detections config." + default_factory=DetectionsConfig, + title="Detections config", + description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.", ) genai: GenAIReviewConfig = Field( - default_factory=GenAIReviewConfig, title="Review description genai config." + default_factory=GenAIReviewConfig, + title="GenAI config", + description="Controls use of generative AI for producing descriptions and summaries of review items.", ) diff --git a/frigate/config/camera/snapshots.py b/frigate/config/camera/snapshots.py index 156b56a7ed..7f5590a06c 100644 --- a/frigate/config/camera/snapshots.py +++ b/frigate/config/camera/snapshots.py @@ -1,44 +1,63 @@ -from typing import Optional - from pydantic import Field from ..base import FrigateBaseModel -from .record import RetainModeEnum __all__ = ["SnapshotsConfig", "RetainConfig"] class RetainConfig(FrigateBaseModel): - default: float = Field(default=10, title="Default retention period.") - mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.") + default: float = Field( + default=10, + title="Default retention", + description="Default number of days to retain snapshots.", + ) objects: dict[str, float] = Field( - default_factory=dict, title="Object retention period." + default_factory=dict, + title="Object retention", + description="Per-object overrides for snapshot retention days.", ) class SnapshotsConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Snapshots enabled.") - clean_copy: bool = Field( - default=True, title="Create a clean copy of the snapshot image." + enabled: bool = Field( + default=False, + title="Enable snapshots", + description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.", ) timestamp: bool = Field( - default=False, title="Add a timestamp overlay on the snapshot." + default=False, + title="Timestamp overlay", + description="Overlay a timestamp on snapshots from API.", ) bounding_box: bool = Field( - default=True, title="Add a bounding box overlay on the snapshot." + default=True, + title="Bounding box overlay", + description="Draw bounding boxes for tracked objects on snapshots from API.", + ) + crop: bool = Field( + default=False, + title="Crop snapshot", + description="Crop snapshots from API to the detected object's bounding box.", ) - crop: bool = Field(default=False, title="Crop the snapshot to the detected object.") required_zones: list[str] = Field( default_factory=list, - title="List of required zones to be entered in order to save a snapshot.", + title="Required zones", + description="Zones an object must enter for a snapshot to be saved.", + ) + height: int | None = Field( + default=None, + title="Snapshot height", + description="Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", ) - height: Optional[int] = Field(default=None, title="Snapshot image height.") retain: RetainConfig = Field( - default_factory=RetainConfig, title="Snapshot retention." + default_factory=RetainConfig, + title="Snapshot retention", + description="Retention settings for snapshots including default days and per-object overrides.", ) quality: int = Field( - default=70, - title="Quality of the encoded jpeg (0-100).", + default=60, + title="Snapshot quality", + description="Encode quality for saved snapshots (0-100).", ge=0, le=100, ) diff --git a/frigate/config/camera/timestamp.py b/frigate/config/camera/timestamp.py index fcf352a9ba..1245c311bf 100644 --- a/frigate/config/camera/timestamp.py +++ b/frigate/config/camera/timestamp.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Optional from pydantic import Field @@ -27,9 +26,27 @@ class TimestampPositionEnum(str, Enum): class ColorConfig(FrigateBaseModel): - red: int = Field(default=255, ge=0, le=255, title="Red") - green: int = Field(default=255, ge=0, le=255, title="Green") - blue: int = Field(default=255, ge=0, le=255, title="Blue") + red: int = Field( + default=255, + ge=0, + le=255, + title="Red", + description="Red component (0-255) for timestamp color.", + ) + green: int = Field( + default=255, + ge=0, + le=255, + title="Green", + description="Green component (0-255) for timestamp color.", + ) + blue: int = Field( + default=255, + ge=0, + le=255, + title="Blue", + description="Blue component (0-255) for timestamp color.", + ) class TimestampEffectEnum(str, Enum): @@ -39,11 +56,27 @@ class TimestampEffectEnum(str, Enum): class TimestampStyleConfig(FrigateBaseModel): position: TimestampPositionEnum = Field( - default=TimestampPositionEnum.tl, title="Timestamp position." + default=TimestampPositionEnum.tl, + title="Timestamp position", + description="Position of the timestamp on the image (tl/tr/bl/br).", ) - format: str = Field(default=DEFAULT_TIME_FORMAT, title="Timestamp format.") - color: ColorConfig = Field(default_factory=ColorConfig, title="Timestamp color.") - thickness: int = Field(default=2, title="Timestamp thickness.") - effect: Optional[TimestampEffectEnum] = Field( - default=None, title="Timestamp effect." + format: str = Field( + default=DEFAULT_TIME_FORMAT, + title="Timestamp format", + description="Datetime format string used for timestamps (Python datetime format codes).", + ) + color: ColorConfig = Field( + default_factory=ColorConfig, + title="Timestamp color", + description="RGB color values for the timestamp text (all values 0-255).", + ) + thickness: int = Field( + default=2, + title="Timestamp thickness", + description="Line thickness of the timestamp text.", + ) + effect: TimestampEffectEnum | None = Field( + default=None, + title="Timestamp effect", + description="Visual effect for the timestamp text (none, solid, shadow).", ) diff --git a/frigate/config/camera/ui.py b/frigate/config/camera/ui.py index b6b9c58ad8..869a28b903 100644 --- a/frigate/config/camera/ui.py +++ b/frigate/config/camera/ui.py @@ -6,7 +6,18 @@ __all__ = ["CameraUiConfig"] class CameraUiConfig(FrigateBaseModel): - order: int = Field(default=0, title="Order of camera in UI.") + order: int = Field( + default=0, + title="UI order", + description="Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", + ) dashboard: bool = Field( - default=True, title="Show this camera in Frigate dashboard UI." + default=True, + title="Show on Live dashboard", + description="Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", + ) + review: bool = Field( + default=True, + title="Show in review", + description="Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", ) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 125094f107..c0b9260873 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -14,19 +14,29 @@ class CameraConfigUpdateEnum(str, Enum): add = "add" # for adding a camera audio = "audio" audio_transcription = "audio_transcription" + autotracking = "autotracking" # ptz autotracking only, without an onvif reinit birdseye = "birdseye" detect = "detect" enabled = "enabled" + ffmpeg = "ffmpeg" + live = "live" motion = "motion" # includes motion and motion masks + mqtt = "mqtt" notifications = "notifications" objects = "objects" 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" semantic_search = "semantic_search" # for semantic search triggers + face_recognition = "face_recognition" + lpr = "lpr" snapshots = "snapshots" + timestamp_style = "timestamp_style" + ui = "ui" zones = "zones" @@ -64,7 +74,12 @@ class CameraConfigUpdateSubscriber: base_topic = "config/cameras" - if len(self.camera_configs) == 1: + # global subscribers must hear every camera; only narrow per-camera workers + is_global_subscriber = ( + CameraConfigUpdateEnum.add in self.topics + or CameraConfigUpdateEnum.remove in self.topics + ) + if not is_global_subscriber and len(self.camera_configs) == 1: base_topic += f"/{list(self.camera_configs.keys())[0]}" self.subscriber = ConfigSubscriber( @@ -76,12 +91,12 @@ 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) - self.camera_configs.pop(camera) + self.config.cameras.pop(camera, None) + self.camera_configs.pop(camera, None) return config = self.camera_configs.get(camera) @@ -91,6 +106,9 @@ class CameraConfigUpdateSubscriber: if update_type == CameraConfigUpdateEnum.audio: config.audio = updated_config + elif update_type == CameraConfigUpdateEnum.ffmpeg: + config.ffmpeg = updated_config + config.recreate_ffmpeg_cmds() elif update_type == CameraConfigUpdateEnum.audio_transcription: config.audio_transcription = updated_config elif update_type == CameraConfigUpdateEnum.birdseye: @@ -101,6 +119,8 @@ class CameraConfigUpdateSubscriber: config.enabled = updated_config elif update_type == CameraConfigUpdateEnum.object_genai: config.objects.genai = updated_config + elif update_type == CameraConfigUpdateEnum.live: + config.live = updated_config elif update_type == CameraConfigUpdateEnum.motion: config.motion = updated_config elif update_type == CameraConfigUpdateEnum.notifications: @@ -108,15 +128,28 @@ class CameraConfigUpdateSubscriber: elif update_type == CameraConfigUpdateEnum.objects: config.objects = updated_config elif update_type == CameraConfigUpdateEnum.record: + old_enabled_in_config = config.record.enabled_in_config config.record = updated_config + if old_enabled_in_config != updated_config.enabled_in_config: + config.recreate_ffmpeg_cmds() elif update_type == CameraConfigUpdateEnum.review: config.review = updated_config elif update_type == CameraConfigUpdateEnum.review_genai: config.review.genai = updated_config elif update_type == CameraConfigUpdateEnum.semantic_search: config.semantic_search = updated_config + elif update_type == CameraConfigUpdateEnum.face_recognition: + config.face_recognition = updated_config + elif update_type == CameraConfigUpdateEnum.lpr: + config.lpr = updated_config elif update_type == CameraConfigUpdateEnum.snapshots: config.snapshots = updated_config + elif update_type == CameraConfigUpdateEnum.onvif: + config.onvif = updated_config + elif update_type == CameraConfigUpdateEnum.autotracking: + config.onvif.autotracking = updated_config + elif update_type == CameraConfigUpdateEnum.timestamp_style: + config.timestamp_style = updated_config elif update_type == CameraConfigUpdateEnum.zones: config.zones = updated_config diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 7df1a1f25f..24c62e0ff6 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -1,6 +1,5 @@ # this uses the base model because the color is an extra attribute import logging -from typing import Optional, Union import numpy as np from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator @@ -13,39 +12,57 @@ logger = logging.getLogger(__name__) class ZoneConfig(BaseModel): - friendly_name: Optional[str] = Field( - None, title="Zone friendly name used in the Frigate UI." + friendly_name: str | None = Field( + None, + title="Zone name", + description="A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", + ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this zone. Disabled zones are ignored at runtime.", + ) + enabled_in_config: bool | None = Field( + default=None, title="Keep track of original state of zone." ) filters: dict[str, FilterConfig] = Field( - default_factory=dict, title="Zone filters." + default_factory=dict, + title="Zone filters", + description="Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", ) - coordinates: Union[str, list[str]] = Field( - title="Coordinates polygon for the defined zone." + coordinates: str | list[str] = Field( + title="Coordinates", + description="Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", ) - distances: Optional[Union[str, list[str]]] = Field( + distances: str | list[str] | None = Field( default_factory=list, - title="Real-world distances for the sides of quadrilateral for the defined zone.", + title="Real-world distances", + description="Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", ) inertia: int = Field( default=3, - title="Number of consecutive frames required for object to be considered present in the zone.", + title="Inertia frames", gt=0, + description="Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", ) loitering_time: int = Field( default=0, ge=0, - title="Number of seconds that an object must loiter to be considered in the zone.", + title="Loitering seconds", + description="Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", ) - speed_threshold: Optional[float] = Field( + speed_threshold: float | None = Field( default=None, ge=0.1, - title="Minimum speed value for an object to be considered in the zone.", + title="Minimum speed", + description="Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", ) - objects: Union[str, list[str]] = Field( + objects: str | list[str] = Field( default_factory=list, - title="List of objects that can trigger the zone.", + title="Trigger objects", + description="List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", ) - _color: Optional[tuple[int, int, int]] = PrivateAttr() + _color: tuple[int, int, int] | None = PrivateAttr() _contour: np.ndarray = PrivateAttr() @property @@ -129,7 +146,7 @@ class ZoneConfig(BaseModel): except ValueError: raise ValueError( f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" - ) + ) from None if explicit: self.coordinates = ",".join( @@ -158,7 +175,7 @@ class ZoneConfig(BaseModel): except ValueError: raise ValueError( f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" - ) + ) from None if explicit: self.coordinates = ",".join( diff --git a/frigate/config/camera_group.py b/frigate/config/camera_group.py index 7449e86a17..8e08f560fd 100644 --- a/frigate/config/camera_group.py +++ b/frigate/config/camera_group.py @@ -1,5 +1,3 @@ -from typing import Union - from pydantic import Field, field_validator from .base import FrigateBaseModel @@ -8,13 +6,21 @@ __all__ = ["CameraGroupConfig"] class CameraGroupConfig(FrigateBaseModel): - """Represents a group of cameras.""" - - cameras: Union[str, list[str]] = Field( - default_factory=list, title="List of cameras in this group." + cameras: str | list[str] = Field( + default_factory=list, + title="Camera list", + description="Array of camera names included in this group.", + ) + icon: str = Field( + default="generic", + title="Group icon", + description="Icon used to represent the camera group in the UI.", + ) + order: int = Field( + default=0, + title="Sort order", + description="Numeric order used to sort camera groups in the UI; larger numbers appear later.", ) - icon: str = Field(default="generic", title="Icon that represents camera group.") - order: int = Field(default=0, title="Sort order for group.") @field_validator("cameras", mode="before") @classmethod diff --git a/frigate/config/classification.py b/frigate/config/classification.py index fb8e3de29b..f4dc5f6001 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -1,7 +1,6 @@ from enum import Enum -from typing import Dict, List, Optional -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, field_validator from .base import FrigateBaseModel @@ -26,6 +25,11 @@ class EnrichmentsDeviceEnum(str, Enum): CPU = "CPU" +class ModelSizeEnum(str, Enum): + small = "small" + large = "large" + + class TriggerType(str, Enum): THUMBNAIL = "thumbnail" DESCRIPTION = "description" @@ -43,28 +47,43 @@ class ObjectClassificationType(str, Enum): class AudioTranscriptionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable audio transcription.") + enabled: bool = Field( + default=False, + title="Enable audio transcription", + description="Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", + ) language: str = Field( default="en", - title="Language abbreviation to use for audio event transcription/translation.", + title="Transcription language", + description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", ) - device: Optional[EnrichmentsDeviceEnum] = Field( + device: EnrichmentsDeviceEnum = Field( default=EnrichmentsDeviceEnum.CPU, - title="The device used for audio transcription.", + title="Transcription device", + description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", ) - model_size: str = Field( - default="small", title="The size of the embeddings model used." + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, + title="Model size", + description="Model size to use for offline audio event transcription.", ) - live_enabled: Optional[bool] = Field( - default=False, title="Enable live transcriptions." + live_enabled: bool | None = Field( + default=False, + title="Live transcription", + description="Enable streaming live transcription for audio as it is received.", ) class BirdClassificationConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable bird classification.") + enabled: bool = Field( + default=False, + title="Bird classification", + description="Enable or disable bird classification.", + ) threshold: float = Field( default=0.9, - title="Minimum classification score required to be considered a match.", + title="Minimum score", + description="Minimum classification score required to accept a bird classification.", gt=0.0, le=1.0, ) @@ -72,42 +91,62 @@ class BirdClassificationConfig(FrigateBaseModel): class CustomClassificationStateCameraConfig(FrigateBaseModel): crop: list[float, float, float, float] = Field( - title="Crop of image frame on this camera to run classification on." + title="Classification crop", + description="Crop coordinates to use for running classification on this camera.", ) class CustomClassificationStateConfig(FrigateBaseModel): - cameras: Dict[str, CustomClassificationStateCameraConfig] = Field( - title="Cameras to run classification on." + cameras: dict[str, CustomClassificationStateCameraConfig] = Field( + title="Classification cameras", + description="Per-camera crop and settings for running state classification.", ) motion: bool = Field( default=False, - title="If classification should be run when motion is detected in the crop.", + title="Run on motion", + description="If true, run classification when motion is detected within the specified crop.", ) interval: int | None = Field( default=None, - title="Interval to run classification on in seconds.", + title="Classification interval", + description="Interval (seconds) between periodic classification runs for state classification.", gt=0, ) class CustomClassificationObjectConfig(FrigateBaseModel): - objects: list[str] = Field(title="Object types to classify.") + objects: list[str] = Field( + default_factory=list, + title="Classify objects", + description="List of object types to run object classification on.", + ) classification_type: ObjectClassificationType = Field( default=ObjectClassificationType.sub_label, - title="Type of classification that is applied.", + title="Classification type", + description="Classification type applied: 'sub_label' (adds sub_label) or other supported types.", ) class CustomClassificationConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable running the model.") - name: str | None = Field(default=None, title="Name of classification model.") + enabled: bool = Field( + default=True, + title="Enable model", + description="Enable or disable the custom classification model.", + ) + name: str | None = Field( + default=None, + title="Model name", + description="Identifier for the custom classification model to use.", + ) threshold: float = Field( - default=0.8, title="Classification score threshold to change the state." + default=0.8, + title="Score threshold", + description="Score threshold used to change the classification state.", ) save_attempts: int | None = Field( default=None, - title="Number of classification attempts to save in the recent classifications tab. If not specified, defaults to 200 for object classification and 100 for state classification.", + title="Save attempts", + description="How many classification attempts to save for recent classifications UI.", ge=0, ) object_config: CustomClassificationObjectConfig | None = Field(default=None) @@ -116,196 +155,280 @@ class CustomClassificationConfig(FrigateBaseModel): class ClassificationConfig(FrigateBaseModel): bird: BirdClassificationConfig = Field( - default_factory=BirdClassificationConfig, title="Bird classification config." + default_factory=BirdClassificationConfig, + title="Bird classification config", + description="Settings specific to bird classification models.", ) - custom: Dict[str, CustomClassificationConfig] = Field( - default={}, title="Custom Classification Model Configs." + custom: dict[str, CustomClassificationConfig] = Field( + default={}, + title="Custom Classification Models", + description="Configuration for custom classification models used for objects or state detection.", ) class SemanticSearchConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable semantic search.") - reindex: Optional[bool] = Field( - default=False, title="Reindex all tracked objects on startup." + enabled: bool = Field( + default=False, + title="Enable semantic search", + description="Enable or disable the semantic search feature.", ) - model: Optional[SemanticSearchModelEnum] = Field( + reindex: bool | None = Field( + default=False, + title="Reindex on startup", + description="Trigger a full reindex of historical tracked objects into the embeddings database.", + ) + model: SemanticSearchModelEnum | str | None = Field( default=SemanticSearchModelEnum.jinav1, - title="The CLIP model to use for semantic search.", + title="Semantic search model or GenAI provider name", + description="The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", ) - model_size: str = Field( - default="small", title="The size of the embeddings model used." + + @field_validator("model", mode="before") + @classmethod + def coerce_model_enum(cls, v): + if isinstance(v, str): + try: + return SemanticSearchModelEnum(v) + except ValueError: + return v + return v + + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, + title="Model size", + description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.", ) - device: Optional[str] = Field( + device: str | None = Field( default=None, - title="The device key to use for semantic search.", + title="Device", description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", ) class TriggerConfig(FrigateBaseModel): - friendly_name: Optional[str] = Field( - None, title="Trigger friendly name used in the Frigate UI." + friendly_name: str | None = Field( + None, + title="Friendly name", + description="Optional friendly name displayed in the UI for this trigger.", + ) + enabled: bool = Field( + default=True, + title="Enable this trigger", + description="Enable or disable this semantic search trigger.", + ) + type: TriggerType = Field( + default=TriggerType.DESCRIPTION, + title="Trigger type", + description="Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", + ) + data: str = Field( + title="Trigger content", + description="Text phrase or thumbnail ID to match against tracked objects.", ) - enabled: bool = Field(default=True, title="Enable this trigger") - type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger") - data: str = Field(title="Trigger content (text phrase or image ID)") threshold: float = Field( - title="Confidence score required to run the trigger", + title="Trigger threshold", + description="Minimum similarity score (0-1) required to activate this trigger.", default=0.8, gt=0.0, le=1.0, ) - actions: List[TriggerAction] = Field( - default=[], title="Actions to perform when trigger is matched" + actions: list[TriggerAction] = Field( + default=[], + title="Trigger actions", + description="List of actions to execute when trigger matches (notification, sub_label, attribute).", ) model_config = ConfigDict(extra="forbid", protected_namespaces=()) class CameraSemanticSearchConfig(FrigateBaseModel): - triggers: Dict[str, TriggerConfig] = Field( + triggers: dict[str, TriggerConfig] = Field( default={}, - title="Trigger actions on tracked objects that match existing thumbnails or descriptions", + title="Triggers", + description="Actions and matching criteria for camera-specific semantic search triggers.", ) model_config = ConfigDict(extra="forbid", protected_namespaces=()) class FaceRecognitionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable face recognition.") - model_size: str = Field( - default="small", title="The size of the embeddings model used." + enabled: bool = Field( + default=False, + title="Enable face recognition", + description="Enable or disable face recognition for all cameras; can be overridden per-camera.", + ) + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, + title="Model size", + description="Model size to use for face embeddings (small/large); larger may require GPU.", ) unknown_score: float = Field( - title="Minimum face distance score required to be marked as a potential match.", + title="Unknown score threshold", + description="Distance threshold below which a face is considered a potential match (higher = stricter).", default=0.8, gt=0.0, le=1.0, ) detection_threshold: float = Field( default=0.7, - title="Minimum face detection score required to be considered a face.", + title="Detection threshold", + description="Minimum detection confidence required to consider a face detection valid.", gt=0.0, le=1.0, ) recognition_threshold: float = Field( default=0.9, - title="Minimum face distance score required to be considered a match.", + title="Recognition threshold", + description="Face embedding distance threshold to consider two faces a match.", gt=0.0, le=1.0, ) min_area: int = Field( - default=750, title="Min area of face box to consider running face recognition." + default=750, + title="Minimum face area", + description="Minimum area (pixels) of a detected face box required to attempt recognition.", ) min_faces: int = Field( default=1, gt=0, le=6, - title="Min face recognitions for the sub label to be applied to the person object.", + title="Minimum faces", + description="Minimum number of face recognitions required before applying a recognized sub-label to a person.", ) save_attempts: int = Field( default=200, ge=0, - title="Number of face attempts to save in the recent recognitions tab.", + title="Save attempts", + description="Number of face recognition attempts to retain for recent recognition UI.", ) blur_confidence_filter: bool = Field( - default=True, title="Apply blur quality filter to face confidence." + default=True, + title="Blur confidence filter", + description="Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", ) - device: Optional[str] = Field( + device: str | None = Field( default=None, - title="The device key to use for face recognition.", + title="Device", description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", ) class CameraFaceRecognitionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable face recognition.") + enabled: bool = Field( + default=False, + title="Enable face recognition", + description="Enable or disable face recognition.", + ) min_area: int = Field( - default=750, title="Min area of face box to consider running face recognition." + default=750, + title="Minimum face area", + description="Minimum area (pixels) of a detected face box required to attempt recognition.", ) model_config = ConfigDict(extra="forbid", protected_namespaces=()) class ReplaceRule(FrigateBaseModel): - pattern: str = Field(..., title="Regex pattern to match.") - replacement: str = Field( - ..., title="Replacement string (supports backrefs like '\\1')." - ) + pattern: str = Field(..., title="Regex pattern") + replacement: str = Field(..., title="Replacement string") class LicensePlateRecognitionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable license plate recognition.") - model_size: str = Field( - default="small", title="The size of the embeddings model used." + enabled: bool = Field( + default=False, + title="Enable LPR", + description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.", + ) + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, + title="Model size", + description="Model size used for text detection/recognition. Most users should use 'small'.", ) detection_threshold: float = Field( default=0.7, - title="License plate object confidence score required to begin running recognition.", + title="Detection threshold", + description="Detection confidence threshold to begin running OCR on a suspected plate.", gt=0.0, le=1.0, ) min_area: int = Field( default=1000, - title="Minimum area of license plate to begin running recognition.", + title="Minimum plate area", + description="Minimum plate area (pixels) required to attempt recognition.", ) recognition_threshold: float = Field( default=0.9, - title="Recognition confidence score required to add the plate to the object as a sub label.", + title="Recognition threshold", + description="Confidence threshold required for recognized plate text to be attached as a sub-label.", gt=0.0, le=1.0, ) min_plate_length: int = Field( default=4, - title="Minimum number of characters a license plate must have to be added to the object as a sub label.", + title="Min plate length", + description="Minimum number of characters a recognized plate must contain to be considered valid.", ) - format: Optional[str] = Field( + format: str | None = Field( default=None, - title="Regular expression for the expected format of license plate.", + title="Plate format regex", + description="Optional regex to validate recognized plate strings against an expected format.", ) match_distance: int = Field( default=1, - title="Allow this number of missing/incorrect characters to still cause a detected plate to match a known plate.", + title="Match distance", + description="Number of character mismatches allowed when comparing detected plates to known plates.", ge=0, ) - known_plates: Optional[Dict[str, List[str]]] = Field( - default={}, title="Known plates to track (strings or regular expressions)." + known_plates: dict[str, list[str]] | None = Field( + default={}, + title="Known plates", + description="List of plates or regexes to specially track or alert on.", ) enhancement: int = Field( default=0, - title="Amount of contrast adjustment and denoising to apply to license plate images before recognition.", + title="Enhancement level", + description="Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", ge=0, le=10, ) debug_save_plates: bool = Field( default=False, - title="Save plates captured for LPR for debugging purposes.", + title="Save debug plates", + description="Save plate crop images for debugging LPR performance.", ) - device: Optional[str] = Field( + device: str | None = Field( default=None, - title="The device key to use for LPR.", + title="Device", description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", ) - replace_rules: List[ReplaceRule] = Field( + replace_rules: list[ReplaceRule] = Field( default_factory=list, - title="List of regex replacement rules for normalizing detected plates. Each rule has 'pattern' and 'replacement'.", + title="Replacement rules", + description="Regex replacement rules used to normalize detected plate strings before matching.", ) class CameraLicensePlateRecognitionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable license plate recognition.") + enabled: bool = Field( + default=False, + title="Enable LPR", + description="Enable or disable LPR on this camera.", + ) expire_time: int = Field( default=3, - title="Expire plates not seen after number of seconds (for dedicated LPR cameras only).", + title="Expire seconds", + description="Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", gt=0, ) min_area: int = Field( default=1000, - title="Minimum area of license plate to begin running recognition.", + title="Minimum plate area", + description="Minimum plate area (pixels) required to attempt recognition.", ) enhancement: int = Field( default=0, - title="Amount of contrast adjustment and denoising to apply to license plate images before recognition.", + title="Enhancement level", + description="Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", ge=0, le=10, ) @@ -314,12 +437,18 @@ class CameraLicensePlateRecognitionConfig(FrigateBaseModel): class CameraAudioTranscriptionConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable audio transcription.") - enabled_in_config: Optional[bool] = Field( - default=None, title="Keep track of original state of audio transcription." + enabled: bool = Field( + default=False, + title="Enable transcription", + description="Enable or disable manually triggered audio event transcription.", ) - live_enabled: Optional[bool] = Field( - default=False, title="Enable live transcriptions." + enabled_in_config: bool | None = Field( + default=None, title="Original transcription state" + ) + live_enabled: bool | None = Field( + default=False, + title="Live transcription", + description="Enable streaming live transcription for audio as it is received.", ) model_config = ConfigDict(extra="forbid", protected_namespaces=()) diff --git a/frigate/config/config.py b/frigate/config/config.py index a26d4c50e4..470ce15a2a 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -1,9 +1,10 @@ from __future__ import annotations +import io import json import logging import os -from typing import Any, Dict, List, Optional, Union +from typing import Any, Self import numpy as np from pydantic import ( @@ -12,12 +13,10 @@ from pydantic import ( Field, TypeAdapter, ValidationInfo, - field_serializer, field_validator, model_validator, ) from ruamel.yaml import YAML -from typing_extensions import Self from frigate.const import REGEX_JSON from frigate.detectors import DetectorConfig, ModelConfig @@ -41,11 +40,12 @@ from frigate.util.services import auto_detect_hwaccel from .auth import AuthConfig from .base import FrigateBaseModel from .camera import CameraConfig, CameraLiveConfig -from .camera.audio import AudioConfig +from .camera.audio import AudioConfig, AudioFilterConfig from .camera.birdseye import BirdseyeConfig from .camera.detect import DetectConfig from .camera.ffmpeg import FfmpegConfig -from .camera.genai import GenAIConfig +from .camera.genai import GenAIConfig, GenAIRoleEnum +from .camera.mask import ObjectMaskConfig from .camera.motion import MotionConfig from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig @@ -60,12 +60,14 @@ from .classification import ( FaceRecognitionConfig, LicensePlateRecognitionConfig, SemanticSearchConfig, + SemanticSearchModelEnum, ) from .database import DatabaseConfig from .env import EnvVars from .logger import LoggerConfig from .mqtt import MqttConfig from .network import NetworkingConfig +from .profile import ProfileDefinitionConfig from .proxy import ProxyConfig from .telemetry import TelemetryConfig from .tls import TlsConfig @@ -77,70 +79,139 @@ logger = logging.getLogger(__name__) yaml = YAML() +# Pydantic field default applied when an existing config omits `detectors:`. +# Kept as cpu tflite for backwards compatibility with 0.17 configs. +DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}} + +# Used by the openvino branch below and rendered into the new-config YAML +# template so first-time setups default to openvino on CPU. +DEFAULT_MODEL = { + "width": 300, + "height": 300, + "input_tensor": "nhwc", + "input_pixel_format": "bgr", + "path": "/openvino-model/ssdlite_mobilenet_v2.xml", + "labelmap_path": "/openvino-model/coco_91cl_bkgr.txt", +} +NEW_CONFIG_DETECTORS = {"ov": {"type": "openvino", "device": "CPU"}} +DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} + + +def _render_default_yaml(data: dict) -> str: + buf = io.StringIO() + _yaml_writer = YAML() + _yaml_writer.indent(mapping=2, sequence=4, offset=2) + _yaml_writer.dump(data, buf) + return buf.getvalue() + + DEFAULT_CONFIG = f""" mqtt: enabled: False +{_render_default_yaml({"detectors": NEW_CONFIG_DETECTORS, "model": DEFAULT_MODEL})} cameras: {{}} # No cameras defined, UI wizard should be used version: {CURRENT_CONFIG_VERSION} """ -DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}} -DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} - # stream info handler stream_info_retriever = StreamInfoRetriever() class RuntimeMotionConfig(MotionConfig): - raw_mask: Union[str, List[str]] = "" - mask: np.ndarray = None + """Runtime version of MotionConfig with rasterized masks.""" + + rasterized_mask: np.ndarray = Field(default=None, exclude=True) def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = get_relative_coordinates(config.get("mask", ""), frame_shape) - config["raw_mask"] = mask - - if mask: - config["mask"] = create_mask(frame_shape, mask) - else: - empty_mask = np.zeros(frame_shape, np.uint8) - empty_mask[:] = 255 - config["mask"] = empty_mask + # Store original mask dict for serialization + original_mask = config.get("mask", {}) + if isinstance(original_mask, dict): + # Process the new dict format - update raw_coordinates for each mask + processed_mask = {} + for mask_id, mask_config in original_mask.items(): + if isinstance(mask_config, dict): + coords = mask_config.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_config_copy = mask_config.copy() + mask_config_copy["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_config_copy["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_config_copy + else: + processed_mask[mask_id] = mask_config + config["mask"] = processed_mask + config["raw_mask"] = processed_mask super().__init__(**config) - def dict(self, **kwargs): - ret = super().model_dump(**kwargs) - if "mask" in ret: - ret["mask"] = ret["raw_mask"] - ret.pop("raw_mask") - return ret + # Rasterize only enabled masks + enabled_coords = [] + for mask_config in self.mask.values(): + if mask_config.enabled and mask_config.coordinates: + coords = mask_config.coordinates + if isinstance(coords, list): + enabled_coords.extend(coords) + else: + enabled_coords.append(coords) - @field_serializer("mask", when_used="json") - def serialize_mask(self, value: Any, info): - return self.raw_mask - - @field_serializer("raw_mask", when_used="json") - def serialize_raw_mask(self, value: Any, info): - return None + if enabled_coords: + self.rasterized_mask = create_mask(frame_shape, enabled_coords) + else: + empty_mask = np.zeros(frame_shape, np.uint8) + empty_mask[:] = 255 + self.rasterized_mask = empty_mask model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") class RuntimeFilterConfig(FilterConfig): - mask: Optional[np.ndarray] = None - raw_mask: Optional[Union[str, List[str]]] = None + """Runtime version of FilterConfig with rasterized masks.""" + + rasterized_mask: np.ndarray | None = Field(default=None, exclude=True) def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = get_relative_coordinates(config.get("mask"), frame_shape) - config["raw_mask"] = mask - - if mask is not None: - config["mask"] = create_mask(frame_shape, mask) + # Store original mask dict for serialization + original_mask = config.get("mask", {}) + if isinstance(original_mask, dict): + # Process the new dict format - update raw_coordinates for each mask + processed_mask = {} + for mask_id, mask_config in original_mask.items(): + # Handle both dict and ObjectMaskConfig formats + if hasattr(mask_config, "model_dump"): + # It's an ObjectMaskConfig object + mask_dict = mask_config.model_dump() + coords = mask_dict.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_dict["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_dict["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_dict + elif isinstance(mask_config, dict): + coords = mask_config.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_config_copy = mask_config.copy() + mask_config_copy["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_config_copy["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_config_copy + else: + processed_mask[mask_id] = mask_config + config["mask"] = processed_mask + config["raw_mask"] = processed_mask # Convert min_area and max_area to pixels if they're percentages if "min_area" in config: @@ -151,12 +222,20 @@ class RuntimeFilterConfig(FilterConfig): super().__init__(**config) - def dict(self, **kwargs): - ret = super().model_dump(**kwargs) - if "mask" in ret: - ret["mask"] = ret["raw_mask"] - ret.pop("raw_mask") - return ret + # Rasterize only enabled masks + enabled_coords = [] + for mask_config in self.mask.values(): + if mask_config.enabled and mask_config.coordinates: + coords = mask_config.coordinates + if isinstance(coords, list): + enabled_coords.extend(coords) + else: + enabled_coords.append(coords) + + if enabled_coords: + self.rasterized_mask = create_mask(frame_shape, enabled_coords) + else: + self.rasterized_mask = None model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") @@ -213,7 +292,7 @@ def verify_recording_segments_setup_with_reasonable_time( raise ValueError( f"Camera {camera_config.name} has no segment_time in \ recording output args, segment args are required for record." - ) + ) from None if int(record_args[seg_arg_index + 1]) > 60: raise ValueError( @@ -246,6 +325,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 ( @@ -280,6 +400,9 @@ def verify_objects_track( ) camera_config.objects.track = valid_objects + for label in invalid_objects: + camera_config.objects.filters.pop(label, None) + def verify_lpr_and_face( frigate_config: FrigateConfig, camera_config: CameraConfig @@ -299,116 +422,202 @@ def verify_lpr_and_face( class FrigateConfig(FrigateBaseModel): - version: Optional[str] = Field(default=None, title="Current config version.") + version: str | None = Field( + default=None, + title="Current config version", + description="Numeric or string version of the active configuration to help detect migrations or format changes.", + ) safe_mode: bool = Field( - default=False, title="If Frigate should be started in safe mode." + default=False, + title="Safe mode", + description="When enabled, start Frigate in safe mode with reduced features for troubleshooting.", ) # Fields that install global state should be defined first, so that their validators run first. environment_vars: EnvVars = Field( - default_factory=dict, title="Frigate environment variables." + default_factory=dict, + title="Environment variables", + description="Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", ) logger: LoggerConfig = Field( default_factory=LoggerConfig, - title="Logging configuration.", + title="Logging", + description="Controls default log verbosity and per-component log level overrides.", validate_default=True, ) # Global config - auth: AuthConfig = Field(default_factory=AuthConfig, title="Auth configuration.") + auth: AuthConfig = Field( + default_factory=AuthConfig, + title="Authentication", + description="Authentication and session-related settings including cookie and rate limit options.", + ) database: DatabaseConfig = Field( - default_factory=DatabaseConfig, title="Database configuration." + default_factory=DatabaseConfig, + title="Database", + description="Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", ) go2rtc: RestreamConfig = Field( - default_factory=RestreamConfig, title="Global restream configuration." + default_factory=RestreamConfig, + title="go2rtc", + description="Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", + ) + mqtt: MqttConfig = Field( + title="MQTT", + description="Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", ) - mqtt: MqttConfig = Field(title="MQTT configuration.") notifications: NotificationConfig = Field( - default_factory=NotificationConfig, title="Global notification configuration." + default_factory=NotificationConfig, + title="Notifications", + description="Settings to enable and control notifications for all cameras; can be overridden per-camera.", ) networking: NetworkingConfig = Field( - default_factory=NetworkingConfig, title="Networking configuration" + default_factory=NetworkingConfig, + title="Networking", + description="Network-related settings such as IPv6 enablement for Frigate endpoints.", ) proxy: ProxyConfig = Field( - default_factory=ProxyConfig, title="Proxy configuration." + default_factory=ProxyConfig, + title="Proxy", + description="Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", ) telemetry: TelemetryConfig = Field( - default_factory=TelemetryConfig, title="Telemetry configuration." + default_factory=TelemetryConfig, + title="Telemetry", + description="System telemetry and stats options including GPU and network bandwidth monitoring.", + ) + tls: TlsConfig = Field( + default_factory=TlsConfig, + title="TLS", + description="TLS settings for Frigate's web endpoints (port 8971).", + ) + ui: UIConfig = Field( + default_factory=UIConfig, + title="UI", + description="User interface preferences such as timezone, time/date formatting, and units.", ) - tls: TlsConfig = Field(default_factory=TlsConfig, title="TLS configuration.") - ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") # Detector config - detectors: Dict[str, BaseDetectorConfig] = Field( + detectors: dict[str, BaseDetectorConfig] = Field( default=DEFAULT_DETECTORS, - title="Detector hardware configuration.", + title="Detector hardware", + description="Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", ) model: ModelConfig = Field( - default_factory=ModelConfig, title="Detection model configuration." + default_factory=ModelConfig, + title="Detection model", + description="Settings to configure a custom object detection model and its input shape.", ) - # GenAI config - genai: GenAIConfig = Field( - default_factory=GenAIConfig, title="Generative AI configuration." + # GenAI config (named provider configs: name -> GenAIConfig) + genai: dict[str, GenAIConfig] = Field( + default_factory=dict, + title="Generative AI configuration", + description="Settings for integrated generative AI providers used to generate object descriptions and review summaries.", ) # Camera config - cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") + cameras: dict[str, CameraConfig] = Field(title="Cameras", description="Cameras") audio: AudioConfig = Field( - default_factory=AudioConfig, title="Global Audio events configuration." + default_factory=AudioConfig, + title="Audio detection", + description="Settings for audio-based event detection for all cameras; can be overridden per-camera.", ) birdseye: BirdseyeConfig = Field( - default_factory=BirdseyeConfig, title="Birdseye configuration." + default_factory=BirdseyeConfig, + title="Birdseye", + description="Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", ) detect: DetectConfig = Field( - default_factory=DetectConfig, title="Global object tracking configuration." + default_factory=DetectConfig, + title="Object Detection", + description="Settings for the detection/detect role used to run object detection and initialize trackers.", ) ffmpeg: FfmpegConfig = Field( - default_factory=FfmpegConfig, title="Global FFmpeg configuration." + default_factory=FfmpegConfig, + title="FFmpeg", + description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", ) live: CameraLiveConfig = Field( - default_factory=CameraLiveConfig, title="Live playback settings." + default_factory=CameraLiveConfig, + title="Live playback", + description="Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", ) - motion: Optional[MotionConfig] = Field( - default=None, title="Global motion detection configuration." + motion: MotionConfig | None = Field( + default=None, + title="Motion detection", + description="Default motion detection settings applied to cameras unless overridden per-camera.", ) objects: ObjectConfig = Field( - default_factory=ObjectConfig, title="Global object configuration." + default_factory=ObjectConfig, + title="Objects", + description="Object tracking defaults including which labels to track and per-object filters.", ) record: RecordConfig = Field( - default_factory=RecordConfig, title="Global record configuration." + default_factory=RecordConfig, + title="Recording", + description="Recording and retention settings applied to cameras unless overridden per-camera.", ) review: ReviewConfig = Field( - default_factory=ReviewConfig, title="Review configuration." + default_factory=ReviewConfig, + title="Review", + description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", ) snapshots: SnapshotsConfig = Field( - default_factory=SnapshotsConfig, title="Global snapshots configuration." + default_factory=SnapshotsConfig, + title="Snapshots", + description="Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", ) timestamp_style: TimestampStyleConfig = Field( default_factory=TimestampStyleConfig, - title="Global timestamp style configuration.", + title="Timestamp style", + description="Styling options for in-feed timestamps applied to debug view and snapshots.", ) # Classification Config audio_transcription: AudioTranscriptionConfig = Field( - default_factory=AudioTranscriptionConfig, title="Audio transcription config." + default_factory=AudioTranscriptionConfig, + title="Audio transcription", + description="Settings for live and speech audio transcription used for events and live captions.", ) classification: ClassificationConfig = Field( - default_factory=ClassificationConfig, title="Object classification config." + default_factory=ClassificationConfig, + title="Object classification", + description="Settings for classification models used to refine object labels or state classification.", ) semantic_search: SemanticSearchConfig = Field( - default_factory=SemanticSearchConfig, title="Semantic search configuration." + default_factory=SemanticSearchConfig, + title="Semantic Search", + description="Settings for Semantic Search which builds and queries object embeddings to find similar items.", ) face_recognition: FaceRecognitionConfig = Field( - default_factory=FaceRecognitionConfig, title="Face recognition config." + default_factory=FaceRecognitionConfig, + title="Face recognition", + description="Settings for face detection and recognition for all cameras; can be overridden per-camera.", ) lpr: LicensePlateRecognitionConfig = Field( default_factory=LicensePlateRecognitionConfig, - title="License Plate recognition config.", + title="License Plate Recognition", + description="License plate recognition settings including detection thresholds, formatting, and known plates.", ) - camera_groups: Dict[str, CameraGroupConfig] = Field( - default_factory=dict, title="Camera group configuration" + camera_groups: dict[str, CameraGroupConfig] = Field( + default_factory=dict, + title="Camera groups", + description="Configuration for named camera groups used to organize cameras in the UI.", + ) + + profiles: dict[str, ProfileDefinitionConfig] = Field( + default_factory=dict, + title="Profiles", + description="Named profile definitions with friendly names. Camera profiles must reference names defined here.", + ) + + active_profile: str | None = Field( + default=None, + title="Active profile", + description="Currently active profile name. Runtime-only, not persisted in YAML.", + exclude=True, ) _plus_api: PlusApi @@ -431,17 +640,65 @@ class FrigateConfig(FrigateBaseModel): # set notifications state self.notifications.enabled_in_config = self.notifications.enabled + # validate genai: each role (chat, descriptions, embeddings) at most once + role_to_name: dict[GenAIRoleEnum, str] = {} + for name, genai_cfg in self.genai.items(): + for role in genai_cfg.roles: + if role in role_to_name: + raise ValueError( + f"GenAI role '{role.value}' is assigned to both " + f"'{role_to_name[role]}' and '{name}'; each role must have " + "exactly one provider." + ) + role_to_name[role] = name + + # validate semantic_search.model when it is a GenAI provider name + if ( + self.semantic_search.enabled + and isinstance(self.semantic_search.model, str) + and not isinstance(self.semantic_search.model, SemanticSearchModelEnum) + ): + if self.semantic_search.model not in self.genai: + raise ValueError( + f"semantic_search.model '{self.semantic_search.model}' is not a " + "valid GenAI config key. Must match a key in genai config." + ) + genai_cfg = self.genai[self.semantic_search.model] + if GenAIRoleEnum.embeddings not in genai_cfg.roles: + raise ValueError( + f"GenAI provider '{self.semantic_search.model}' must have " + "'embeddings' in its roles for semantic search." + ) + # set default min_score for object attributes for attribute in self.model.all_attributes: - if not self.objects.filters.get(attribute): + existing = self.objects.filters.get(attribute) + if existing is None: self.objects.filters[attribute] = FilterConfig(min_score=0.7) - elif self.objects.filters[attribute].min_score == 0.5: - self.objects.filters[attribute].min_score = 0.7 + elif "min_score" not in existing.model_fields_set: + existing.min_score = 0.7 # auto detect hwaccel args if self.ffmpeg.hwaccel_args == "auto": self.ffmpeg.hwaccel_args = auto_detect_hwaccel() + # Resolve global export hwaccel_args so it matches the per-camera + # resolution below. Without this, every camera reads as overriding + # record.export.hwaccel_args because the global stays "auto" while + # the camera value gets resolved to the actual args list. + if self.record.export.hwaccel_args == "auto": + self.record.export.hwaccel_args = self.ffmpeg.hwaccel_args + + # Populate global audio filters from listen. Existing user-defined + # entries for labels not in listen are preserved but unused at runtime. + if self.audio.filters is None: + self.audio.filters = {} + + for key in sorted(set(self.audio.listen) - self.audio.filters.keys()): + self.audio.filters[key] = AudioFilterConfig() + + self.audio.filters = dict(sorted(self.audio.filters.items())) + # Global config to propagate down to camera level global_config = self.model_dump( include={ @@ -475,6 +732,9 @@ class FrigateConfig(FrigateBaseModel): # users should not set model themselves if detector_config.model: + logger.warning( + "The model key should be specified at the root level of the config, not under detectors. The nested model key will be ignored." + ) detector_config.model = None model_config = self.model.model_dump(exclude_unset=True, warnings="none") @@ -489,6 +749,9 @@ class FrigateConfig(FrigateBaseModel): model_config["path"] = "/cpu_model.tflite" elif detector_config.type == "edgetpu": model_config["path"] = "/edgetpu_model.tflite" + elif detector_config.type == "openvino": + for default_key, default_value in DEFAULT_MODEL.items(): + model_config.setdefault(default_key, default_value) model = ModelConfig.model_validate(model_config) model.check_and_load_plus_model(self.plus_api, detector_config.type) @@ -525,6 +788,14 @@ class FrigateConfig(FrigateBaseModel): if camera_config.ffmpeg.hwaccel_args == "auto": camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args + # Resolve export hwaccel_args: camera export -> camera ffmpeg -> global ffmpeg + # This allows per-camera override for exports (e.g., when camera resolution + # exceeds hardware encoder limits) + if camera_config.record.export.hwaccel_args == "auto": + camera_config.record.export.hwaccel_args = ( + camera_config.ffmpeg.hwaccel_args + ) + for input in camera_config.ffmpeg.inputs: need_detect_dimensions = "detect" in input.roles and ( camera_config.detect.height is None @@ -532,6 +803,9 @@ class FrigateConfig(FrigateBaseModel): ) if need_detect_dimensions: + logger.info( + f"detect.width and detect.height not set for {camera_config.name}, probing detect stream to determine resolution." + ) stream_info = {"width": 0, "height": 0, "fourcc": None} try: stream_info = stream_info_retriever.get_stream_info( @@ -566,7 +840,7 @@ class FrigateConfig(FrigateBaseModel): ) # Default min_initialized configuration - min_initialized = int(camera_config.detect.fps / 2) + min_initialized = max(int(camera_config.detect.fps / 2), 2) if camera_config.detect.min_initialized is None: camera_config.detect.min_initialized = min_initialized @@ -609,6 +883,18 @@ class FrigateConfig(FrigateBaseModel): camera_config.review.genai.enabled ) + if camera_config.audio.filters is None: + camera_config.audio.filters = {} + + for key in sorted( + set(camera_config.audio.listen) - camera_config.audio.filters.keys() + ): + camera_config.audio.filters[key] = AudioFilterConfig() + + camera_config.audio.filters = dict( + sorted(camera_config.audio.filters.items()) + ) + # Add default filters object_keys = camera_config.objects.track if camera_config.objects.filters is None: @@ -617,35 +903,65 @@ class FrigateConfig(FrigateBaseModel): for key in object_keys: camera_config.objects.filters[key] = FilterConfig() + # Process global object masks to set raw_coordinates + if camera_config.objects.mask: + processed_global_masks = {} + for mask_id, mask_config in camera_config.objects.mask.items(): + if mask_config: + coords = mask_config.coordinates + relative_coords = get_relative_coordinates( + coords, + camera_config.frame_shape, + camera_name=camera_config.name, + ) + # Create a new ObjectMaskConfig with raw_coordinates set + processed_global_masks[mask_id] = ObjectMaskConfig( + friendly_name=mask_config.friendly_name, + enabled=mask_config.enabled, + coordinates=relative_coords if relative_coords else coords, + raw_coordinates=relative_coords + if relative_coords + else coords, + enabled_in_config=mask_config.enabled, + ) + else: + processed_global_masks[mask_id] = mask_config + camera_config.objects.mask = processed_global_masks + camera_config.objects.raw_mask = processed_global_masks + # Apply global object masks and convert masks to numpy array for object, filter in camera_config.objects.filters.items(): + # Set enabled_in_config for per-object masks before processing + for mask_config in filter.mask.values(): + if mask_config: + mask_config.enabled_in_config = mask_config.enabled + + # Merge global object masks with per-object filter masks + merged_mask = dict(filter.mask) # Copy filter-specific masks + + # Add global object masks if they exist if camera_config.objects.mask: - filter_mask = [] - if filter.mask is not None: - filter_mask = ( - filter.mask - if isinstance(filter.mask, list) - else [filter.mask] - ) - object_mask = ( - get_relative_coordinates( - ( - camera_config.objects.mask - if isinstance(camera_config.objects.mask, list) - else [camera_config.objects.mask] - ), - camera_config.frame_shape, - ) - or [] - ) - filter.mask = filter_mask + object_mask + for mask_id, mask_config in camera_config.objects.mask.items(): + # Use a global prefix to avoid key collisions + global_mask_id = f"global_{mask_id}" + merged_mask[global_mask_id] = mask_config # Set runtime filter to create masks camera_config.objects.filters[object] = RuntimeFilterConfig( frame_shape=camera_config.frame_shape, - **filter.model_dump(exclude_unset=True), + mask=merged_mask, + **filter.model_dump( + exclude_unset=True, exclude={"mask", "raw_mask"} + ), ) + # Set enabled_in_config for motion masks to match config file state BEFORE creating RuntimeMotionConfig + if camera_config.motion: + camera_config.motion.enabled_in_config = camera_config.motion.enabled + for mask_config in camera_config.motion.mask.values(): + if mask_config: + mask_config.enabled_in_config = mask_config.enabled + # Convert motion configuration if camera_config.motion is None: camera_config.motion = RuntimeMotionConfig( @@ -654,10 +970,8 @@ class FrigateConfig(FrigateBaseModel): else: camera_config.motion = RuntimeMotionConfig( frame_shape=camera_config.frame_shape, - raw_mask=camera_config.motion.mask, **camera_config.motion.model_dump(exclude_unset=True), ) - camera_config.motion.enabled_in_config = camera_config.motion.enabled # generate zone contours if len(camera_config.zones) > 0: @@ -671,6 +985,10 @@ class FrigateConfig(FrigateBaseModel): zone.generate_contour(camera_config.frame_shape) + # Set enabled_in_config for zones to match config file state + for zone in camera_config.zones.values(): + zone.enabled_in_config = zone.enabled + # Set live view stream if none is set if not camera_config.live.streams: camera_config.live.streams = {name: name} @@ -684,11 +1002,21 @@ 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) verify_lpr_and_face(self, camera_config) + # Validate camera profiles reference top-level profile definitions + for cam_name, cam_config in self.cameras.items(): + for profile_name in cam_config.profiles: + if profile_name not in self.profiles: + raise ValueError( + f"Camera '{cam_name}' references profile '{profile_name}' " + f"which is not defined in the top-level 'profiles' section" + ) + # set names on classification configs for name, config in self.classification.custom.items(): config.name = name @@ -712,11 +1040,6 @@ class FrigateConfig(FrigateBaseModel): f"Camera {camera.name} has audio transcription enabled, but audio detection is not enabled for this camera. Audio detection must be enabled for cameras with audio transcription when it is disabled globally." ) - if self.plus_api and not self.snapshots.clean_copy: - logger.warning( - "Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./" - ) - # Validate auth roles against cameras camera_names = set(self.cameras.keys()) @@ -733,7 +1056,7 @@ class FrigateConfig(FrigateBaseModel): @field_validator("cameras") @classmethod - def ensure_zones_and_cameras_have_different_names(cls, v: Dict[str, CameraConfig]): + def ensure_zones_and_cameras_have_different_names(cls, v: dict[str, CameraConfig]): zones = [zone for camera in v.values() for zone in camera.zones.keys()] for zone in zones: if zone in v.keys(): @@ -815,7 +1138,7 @@ class FrigateConfig(FrigateBaseModel): @classmethod def parse_object( - cls, obj: Any, *, plus_api: Optional[PlusApi] = None, install: bool = False + cls, obj: Any, *, plus_api: PlusApi | None = None, install: bool = False ): return cls.model_validate( obj, context={"plus_api": plus_api, "install": install} diff --git a/frigate/config/database.py b/frigate/config/database.py index 8daca0d49e..8064561f13 100644 --- a/frigate/config/database.py +++ b/frigate/config/database.py @@ -8,4 +8,8 @@ __all__ = ["DatabaseConfig"] class DatabaseConfig(FrigateBaseModel): - path: str = Field(default=DEFAULT_DB_PATH, title="Database path.") # noqa: F821 + path: str = Field( + default=DEFAULT_DB_PATH, + title="Database path", + description="Filesystem path where the Frigate SQLite database file will be stored.", + ) # noqa: F821 diff --git a/frigate/config/env.py b/frigate/config/env.py index db094a8af1..209dda67bf 100644 --- a/frigate/config/env.py +++ b/frigate/config/env.py @@ -1,4 +1,5 @@ import os +import re from pathlib import Path from typing import Annotated @@ -15,8 +16,77 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK): ) +# Matches a FRIGATE_* identifier following an opening brace. +_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+") + + +def substitute_frigate_vars(value: str) -> str: + """Substitute `{FRIGATE_*}` placeholders in *value*. + + Reproduces the subset of `str.format()` brace semantics that Frigate's + config has historically supported, while leaving unrelated brace content + (e.g. ffmpeg `%{localtime\\:...}` expressions) untouched: + + * `{{` and `}}` collapse to literal `{` / `}` (the documented escape). + * `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name + raises `KeyError` to preserve the existing "Invalid substitution" + error path. + * A `{` that begins `{FRIGATE_` but is not a well-formed + `{FRIGATE_NAME}` placeholder raises `ValueError` (malformed + placeholder). Callers that catch `KeyError` to allow unknown-var + passthrough will still surface malformed syntax as an error. + * Any other `{` or `}` is treated as a literal and passed through. + """ + out: list[str] = [] + i = 0 + n = len(value) + while i < n: + ch = value[i] + if ch == "{": + # Escaped literal `{{`. + if i + 1 < n and value[i + 1] == "{": + out.append("{") + i += 2 + continue + # Possible `{FRIGATE_*}` placeholder. + if value.startswith("{FRIGATE_", i): + ident_match = _FRIGATE_IDENT_RE.match(value, i + 1) + if ( + ident_match is not None + and ident_match.end() < n + and value[ident_match.end()] == "}" + ): + key = ident_match.group(0) + if key not in FRIGATE_ENV_VARS: + raise KeyError(key) + out.append(FRIGATE_ENV_VARS[key]) + i = ident_match.end() + 1 + continue + # Looks like a FRIGATE placeholder but is malformed + # (no closing brace, illegal char, format spec, etc.). + raise ValueError( + f"Malformed FRIGATE_ placeholder near {value[i : i + 32]!r}" + ) + # Plain `{` — pass through (e.g. `%{localtime\:...}`). + out.append("{") + i += 1 + continue + if ch == "}": + # Escaped literal `}}`. + if i + 1 < n and value[i + 1] == "}": + out.append("}") + i += 2 + continue + out.append("}") + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + + def validate_env_string(v: str) -> str: - return v.format(**FRIGATE_ENV_VARS) + return substitute_frigate_vars(v) EnvString = Annotated[str, AfterValidator(validate_env_string)] diff --git a/frigate/config/holder.py b/frigate/config/holder.py new file mode 100644 index 0000000000..e5f3e7bd8f --- /dev/null +++ b/frigate/config/holder.py @@ -0,0 +1,34 @@ +"""Shared handle on the config object that is current for this instance.""" + +from .config import FrigateConfig + +__all__ = ["ConfigHolder"] + + +class ConfigHolder: + """Indirection for the most recently parsed config. + + /api/config/set re-parses yaml into a brand new FrigateConfig instead of + mutating the old one, so any reference captured during startup goes stale + the first time a user saves. Anything that has to build something after + startup, most importantly the watchdog factories that rebuild a crashed + process, must read through a holder rather than close over a config + object, or the rebuilt process comes back with the config as it was at + boot and silently discards every change made since. + + There is deliberately no setter on the read side: the swap runs in exactly + one place (frigate.api.config_util.swap_runtime_config) and everyone else + only reads. + """ + + def __init__(self, config: FrigateConfig) -> None: + self._config = config + + @property + def config(self) -> FrigateConfig: + """The config as of the most recent successful save.""" + return self._config + + def set(self, config: FrigateConfig) -> None: + """Install a freshly parsed config as the current one.""" + self._config = config diff --git a/frigate/config/logger.py b/frigate/config/logger.py index 0ba3e6972d..906f75e4a3 100644 --- a/frigate/config/logger.py +++ b/frigate/config/logger.py @@ -1,5 +1,6 @@ +from typing import Self + from pydantic import Field, ValidationInfo, model_validator -from typing_extensions import Self from frigate.log import LogLevel, apply_log_levels @@ -9,9 +10,15 @@ __all__ = ["LoggerConfig"] class LoggerConfig(FrigateBaseModel): - default: LogLevel = Field(default=LogLevel.info, title="Default logging level.") + default: LogLevel = Field( + default=LogLevel.info, + title="Logging level", + description="Default global log verbosity (debug, info, warning, error).", + ) logs: dict[str, LogLevel] = Field( - default_factory=dict, title="Log level for specified processes." + default_factory=dict, + title="Per-process log level", + description="Per-component log level overrides to increase or decrease verbosity for specific modules.", ) @model_validator(mode="after") diff --git a/frigate/config/mqtt.py b/frigate/config/mqtt.py index 3e2f992949..28c002a0de 100644 --- a/frigate/config/mqtt.py +++ b/frigate/config/mqtt.py @@ -1,7 +1,6 @@ -from typing import Optional +from typing import Self from pydantic import Field, ValidationInfo, model_validator -from typing_extensions import Self from frigate.const import FREQUENCY_STATS_POINTS @@ -12,25 +11,73 @@ __all__ = ["MqttConfig"] class MqttConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable MQTT Communication.") - host: EnvString = Field(default="", title="MQTT Host") - port: int = Field(default=1883, title="MQTT Port") - topic_prefix: str = Field(default="frigate", title="MQTT Topic Prefix") - client_id: str = Field(default="frigate", title="MQTT Client ID") + enabled: bool = Field( + default=True, + title="Enable MQTT", + description="Enable or disable MQTT integration for state, events, and snapshots.", + ) + host: EnvString = Field( + default="", + title="MQTT host", + description="Hostname or IP address of the MQTT broker.", + ) + port: int = Field( + default=1883, + title="MQTT port", + description="Port of the MQTT broker (usually 1883 for plain MQTT).", + ) + topic_prefix: str = Field( + default="frigate", + title="Topic prefix", + description="MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", + ) + client_id: str = Field( + default="frigate", + title="Client ID", + description="Client identifier used when connecting to the MQTT broker; should be unique per instance.", + ) stats_interval: int = Field( - default=60, ge=FREQUENCY_STATS_POINTS, title="MQTT Camera Stats Interval" + default=60, + ge=FREQUENCY_STATS_POINTS, + title="Stats interval", + description="Interval in seconds for publishing system and camera stats to MQTT.", ) - user: Optional[EnvString] = Field(default=None, title="MQTT Username") - password: Optional[EnvString] = Field( - default=None, title="MQTT Password", validate_default=True + user: EnvString | None = Field( + default=None, + title="MQTT username", + description="Optional MQTT username; can be provided via environment variables or secrets.", ) - tls_ca_certs: Optional[str] = Field(default=None, title="MQTT TLS CA Certificates") - tls_client_cert: Optional[str] = Field( - default=None, title="MQTT TLS Client Certificate" + password: EnvString | None = Field( + default=None, + title="MQTT password", + description="Optional MQTT password; can be provided via environment variables or secrets.", + validate_default=True, + ) + tls_ca_certs: str | None = Field( + default=None, + title="TLS CA certs", + description="Path to CA certificate for TLS connections to the broker (for self-signed certs).", + ) + tls_client_cert: str | None = Field( + default=None, + title="Client cert", + description="Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", + ) + tls_client_key: str | None = Field( + default=None, + title="Client key", + description="Private key path for the client certificate.", + ) + tls_insecure: bool | None = Field( + default=None, + title="TLS insecure", + description="Allow insecure TLS connections by skipping hostname verification (not recommended).", + ) + qos: int = Field( + default=0, + title="MQTT QoS", + description="Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", ) - tls_client_key: Optional[str] = Field(default=None, title="MQTT TLS Client Key") - tls_insecure: Optional[bool] = Field(default=None, title="MQTT TLS Insecure") - qos: int = Field(default=0, title="MQTT QoS") @model_validator(mode="after") def user_requires_pass(self, info: ValidationInfo) -> Self: diff --git a/frigate/config/network.py b/frigate/config/network.py index c8b3cfd1c1..1838b410bd 100644 --- a/frigate/config/network.py +++ b/frigate/config/network.py @@ -1,13 +1,62 @@ -from pydantic import Field +from pydantic import Field, model_validator from .base import FrigateBaseModel -__all__ = ["IPv6Config", "NetworkingConfig"] +__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"] + + +def parse_listen_port(value: int | str) -> int: + """Return the port number from a bare port or an "address:port" value.""" + if isinstance(value, str): + return int(value.split(":")[-1]) + + return value class IPv6Config(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable IPv6 for port 5000 and/or 8971") + enabled: bool = Field( + default=False, + title="Enable IPv6", + description="Enable IPv6 support for Frigate services (API and UI) where applicable.", + ) + + +class ListenConfig(FrigateBaseModel): + internal: int | str = Field( + default=5000, + title="Internal port", + description="Internal listening port for Frigate (default 5000).", + ) + external: int | str = Field( + default=8971, + title="External port", + description="External listening port for Frigate (default 8971).", + ) + + @property + def internal_port(self) -> int: + return parse_listen_port(self.internal) + + @property + def external_port(self) -> int: + return parse_listen_port(self.external) + + @model_validator(mode="after") + def validate_distinct_ports(self) -> "ListenConfig": + if self.internal_port == self.external_port: + raise ValueError("internal and external must listen on different ports") + + return self class NetworkingConfig(FrigateBaseModel): - ipv6: IPv6Config = Field(default_factory=IPv6Config, title="Network configuration") + ipv6: IPv6Config = Field( + default_factory=IPv6Config, + title="IPv6 configuration", + description="IPv6-specific settings for Frigate network services.", + ) + listen: ListenConfig = Field( + default_factory=ListenConfig, + title="Listening ports configuration", + description="Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", + ) diff --git a/frigate/config/profile.py b/frigate/config/profile.py new file mode 100644 index 0000000000..2d6dd1be33 --- /dev/null +++ b/frigate/config/profile.py @@ -0,0 +1,20 @@ +"""Top-level profile definition configuration.""" + +from pydantic import Field + +from .base import FrigateBaseModel + +__all__ = ["ProfileDefinitionConfig"] + + +class ProfileDefinitionConfig(FrigateBaseModel): + """Defines a named profile with a human-readable display name. + + The dict key is the machine name used internally; friendly_name + is the label shown in the UI and API responses. + """ + + friendly_name: str = Field( + title="Friendly name", + description="Display name for this profile shown in the UI.", + ) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py new file mode 100644 index 0000000000..cfc52ce758 --- /dev/null +++ b/frigate/config/profile_manager.py @@ -0,0 +1,497 @@ +"""Profile manager for activating/deactivating named config profiles.""" + +import copy +import json +import logging +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) +from frigate.config.camera.zone import ZoneConfig +from frigate.const import CONFIG_DIR +from frigate.util.builtin import deep_merge +from frigate.util.config import apply_section_update + +logger = logging.getLogger(__name__) + +PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { + "audio": CameraConfigUpdateEnum.audio, + "birdseye": CameraConfigUpdateEnum.birdseye, + "detect": CameraConfigUpdateEnum.detect, + "face_recognition": CameraConfigUpdateEnum.face_recognition, + "lpr": CameraConfigUpdateEnum.lpr, + "motion": CameraConfigUpdateEnum.motion, + "notifications": CameraConfigUpdateEnum.notifications, + "objects": CameraConfigUpdateEnum.objects, + "record": CameraConfigUpdateEnum.record, + "review": CameraConfigUpdateEnum.review, + "snapshots": CameraConfigUpdateEnum.snapshots, + "zones": CameraConfigUpdateEnum.zones, +} + +# Retained MQTT switch topics per profile section, with a payload getter. +# Republished on profile change so MQTT/HA don't show a stale toggle. +SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = { + "audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")], + "birdseye": [ + ("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"), + ( + "birdseye_mode", + lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF", + ), + ], + "detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")], + "motion": [ + ("motion", lambda c: "ON" if c.motion.enabled else "OFF"), + ("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"), + ("motion_threshold", lambda c: c.motion.threshold), + ("motion_contour_area", lambda c: c.motion.contour_area), + ], + "notifications": [ + ("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"), + ], + "objects": [ + ("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"), + ], + "record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")], + "review": [ + ("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"), + ( + "review_detections", + lambda c: "ON" if c.review.detections.enabled else "OFF", + ), + ( + "review_descriptions", + lambda c: "ON" if c.review.genai.enabled else "OFF", + ), + ], + "snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")], +} + +PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles" + + +class ProfileManager: + """Manages profile activation, persistence, and config application.""" + + def __init__( + self, + config, + config_updater: CameraConfigUpdatePublisher, + dispatcher=None, + ): + from frigate.config.config import FrigateConfig + + self.config: FrigateConfig = config + self.config_updater = config_updater + self.dispatcher = dispatcher + self._base_configs: dict[str, dict[str, dict]] = {} + self._base_api_configs: dict[str, dict[str, dict]] = {} + self._base_enabled: dict[str, bool] = {} + self._base_zones: dict[str, dict[str, ZoneConfig]] = {} + self._snapshot_base_configs() + + def _snapshot_base_configs(self) -> None: + """Snapshot each camera's current section configs, enabled, and zones.""" + for cam_name, cam_config in self.config.cameras.items(): + self._base_configs[cam_name] = {} + self._base_api_configs[cam_name] = {} + self._base_enabled[cam_name] = cam_config.enabled + self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) + for section in PROFILE_SECTION_UPDATES: + section_value = getattr(cam_config, section, None) + if section_value is None: + continue + + if section == "zones": + # zones is a dict of ZoneConfig models + self._base_configs[cam_name][section] = { + name: zone.model_dump() for name, zone in section_value.items() + } + self._base_api_configs[cam_name][section] = { + name: { + **zone.model_dump( + mode="json", + warnings="none", + exclude_none=True, + ), + "color": zone.color, + } + for name, zone in section_value.items() + } + else: + self._base_configs[cam_name][section] = section_value.model_dump() + self._base_api_configs[cam_name][section] = ( + section_value.model_dump( + mode="json", + warnings="none", + exclude_none=True, + ) + ) + + def update_config(self, new_config) -> None: + """Update config reference after config/set replaces the in-memory config. + + Preserves active profile state: re-snapshots base configs from the new + (freshly parsed) config, then re-applies profile overrides if a profile + was active. + + Deliberately does not clear the dispatcher's runtime overrides. This is + the config-save path, not a profile switch: the save only invalidates + the toggles it rewrote in yaml, which /api/config/set already clears by + key. The broad wipe belongs to activate_profile alone. + """ + current_active = self.config.active_profile + self.config = new_config + + # Re-snapshot base configs from the new config (which has base values) + self._base_configs.clear() + self._base_api_configs.clear() + self._base_enabled.clear() + self._base_zones.clear() + self._snapshot_base_configs() + + # Re-apply profile overrides without publishing ZMQ updates + # (the config/set caller handles its own ZMQ publishing) + if current_active is not None: + if current_active in self.config.profiles: + changed: dict[str, set[str]] = {} + self._apply_profile_overrides(current_active, changed) + self.config.active_profile = current_active + else: + # Profile was deleted — deactivate + self.config.active_profile = None + self._persist_active_profile(None) + + def _validate_profile_name(self, profile_name: str | None) -> str | None: + """Return an error message if the name is not a defined profile.""" + if profile_name is not None and profile_name not in self.config.profiles: + return f"Profile '{profile_name}' is not defined in the profiles section" + + return None + + def _apply_to_config( + self, profile_name: str | None + ) -> tuple[dict[str, set[str]], str | None]: + """Reset every camera to base, then apply the named profile on top. + + Returns the changed camera/section pairs, plus an error message if + applying the profile failed partway through. + """ + changed: dict[str, set[str]] = {} + + self._reset_to_base(changed) + + if profile_name is not None: + err = self._apply_profile_overrides(profile_name, changed) + if err: + return changed, err + + return changed, None + + def apply_profile_to_config(self, profile_name: str | None) -> str | None: + """Apply a profile to the in-memory config, without publishing it. + + Safe to call ahead of activate_profile: both reset to the base config + first, so the later call re-derives the same state and still reports + every section as changed. + + Returns: + None on success, or an error message string on failure. + """ + err = self._validate_profile_name(profile_name) + + if err: + return err + + return self._apply_to_config(profile_name)[1] + + def _persisted_profile_to_restore(self) -> str | None: + """Return the persisted profile name, if it still applies to a camera.""" + persisted = self.load_persisted_profile() + + if not persisted or not any( + persisted in cam.profiles for cam in self.config.cameras.values() + ): + return None + + return persisted + + def restore_persisted_profile_to_config(self) -> None: + """Restore the persisted profile into the config, without publishing. + + Called before worker processes start, so they are handed a config that + already carries the profile rather than relying on the broadcast that + restore_persisted_profile() sends later. + """ + persisted = self._persisted_profile_to_restore() + + if persisted is None: + return + + err = self.apply_profile_to_config(persisted) + + if err: + logger.error("Failed to apply persisted profile '%s': %s", persisted, err) + + def restore_persisted_profile(self) -> None: + """Re-activate the persisted profile once subscribers are connected. + + The config already carries the profile; this pass publishes it for the + processes that start before the config can be corrected, and for the + retained MQTT states. + """ + persisted = self._persisted_profile_to_restore() + + if persisted is None: + return + + logger.info("Restoring persisted profile '%s'", persisted) + # runtime overrides are layered on top by the dispatcher's replay + self.activate_profile(persisted, clear_runtime_overrides=False) + + def activate_profile( + self, + profile_name: str | None, + clear_runtime_overrides: bool = True, + ) -> str | None: + """Activate a profile by name, or deactivate if None. + + Args: + profile_name: Profile name to activate, or None to deactivate. + clear_runtime_overrides: When True (the default, for user-initiated + activations) drop the dispatcher's runtime override file because + the layer below changed. Startup callers that are replaying a + persisted profile pass False so the runtime state stays + available for the subsequent replay step. + + Returns: + None on success, or an error message string on failure. + """ + err = self._validate_profile_name(profile_name) + + if err: + return err + + # Track which camera/section pairs get changed for ZMQ publishing + changed, err = self._apply_to_config(profile_name) + + if err: + return err + + # Publish ZMQ updates only for sections that actually changed + self._publish_updates(changed) + + self.config.active_profile = profile_name + self._persist_active_profile(profile_name) + + # a profile switch invalidates the steady-state runtime overrides + if clear_runtime_overrides and self.dispatcher is not None: + self.dispatcher.clear_runtime_state() + + logger.info( + "Profile %s", + f"'{profile_name}' activated" if profile_name else "deactivated", + ) + return None + + def _reset_to_base(self, changed: dict[str, set[str]]) -> None: + """Reset all cameras to their base (no-profile) config.""" + for cam_name, cam_config in self.config.cameras.items(): + # Restore enabled state + base_enabled = self._base_enabled.get(cam_name) + if base_enabled is not None and cam_config.enabled != base_enabled: + cam_config.enabled = base_enabled + changed.setdefault(cam_name, set()).add("enabled") + + # Restore zones (always restore from snapshot; direct Pydantic + # comparison fails when ZoneConfig contains numpy arrays) + base_zones = self._base_zones.get(cam_name) + if base_zones is not None: + cam_config.zones = copy.deepcopy(base_zones) + changed.setdefault(cam_name, set()).add("zones") + + # Restore section configs (zones handled above) + base = self._base_configs.get(cam_name, {}) + for section in PROFILE_SECTION_UPDATES: + if section == "zones": + continue + base_data = base.get(section) + if base_data is None: + continue + err = apply_section_update(cam_config, section, base_data) + if err: + logger.error( + "Failed to reset section '%s' on camera '%s': %s", + section, + cam_name, + err, + ) + else: + changed.setdefault(cam_name, set()).add(section) + + def _apply_profile_overrides( + self, profile_name: str, changed: dict[str, set[str]] + ) -> str | None: + """Apply profile overrides for all cameras that have the named profile.""" + for cam_name, cam_config in self.config.cameras.items(): + profile = cam_config.profiles.get(profile_name) + if profile is None: + continue + + # Apply enabled override + if profile.enabled is not None and cam_config.enabled != profile.enabled: + cam_config.enabled = profile.enabled + changed.setdefault(cam_name, set()).add("enabled") + + # Apply zones override — merge profile zones into base zones + if profile.zones is not None: + base_zones = self._base_zones.get(cam_name, {}) + merged_zones = copy.deepcopy(base_zones) + merged_zones.update(profile.zones) + # Profile zone objects are parsed without colors or contours + # (those are set during CameraConfig init / post-validation). + # Inherit the base zone's color when available, and ensure + # every zone has a valid contour for rendering. + for name, zone in merged_zones.items(): + if zone.contour.size == 0: + zone.generate_contour(cam_config.frame_shape) + if zone.color == (0, 0, 0) and name in base_zones: + zone._color = base_zones[name].color + cam_config.zones = merged_zones + changed.setdefault(cam_name, set()).add("zones") + + base = self._base_configs.get(cam_name, {}) + + for section in PROFILE_SECTION_UPDATES: + if section == "zones": + continue + profile_section = getattr(profile, section, None) + if profile_section is None: + continue + + overrides = profile_section.model_dump(exclude_unset=True) + if not overrides: + continue + + base_data = base.get(section, {}) + merged = deep_merge(overrides, base_data) + + err = apply_section_update(cam_config, section, merged) + if err: + return f"Failed to apply profile '{profile_name}' section '{section}' on camera '{cam_name}': {err}" + + changed.setdefault(cam_name, set()).add(section) + + return None + + def _publish_updates(self, changed: dict[str, set[str]]) -> None: + """Publish ZMQ config updates only for sections that changed.""" + for cam_name, sections in changed.items(): + cam_config = self.config.cameras.get(cam_name) + if cam_config is None: + continue + + for section in sections: + if section == "enabled": + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.enabled, cam_name + ), + cam_config.enabled, + ) + if self.dispatcher is not None: + self.dispatcher.publish( + f"{cam_name}/enabled/state", + "ON" if cam_config.enabled else "OFF", + retain=True, + ) + continue + + if section == "zones": + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, cam_name), + cam_config.zones, + ) + continue + + update_enum = PROFILE_SECTION_UPDATES.get(section) + if update_enum is None: + continue + settings = getattr(cam_config, section, None) + if settings is not None: + self.config_updater.publish_update( + CameraConfigUpdateTopic(update_enum, cam_name), + settings, + ) + + # republish MQTT switch states + if self.dispatcher is not None: + for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()): + self.dispatcher.publish( + f"{cam_name}/{suffix}/state", + get_payload(cam_config), + retain=True, + ) + + def _persist_active_profile(self, profile_name: str | None) -> None: + """Persist the active profile state to disk as JSON.""" + try: + data = self._load_persisted_data() + data["active"] = profile_name + if profile_name is not None: + data.setdefault("last_activated", {})[profile_name] = datetime.now( + UTC + ).timestamp() + PERSISTENCE_FILE.write_text(json.dumps(data)) + except OSError: + logger.exception("Failed to persist active profile") + + @staticmethod + def _load_persisted_data() -> dict: + """Load the full persisted profile data from disk.""" + try: + if PERSISTENCE_FILE.exists(): + raw = PERSISTENCE_FILE.read_text().strip() + if raw: + return json.loads(raw) + except (OSError, json.JSONDecodeError): + logger.exception("Failed to load persisted profile data") + return {"active": None, "last_activated": {}} + + @staticmethod + def load_persisted_profile() -> str | None: + """Load the persisted active profile name from disk.""" + data = ProfileManager._load_persisted_data() + name = data.get("active") + return name if name else None + + def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]: + """Return base (pre-profile) section configs for a camera. + + These are JSON-serializable dicts suitable for direct inclusion in + the /api/config response, with None values already excluded. + """ + return self._base_api_configs.get(camera_name, {}) + + def get_available_profiles(self) -> list[dict[str, str]]: + """Get list of all profile definitions from the top-level config.""" + return [ + {"name": name, "friendly_name": defn.friendly_name} + for name, defn in sorted(self.config.profiles.items()) + ] + + def get_profile_info(self) -> dict: + """Get profile state info for API responses.""" + data = self._load_persisted_data() + return { + "profiles": self.get_available_profiles(), + "active_profile": self.config.active_profile, + "last_activated": data.get("last_activated", {}), + } diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index a46b7b8973..196110520b 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import Field, field_validator from .base import FrigateBaseModel @@ -10,36 +8,47 @@ __all__ = ["ProxyConfig", "HeaderMappingConfig"] class HeaderMappingConfig(FrigateBaseModel): user: str = Field( - default=None, title="Header name from upstream proxy to identify user." + default=None, + title="User header", + description="Header containing the authenticated username provided by the upstream proxy.", ) role: str = Field( default=None, - title="Header name from upstream proxy to identify user role.", + title="Role header", + description="Header containing the authenticated user's role or groups from the upstream proxy.", ) - role_map: Optional[dict[str, list[str]]] = Field( + role_map: dict[str, list[str]] | None = Field( default_factory=dict, - title=("Mapping of Frigate roles to upstream group values. "), + title=("Role mapping"), + description="Map upstream group values to Frigate roles (for example map admin groups to the admin role).", ) class ProxyConfig(FrigateBaseModel): header_map: HeaderMappingConfig = Field( default_factory=HeaderMappingConfig, - title="Header mapping definitions for proxy user passing.", + title="Header mapping", + description="Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", ) - logout_url: Optional[str] = Field( - default=None, title="Redirect url for logging out with proxy." - ) - auth_secret: Optional[EnvString] = Field( + logout_url: str | None = Field( default=None, - title="Secret value for proxy authentication.", + title="Logout URL", + description="URL to redirect users to when logging out via the proxy.", ) - default_role: Optional[str] = Field( - default="viewer", title="Default role for proxy users." + auth_secret: EnvString | None = Field( + default=None, + title="Proxy secret", + description="Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", ) - separator: Optional[str] = Field( + default_role: str | None = Field( + default="viewer", + title="Default role", + description="Default role assigned to proxy-authenticated users when no role mapping applies.", + ) + separator: str | None = Field( default=",", - title="The character used to separate values in a mapped header.", + title="Separator character", + description="Character used to split multiple values provided in proxy headers.", ) @field_validator("separator", mode="before") diff --git a/frigate/config/telemetry.py b/frigate/config/telemetry.py index ab18831e1c..3c219d7460 100644 --- a/frigate/config/telemetry.py +++ b/frigate/config/telemetry.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import Field from .base import FrigateBaseModel @@ -8,22 +6,41 @@ __all__ = ["TelemetryConfig", "StatsConfig"] class StatsConfig(FrigateBaseModel): - amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.") - intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.") - network_bandwidth: bool = Field( - default=False, title="Enable network bandwidth for ffmpeg processes." + amd_gpu_stats: bool = Field( + default=True, + title="AMD GPU stats", + description="Enable collection of AMD GPU statistics if an AMD GPU is present.", ) - intel_gpu_device: Optional[str] = Field( - default=None, title="Define the device to use when gathering SR-IOV stats." + intel_gpu_stats: bool = Field( + default=True, + title="Intel GPU stats", + description="Enable collection of Intel GPU statistics if an Intel GPU is present.", + ) + network_bandwidth: bool = Field( + default=False, + title="Network bandwidth", + description="Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", + ) + intel_gpu_device: str | None = Field( + default=None, + title="Intel GPU device", + description="PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", ) class TelemetryConfig(FrigateBaseModel): network_interfaces: list[str] = Field( default=[], - title="Enabled network interfaces for bandwidth calculation.", + title="Network interfaces", + description="List of network interface name prefixes to monitor for bandwidth statistics.", ) stats: StatsConfig = Field( - default_factory=StatsConfig, title="System Stats Configuration" + default_factory=StatsConfig, + title="System stats", + description="Options to enable/disable collection of various system and GPU statistics.", + ) + version_check: bool = Field( + default=True, + title="Version check", + description="Enable an outbound check to detect if a newer Frigate version is available.", ) - version_check: bool = Field(default=True, title="Enable latest version check.") diff --git a/frigate/config/tls.py b/frigate/config/tls.py index 673e105e95..cada11087f 100644 --- a/frigate/config/tls.py +++ b/frigate/config/tls.py @@ -6,4 +6,8 @@ __all__ = ["TlsConfig"] class TlsConfig(FrigateBaseModel): - enabled: bool = Field(default=True, title="Enable TLS for port 8971") + enabled: bool = Field( + default=True, + title="Enable TLS", + description="Enable TLS for Frigate's web UI and API on the configured TLS port.", + ) diff --git a/frigate/config/ui.py b/frigate/config/ui.py index 8e0d4d77df..5958c0e2c5 100644 --- a/frigate/config/ui.py +++ b/frigate/config/ui.py @@ -1,11 +1,10 @@ from enum import Enum -from typing import Optional from pydantic import Field from .base import FrigateBaseModel -__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"] +__all__ = ["TimeFormatEnum", "UnitSystemEnum", "UIConfig"] class TimeFormatEnum(str, Enum): @@ -14,29 +13,24 @@ class TimeFormatEnum(str, Enum): hours24 = "24hour" -class DateTimeStyleEnum(str, Enum): - full = "full" - long = "long" - medium = "medium" - short = "short" - - class UnitSystemEnum(str, Enum): imperial = "imperial" metric = "metric" class UIConfig(FrigateBaseModel): - timezone: Optional[str] = Field(default=None, title="Override UI timezone.") + timezone: str | None = Field( + default=None, + title="Timezone", + description="Optional timezone to display across the UI (defaults to browser local time if unset).", + ) time_format: TimeFormatEnum = Field( - default=TimeFormatEnum.browser, title="Override UI time format." - ) - date_style: DateTimeStyleEnum = Field( - default=DateTimeStyleEnum.short, title="Override UI dateStyle." - ) - time_style: DateTimeStyleEnum = Field( - default=DateTimeStyleEnum.medium, title="Override UI timeStyle." + default=TimeFormatEnum.browser, + title="Time format", + description="Time format to use in the UI (browser, 12hour, or 24hour).", ) unit_system: UnitSystemEnum = Field( - default=UnitSystemEnum.metric, title="The unit system to use for measurements." + default=UnitSystemEnum.metric, + title="Unit system", + description="Unit system for display (metric or imperial) used in the UI and MQTT.", ) diff --git a/frigate/const.py b/frigate/const.py index 41c24f0874..5ca1b2d3f0 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -14,12 +14,15 @@ RECORD_DIR = f"{BASE_DIR}/recordings" TRIGGER_DIR = f"{CLIPS_DIR}/triggers" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" -FRIGATE_LOCALHOST = "http://127.0.0.1:5000" +REPLAY_CAMERA_PREFIX = "_replay_" +REPLAY_DIR = os.path.join(CLIPS_DIR, "replay") PLUS_ENV_VAR = "PLUS_API_KEY" 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 = { @@ -41,7 +44,27 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = { "ups", "usps", ], + "truck": ["license_plate"], + "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], + "bus": ["license_plate"], + "school_bus": ["license_plate"], +} +ATTRIBUTE_LABEL_DISPLAY_MAP = { + "amazon": "Amazon", + "an_post": "An Post", + "canada_post": "Canada Post", + "dhl": "DHL", + "dpd": "DPD", + "fedex": "FedEx", + "gls": "GLS", + "nzpost": "NZ Post", + "postnl": "PostNL", + "postnord": "PostNord", + "purolator": "Purolator", + "royal_mail": "Royal Mail", + "ups": "UPS", + "usps": "USPS", } LABEL_CONSOLIDATION_MAP = { "car": 0.8, @@ -122,6 +145,7 @@ UPDATE_REVIEW_DESCRIPTION = "update_review_description" UPDATE_MODEL_STATE = "update_model_state" UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress" UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout" +UPDATE_JOB_STATE = "update_job_state" NOTIFICATION_TEST = "notification_test" # IO Nice Values diff --git a/frigate/data_processing/common/audio_transcription/model.py b/frigate/data_processing/common/audio_transcription/model.py index 82472ad628..a610ca9e91 100644 --- a/frigate/data_processing/common/audio_transcription/model.py +++ b/frigate/data_processing/common/audio_transcription/model.py @@ -53,7 +53,7 @@ class AudioTranscriptionModelRunner: self.downloader = ModelDownloader( model_name="sherpa-onnx", download_path=download_path, - file_names=self.model_files.keys(), + file_names=list(self.model_files.keys()), download_func=self.__download_models, ) self.downloader.ensure_model_files() diff --git a/frigate/data_processing/common/face/model.py b/frigate/data_processing/common/face/model.py index 51ee649380..87293f7f02 100644 --- a/frigate/data_processing/common/face/model.py +++ b/frigate/data_processing/common/face/model.py @@ -21,7 +21,7 @@ class FaceRecognizer(ABC): def __init__(self, config: FrigateConfig) -> None: self.config = config - self.landmark_detector: cv2.face.FacemarkLBF = None + self.landmark_detector: cv2.face.Facemark | None = None self.init_landmark_detector() @abstractmethod @@ -38,13 +38,14 @@ class FaceRecognizer(ABC): def classify(self, face_image: np.ndarray) -> tuple[str, float] | None: pass - @redirect_output_to_logger(logger, logging.DEBUG) + @redirect_output_to_logger(logger, logging.DEBUG) # type: ignore[misc] def init_landmark_detector(self) -> None: landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml") if os.path.exists(landmark_model): - self.landmark_detector = cv2.face.createFacemarkLBF() - self.landmark_detector.loadModel(landmark_model) + landmark_detector = cv2.face.createFacemarkLBF() + landmark_detector.loadModel(landmark_model) + self.landmark_detector = landmark_detector def align_face( self, @@ -52,8 +53,10 @@ class FaceRecognizer(ABC): output_width: int, output_height: int, ) -> np.ndarray: - # landmark is run on grayscale images + if not self.landmark_detector: + raise ValueError("Landmark detector not initialized") + # landmark is run on grayscale images if image.ndim == 3: land_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) else: @@ -130,9 +133,67 @@ class FaceRecognizer(ABC): return 0.0 +def build_class_mean( + embs: list[np.ndarray], + trim: float = 0.15, + outlier_threshold: float = 0.30, + min_keep_frac: float = 0.7, + max_iters: int = 3, +) -> np.ndarray: + """Build a class-mean embedding with two-layer outlier protection. + + Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine + similarity to the current class mean is below ``outlier_threshold``. + Catches mislabeled or corrupted training samples (wrong face in the + folder, full-frame screenshots, extreme crops) that per-dimension + trimming cannot detect. + + Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set + to smooth per-component noise (lighting, expression, alignment jitter). + + Collections with fewer than 5 images bypass outlier rejection — too few + samples to establish a reliable class center. + """ + arr = np.stack(embs, axis=0) + + if len(arr) < 5: + return np.asarray(stats.trim_mean(arr, trim, axis=0)) + + keep = np.ones(len(arr), dtype=bool) + floor = max(5, int(np.ceil(min_keep_frac * len(arr)))) + + for _ in range(max_iters): + mean = stats.trim_mean(arr[keep], trim, axis=0) + m_norm = mean / (np.linalg.norm(mean) + 1e-9) + e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9) + cos = e_norms @ m_norm + new_keep = cos >= outlier_threshold + + if new_keep.sum() < floor: + top = np.argsort(-cos)[:floor] + new_keep = np.zeros(len(arr), dtype=bool) + new_keep[top] = True + + if np.array_equal(new_keep, keep): + break + keep = new_keep + + dropped = int((~keep).sum()) + + if dropped: + logger.debug( + f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings" + ) + + return np.asarray(stats.trim_mean(arr[keep], trim, axis=0)) + + def similarity_to_confidence( - cosine_similarity: float, median=0.3, range_width=0.6, slope_factor=12 -): + cosine_similarity: float, + median: float = 0.3, + range_width: float = 0.6, + slope_factor: float = 12, +) -> float: """ Default sigmoid function to map cosine similarity to confidence. @@ -151,14 +212,14 @@ def similarity_to_confidence( bias = median # Calculate confidence - confidence = 1 / (1 + np.exp(-slope * (cosine_similarity - bias))) + confidence: float = 1 / (1 + np.exp(-slope * (cosine_similarity - bias))) return confidence class FaceNetRecognizer(FaceRecognizer): def __init__(self, config: FrigateConfig): super().__init__(config) - self.mean_embs: dict[int, np.ndarray] = {} + self.mean_embs: dict[str, np.ndarray] = {} self.face_embedder: FaceNetEmbedding = FaceNetEmbedding() self.model_builder_queue: queue.Queue | None = None @@ -168,7 +229,7 @@ class FaceNetRecognizer(FaceRecognizer): def run_build_task(self) -> None: self.model_builder_queue = queue.Queue() - def build_model(): + def build_model() -> None: face_embeddings_map: dict[str, list[np.ndarray]] = {} idx = 0 @@ -187,7 +248,7 @@ class FaceNetRecognizer(FaceRecognizer): img = cv2.imread(os.path.join(face_folder, image)) if img is None: - continue + continue # type: ignore[unreachable] img = self.align_face(img, img.shape[1], img.shape[0]) emb = self.face_embedder([img])[0].squeeze() @@ -195,12 +256,13 @@ class FaceNetRecognizer(FaceRecognizer): idx += 1 + assert self.model_builder_queue is not None self.model_builder_queue.put(face_embeddings_map) thread = threading.Thread(target=build_model, daemon=True) thread.start() - def build(self): + def build(self) -> None: if not self.landmark_detector: self.init_landmark_detector() return None @@ -222,11 +284,11 @@ class FaceNetRecognizer(FaceRecognizer): for name, embs in face_embeddings_map.items(): if embs: - self.mean_embs[name] = stats.trim_mean(embs, 0.15) + self.mean_embs[name] = build_class_mean(embs) logger.debug("Finished building ArcFace model") - def classify(self, face_image): + def classify(self, face_image: np.ndarray) -> tuple[str, float] | None: if not self.landmark_detector: return None @@ -245,7 +307,7 @@ class FaceNetRecognizer(FaceRecognizer): img = self.align_face(face_image, face_image.shape[1], face_image.shape[0]) embedding = self.face_embedder([img])[0].squeeze() - score = 0 + score: float = 0 label = "" for name, mean_emb in self.mean_embs.items(): @@ -268,7 +330,7 @@ class FaceNetRecognizer(FaceRecognizer): class ArcFaceRecognizer(FaceRecognizer): def __init__(self, config: FrigateConfig): super().__init__(config) - self.mean_embs: dict[int, np.ndarray] = {} + self.mean_embs: dict[str, np.ndarray] = {} self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition) self.model_builder_queue: queue.Queue | None = None @@ -278,7 +340,7 @@ class ArcFaceRecognizer(FaceRecognizer): def run_build_task(self) -> None: self.model_builder_queue = queue.Queue() - def build_model(): + def build_model() -> None: face_embeddings_map: dict[str, list[np.ndarray]] = {} idx = 0 @@ -297,20 +359,21 @@ class ArcFaceRecognizer(FaceRecognizer): img = cv2.imread(os.path.join(face_folder, image)) if img is None: - continue + continue # type: ignore[unreachable] img = self.align_face(img, img.shape[1], img.shape[0]) - emb = self.face_embedder([img])[0].squeeze() + emb = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type] face_embeddings_map[name].append(emb) idx += 1 + assert self.model_builder_queue is not None self.model_builder_queue.put(face_embeddings_map) thread = threading.Thread(target=build_model, daemon=True) thread.start() - def build(self): + def build(self) -> None: if not self.landmark_detector: self.init_landmark_detector() return None @@ -332,11 +395,11 @@ class ArcFaceRecognizer(FaceRecognizer): for name, embs in face_embeddings_map.items(): if embs: - self.mean_embs[name] = stats.trim_mean(embs, 0.15) + self.mean_embs[name] = build_class_mean(embs) logger.debug("Finished building ArcFace model") - def classify(self, face_image): + def classify(self, face_image: np.ndarray) -> tuple[str, float] | None: if not self.landmark_detector: return None @@ -353,9 +416,9 @@ class ArcFaceRecognizer(FaceRecognizer): # align face and run recognition img = self.align_face(face_image, face_image.shape[1], face_image.shape[0]) - embedding = self.face_embedder([img])[0].squeeze() + embedding = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type] - score = 0 + score: float = 0 label = "" for name, mean_emb in self.mean_embs.items(): diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index b56c66a19a..a7c42b9124 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -10,7 +10,7 @@ import random import re import string from pathlib import Path -from typing import Any, List, Optional, Tuple +from typing import Any import cv2 import numpy as np @@ -22,19 +22,35 @@ from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataTypeEnum, ) +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.config.classification import LicensePlateRecognitionConfig from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR +from frigate.data_processing.common.license_plate.model import LicensePlateModelRunner from frigate.embeddings.onnx.lpr_embedding import LPR_EMBEDDING_SIZE from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed from frigate.util.image import area +from ...types import DataProcessorMetrics + logger = logging.getLogger(__name__) WRITE_DEBUG_IMAGES = False class LicensePlateProcessingMixin: - def __init__(self, *args, **kwargs): + # Attributes expected from consuming classes (set before super().__init__) + config: FrigateConfig + metrics: DataProcessorMetrics + model_runner: LicensePlateModelRunner + lpr_config: LicensePlateRecognitionConfig + requestor: InterProcessRequestor + detected_license_plates: dict[str, dict[str, Any]] + camera_current_cars: dict[str, list[str]] + sub_label_publisher: EventMetadataPublisher + + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.plate_rec_speed = InferenceSpeed(self.metrics.alpr_speed) self.plates_rec_second = EventsPerSecond() @@ -70,13 +86,15 @@ class LicensePlateProcessingMixin: self.similarity_threshold = 0.8 self.cluster_threshold = 0.85 - def _detect(self, image: np.ndarray) -> List[np.ndarray]: + def _detect(self, image: np.ndarray, debug_frame_id: int) -> list[np.ndarray]: """ Detect possible areas of text in the input image by first resizing and normalizing it, running a detection model, and filtering out low-probability regions. Args: image (np.ndarray): The input image in which license plates will be detected. + debug_frame_id (int): Shared id used to name debug images so all artifacts + from a single LPR pass share the same filename suffix. Returns: List[np.ndarray]: A list of bounding box coordinates representing detected license plates. @@ -90,14 +108,13 @@ class LicensePlateProcessingMixin: normalized_image = self._normalize_image(resized_image) if WRITE_DEBUG_IMAGES: - current_time = int(datetime.datetime.now().timestamp()) cv2.imwrite( - f"debug/frames/license_plate_resized_{current_time}.jpg", + f"debug/frames/license_plate_resized_{debug_frame_id}.jpg", resized_image, ) try: - outputs = self.model_runner.detection_model([normalized_image])[0] + outputs = self.model_runner.detection_model([normalized_image])[0] # type: ignore[arg-type] except Exception as e: logger.warning(f"Error running LPR box detection model: {e}") return [] @@ -105,18 +122,18 @@ class LicensePlateProcessingMixin: outputs = outputs[0, :, :] if False: - current_time = int(datetime.datetime.now().timestamp()) + current_time = int(datetime.datetime.now().timestamp()) # type: ignore[unreachable] cv2.imwrite( f"debug/frames/probability_map_{current_time}.jpg", (outputs * 255).astype(np.uint8), ) boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h) - return self._filter_polygon(boxes, (h, w)) + return self._filter_polygon(boxes, (h, w)) # type: ignore[return-value,arg-type] def _classify( - self, images: List[np.ndarray] - ) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]: + self, images: list[np.ndarray] + ) -> tuple[list[np.ndarray], list[tuple[str, float]]] | None: """ Classify the orientation or category of each detected license plate. @@ -138,16 +155,16 @@ class LicensePlateProcessingMixin: norm_images.append(norm_img) try: - outputs = self.model_runner.classification_model(norm_images) + outputs = self.model_runner.classification_model(norm_images) # type: ignore[arg-type] except Exception as e: logger.warning(f"Error running LPR classification model: {e}") - return + return None return self._process_classification_output(images, outputs) def _recognize( - self, camera: string, images: List[np.ndarray] - ) -> Tuple[List[str], List[List[float]]]: + self, camera: str, images: list[np.ndarray] + ) -> tuple[list[str], list[list[float]]]: """ Recognize the characters on the detected license plates using the recognition model. @@ -179,7 +196,7 @@ class LicensePlateProcessingMixin: norm_images.append(norm_image) try: - outputs = self.model_runner.recognition_model(norm_images) + outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type] except Exception as e: logger.warning(f"Error running LPR recognition model: {e}") return [], [] @@ -187,8 +204,8 @@ class LicensePlateProcessingMixin: return self.ctc_decoder(outputs) def _process_license_plate( - self, camera: str, id: str, image: np.ndarray - ) -> Tuple[List[str], List[List[float]], List[int]]: + self, camera: str, id: str, image: np.ndarray, debug_frame_id: int + ) -> tuple[list[str], list[list[float]], list[int]]: """ Complete pipeline for detecting, classifying, and recognizing license plates in the input image. Combines multi-line plates into a single plate string, grouping boxes by vertical alignment and ordering top to bottom, @@ -198,6 +215,8 @@ class LicensePlateProcessingMixin: camera (str): Camera identifier. id (str): Event identifier. image (np.ndarray): The input image in which to detect, classify, and recognize license plates. + debug_frame_id (int): Shared id used to name debug images so all artifacts + from a single LPR pass share the same filename suffix. Returns: Tuple[List[str], List[List[float]], List[int]]: Detected license plate texts, character-level confidence scores for each plate (flattened into a single list per plate), and areas of the plates. @@ -211,7 +230,7 @@ class LicensePlateProcessingMixin: logger.debug("Model runners not loaded") return [], [], [] - boxes = self._detect(image) + boxes = self._detect(image, debug_frame_id) if len(boxes) == 0: logger.debug(f"{camera}: No boxes found by OCR detector model") return [], [], [] @@ -227,7 +246,6 @@ class LicensePlateProcessingMixin: boxes, plate_width=plate_width, gap_fraction=0.1 ) - current_time = int(datetime.datetime.now().timestamp()) if WRITE_DEBUG_IMAGES: debug_image = image.copy() for box in boxes: @@ -243,7 +261,7 @@ class LicensePlateProcessingMixin: ) cv2.imwrite( - f"debug/frames/license_plate_boxes_{current_time}.jpg", debug_image + f"debug/frames/license_plate_boxes_{debug_frame_id}.jpg", debug_image ) boxes = self._sort_boxes(list(boxes)) @@ -306,7 +324,7 @@ class LicensePlateProcessingMixin: if WRITE_DEBUG_IMAGES: for i, img in enumerate(group_plate_images): cv2.imwrite( - f"debug/frames/license_plate_cropped_{current_time}_{group_indices[i] + 1}.jpg", + f"debug/frames/license_plate_cropped_{debug_frame_id}_{group_indices[i] + 1}.jpg", img, ) @@ -319,7 +337,7 @@ class LicensePlateProcessingMixin: cv2.imwrite( os.path.join( CLIPS_DIR, - f"lpr/{camera}/{id}/{current_time}_{group_indices[i] + 1}.jpg", + f"lpr/{camera}/{id}/{debug_frame_id}_{group_indices[i] + 1}.jpg", ), img, ) @@ -401,41 +419,17 @@ class LicensePlateProcessingMixin: all_confidences.append(flat_confidences) all_areas.append(combined_area) - # Step 3: Filter and sort the combined plates + # Step 3: Sort the combined plates if all_license_plates: - filtered_data = [] - for plate, conf_list, area in zip( - all_license_plates, all_confidences, all_areas - ): - if len(plate) < self.lpr_config.min_plate_length: - logger.debug( - f"{camera}: Filtered out '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})" - ) - continue - - if self.lpr_config.format: - try: - if not re.fullmatch(self.lpr_config.format, plate): - logger.debug( - f"{camera}: Filtered out '{plate}' due to format mismatch" - ) - continue - except re.error: - # Skip format filtering if regex is invalid - logger.error( - f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}" - ) - - filtered_data.append((plate, conf_list, area)) - sorted_data = sorted( - filtered_data, + zip(all_license_plates, all_confidences, all_areas), key=lambda x: (x[2], len(x[0]), sum(x[1]) / len(x[1]) if x[1] else 0), reverse=True, ) if sorted_data: - return map(list, zip(*sorted_data)) + plates, confs, areas_list = zip(*sorted_data) + return list(plates), list(confs), list(areas_list) return [], [], [] @@ -475,11 +469,11 @@ class LicensePlateProcessingMixin: def _merge_nearby_boxes( self, - boxes: List[np.ndarray], + boxes: list[np.ndarray], plate_width: float, gap_fraction: float = 0.1, min_overlap_fraction: float = -0.2, - ) -> List[np.ndarray]: + ) -> list[np.ndarray]: """ Merge bounding boxes that are likely part of the same license plate based on proximity, with a dynamic max_gap based on the provided width of the entire license plate. @@ -557,11 +551,11 @@ class LicensePlateProcessingMixin: # Add the last box merged_boxes.append(current_box) - return np.array(merged_boxes, dtype=np.int32) + return np.array(merged_boxes, dtype=np.int32) # type: ignore[return-value] def _boxes_from_bitmap( self, output: np.ndarray, mask: np.ndarray, dest_width: int, dest_height: int - ) -> Tuple[np.ndarray, List[float]]: + ) -> tuple[np.ndarray, list[float]]: """ Process the binary mask to extract bounding boxes and associated confidence scores. @@ -585,44 +579,48 @@ class LicensePlateProcessingMixin: boxes = [] scores = [] - for index in range(len(contours)): - contour = contours[index] + for index in range(len(contours)): # type: ignore[arg-type] + contour = contours[index] # type: ignore[index] # get minimum bounding box (rotated rectangle) around the contour and the smallest side length. points, sside = self._get_min_boxes(contour) if sside < self.min_size: continue - points = np.array(points, dtype=np.float32) + points = np.array(points, dtype=np.float32) # type: ignore[assignment] score = self._box_score(output, contour) if self.box_thresh > score: continue - points = self._expand_box(points) + points = self._expand_box(points) # type: ignore[assignment] # Get the minimum area rectangle again after expansion - points, sside = self._get_min_boxes(points.reshape(-1, 1, 2)) + points, sside = self._get_min_boxes(points.reshape(-1, 1, 2)) # type: ignore[attr-defined] if sside < self.min_size + 2: continue - points = np.array(points, dtype=np.float32) + points = np.array(points, dtype=np.float32) # type: ignore[assignment] # normalize and clip box coordinates to fit within the destination image size. - points[:, 0] = np.clip( - np.round(points[:, 0] / width * dest_width), 0, dest_width + points[:, 0] = np.clip( # type: ignore[call-overload] + np.round(points[:, 0] / width * dest_width), # type: ignore[call-overload] + 0, + dest_width, ) - points[:, 1] = np.clip( - np.round(points[:, 1] / height * dest_height), 0, dest_height + points[:, 1] = np.clip( # type: ignore[call-overload] + np.round(points[:, 1] / height * dest_height), # type: ignore[call-overload] + 0, + dest_height, ) - boxes.append(points.astype("int32")) + boxes.append(points.astype("int32")) # type: ignore[attr-defined] scores.append(score) return np.array(boxes, dtype="int32"), scores @staticmethod - def _get_min_boxes(contour: np.ndarray) -> Tuple[List[Tuple[float, float]], float]: + def _get_min_boxes(contour: np.ndarray) -> tuple[list[tuple[float, float]], float]: """ Calculate the minimum bounding box (rotated rectangle) for a given contour. @@ -657,11 +655,11 @@ class LicensePlateProcessingMixin: x1, y1 = np.clip(contour.min(axis=0), 0, [w - 1, h - 1]) x2, y2 = np.clip(contour.max(axis=0), 0, [w - 1, h - 1]) mask = np.zeros((y2 - y1 + 1, x2 - x1 + 1), dtype=np.uint8) - cv2.fillPoly(mask, [contour - [x1, y1]], 1) + cv2.fillPoly(mask, [contour - [x1, y1]], 1) # type: ignore[call-overload] return cv2.mean(bitmap[y1 : y2 + 1, x1 : x2 + 1], mask)[0] @staticmethod - def _expand_box(points: List[Tuple[float, float]]) -> np.ndarray: + def _expand_box(points: list[tuple[float, float]]) -> np.ndarray: """ Expand a polygonal shape slightly by a factor determined by the area-to-perimeter ratio. @@ -679,7 +677,7 @@ class LicensePlateProcessingMixin: return expanded def _filter_polygon( - self, points: List[np.ndarray], shape: Tuple[int, int] + self, points: list[np.ndarray], shape: tuple[int, int] ) -> np.ndarray: """ Filter a set of polygons to include only valid ones that fit within an image shape @@ -715,7 +713,7 @@ class LicensePlateProcessingMixin: Returns: bool: Whether the polygon is valid or not. """ - return ( + return bool( point[:, 0].min() >= 0 and point[:, 0].max() < width and point[:, 1].min() >= 0 @@ -760,7 +758,7 @@ class LicensePlateProcessingMixin: return np.array([tl, tr, br, bl]) @staticmethod - def _sort_boxes(boxes): + def _sort_boxes(boxes: list[np.ndarray]) -> list[np.ndarray]: """ Sort polygons based on their position in the image. If boxes are close in vertical position (within 5 pixels), sort them by horizontal position. @@ -841,8 +839,8 @@ class LicensePlateProcessingMixin: return padded_image def _process_classification_output( - self, images: List[np.ndarray], outputs: List[np.ndarray] - ) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]: + self, images: list[np.ndarray], outputs: list[np.ndarray] + ) -> tuple[list[np.ndarray], list[tuple[str, float]]]: """ Process the classification model output by matching labels with confidence scores. @@ -862,16 +860,16 @@ class LicensePlateProcessingMixin: results = [["", 0.0]] * len(images) indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images])) - outputs = np.stack(outputs) + stacked_outputs = np.stack(outputs) - outputs = [ - (labels[idx], outputs[i, idx]) - for i, idx in enumerate(outputs.argmax(axis=1)) + stacked_outputs = [ + (labels[idx], stacked_outputs[i, idx]) + for i, idx in enumerate(stacked_outputs.argmax(axis=1)) ] for i in range(0, len(images), self.batch_size): - for j in range(len(outputs)): - label, score = outputs[j] + for j in range(len(stacked_outputs)): + label, score = stacked_outputs[j] results[indices[i + j]] = [label, score] # make sure we have high confidence if we need to flip a box if "180" in label and score >= 0.7: @@ -879,10 +877,10 @@ class LicensePlateProcessingMixin: images[indices[i + j]], cv2.ROTATE_180 ) - return images, results + return images, results # type: ignore[return-value] def _preprocess_recognition_image( - self, camera: string, image: np.ndarray, max_wh_ratio: float + self, camera: str, image: np.ndarray, max_wh_ratio: float ) -> np.ndarray: """ Preprocess an image for recognition by dynamically adjusting its width. @@ -950,7 +948,7 @@ class LicensePlateProcessingMixin: input_w = int(input_h * max_wh_ratio) # check for model-specific input width - model_input_w = self.model_runner.recognition_model.runner.get_input_width() + model_input_w = self.model_runner.recognition_model.runner.get_input_width() # type: ignore[union-attr] if isinstance(model_input_w, int) and model_input_w > 0: input_w = model_input_w @@ -970,7 +968,7 @@ class LicensePlateProcessingMixin: padded_image[:, :, :resized_w] = resized_image if False: - current_time = int(datetime.datetime.now().timestamp() * 1000) + current_time = int(datetime.datetime.now().timestamp() * 1000) # type: ignore[unreachable] cv2.imwrite( f"debug/frames/preprocessed_recognition_{current_time}.jpg", image, @@ -1008,8 +1006,9 @@ class LicensePlateProcessingMixin: np.linalg.norm(points[1] - points[2]), ) ) - pts_std = np.float32( - [[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]] + pts_std = np.array( + [[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]], + dtype=np.float32, ) matrix = cv2.getPerspectiveTransform(points, pts_std) image = cv2.warpPerspective( @@ -1025,15 +1024,15 @@ class LicensePlateProcessingMixin: return image def _detect_license_plate( - self, camera: string, input: np.ndarray - ) -> tuple[int, int, int, int]: + self, camera: str, input: np.ndarray + ) -> tuple[int, int, int, int] | None: """ Use a lightweight YOLOv9 model to detect license plates for users without Frigate+ Return the dimensions of the detected plate as [x1, y1, x2, y2]. """ try: - predictions = self.model_runner.yolov9_detection_model(input) + predictions = self.model_runner.yolov9_detection_model(input) # type: ignore[arg-type] except Exception as e: logger.warning(f"Error running YOLOv9 license plate detection model: {e}") return None @@ -1076,10 +1075,6 @@ class LicensePlateProcessingMixin: top_score = score top_box = bbox - if score > top_score: - top_score = score - top_box = bbox - # Return the top scoring bounding box if found if top_box is not None: # expand box by 5% to help with OCR @@ -1095,16 +1090,13 @@ class LicensePlateProcessingMixin: ] ).clip(0, [input.shape[1], input.shape[0]] * 2) - logger.debug( - f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}" - ) - return tuple(expanded_box.astype(int)) + return tuple(int(x) for x in expanded_box) # type: ignore[return-value] else: return None # No detection above the threshold def _get_cluster_rep( - self, plates: List[dict] - ) -> Tuple[str, float, List[float], int]: + self, plates: list[dict] + ) -> tuple[str, float, list[float], int]: """ Cluster plate variants and select the representative from the best cluster. """ @@ -1122,7 +1114,7 @@ class LicensePlateProcessingMixin: f" Variant {i + 1}: '{p['plate']}' (conf: {p['conf']:.3f}, area: {p['area']})" ) - clusters = [] + clusters: list[list[dict[str, Any]]] = [] for i, plate in enumerate(plates): merged = False for j, cluster in enumerate(clusters): @@ -1157,7 +1149,7 @@ class LicensePlateProcessingMixin: ) # Best cluster: largest size, tiebroken by max conf - def cluster_score(c): + def cluster_score(c: list[dict[str, Any]]) -> tuple[int, float]: return (len(c), max(v["conf"] for v in c)) best_cluster_idx = max( @@ -1180,6 +1172,28 @@ class LicensePlateProcessingMixin: return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"] + def _passes_plate_filters(self, camera: str, plate: str) -> bool: + """Check a plate against the configured length and format filters.""" + if len(plate) < self.lpr_config.min_plate_length: + logger.debug( + f"{camera}: Filtered out plate '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})" + ) + return False + + if self.lpr_config.format: + try: + if not re.fullmatch(self.lpr_config.format, plate): + logger.debug( + f"{camera}: Filtered out plate '{plate}' due to format mismatch" + ) + return False + except re.error: + logger.error( + f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}" + ) + + return True + def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str: """Generate a unique ID for a plate event based on camera and text.""" now = datetime.datetime.now().timestamp() @@ -1203,12 +1217,13 @@ class LicensePlateProcessingMixin: def lpr_process( self, obj_data: dict[str, Any], frame: np.ndarray, dedicated_lpr: bool = False - ): + ) -> None: """Look for license plates in image.""" self.metrics.alpr_pps.value = self.plates_rec_second.eps() self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps() camera = obj_data if dedicated_lpr else obj_data["camera"] current_time = int(datetime.datetime.now().timestamp()) + debug_frame_id = int(datetime.datetime.now().timestamp() * 1000) if not self.config.cameras[camera].lpr.enabled: return @@ -1220,11 +1235,11 @@ class LicensePlateProcessingMixin: rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) # apply motion mask - rgb[self.config.cameras[obj_data].motion.mask == 0] = [0, 0, 0] + rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined] if WRITE_DEBUG_IMAGES: cv2.imwrite( - f"debug/frames/dedicated_lpr_masked_{current_time}.jpg", + f"debug/frames/dedicated_lpr_masked_{debug_frame_id}.jpg", rgb, ) @@ -1250,6 +1265,8 @@ class LicensePlateProcessingMixin: logger.debug(f"{camera}: License plate area below minimum threshold.") return + plate_box = license_plate + license_plate_frame = rgb[ license_plate[1] : license_plate[3], license_plate[0] : license_plate[2], @@ -1273,7 +1290,7 @@ class LicensePlateProcessingMixin: and obj_data.get("label") != "license_plate" ): logger.debug( - f"{camera}: Not a processing license plate for non car/motorcycle object." + f"{camera}: Not a processing license plate for {obj_data.get('label', 'unknown')}." ) return @@ -1284,7 +1301,7 @@ class LicensePlateProcessingMixin: "stationary", False ): logger.debug( - f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)" + f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)" # type: ignore[operator] ) return @@ -1311,7 +1328,7 @@ class LicensePlateProcessingMixin: if time_since_stationary > self.stationary_scan_duration: return - license_plate: Optional[dict[str, Any]] = None + license_plate = None if "license_plate" not in self.config.cameras[camera].objects.track: logger.debug(f"{camera}: Running manual license_plate detection.") @@ -1324,7 +1341,7 @@ class LicensePlateProcessingMixin: rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) # apply motion mask - rgb[self.config.cameras[camera].motion.mask == 0] = [0, 0, 0] + rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined] left, top, right, bottom = car_box car = rgb[top:bottom, left:right] @@ -1334,7 +1351,7 @@ class LicensePlateProcessingMixin: if WRITE_DEBUG_IMAGES: cv2.imwrite( - f"debug/frames/car_frame_{current_time}.jpg", + f"debug/frames/car_frame_{debug_frame_id}.jpg", car, ) @@ -1350,7 +1367,7 @@ class LicensePlateProcessingMixin: if not license_plate: logger.debug( - f"{camera}: Detected no license plates for car/motorcycle object." + f"{camera}: Detected no license plates for {obj_data.get('label', 'unknown')} object." ) return @@ -1361,11 +1378,25 @@ class LicensePlateProcessingMixin: ) # check that license plate is valid - # double the value because we've doubled the size of the car - if license_plate_area < self.config.cameras[camera].lpr.min_area * 2: + # quadruple the value because we've doubled both dimensions of the car + if license_plate_area < self.config.cameras[camera].lpr.min_area * 4: logger.debug(f"{camera}: License plate is less than min_area") return + # Scale back to original car coordinates and then to frame + plate_box_in_car = ( + license_plate[0] // 2, + license_plate[1] // 2, + license_plate[2] // 2, + license_plate[3] // 2, + ) + plate_box = ( + left + plate_box_in_car[0], + top + plate_box_in_car[1], + left + plate_box_in_car[2], + top + plate_box_in_car[3], + ) + license_plate_frame = car[ license_plate[1] : license_plate[3], license_plate[0] : license_plate[2], @@ -1387,10 +1418,10 @@ class LicensePlateProcessingMixin: if attr.get("label") != "license_plate": continue - if license_plate is None or attr.get( + if license_plate is None or attr.get( # type: ignore[unreachable] "score", 0.0 ) > license_plate.get("score", 0.0): - license_plate = attr + license_plate = attr # type: ignore[assignment] # no license plates detected in this frame if not license_plate: @@ -1398,9 +1429,9 @@ class LicensePlateProcessingMixin: # we are using dedicated lpr with frigate+ if obj_data.get("label") == "license_plate": - license_plate = obj_data + license_plate = obj_data # type: ignore[assignment] - license_plate_box = license_plate.get("box") + license_plate_box = license_plate.get("box") # type: ignore[attr-defined] # check that license plate is valid if ( @@ -1429,6 +1460,8 @@ class LicensePlateProcessingMixin: 0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2 ) + plate_box = tuple(int(x) for x in expanded_box) # type: ignore[assignment] + # Crop using the expanded box license_plate_frame = license_plate_frame[ int(expanded_box[1]) : int(expanded_box[3]), @@ -1446,16 +1479,17 @@ class LicensePlateProcessingMixin: if WRITE_DEBUG_IMAGES: cv2.imwrite( - f"debug/frames/license_plate_frame_{current_time}.jpg", + f"debug/frames/license_plate_frame_{debug_frame_id}.jpg", license_plate_frame, ) + logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}") logger.debug(f"{camera}: Running plate recognition for id: {id}.") # run detection, returns results sorted by confidence, best first start = datetime.datetime.now().timestamp() license_plates, confidences, areas = self._process_license_plate( - camera, id, license_plate_frame + camera, id, license_plate_frame, debug_frame_id ) self.plates_rec_second.update() self.plate_rec_speed.update(datetime.datetime.now().timestamp() - start) @@ -1499,10 +1533,14 @@ class LicensePlateProcessingMixin: plate_id = None for existing_id, data in self.detected_license_plates.items(): + # entries from the object pipeline on this camera have no + # last_seen until they pass the filters below + last_seen = data.get("last_seen") + if ( data["camera"] == camera - and data["last_seen"] is not None - and current_time - data["last_seen"] + and last_seen is not None + and current_time - last_seen <= self.config.cameras[camera].lpr.expire_time ): similarity = JaroWinkler.similarity(data["plate"], top_plate) @@ -1513,6 +1551,11 @@ class LicensePlateProcessingMixin: ) break if plate_id is None: + # the event id doubles as the cluster key, so a plate rejected + # after this point would leave an entry that never expires + if not self._passes_plate_filters(camera, top_plate): + return + plate_id = self._generate_plate_event(camera, top_plate, avg_confidence) logger.debug( f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}" @@ -1557,6 +1600,12 @@ class LicensePlateProcessingMixin: f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})" ) + # filter the clustered representative rather than individual OCR + # readings, so noisy variants still contribute to clustering even + # when they don't pass on their own + if not self._passes_plate_filters(camera, rep_plate): + return + # Update stored rep self.detected_license_plates[id].update( { @@ -1582,7 +1631,7 @@ class LicensePlateProcessingMixin: sub_label = next( ( label - for label, plates_list in self.lpr_config.known_plates.items() + for label, plates_list in self.lpr_config.known_plates.items() # type: ignore[union-attr] if any( re.match(f"^{plate}$", rep_plate) or Levenshtein.distance(plate, rep_plate) @@ -1615,6 +1664,7 @@ class LicensePlateProcessingMixin: "id": id, "camera": camera, "timestamp": start, + "plate_box": plate_box, } ), ) @@ -1634,14 +1684,16 @@ class LicensePlateProcessingMixin: frame_bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) _, encoded_img = cv2.imencode(".jpg", frame_bgr) self.sub_label_publisher.publish( - (base64.b64encode(encoded_img).decode("ASCII"), id, camera), + (base64.b64encode(encoded_img.tobytes()).decode("ASCII"), id, camera), EventMetadataTypeEnum.save_lpr_snapshot.value, ) - def handle_request(self, topic, request_data) -> dict[str, Any] | None: - return + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: + return None - def lpr_expire(self, object_id: str, camera: str): + def lpr_expire(self, object_id: str, camera: str) -> None: if object_id in self.detected_license_plates: self.detected_license_plates.pop(object_id) @@ -1658,7 +1710,7 @@ class CTCDecoder: for each decoded character sequence. """ - def __init__(self, character_dict_path=None): + def __init__(self, character_dict_path: str | None = None) -> None: """ Initializes the CTCDecoder. :param character_dict_path: Path to the character dictionary file. @@ -1668,7 +1720,7 @@ class CTCDecoder: """ self.characters = [] if character_dict_path and os.path.exists(character_dict_path): - with open(character_dict_path, "r", encoding="utf-8") as f: + with open(character_dict_path, encoding="utf-8") as f: self.characters = ( ["blank"] + [line.strip() for line in f if line.strip()] + [" "] ) @@ -1776,8 +1828,8 @@ class CTCDecoder: self.char_map = {i: char for i, char in enumerate(self.characters)} def __call__( - self, outputs: List[np.ndarray] - ) -> Tuple[List[str], List[List[float]]]: + self, outputs: list[np.ndarray] + ) -> tuple[list[str], list[list[float]]]: """ Decode a batch of model outputs into character sequences and their confidence scores. diff --git a/frigate/data_processing/common/license_plate/model.py b/frigate/data_processing/common/license_plate/model.py index f53ed7d951..f7121e65d9 100644 --- a/frigate/data_processing/common/license_plate/model.py +++ b/frigate/data_processing/common/license_plate/model.py @@ -1,3 +1,4 @@ +from frigate.comms.inter_process import InterProcessRequestor from frigate.embeddings.onnx.lpr_embedding import ( LicensePlateDetector, PaddleOCRClassification, @@ -9,7 +10,12 @@ from ...types import DataProcessorModelRunner class LicensePlateModelRunner(DataProcessorModelRunner): - def __init__(self, requestor, device: str = "CPU", model_size: str = "small"): + def __init__( + self, + requestor: InterProcessRequestor, + device: str = "CPU", + model_size: str = "small", + ): super().__init__(requestor, device, model_size) self.detection_model = PaddleOCRDetection( model_size=model_size, requestor=requestor, device=device diff --git a/frigate/data_processing/post/api.py b/frigate/data_processing/post/api.py index c341bd8ef9..044e5d245c 100644 --- a/frigate/data_processing/post/api.py +++ b/frigate/data_processing/post/api.py @@ -17,7 +17,7 @@ class PostProcessorApi(ABC): self, config: FrigateConfig, metrics: DataProcessorMetrics, - model_runner: DataProcessorModelRunner, + model_runner: DataProcessorModelRunner | None, ) -> None: self.config = config self.metrics = metrics @@ -41,7 +41,7 @@ class PostProcessorApi(ABC): @abstractmethod def handle_request( self, topic: str, request_data: dict[str, Any] - ) -> dict[str, Any] | None: + ) -> dict[str, Any] | str | None: """Handle metadata requests. Args: request_data (dict): containing data about requested change to process. @@ -50,3 +50,16 @@ class PostProcessorApi(ABC): None if request was not handled, otherwise return response. """ pass + + def update_config(self, topic: str, payload: Any) -> None: + """Handle a config change notification. + + Called for every config update published under ``config/``. + Processors should override this to check the topic and act only + on changes relevant to them. Default is a no-op. + + Args: + topic: The config topic that changed. + payload: The updated configuration object. + """ + pass diff --git a/frigate/data_processing/post/audio_transcription.py b/frigate/data_processing/post/audio_transcription.py index 558ab433e5..16895dc873 100644 --- a/frigate/data_processing/post/audio_transcription.py +++ b/frigate/data_processing/post/audio_transcription.py @@ -4,7 +4,7 @@ import logging import os import threading import time -from typing import Optional +from typing import Any from peewee import DoesNotExist @@ -17,6 +17,7 @@ from frigate.const import ( UPDATE_EVENT_DESCRIPTION, ) from frigate.data_processing.types import PostProcessDataEnum +from frigate.embeddings.embeddings import Embeddings from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.audio import get_audio_from_recording @@ -31,7 +32,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): self, config: FrigateConfig, requestor: InterProcessRequestor, - embeddings, + embeddings: Embeddings, metrics: DataProcessorMetrics, ): super().__init__(config, metrics, None) @@ -40,7 +41,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): self.embeddings = embeddings self.recognizer = None self.transcription_lock = threading.Lock() - self.transcription_thread = None + self.transcription_thread: threading.Thread | None = None self.transcription_running = False # faster-whisper handles model downloading automatically @@ -69,7 +70,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): self.recognizer = None def process_data( - self, data: dict[str, any], data_type: PostProcessDataEnum + self, data: dict[str, Any], data_type: PostProcessDataEnum ) -> None: """Transcribe audio from a recording. @@ -141,13 +142,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): except Exception as e: logger.error(f"Error in audio transcription post-processing: {e}") - def __transcribe_audio(self, audio_data: bytes) -> Optional[tuple[str, float]]: + def __transcribe_audio(self, audio_data: bytes) -> str | None: """Transcribe WAV audio data using faster-whisper.""" if not self.recognizer: logger.debug("Recognizer not initialized") return None - try: + try: # type: ignore[unreachable] # Save audio data to a temporary wav (faster-whisper expects a file) temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") with open(temp_wav, "wb") as f: @@ -167,8 +168,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): return None logger.debug( - "Detected language '%s' with probability %f" - % (info.language, info.language_probability) + "Detected language '%s' with probability %f", + info.language, + info.language_probability, ) return text @@ -176,7 +178,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): logger.error(f"Error transcribing audio: {e}") return None - def _transcription_wrapper(self, event: dict[str, any]) -> None: + def _transcription_wrapper(self, event: dict[str, Any]) -> None: """Wrapper to run transcription and reset running flag when done.""" try: self.process_data( @@ -194,7 +196,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi): self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle") - def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None: + def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None: if topic == "transcribe_audio": event = request_data["event"] diff --git a/frigate/data_processing/post/license_plate.py b/frigate/data_processing/post/license_plate.py index e95cf234e2..55f863cf0b 100644 --- a/frigate/data_processing/post/license_plate.py +++ b/frigate/data_processing/post/license_plate.py @@ -29,7 +29,7 @@ from .api import PostProcessorApi logger = logging.getLogger(__name__) -class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): +class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): # type: ignore[misc] def __init__( self, config: FrigateConfig, @@ -47,6 +47,16 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): self.sub_label_publisher = sub_label_publisher super().__init__(config, metrics, model_runner) + CONFIG_UPDATE_TOPIC = "config/lpr" + + def update_config(self, topic: str, payload: Any) -> None: + """Update LPR config at runtime.""" + if topic != self.CONFIG_UPDATE_TOPIC: + return + + self.lpr_config = payload + logger.debug("LPR post-processor config updated dynamically") + def process_data( self, data: dict[str, Any], data_type: PostProcessDataEnum ) -> None: @@ -61,7 +71,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): # don't run LPR post processing for now return - event_id = data["event_id"] + event_id = data["event_id"] # type: ignore[unreachable] camera_name = data["camera"] if data_type == PostProcessDataEnum.recording: @@ -92,10 +102,8 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): Recordings.start_time, ) .where( - ( - (frame_time >= Recordings.start_time) - & (frame_time <= Recordings.end_time) - ) + (frame_time >= Recordings.start_time) + & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) .order_by(Recordings.start_time.desc()) @@ -215,7 +223,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): logger.debug(f"Post processing plate: {event_id}, {frame_time}") self.lpr_process(keyframe_obj_data, frame) - def handle_request(self, topic, request_data) -> dict[str, Any] | None: + def handle_request(self, topic: str, request_data: dict) -> dict[str, Any] | None: if topic == EmbeddingsRequestEnum.reprocess_plate.value: event = request_data["event"] @@ -232,3 +240,5 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): "message": "Successfully requested reprocessing of license plate.", "success": True, } + + return None diff --git a/frigate/data_processing/post/object_descriptions.py b/frigate/data_processing/post/object_descriptions.py index 266ede316b..9612ce7be9 100644 --- a/frigate/data_processing/post/object_descriptions.py +++ b/frigate/data_processing/post/object_descriptions.py @@ -16,15 +16,15 @@ from frigate.config import CameraConfig, FrigateConfig from frigate.const import CLIPS_DIR, UPDATE_EVENT_DESCRIPTION from frigate.data_processing.post.semantic_trigger import SemanticTriggerProcessor from frigate.data_processing.types import PostProcessDataEnum -from frigate.genai import GenAIClient +from frigate.genai.manager import GenAIClientManager from frigate.models import Event from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed -from frigate.util.file import get_event_thumbnail_bytes +from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image from frigate.util.image import create_thumbnail, ensure_jpeg_bytes if TYPE_CHECKING: - from frigate.embeddings import Embeddings + from frigate.embeddings.embeddings import Embeddings from ..post.api import PostProcessorApi from ..types import DataProcessorMetrics @@ -41,7 +41,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): embeddings: "Embeddings", requestor: InterProcessRequestor, metrics: DataProcessorMetrics, - client: GenAIClient, + genai_manager: GenAIClientManager, semantic_trigger_processor: SemanticTriggerProcessor | None, ): super().__init__(config, metrics, None) @@ -49,7 +49,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): self.embeddings = embeddings self.requestor = requestor self.metrics = metrics - self.genai_client = client + self.genai_manager = genai_manager self.semantic_trigger_processor = semantic_trigger_processor self.tracked_events: dict[str, list[Any]] = {} self.early_request_sent: dict[str, bool] = {} @@ -63,8 +63,10 @@ class ObjectDescriptionProcessor(PostProcessorApi): """Handle an update to a frame for an object.""" camera_config = self.config.cameras[camera] - # no need to save our own thumbnails if genai is not enabled - # or if the object has become stationary + if not camera_config.objects.genai.enabled: + return + + # no need to save our own thumbnails if the object has become stationary if not data["stationary"]: if data["id"] not in self.tracked_events: self.tracked_events[data["id"]] = [] @@ -139,7 +141,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): ): self._process_genai_description(event, camera_config, thumbnail) else: - self.cleanup_event(event.id) + self.cleanup_event(str(event.id)) def __regenerate_description(self, event_id: str, source: str, force: bool) -> None: """Regenerate the description for an event.""" @@ -149,17 +151,17 @@ class ObjectDescriptionProcessor(PostProcessorApi): logger.error(f"Event {event_id} not found for description regeneration") return - if self.genai_client is None: - logger.error("GenAI not enabled") - return - - camera_config = self.config.cameras[event.camera] + camera_config = self.config.cameras[str(event.camera)] if not camera_config.objects.genai.enabled and not force: logger.error(f"GenAI not enabled for camera {event.camera}") return thumbnail = get_event_thumbnail_bytes(event) + if thumbnail is None: + logger.error("No thumbnail available for %s", event.id) + return + # ensure we have a jpeg to pass to the model thumbnail = ensure_jpeg_bytes(thumbnail) @@ -187,7 +189,9 @@ class ObjectDescriptionProcessor(PostProcessorApi): ) ) - self._genai_embed_description(event, embed_image) + self._genai_embed_description( + event, [img for img in embed_image if img is not None] + ) def process_data(self, frame_data: dict, data_type: PostProcessDataEnum) -> None: """Process a frame update.""" @@ -196,6 +200,9 @@ class ObjectDescriptionProcessor(PostProcessorApi): if data_type != PostProcessDataEnum.tracked_object: return + if self.genai_manager.description_client is None: + return + state: str | None = frame_data.get("state", None) if state is not None: @@ -232,51 +239,44 @@ class ObjectDescriptionProcessor(PostProcessorApi): def _read_and_crop_snapshot(self, event: Event) -> bytes | None: """Read, decode, and crop the snapshot image.""" - snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") - - if not os.path.isfile(snapshot_file): - logger.error( - f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}" - ) - return None - try: - with open(snapshot_file, "rb") as image_file: - snapshot_image = image_file.read() + img, _ = load_event_snapshot_image(event) + if img is None: + logger.error(f"Cannot load snapshot for {event.id}, file not found") + return None - img = cv2.imdecode( - np.frombuffer(snapshot_image, dtype=np.int8), - cv2.IMREAD_COLOR, - ) + # Crop snapshot based on region + # provide full image if region doesn't exist (manual events) + height, width = img.shape[:2] + x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined] + "region", [0, 0, 1, 1] + ) + x1, y1 = int(x1_rel * width), int(y1_rel * height) - # Crop snapshot based on region - # provide full image if region doesn't exist (manual events) - height, width = img.shape[:2] - x1_rel, y1_rel, width_rel, height_rel = event.data.get( - "region", [0, 0, 1, 1] - ) - x1, y1 = int(x1_rel * width), int(y1_rel * height) + cropped_image = img[ + y1 : y1 + int(height_rel * height), + x1 : x1 + int(width_rel * width), + ] - cropped_image = img[ - y1 : y1 + int(height_rel * height), - x1 : x1 + int(width_rel * width), - ] + _, buffer = cv2.imencode(".jpg", cropped_image) - _, buffer = cv2.imencode(".jpg", cropped_image) - - return buffer.tobytes() + return buffer.tobytes() except Exception: return None def _process_genai_description( - self, event: Event, camera_config: CameraConfig, thumbnail + self, event: Event, camera_config: CameraConfig, thumbnail: bytes ) -> None: + event_id = str(event.id) + if event.has_snapshot and camera_config.objects.genai.use_snapshot: snapshot_image = self._read_and_crop_snapshot(event) + if not snapshot_image: + self.cleanup_event(event_id) return - num_thumbnails = len(self.tracked_events.get(event.id, [])) + num_thumbnails = len(self.tracked_events.get(event_id, [])) # ensure we have a jpeg to pass to the model thumbnail = ensure_jpeg_bytes(thumbnail) @@ -288,7 +288,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): else ( [ data["thumbnail"][:] if data.get("thumbnail") else None - for data in self.tracked_events[event.id] + for data in self.tracked_events[event_id] if data.get("thumbnail") ] if num_thumbnails > 0 @@ -297,22 +297,22 @@ class ObjectDescriptionProcessor(PostProcessorApi): ) if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0: - logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}") + logger.debug(f"Saving {num_thumbnails} thumbnails for event {event_id}") - Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir( + Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir( parents=True, exist_ok=True ) - for idx, data in enumerate(self.tracked_events[event.id], 1): + for idx, data in enumerate(self.tracked_events[event_id], 1): jpg_bytes: bytes | None = data["thumbnail"] if jpg_bytes is None: - logger.warning(f"Unable to save thumbnail {idx} for {event.id}.") + logger.warning(f"Unable to save thumbnail {idx} for {event_id}.") else: with open( os.path.join( CLIPS_DIR, - f"genai-requests/{event.id}/{idx}.jpg", + f"genai-requests/{event_id}/{idx}.jpg", ), "wb", ) as j: @@ -321,7 +321,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): # Generate the description. Call happens in a thread since it is network bound. threading.Thread( target=self._genai_embed_description, - name=f"_genai_embed_description_{event.id}", + name=f"_genai_embed_description_{event_id}", daemon=True, args=( event, @@ -330,13 +330,18 @@ class ObjectDescriptionProcessor(PostProcessorApi): ).start() # Clean up tracked events and early request state - self.cleanup_event(event.id) + self.cleanup_event(event_id) def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None: """Embed the description for an event.""" start = datetime.datetime.now().timestamp() - camera_config = self.config.cameras[event.camera] - description = self.genai_client.generate_object_description( + camera_config = self.config.cameras[str(event.camera)] + client = self.genai_manager.description_client + + if client is None: + return + + description = client.generate_object_description( camera_config, thumbnails, event ) @@ -357,7 +362,7 @@ class ObjectDescriptionProcessor(PostProcessorApi): # Embed the description if self.config.semantic_search.enabled: - self.embeddings.embed_description(event.id, description) + self.embeddings.embed_description(str(event.id), description) # Check semantic trigger for this description if self.semantic_trigger_processor is not None: diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 9949d766ca..3740ac25f7 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -19,9 +19,15 @@ from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.config.camera import CameraConfig from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION +from frigate.const import ( + ATTRIBUTE_LABEL_DISPLAY_MAP, + CACHE_DIR, + CLIPS_DIR, + UPDATE_REVIEW_DESCRIPTION, +) from frigate.data_processing.types import PostProcessDataEnum from frigate.genai import GenAIClient +from frigate.genai.manager import GenAIClientManager from frigate.models import Recordings, ReviewSegment from frigate.util.builtin import EventsPerSecond, InferenceSpeed from frigate.util.image import get_image_from_recording @@ -33,6 +39,8 @@ logger = logging.getLogger(__name__) RECORDING_BUFFER_EXTENSION_PERCENT = 0.10 MIN_RECORDING_DURATION = 10 +MAX_IMAGE_TOKENS = 24000 +MAX_FRAMES_PER_SECOND = 1 class ReviewDescriptionProcessor(PostProcessorApi): @@ -41,34 +49,51 @@ class ReviewDescriptionProcessor(PostProcessorApi): config: FrigateConfig, requestor: InterProcessRequestor, metrics: DataProcessorMetrics, - client: GenAIClient, + genai_manager: GenAIClientManager, ): super().__init__(config, metrics, None) self.requestor = requestor self.metrics = metrics - self.genai_client = client + self.genai_manager = genai_manager self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed) - self.review_descs_dps = EventsPerSecond() - self.review_descs_dps.start() + self.review_desc_dps = EventsPerSecond() + self.review_desc_dps.start() def calculate_frame_count( self, camera: str, + duration: float, image_source: ImageSourceEnum = ImageSourceEnum.preview, height: int = 480, ) -> int: - """Calculate optimal number of frames based on context size, image source, and resolution. + """Calculate optimal number of frames based on event duration, context size, + image source, and resolution. - Token usage varies by resolution: larger images (ultrawide aspect ratios) use more tokens. - Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin. - Capped at 20 frames. + Per-image token cost is asked of the GenAI provider so providers that know + their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can + diverge from the default ~1-token-per-1250-pixels heuristic. The frame + budget is bounded by: + - remaining context window after prompt + response reservations + - a fixed MAX_IMAGE_TOKENS ceiling + - MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in + near-duplicate frames where the model latches onto the redundant middle + and skips the start/end action """ - context_size = self.genai_client.get_context_size() + client = self.genai_manager.description_client + + if client is None: + return 3 + + context_size = client.get_context_size() camera_config = self.config.cameras[camera] detect_width = camera_config.detect.width detect_height = camera_config.detect.height - aspect_ratio = detect_width / detect_height + + if not detect_width or not detect_height: + aspect_ratio = 16 / 9 + else: + aspect_ratio = detect_width / detect_height if image_source == ImageSourceEnum.recordings: if aspect_ratio >= 1: @@ -90,21 +115,27 @@ class ReviewDescriptionProcessor(PostProcessorApi): width = target_width height = int(target_width / aspect_ratio) - pixels_per_image = width * height - tokens_per_image = pixels_per_image / 1250 + tokens_per_image = client.estimate_image_tokens(width, height) prompt_tokens = 3800 response_tokens = 300 - available_tokens = context_size - prompt_tokens - response_tokens - max_frames = int(available_tokens / tokens_per_image) + context_budget = context_size - prompt_tokens - response_tokens + image_token_budget = min(context_budget, MAX_IMAGE_TOKENS) + max_frames_by_tokens = int(image_token_budget / tokens_per_image) + max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND) + max_frames = min(max_frames_by_tokens, max_frames_by_duration) + return max(max_frames, 3) - return min(max(max_frames, 3), 20) - - def process_data(self, data, data_type): - self.metrics.review_desc_dps.value = self.review_descs_dps.eps() + def process_data( + self, data: dict[str, Any], data_type: PostProcessDataEnum + ) -> None: + self.metrics.review_desc_dps.value = self.review_desc_dps.eps() if data_type != PostProcessDataEnum.review: return + if self.genai_manager.description_client is None: + return + camera = data["after"]["camera"] camera_config = self.config.cameras[camera] @@ -143,10 +174,13 @@ class ReviewDescriptionProcessor(PostProcessorApi): additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2 buffer_extension = min(5, additional_buffer_per_side) + final_data["start_time"] -= buffer_extension + final_data["end_time"] += buffer_extension + thumbs = self.get_recording_frames( camera, - final_data["start_time"] - buffer_extension, - final_data["end_time"] + buffer_extension, + final_data["start_time"], + final_data["end_time"], height=480, # Use 480p for good balance between quality and token usage ) @@ -186,12 +220,12 @@ class ReviewDescriptionProcessor(PostProcessorApi): ) # kickoff analysis - self.review_descs_dps.update() + self.review_desc_dps.update() threading.Thread( target=run_analysis, args=( self.requestor, - self.genai_client, + self.genai_manager.description_client, self.review_desc_speed, camera_config, final_data, @@ -202,7 +236,7 @@ class ReviewDescriptionProcessor(PostProcessorApi): ), ).start() - def handle_request(self, topic, request_data): + def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None: if topic == EmbeddingsRequestEnum.summarize_review.value: start_ts = request_data["start_ts"] end_ts = request_data["end_ts"] @@ -307,7 +341,12 @@ class ReviewDescriptionProcessor(PostProcessorApi): os.path.join(CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}") ).mkdir(parents=True, exist_ok=True) - return self.genai_client.generate_review_summary( + client = self.genai_manager.description_client + + if client is None: + return None + + return client.generate_review_summary( start_ts, end_ts, events_with_context, @@ -324,15 +363,20 @@ class ReviewDescriptionProcessor(PostProcessorApi): end_time: float, ) -> list[str]: preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{camera}" - start_file = f"{file_start}-{start_time}.webp" - end_file = f"{file_start}-{end_time}.webp" - all_frames = [] + file_start = f"preview_{camera}-" + start_file = f"{file_start}{start_time}.webp" + end_file = f"{file_start}{end_time}.webp" - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + all_frames: list[str] = [] + + for file in camera_files: if file < start_file: if len(all_frames): all_frames[0] = os.path.join(preview_dir, file) @@ -348,7 +392,9 @@ class ReviewDescriptionProcessor(PostProcessorApi): all_frames.append(os.path.join(preview_dir, file)) frame_count = len(all_frames) - desired_frame_count = self.calculate_frame_count(camera) + desired_frame_count = self.calculate_frame_count( + camera, duration=end_time - start_time + ) if frame_count <= desired_frame_count: return all_frames @@ -372,7 +418,7 @@ class ReviewDescriptionProcessor(PostProcessorApi): """Get frames from recordings at specified timestamps.""" duration = end_time - start_time desired_frame_count = self.calculate_frame_count( - camera, ImageSourceEnum.recordings, height + camera, duration, ImageSourceEnum.recordings, height ) # Calculate evenly spaced timestamps throughout the duration @@ -465,7 +511,7 @@ class ReviewDescriptionProcessor(PostProcessorApi): thumb_data = cv2.imread(thumb_path) if thumb_data is None: - logger.warning( + logger.warning( # type: ignore[unreachable] "Could not read preview frame at %s, skipping", thumb_path ) continue @@ -488,13 +534,12 @@ class ReviewDescriptionProcessor(PostProcessorApi): return thumbs -@staticmethod def run_analysis( requestor: InterProcessRequestor, genai_client: GenAIClient, review_inference_speed: InferenceSpeed, camera_config: CameraConfig, - final_data: dict[str, str], + final_data: dict[str, Any], thumbs: list[bytes], genai_config: GenAIReviewConfig, labelmap_objects: list[str], @@ -528,16 +573,17 @@ def run_analysis( for i, verified_label in enumerate(final_data["data"]["verified_objects"]): object_type = verified_label.replace("-verified", "").replace("_", " ") name = titlecase(sub_labels_list[i].replace("_", " ")) - unified_objects.append(f"{name} ({object_type})") + unified_objects.append(f"{name} ← {object_type}") for label in objects_list: if "-verified" in label: continue elif label in labelmap_objects: - object_type = titlecase(label.replace("_", " ")) + object_type = label.replace("_", " ") if label in attribute_labels: - unified_objects.append(f"{object_type} (delivery/service)") + display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type) + unified_objects.append(f"{display_name} (delivery/service)") else: unified_objects.append(object_type) diff --git a/frigate/data_processing/post/semantic_trigger.py b/frigate/data_processing/post/semantic_trigger.py index ec9e5d220b..e2b305ea2d 100644 --- a/frigate/data_processing/post/semantic_trigger.py +++ b/frigate/data_processing/post/semantic_trigger.py @@ -19,6 +19,7 @@ from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR from frigate.data_processing.types import PostProcessDataEnum from frigate.db.sqlitevecq import SqliteVecQueueDatabase +from frigate.embeddings.embeddings import Embeddings from frigate.embeddings.util import ZScoreNormalization from frigate.models import Event, Trigger from frigate.util.builtin import cosine_distance @@ -40,8 +41,8 @@ class SemanticTriggerProcessor(PostProcessorApi): requestor: InterProcessRequestor, sub_label_publisher: EventMetadataPublisher, metrics: DataProcessorMetrics, - embeddings, - ): + embeddings: Embeddings, + ) -> None: super().__init__(config, metrics, None) self.db = db self.embeddings = embeddings @@ -54,7 +55,7 @@ class SemanticTriggerProcessor(PostProcessorApi): # load stats from disk try: - with open(os.path.join(CONFIG_DIR, ".search_stats.json"), "r") as f: + with open(os.path.join(CONFIG_DIR, ".search_stats.json")) as f: data = json.loads(f.read()) self.thumb_stats.from_dict(data["thumb_stats"]) self.desc_stats.from_dict(data["desc_stats"]) @@ -236,11 +237,14 @@ class SemanticTriggerProcessor(PostProcessorApi): return # Skip the event if not an object - if event.data.get("type") != "object": + if event.data.get("type") != "object": # type: ignore[attr-defined] return thumbnail_bytes = get_event_thumbnail_bytes(event) + if thumbnail_bytes is None: + return + nparr = np.frombuffer(thumbnail_bytes, np.uint8) thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR) @@ -262,8 +266,10 @@ class SemanticTriggerProcessor(PostProcessorApi): thumbnail, ) - def handle_request(self, topic, request_data): + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | str | None: return None - def expire_object(self, object_id, camera): + def expire_object(self, object_id: str, camera: str) -> None: pass diff --git a/frigate/data_processing/post/types.py b/frigate/data_processing/post/types.py index 44bb09fb02..3698e99a85 100644 --- a/frigate/data_processing/post/types.py +++ b/frigate/data_processing/post/types.py @@ -1,23 +1,42 @@ -from pydantic import BaseModel, ConfigDict, Field +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=200)] class ReviewMetadata(BaseModel): model_config = ConfigDict(extra="ignore", protected_namespaces=()) - title: str = Field(description="A concise title for the activity.") + observations: list[ObservationItem] = Field( + ..., + min_length=3, + max_length=8, + description="Enumerate the significant observations across all frames, in chronological order.", + ) scene: str = Field( - description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence." + min_length=150, + max_length=600, + description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.", + ) + title: str = Field( + max_length=80, + description="Title for the activity.", ) shortSummary: str = Field( - description="A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail." + min_length=70, + max_length=140, + description="A brief summary for the activity.", ) confidence: float = Field( - description="A float between 0 and 1 representing your overall confidence in this analysis." + ge=0.0, + le=1.0, + description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.", ) potential_threat_level: int = Field( ge=0, - le=3, - description="An integer representing the potential threat level (1-3). 1: Minor anomaly. 2: Moderate concern. 3: High threat. Only include this field if a clear security concern is observable; otherwise, omit it.", + le=2, + description="Threat level: 0 = normal, 1 = suspicious, 2 = critical threat.", ) other_concerns: list[str] | None = Field( default=None, diff --git a/frigate/data_processing/real_time/api.py b/frigate/data_processing/real_time/api.py index 0fa0f99529..98efb532b4 100644 --- a/frigate/data_processing/real_time/api.py +++ b/frigate/data_processing/real_time/api.py @@ -1,7 +1,12 @@ """Local only processors for handling real time object processing.""" import logging +import threading from abc import ABC, abstractmethod +from collections import deque +from collections.abc import Callable +from concurrent.futures import Future +from queue import Empty, Full, Queue from typing import Any import numpy as np @@ -61,3 +66,136 @@ class RealTimeProcessorApi(ABC): None. """ pass + + def update_config(self, topic: str, payload: Any) -> None: + """Handle a config change notification. + + Called for every config update published under ``config/``. + Processors should override this to check the topic and act only + on changes relevant to them. Default is a no-op. + + Args: + topic: The config topic that changed. + payload: The updated configuration object. + """ + pass + + def drain_results(self) -> list[dict[str, Any]]: + """Return pending results that need IPC side-effects. + + Deferred processors accumulate results on a worker thread. + The maintainer calls this each loop iteration to collect them + and perform publishes on the main thread. + + Synchronous processors return an empty list (default). + """ + return [] + + def shutdown(self) -> None: + """Stop any background work and release resources. + + Called when the processor is being removed or the maintainer + is shutting down. Default is a no-op for synchronous processors. + """ + pass + + +class DeferredRealtimeProcessorApi(RealTimeProcessorApi): + """Base class for processors that offload heavy work to a background thread. + + Subclasses implement: + - process_frame(): do cheap gating + crop + copy, then call _enqueue_task() + - _process_task(task): heavy work (inference, consensus) on the worker thread + - handle_request(): optionally use _enqueue_request() for sync request/response + - expire_object(): call _enqueue_task() with a control message + + The worker thread owns all processor state. No locks are needed because + only the worker mutates state. Results that need IPC are placed in + _pending_results via _emit_result(), and the maintainer drains them + each loop iteration. + """ + + def __init__( + self, + config: FrigateConfig, + metrics: DataProcessorMetrics, + max_queue: int = 8, + ) -> None: + super().__init__(config, metrics) + self._task_queue: Queue = Queue(maxsize=max_queue) + self._pending_results: deque[dict[str, Any]] = deque() + self._results_lock = threading.Lock() + self._stop_event = threading.Event() + self._worker = threading.Thread( + target=self._drain_loop, + daemon=True, + name=f"{type(self).__name__}_worker", + ) + self._worker.start() + + def _drain_loop(self) -> None: + """Worker thread main loop — drains the task queue until stopped.""" + while not self._stop_event.is_set(): + try: + task = self._task_queue.get(timeout=0.5) + except Empty: + continue + + if ( + isinstance(task, tuple) + and len(task) == 2 + and isinstance(task[1], Future) + ): + # Request/response: (callable_and_args, future) + (func, args), future = task + try: + result = func(args) + future.set_result(result) + except Exception as e: + future.set_exception(e) + else: + try: + self._process_task(task) + except Exception: + logger.exception("Error processing deferred task") + + def _enqueue_task(self, task: Any) -> bool: + """Enqueue a task for the worker. Returns False if queue is full (dropped).""" + try: + self._task_queue.put_nowait(task) + return True + except Full: + logger.debug("Deferred processor queue full, dropping task") + return False + + def _enqueue_request(self, func: Callable, args: Any, timeout: float = 10.0) -> Any: + """Enqueue a request and block until the worker returns a result.""" + future: Future = Future() + self._task_queue.put(((func, args), future), timeout=timeout) + return future.result(timeout=timeout) + + def _emit_result(self, result: dict[str, Any]) -> None: + """Called by the worker thread to stage a result for the maintainer.""" + with self._results_lock: + self._pending_results.append(result) + + def drain_results(self) -> list[dict[str, Any]]: + """Called by the maintainer on the main thread to collect pending results.""" + with self._results_lock: + results = list(self._pending_results) + self._pending_results.clear() + return results + + def shutdown(self) -> None: + """Signal the worker to stop and wait for it to finish.""" + self._stop_event.set() + self._worker.join(timeout=5.0) + + @abstractmethod + def _process_task(self, task: Any) -> None: + """Process a single task on the worker thread. + + Subclasses implement inference, consensus, training image saves here. + Call _emit_result() to stage results for the maintainer to publish. + """ + pass diff --git a/frigate/data_processing/real_time/audio_transcription.py b/frigate/data_processing/real_time/audio_transcription.py index 2e6d599ebf..0e1842b775 100644 --- a/frigate/data_processing/real_time/audio_transcription.py +++ b/frigate/data_processing/real_time/audio_transcription.py @@ -4,7 +4,7 @@ import logging import os import queue import threading -from typing import Optional +from typing import Any import numpy as np @@ -39,11 +39,11 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi): self.config = config self.camera_config = camera_config self.requestor = requestor - self.stream = None - self.whisper_model = None + self.stream: Any = None + self.whisper_model: FasterWhisperASR | None = None self.model_runner = model_runner - self.transcription_segments = [] - self.audio_queue = queue.Queue() + self.transcription_segments: list[str] = [] + self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = queue.Queue() self.stop_event = stop_event def __build_recognizer(self) -> None: @@ -75,9 +75,7 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi): f"Failed to initialize live streaming audio transcription: {e}" ) - def __process_audio_stream( - self, audio_data: np.ndarray - ) -> Optional[tuple[str, bool]]: + def __process_audio_stream(self, audio_data: np.ndarray) -> tuple[str, bool] | None: if ( self.model_runner.model is None and self.config.audio_transcription.model_size == "small" @@ -142,10 +140,10 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi): logger.error(f"Error processing audio stream: {e}") return None - def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None: + def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None: pass - def process_audio(self, obj_data: dict[str, any], audio: np.ndarray) -> bool | None: + def process_audio(self, obj_data: dict[str, Any], audio: np.ndarray) -> bool | None: if audio is None or audio.size == 0: logger.debug("No audio data provided for transcription") return None @@ -269,13 +267,13 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi): ) def handle_request( - self, topic: str, request_data: dict[str, any] - ) -> dict[str, any] | None: + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: if topic == "clear_audio_recognizer": self.stream = None self.__build_recognizer() return {"message": "Audio recognizer cleared and rebuilt", "success": True} return None - def expire_object(self, object_id: str) -> None: + def expire_object(self, object_id: str, camera: str) -> None: pass diff --git a/frigate/data_processing/real_time/bird.py b/frigate/data_processing/real_time/bird.py index 7851c09972..48663f971e 100644 --- a/frigate/data_processing/real_time/bird.py +++ b/frigate/data_processing/real_time/bird.py @@ -14,7 +14,7 @@ from frigate.comms.event_metadata_updater import ( from frigate.config import FrigateConfig from frigate.const import MODEL_CACHE_DIR from frigate.log import suppress_stderr_during -from frigate.util.object import calculate_region +from frigate.util.image import calculate_region from ..types import DataProcessorMetrics from .api import RealTimeProcessorApi @@ -22,7 +22,7 @@ from .api import RealTimeProcessorApi try: from tflite_runtime.interpreter import Interpreter except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter + from ai_edge_litert.interpreter import Interpreter logger = logging.getLogger(__name__) @@ -35,10 +35,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): metrics: DataProcessorMetrics, ): super().__init__(config, metrics) - self.interpreter: Interpreter = None + self.interpreter: Interpreter | None = None self.sub_label_publisher = sub_label_publisher - self.tensor_input_details: dict[str, Any] = None - self.tensor_output_details: dict[str, Any] = None + self.tensor_input_details: list[dict[str, Any]] | None = None + self.tensor_output_details: list[dict[str, Any]] | None = None self.detected_birds: dict[str, float] = {} self.labelmap: dict[int, str] = {} @@ -61,7 +61,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): self.downloader = ModelDownloader( model_name="bird", download_path=download_path, - file_names=self.model_files.keys(), + file_names=list(self.model_files.keys()), download_func=self.__download_models, complete_func=self.__build_detector, ) @@ -102,8 +102,12 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): i += 1 line = f.readline() - def process_frame(self, obj_data, frame): - if not self.interpreter: + def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None: + if ( + not self.interpreter + or not self.tensor_input_details + or not self.tensor_output_details + ): return if obj_data["label"] != "bird": @@ -145,7 +149,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): self.tensor_output_details[0]["index"] )[0] probs = res / res.sum(axis=0) - best_id = np.argmax(probs) + best_id = int(np.argmax(probs)) if best_id == 964: logger.debug("No bird classification was detected.") @@ -169,9 +173,21 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): ) self.detected_birds[obj_data["id"]] = score - def handle_request(self, topic, request_data): + CONFIG_UPDATE_TOPIC = "config/classification" + + def update_config(self, topic: str, payload: Any) -> None: + """Update bird classification config at runtime.""" + if topic != self.CONFIG_UPDATE_TOPIC: + return + + self.config.classification = payload + logger.debug("Bird classification config updated dynamically") + + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: return None - def expire_object(self, object_id, camera): + def expire_object(self, object_id: str, camera: str) -> None: if object_id in self.detected_birds: self.detected_birds.pop(object_id) diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py index 229383d9fd..e3b0e23ed8 100644 --- a/frigate/data_processing/real_time/custom_classification.py +++ b/frigate/data_processing/real_time/custom_classification.py @@ -1,7 +1,6 @@ """Real time processor that works with classification tflite models.""" import datetime -import json import logging import os from typing import Any @@ -10,36 +9,30 @@ import cv2 import numpy as np from frigate.comms.embeddings_updater import EmbeddingsRequestEnum -from frigate.comms.event_metadata_updater import ( - EventMetadataPublisher, - EventMetadataTypeEnum, -) +from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig -from frigate.config.classification import ( - CustomClassificationConfig, - ObjectClassificationType, -) +from frigate.config.classification import CustomClassificationConfig from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR from frigate.log import suppress_stderr_during -from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels -from frigate.util.object import box_overlaps, calculate_region +from frigate.util.image import calculate_region +from frigate.util.object import box_overlaps from ..types import DataProcessorMetrics -from .api import RealTimeProcessorApi +from .api import DeferredRealtimeProcessorApi try: from tflite_runtime.interpreter import Interpreter except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter + from ai_edge_litert.interpreter import Interpreter logger = logging.getLogger(__name__) MAX_OBJECT_CLASSIFICATIONS = 16 -class CustomStateClassificationProcessor(RealTimeProcessorApi): +class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi): def __init__( self, config: FrigateConfig, @@ -47,14 +40,18 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): requestor: InterProcessRequestor, metrics: DataProcessorMetrics, ): - super().__init__(config, metrics) + super().__init__(config, metrics, max_queue=4) self.model_config = model_config + + if not self.model_config.name: + raise ValueError("Custom classification model name must be set.") + self.requestor = requestor self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name) self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train") - self.interpreter: Interpreter = None - self.tensor_input_details: dict[str, Any] | None = None - self.tensor_output_details: dict[str, Any] | None = None + self.interpreter: Interpreter | None = None + self.tensor_input_details: list[dict[str, Any]] | None = None + self.tensor_output_details: list[dict[str, Any]] | None = None self.labelmap: dict[int, str] = {} self.classifications_per_second = EventsPerSecond() self.state_history: dict[str, dict[str, Any]] = {} @@ -63,7 +60,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): self.metrics and self.model_config.name in self.metrics.classification_speeds ): - self.inference_speed = InferenceSpeed( + self.inference_speed: InferenceSpeed | None = InferenceSpeed( self.metrics.classification_speeds[self.model_config.name] ) else: @@ -73,11 +70,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): self.__build_detector() def __build_detector(self) -> None: - try: - from tflite_runtime.interpreter import Interpreter - except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter - model_path = os.path.join(self.model_dir, "model.tflite") labelmap_path = os.path.join(self.model_dir, "labelmap.txt") @@ -177,12 +169,20 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): return None - def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray): + def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None: + if ( + not self.model_config.name + or not self.model_config.state_config + or not self.tensor_input_details + or not self.tensor_output_details + ): + return + if self.metrics and self.model_config.name in self.metrics.classification_cps: self.metrics.classification_cps[ self.model_config.name ].value = self.classifications_per_second.eps() - camera = frame_data.get("camera") + camera = str(frame_data.get("camera")) if camera not in self.model_config.state_config.cameras: return @@ -251,14 +251,34 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): ) return - frame = rgb[y1:y2, x1:x2] + cropped_frame = rgb[y1:y2, x1:x2] try: - resized_frame = cv2.resize(frame, (224, 224)) + resized_frame = cv2.resize(cropped_frame, (224, 224)) except Exception: logger.warning("Failed to resize image for state classification") return + # Copy for training image saves on worker thread + crop_bgr = cv2.cvtColor(cropped_frame, cv2.COLOR_RGB2BGR) + + self._enqueue_task(("classify", camera, now, resized_frame, crop_bgr)) + + def _process_task(self, task: Any) -> None: + kind = task[0] + if kind == "classify": + _, camera, timestamp, resized_frame, crop_bgr = task + self._classify_state(camera, timestamp, resized_frame, crop_bgr) + elif kind == "reload": + self.__build_detector() + + def _classify_state( + self, + camera: str, + timestamp: float, + resized_frame: np.ndarray, + crop_bgr: np.ndarray, + ) -> None: if self.interpreter is None: # When interpreter is None, always save (score is 0.0, which is < 1.0) if self._should_save_image(camera, "unknown", 0.0): @@ -269,15 +289,18 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): ) write_classification_attempt( self.train_dir, - cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), + crop_bgr, "none-none", - now, + timestamp, "unknown", 0.0, max_files=save_attempts, ) return + if not self.tensor_input_details or not self.tensor_output_details: + return + input = np.expand_dims(resized_frame, axis=0) self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) self.interpreter.invoke() @@ -288,9 +311,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): logger.debug( f"{self.model_config.name} Ran state classification with probabilities: {probs}" ) - best_id = np.argmax(probs) + best_id = int(np.argmax(probs)) score = round(probs[best_id], 2) - self.__update_metrics(datetime.datetime.now().timestamp() - now) + self.__update_metrics(datetime.datetime.now().timestamp() - timestamp) detected_state = self.labelmap[best_id] @@ -302,9 +325,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): ) write_classification_attempt( self.train_dir, - cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), + crop_bgr, "none-none", - now, + timestamp, detected_state, score, max_files=save_attempts, @@ -319,32 +342,44 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): verified_state = self.verify_state_change(camera, detected_state) if verified_state is not None: - self.requestor.send_data( - f"{camera}/classification/{self.model_config.name}", - verified_state, + self._emit_result( + { + "type": "classification", + "processor": "state", + "model_name": self.model_config.name, + "camera": camera, + "state": verified_state, + } ) - def handle_request(self, topic, request_data): + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: if topic == EmbeddingsRequestEnum.reload_classification_model.value: if request_data.get("model_name") == self.model_config.name: - self.__build_detector() - logger.info( - f"Successfully loaded updated model for {self.model_config.name}" - ) - return { - "success": True, - "message": f"Loaded {self.model_config.name} model.", - } + + def _do_reload(data: dict[str, Any]) -> dict[str, Any]: + self.__build_detector() + logger.info( + f"Successfully loaded updated model for {self.model_config.name}" + ) + return { + "success": True, + "message": f"Loaded {self.model_config.name} model.", + } + + result: dict[str, Any] = self._enqueue_request(_do_reload, request_data) + return result else: return None else: return None - def expire_object(self, object_id, camera): + def expire_object(self, object_id: str, camera: str) -> None: pass -class CustomObjectClassificationProcessor(RealTimeProcessorApi): +class CustomObjectClassificationProcessor(DeferredRealtimeProcessorApi): def __init__( self, config: FrigateConfig, @@ -353,15 +388,19 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): requestor: InterProcessRequestor, metrics: DataProcessorMetrics, ): - super().__init__(config, metrics) + super().__init__(config, metrics, max_queue=8) self.model_config = model_config + + if not self.model_config.name: + raise ValueError("Custom classification model name must be set.") + self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name) self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train") - self.interpreter: Interpreter = None + self.interpreter: Interpreter | None = None self.sub_label_publisher = sub_label_publisher self.requestor = requestor - self.tensor_input_details: dict[str, Any] | None = None - self.tensor_output_details: dict[str, Any] | None = None + self.tensor_input_details: list[dict[str, Any]] | None = None + self.tensor_output_details: list[dict[str, Any]] | None = None self.classification_history: dict[str, list[tuple[str, float, float]]] = {} self.labelmap: dict[int, str] = {} self.classifications_per_second = EventsPerSecond() @@ -370,7 +409,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): self.metrics and self.model_config.name in self.metrics.classification_speeds ): - self.inference_speed = InferenceSpeed( + self.inference_speed: InferenceSpeed | None = InferenceSpeed( self.metrics.classification_speeds[self.model_config.name] ) else: @@ -436,8 +475,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): ) return None, 0.0 - label_counts = {} - label_scores = {} + label_counts: dict[str, int] = {} + label_scores: dict[str, list[float]] = {} total_attempts = len(history) for label, score, timestamp in history: @@ -448,7 +487,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): label_counts[label] += 1 label_scores[label].append(score) - best_label = max(label_counts, key=label_counts.get) + best_label = max(label_counts, key=lambda k: label_counts[k]) best_count = label_counts[best_label] consensus_threshold = total_attempts * 0.6 @@ -475,7 +514,15 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): ) return best_label, avg_score - def process_frame(self, obj_data, frame): + def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None: + if ( + not self.model_config.name + or not self.model_config.object_config + or not self.tensor_input_details + or not self.tensor_output_details + ): + return + if self.metrics and self.model_config.name in self.metrics.classification_cps: self.metrics.classification_cps[ self.model_config.name @@ -514,18 +561,41 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): ) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) - crop = rgb[ - y:y2, - x:x2, - ] + crop = rgb[y:y2, x:x2] - if crop.shape != (224, 224): - try: - resized_crop = cv2.resize(crop, (224, 224)) - except Exception: - logger.warning("Failed to resize image for state classification") - return + try: + resized_crop = cv2.resize(crop, (224, 224)) + except Exception: + logger.warning("Failed to resize image for object classification") + return + # Copy crop for training images (will be used on worker thread) + crop_bgr = cv2.cvtColor(crop, cv2.COLOR_RGB2BGR) + + self._enqueue_task( + ("classify", object_id, obj_data["camera"], now, resized_crop, crop_bgr) + ) + + def _process_task(self, task: Any) -> None: + kind = task[0] + if kind == "classify": + _, object_id, camera, timestamp, resized_crop, crop_bgr = task + self._classify_object(object_id, camera, timestamp, resized_crop, crop_bgr) + elif kind == "expire": + _, object_id = task + if object_id in self.classification_history: + self.classification_history.pop(object_id) + elif kind == "reload": + self.__build_detector() + + def _classify_object( + self, + object_id: str, + camera: str, + timestamp: float, + resized_crop: np.ndarray, + crop_bgr: np.ndarray, + ) -> None: if self.interpreter is None: save_attempts = ( self.model_config.save_attempts @@ -534,9 +604,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): ) write_classification_attempt( self.train_dir, - cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), + crop_bgr, object_id, - now, + timestamp, "unknown", 0.0, max_files=save_attempts, @@ -547,7 +617,10 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): if object_id not in self.classification_history: self.classification_history[object_id] = [] - self.classification_history[object_id].append(("unknown", 0.0, now)) + self.classification_history[object_id].append(("unknown", 0.0, timestamp)) + return + + if not self.tensor_input_details or not self.tensor_output_details: return input = np.expand_dims(resized_crop, axis=0) @@ -560,9 +633,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): logger.debug( f"{self.model_config.name} Ran object classification with probabilities: {probs}" ) - best_id = np.argmax(probs) + best_id = int(np.argmax(probs)) score = round(probs[best_id], 2) - self.__update_metrics(datetime.datetime.now().timestamp() - now) + self.__update_metrics(datetime.datetime.now().timestamp() - timestamp) save_attempts = ( self.model_config.save_attempts @@ -571,9 +644,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): ) write_classification_attempt( self.train_dir, - cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), + crop_bgr, object_id, - now, + timestamp, self.labelmap[best_id], score, max_files=save_attempts, @@ -588,95 +661,59 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): sub_label = self.labelmap[best_id] logger.debug( - f"{self.model_config.name}: Object {object_id} (label={obj_data['label']}) passed threshold with sub_label={sub_label}, score={score}" + f"{self.model_config.name}: Object {object_id} passed threshold with sub_label={sub_label}, score={score}" ) consensus_label, consensus_score = self.get_weighted_score( - object_id, sub_label, score, now + object_id, sub_label, score, timestamp ) logger.debug( f"{self.model_config.name}: get_weighted_score returned consensus_label={consensus_label}, consensus_score={consensus_score} for {object_id}" ) - if consensus_label is not None: - camera = obj_data["camera"] - logger.debug( - f"{self.model_config.name}: Publishing sub_label={consensus_label} for {obj_data['label']} object {object_id} on {camera}" + if consensus_label is not None and self.model_config.object_config is not None: + self._emit_result( + { + "type": "classification", + "processor": "object", + "model_name": self.model_config.name, + "classification_type": self.model_config.object_config.classification_type, + "object_id": object_id, + "camera": camera, + "timestamp": timestamp, + "label": consensus_label, + "score": consensus_score, + } ) - if ( - self.model_config.object_config.classification_type - == ObjectClassificationType.sub_label - ): - self.sub_label_publisher.publish( - (object_id, consensus_label, consensus_score), - EventMetadataTypeEnum.sub_label, - ) - self.requestor.send_data( - "tracked_object_update", - json.dumps( - { - "type": TrackedObjectUpdateTypesEnum.classification, - "id": object_id, - "camera": camera, - "timestamp": now, - "model": self.model_config.name, - "sub_label": consensus_label, - "score": consensus_score, - } - ), - ) - elif ( - self.model_config.object_config.classification_type - == ObjectClassificationType.attribute - ): - self.sub_label_publisher.publish( - ( - object_id, - self.model_config.name, - consensus_label, - consensus_score, - ), - EventMetadataTypeEnum.attribute.value, - ) - self.requestor.send_data( - "tracked_object_update", - json.dumps( - { - "type": TrackedObjectUpdateTypesEnum.classification, - "id": object_id, - "camera": camera, - "timestamp": now, - "model": self.model_config.name, - "attribute": consensus_label, - "score": consensus_score, - } - ), - ) - - def handle_request(self, topic, request_data): + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: if topic == EmbeddingsRequestEnum.reload_classification_model.value: if request_data.get("model_name") == self.model_config.name: - self.__build_detector() - logger.info( - f"Successfully loaded updated model for {self.model_config.name}" - ) - return { - "success": True, - "message": f"Loaded {self.model_config.name} model.", - } + + def _do_reload(data: dict[str, Any]) -> dict[str, Any]: + self.__build_detector() + logger.info( + f"Successfully loaded updated model for {self.model_config.name}" + ) + return { + "success": True, + "message": f"Loaded {self.model_config.name} model.", + } + + result: dict[str, Any] = self._enqueue_request(_do_reload, request_data) + return result else: return None else: return None - def expire_object(self, object_id, camera): - if object_id in self.classification_history: - self.classification_history.pop(object_id) + def expire_object(self, object_id: str, camera: str) -> None: + self._enqueue_task(("expire", object_id)) -@staticmethod def write_classification_attempt( folder: str, frame: np.ndarray, diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index e1c11bf113..a55c2566e1 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -7,7 +7,7 @@ import logging import os import shutil from pathlib import Path -from typing import Any, Optional +from typing import Any import cv2 import numpy as np @@ -28,6 +28,7 @@ from frigate.data_processing.common.face.model import ( from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed from frigate.util.image import area +from frigate.util.path import safe_join, sanitize_path_component from ..types import DataProcessorMetrics from .api import RealTimeProcessorApi @@ -52,11 +53,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.face_config = config.face_recognition self.requestor = requestor self.sub_label_publisher = sub_label_publisher - self.face_detector: cv2.FaceDetectorYN = None + self.face_detector: cv2.FaceDetectorYN | None = None self.requires_face_detection = "face" not in self.config.objects.all_objects self.person_face_history: dict[str, list[tuple[str, float, int]]] = {} self.camera_current_people: dict[str, list[str]] = {} - self.recognizer: FaceRecognizer | None = None + self.recognizer: FaceRecognizer self.faces_per_second = EventsPerSecond() self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed) @@ -78,7 +79,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.downloader = ModelDownloader( model_name="facedet", download_path=download_path, - file_names=self.model_files.keys(), + file_names=list(self.model_files.keys()), download_func=self.__download_models, complete_func=self.__build_detector, ) @@ -95,6 +96,23 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.recognizer.build() + CONFIG_UPDATE_TOPIC = "config/face_recognition" + + def update_config(self, topic: str, payload: Any) -> None: + """Update face recognition config at runtime.""" + if topic != self.CONFIG_UPDATE_TOPIC: + return + + previous_min_area = self.config.face_recognition.min_area + self.config.face_recognition = payload + self.face_config = payload + + for camera_config in self.config.cameras.values(): + if camera_config.face_recognition.min_area == previous_min_area: + camera_config.face_recognition.min_area = payload.min_area + + logger.debug("Face recognition config updated dynamically") + def __download_models(self, path: str) -> None: try: file_name = os.path.basename(path) @@ -117,7 +135,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): def __detect_face( self, input: np.ndarray, threshold: float - ) -> tuple[int, int, int, int]: + ) -> tuple[int, int, int, int] | None: """Detect faces in input image.""" if not self.face_detector: return None @@ -136,7 +154,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): faces = self.face_detector.detect(input) if faces is None or faces[1] is None: - return None + return None # type: ignore[unreachable] face = None @@ -151,7 +169,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): h: int = int(raw_bbox[3] / scale_factor) bbox = (x, y, x + w, y + h) - if face is None or area(bbox) > area(face): + if face is None or area(bbox) > area(face): # type: ignore[unreachable] face = bbox return face @@ -160,7 +178,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.faces_per_second.update() self.inference_speed.update(duration) - def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray): + def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None: """Look for faces in image.""" self.metrics.face_rec_fps.value = self.faces_per_second.eps() camera = obj_data["camera"] @@ -202,7 +220,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): logger.debug("Not processing due to hitting max rec attempts.") return - face: Optional[dict[str, Any]] = None + face: dict[str, Any] | None = None if self.requires_face_detection: logger.debug("Running manual face detection.") @@ -212,9 +230,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): logger.debug(f"No person box available for {id}") return - rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + # YuNet (cv2.FaceDetectorYN) is trained on BGR + bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) left, top, right, bottom = person_box - person = rgb[top:bottom, left:right] + person = bgr[top:bottom, left:right] face_box = self.__detect_face(person, self.face_config.detection_threshold) if not face_box: @@ -233,11 +252,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): ) return - try: - face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) - except Exception as e: - logger.debug(f"Failed to convert face frame color for {id}: {e}") - return else: # don't run for object without attributes if not obj_data.get("current_attributes"): @@ -275,6 +289,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): max(0, face_box[0]) : min(frame.shape[1], face_box[2]), ] + if face_frame.size == 0: + logger.debug(f"Empty face crop for {id}") + return + res = self.recognizer.classify(face_frame) if not res: @@ -332,7 +350,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.__update_metrics(datetime.datetime.now().timestamp() - start) - def handle_request(self, topic, request_data) -> dict[str, Any] | None: + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: if topic == EmbeddingsRequestEnum.clear_face_classifier.value: self.recognizer.clear() return {"success": True, "message": "Face classifier cleared."} @@ -390,9 +410,17 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): ) # write face to library - folder = os.path.join(FACE_DIR, label) + sanitized_label = sanitize_path_component(label) + folder = safe_join(FACE_DIR, label) + + if sanitized_label is None or folder is None: + return { + "message": f"Invalid face name: {label}", + "success": False, + } + file = os.path.join( - folder, f"{label}_{datetime.datetime.now().timestamp()}.webp" + folder, f"{sanitized_label}_{datetime.datetime.now().timestamp()}.webp" ) os.makedirs(folder, exist_ok=True) @@ -415,7 +443,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): img = cv2.imread(current_file) if img is None: - return { + return { # type: ignore[unreachable] "message": "Invalid image file.", "success": False, } @@ -452,7 +480,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): "score": score, } - def expire_object(self, object_id: str, camera: str): + return None + + def expire_object(self, object_id: str, camera: str) -> None: if object_id in self.person_face_history: self.person_face_history.pop(object_id) @@ -461,7 +491,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): def weighted_average( self, results_list: list[tuple[str, float, int]], max_weight: int = 4000 - ): + ) -> tuple[str | None, float]: """ Calculates a robust weighted average, capping the area weight and giving more weight to higher scores. @@ -476,8 +506,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): return None, 0.0 counts: dict[str, int] = {} - weighted_scores: dict[str, int] = {} - total_weights: dict[str, int] = {} + weighted_scores: dict[str, float] = {} + total_weights: dict[str, float] = {} for name, score, face_area in results_list: if name == "unknown": @@ -492,7 +522,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): counts[name] += 1 # Capped weight based on face area - weight = min(face_area, max_weight) + weight: float = min(face_area, max_weight) # Score-based weighting (higher scores get more weight) weight *= (score - self.face_config.unknown_score) * 10 @@ -502,7 +532,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): if not weighted_scores: return None, 0.0 - best_name = max(weighted_scores, key=weighted_scores.get) + best_name = max(weighted_scores, key=lambda k: weighted_scores[k]) # If the number of faces for this person < min_faces, we are not confident it is a correct result if counts[best_name] < self.face_config.min_faces: diff --git a/frigate/data_processing/real_time/license_plate.py b/frigate/data_processing/real_time/license_plate.py index 59c625de2b..c2ea28b231 100644 --- a/frigate/data_processing/real_time/license_plate.py +++ b/frigate/data_processing/real_time/license_plate.py @@ -40,18 +40,37 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess self.camera_current_cars: dict[str, list[str]] = {} super().__init__(config, metrics) + CONFIG_UPDATE_TOPIC = "config/lpr" + + def update_config(self, topic: str, payload: Any) -> None: + """Update LPR config at runtime.""" + if topic != self.CONFIG_UPDATE_TOPIC: + return + + previous_min_area = self.config.lpr.min_area + self.config.lpr = payload + self.lpr_config = payload + + for camera_config in self.config.cameras.values(): + if camera_config.lpr.min_area == previous_min_area: + camera_config.lpr.min_area = payload.min_area + + logger.debug("LPR config updated dynamically") + def process_frame( self, obj_data: dict[str, Any], frame: np.ndarray, - dedicated_lpr: bool | None = False, - ): + dedicated_lpr: bool = False, + ) -> None: """Look for license plates in image.""" self.lpr_process(obj_data, frame, dedicated_lpr) - def handle_request(self, topic, request_data) -> dict[str, Any] | None: - return + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: + return None - def expire_object(self, object_id: str, camera: str): + def expire_object(self, object_id: str, camera: str) -> None: """Expire lpr objects.""" self.lpr_expire(object_id, camera) diff --git a/frigate/data_processing/real_time/whisper_online.py b/frigate/data_processing/real_time/whisper_online.py index 024b19fba3..066303c93e 100644 --- a/frigate/data_processing/real_time/whisper_online.py +++ b/frigate/data_processing/real_time/whisper_online.py @@ -1053,7 +1053,7 @@ if __name__ == "__main__": SAMPLING_RATE = 16000 duration = len(load_audio(audio_path)) / SAMPLING_RATE - logger.info("Audio duration is: %2.2f seconds" % duration) + logger.info("Audio duration is: %2.2f seconds", duration) asr, online = asr_factory(args, logfile=logfile) if args.vac: diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py index 263a8b987c..5cd1f50087 100644 --- a/frigate/data_processing/types.py +++ b/frigate/data_processing/types.py @@ -1,8 +1,10 @@ """Embeddings types.""" +from __future__ import annotations + from enum import Enum -from multiprocessing.managers import SyncManager -from multiprocessing.sharedctypes import Synchronized +from multiprocessing.managers import DictProxy, SyncManager, ValueProxy +from typing import Any import sherpa_onnx @@ -10,22 +12,22 @@ from frigate.data_processing.real_time.whisper_online import FasterWhisperASR class DataProcessorMetrics: - image_embeddings_speed: Synchronized - image_embeddings_eps: Synchronized - text_embeddings_speed: Synchronized - text_embeddings_eps: Synchronized - face_rec_speed: Synchronized - face_rec_fps: Synchronized - alpr_speed: Synchronized - alpr_pps: Synchronized - yolov9_lpr_speed: Synchronized - yolov9_lpr_pps: Synchronized - review_desc_speed: Synchronized - review_desc_dps: Synchronized - object_desc_speed: Synchronized - object_desc_dps: Synchronized - classification_speeds: dict[str, Synchronized] - classification_cps: dict[str, Synchronized] + image_embeddings_speed: ValueProxy[float] + image_embeddings_eps: ValueProxy[float] + text_embeddings_speed: ValueProxy[float] + text_embeddings_eps: ValueProxy[float] + face_rec_speed: ValueProxy[float] + face_rec_fps: ValueProxy[float] + alpr_speed: ValueProxy[float] + alpr_pps: ValueProxy[float] + yolov9_lpr_speed: ValueProxy[float] + yolov9_lpr_pps: ValueProxy[float] + review_desc_speed: ValueProxy[float] + review_desc_dps: ValueProxy[float] + object_desc_speed: ValueProxy[float] + object_desc_dps: ValueProxy[float] + classification_speeds: DictProxy[str, ValueProxy[float]] + classification_cps: DictProxy[str, ValueProxy[float]] def __init__(self, manager: SyncManager, custom_classification_models: list[str]): self.image_embeddings_speed = manager.Value("d", 0.0) @@ -52,7 +54,7 @@ class DataProcessorMetrics: class DataProcessorModelRunner: - def __init__(self, requestor, device: str = "CPU", model_size: str = "large"): + def __init__(self, requestor: Any, device: str = "CPU", model_size: str = "large"): self.requestor = requestor self.device = device self.model_size = model_size diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index aa4928e849..2d740f3736 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,18 +1,26 @@ -import re +import logging import sqlite3 +from typing import Any +import regex from playhouse.sqliteq import SqliteQueueDatabase +logger = logging.getLogger(__name__) + +REGEXP_TIMEOUT_SECONDS = 1.0 + class SqliteVecQueueDatabase(SqliteQueueDatabase): - def __init__(self, *args, load_vec_extension: bool = False, **kwargs) -> None: + def __init__( + self, *args: Any, load_vec_extension: bool = False, **kwargs: Any + ) -> None: self.load_vec_extension: bool = load_vec_extension # no extension necessary, sqlite will load correctly for each platform self.sqlite_vec_path = "/usr/local/lib/vec0" super().__init__(*args, **kwargs) - def _connect(self, *args, **kwargs) -> sqlite3.Connection: - conn: sqlite3.Connection = super()._connect(*args, **kwargs) + def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection: + conn: sqlite3.Connection = super()._connect(*args, **kwargs) # type: ignore[misc] if self.load_vec_extension: self._load_vec_extension(conn) @@ -23,27 +31,55 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): def _load_vec_extension(self, conn: sqlite3.Connection) -> None: conn.enable_load_extension(True) - conn.load_extension(self.sqlite_vec_path) - conn.enable_load_extension(False) + + try: + conn.load_extension(self.sqlite_vec_path) + except conn.OperationalError: + logger.error("Unable to load the sqlite-vec extension") + self.load_vec_extension = False + finally: + conn.enable_load_extension(False) def _register_regexp(self, conn: sqlite3.Connection) -> None: - def regexp(expr: str, item: str) -> bool: + def regexp(expr: str, item: str | None) -> bool: if item is None: return False try: - return re.search(expr, item) is not None - except re.error: + return ( + regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None + ) + except (regex.error, TimeoutError): return False conn.create_function("REGEXP", 2, regexp) - def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + def _delete_embeddings(self, table: str, event_ids: list[str]) -> None: + """Delete embeddings for the given events, if the table exists. + + Embeddings outlive the events they belong to when semantic search is + disabled, so deletes are attempted regardless of the current config. + """ + if not event_ids or not self.load_vec_extension: + return + + # the embeddings tables are only created once semantic search has run + cursor = self.execute_sql( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ) + + if cursor.fetchone() is None: + logger.debug("Skipping %s cleanup, table does not exist", table) + return + ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) + self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + + def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + self._delete_embeddings("vec_thumbnails", event_ids) def delete_embeddings_description(self, event_ids: list[str]) -> None: - ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids) + self._delete_embeddings("vec_descriptions", event_ids) def drop_embeddings_tables(self) -> None: self.execute_sql(""" diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py new file mode 100644 index 0000000000..f8e801c3f3 --- /dev/null +++ b/frigate/debug_replay.py @@ -0,0 +1,398 @@ +"""Debug replay camera management for replaying recordings with detection overlays. + +The startup work (ffmpeg concat + camera config publish) lives in +frigate.jobs.debug_replay. This module owns only session presence +(active), session metadata, and post-session cleanup. +""" + +import asyncio +import logging +import os +import shutil +import threading +import time + +from ruamel.yaml import YAML + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) +from frigate.const import ( + REPLAY_CAMERA_PREFIX, + REPLAY_DIR, + THUMB_DIR, +) +from frigate.jobs.debug_replay import ( + JOB_TYPE as DEBUG_REPLAY_JOB_TYPE, +) +from frigate.jobs.debug_replay import ( + cancel_debug_replay_job, + wait_for_runner, +) +from frigate.jobs.export import JobStatePublisher +from frigate.types import JobStatusTypesEnum +from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files +from frigate.util.config import find_config_file + +logger = logging.getLogger(__name__) + +MAX_SESSION_DURATION_SECONDS = 12 * 60 * 60 +AUTO_STOP_CHECK_INTERVAL_SECONDS = 60 + + +class DebugReplayManager: + """Owns the lifecycle pointers for a single debug replay session. + + A session exists from the moment mark_starting is called (synchronously, + inside the API handler) until clear_session runs (on success cleanup, + failure, or stop). The active property is the source of truth that the + status bar consumes — broader than the startup job, which only covers the + preparing_clip / starting_camera window. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self.replay_camera_name: str | None = None + self.source_camera: str | None = None + self.clip_path: str | None = None + self.start_ts: float | None = None + self.end_ts: float | None = None + self.session_started_at: float | None = None + self._job_state_publisher = JobStatePublisher() + + @property + def active(self) -> bool: + """True from mark_starting until clear_session.""" + return self.replay_camera_name is not None + + def mark_starting( + self, + source_camera: str, + replay_camera_name: str, + start_ts: float, + end_ts: float, + ) -> None: + """Synchronously claim the session before the job runner starts. + + Called inside the API handler so the status bar sees active=True + immediately, before the worker thread does any ffmpeg work. + """ + with self._lock: + self.replay_camera_name = replay_camera_name + self.source_camera = source_camera + self.start_ts = start_ts + self.end_ts = end_ts + self.clip_path = None + self.session_started_at = time.time() + + def mark_session_ready(self, clip_path: str) -> None: + """Record the on-disk clip path after the camera has been published.""" + with self._lock: + self.clip_path = clip_path + + def clear_session(self) -> None: + """Reset session pointers without publishing camera removal. + + Used by the job runner on failure paths. stop() does the camera + teardown plus this clear in one step. + """ + with self._lock: + self._clear_locked() + + def _clear_locked(self) -> None: + self.replay_camera_name = None + self.source_camera = None + self.clip_path = None + self.start_ts = None + self.end_ts = None + self.session_started_at = None + + def publish_camera( + self, + source_camera: str, + replay_name: str, + clip_path: str, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> None: + """Build the in-memory replay camera config and publish the add event. + + Called by the job runner during the starting_camera phase. + """ + source_config = frigate_config.cameras[source_camera] + camera_dict = self._build_camera_config_dict( + source_config, replay_name, clip_path + ) + + config_file = find_config_file() + yaml_parser = YAML() + with open(config_file) as f: + config_data = yaml_parser.load(f) + + if "cameras" not in config_data or config_data["cameras"] is None: + config_data["cameras"] = {} + config_data["cameras"][replay_name] = camera_dict + + try: + new_config = FrigateConfig.parse_object(config_data) + except Exception as e: + raise RuntimeError(f"Failed to validate replay camera config: {e}") from e + frigate_config.cameras[replay_name] = new_config.cameras[replay_name] + + config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name), + new_config.cameras[replay_name], + ) + + def stop( + self, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + ) -> None: + """Cancel any in-flight startup job and tear down the active session. + + Safe to call when no session is active (no-op with a warning). + """ + cancel_debug_replay_job() + wait_for_runner(timeout=2.0) + + with self._lock: + if not self.active: + logger.warning("No active replay session to stop") + return + + replay_name = self.replay_camera_name + source_camera = self.source_camera + + # Only publish remove if the camera was actually added to the live + # config (i.e. the runner reached the starting_camera phase). + if replay_name is not None and replay_name in frigate_config.cameras: + config_publisher.publish_update( + 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) + self._cleanup_files(replay_name) + + self._job_state_publisher.publish( + { + "id": "stopped", + "job_type": DEBUG_REPLAY_JOB_TYPE, + "status": JobStatusTypesEnum.cancelled, + "start_time": None, + "end_time": time.time(), + "error_message": None, + "results": { + "source_camera": source_camera, + "replay_camera_name": replay_name, + }, + } + ) + + self._clear_locked() + + logger.info("Debug replay stopped and cleaned up: %s", replay_name) + + def _build_camera_config_dict( + self, + source_config, + replay_name: str, + clip_path: str, + ) -> dict: + """Build a camera config dictionary for the replay camera.""" + # Extract detect config (exclude computed fields) + detect_dict = source_config.detect.model_dump( + exclude={"min_initialized", "max_disappeared", "enabled_in_config"} + ) + + # Extract objects config, using .dict() on filters to convert + # RuntimeFilterConfig ndarray masks back to string coordinates + objects_dict = { + "track": source_config.objects.track, + "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.objects.mask.items() + } + if source_config.objects.mask + else {}, + "filters": { + name: filt.dict() if hasattr(filt, "dict") else filt.model_dump() + for name, filt in source_config.objects.filters.items() + }, + } + + # Extract zones (exclude_defaults avoids serializing empty defaults + # like distances=[] that fail validation on re-parse) + zones_dict = {} + for zone_name, zone_config in source_config.zones.items(): + zone_dump = zone_config.model_dump( + exclude={"contour", "color"}, exclude_defaults=True + ) + 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: + motion_dict = source_config.motion.model_dump( + exclude={ + "frame_shape", + "raw_mask", + "mask", + "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": { + "inputs": [ + { + "path": clip_path, + "roles": ["detect"], + "input_args": "-re -stream_loop -1 -fflags +genpts", + } + ], + "hwaccel_args": [], + }, + "detect": detect_dict, + "objects": objects_dict, + "zones": zones_dict, + "motion": motion_dict, + "record": {"enabled": False}, + "snapshots": {"enabled": False}, + "review": { + "alerts": {"enabled": False}, + "detections": {"enabled": False}, + }, + "birdseye": {"enabled": False}, + "audio": {"enabled": False}, + "lpr": lpr_dict, + "face_recognition": face_recognition_dict, + } + + def _cleanup_db(self, camera_name: str) -> None: + """Defensively remove any database rows for the replay camera.""" + cleanup_camera_db(camera_name) + + def _cleanup_files(self, camera_name: str) -> None: + """Remove filesystem artifacts for the replay camera.""" + cleanup_camera_files(camera_name) + + # Remove replay-specific cache directory + if os.path.exists(REPLAY_DIR): + try: + shutil.rmtree(REPLAY_DIR) + logger.debug("Removed replay cache directory") + except Exception as e: + logger.error("Failed to remove replay cache: %s", e) + + +def cleanup_replay_cameras() -> None: + """Remove any stale replay camera artifacts on startup. + + Since replay cameras are memory-only and never written to YAML, they + won't appear in the config after a restart. This function cleans up + filesystem and database artifacts from any replay that was running when + the process stopped. + + Must be called AFTER the database is bound. + """ + stale_cameras: set[str] = set() + + # Derive stale camera names from THUMB_DIR (per-camera dirs) and + # REPLAY_DIR (the session's source clip); both listings are bounded by + # camera count. cleanup_camera_files below removes any remaining + # per-camera artifacts (snapshots, thumbnails, LPR images, etc.) by name. + if os.path.isdir(THUMB_DIR): + for entry in os.listdir(THUMB_DIR): + if entry.startswith(REPLAY_CAMERA_PREFIX): + stale_cameras.add(entry) + + if os.path.isdir(REPLAY_DIR): + for entry in os.listdir(REPLAY_DIR): + if entry.startswith(REPLAY_CAMERA_PREFIX) and entry.endswith(".mp4"): + stale_cameras.add(entry.removesuffix(".mp4")) + + if not stale_cameras: + return + + logger.info("Cleaning up stale replay camera artifacts: %s", list(stale_cameras)) + + manager = DebugReplayManager() + for camera_name in stale_cameras: + manager._cleanup_db(camera_name) + manager._cleanup_files(camera_name) + + if os.path.exists(REPLAY_DIR): + try: + shutil.rmtree(REPLAY_DIR) + except Exception as e: + logger.error("Failed to remove replay cache directory: %s", e) + + +async def debug_replay_auto_stop_watchdog( + manager: DebugReplayManager, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, +) -> None: + """Auto-stop debug replay sessions that exceed MAX_SESSION_DURATION_SECONDS. + + Backstop against a session left running for days. The cap is intentionally + generous so realistic tuning and overnight soak workflows aren't disrupted. + """ + while True: + try: + await asyncio.sleep(AUTO_STOP_CHECK_INTERVAL_SECONDS) + + started_at = manager.session_started_at + if not manager.active or started_at is None: + continue + + if time.time() - started_at < MAX_SESSION_DURATION_SECONDS: + continue + + replay_name = manager.replay_camera_name + await asyncio.to_thread( + manager.stop, + frigate_config=frigate_config, + config_publisher=config_publisher, + ) + logger.info( + "Debug replay auto-stopped after exceeding max session duration of %d hours: %s", + MAX_SESSION_DURATION_SECONDS // 3600, + replay_name, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error in debug replay auto-stop watchdog") diff --git a/frigate/detectors/detection_api.py b/frigate/detectors/detection_api.py index 4f03f28aa4..b03d607d5a 100644 --- a/frigate/detectors/detection_api.py +++ b/frigate/detectors/detection_api.py @@ -1,6 +1,5 @@ import logging from abc import ABC, abstractmethod -from typing import List import numpy as np @@ -11,7 +10,7 @@ logger = logging.getLogger(__name__) class DetectionApi(ABC): type_key: str - supported_models: List[ModelTypeEnum] + supported_models: list[ModelTypeEnum] @abstractmethod def __init__(self, detector_config: BaseDetectorConfig): diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py index 36bf24ce18..7cdd4a0256 100644 --- a/frigate/detectors/detection_runners.py +++ b/frigate/detectors/detection_runners.py @@ -15,6 +15,9 @@ from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible logger = logging.getLogger(__name__) +# Process-wide lock serializing all OpenVINO compile/inference calls +_OPENVINO_LOCK = threading.Lock() + def is_arm64_platform() -> bool: """Check if we're running on an ARM platform.""" @@ -22,25 +25,31 @@ def is_arm64_platform() -> bool: return machine in ("aarch64", "arm64", "armv8", "armv7l") -def get_ort_session_options( - is_complex_model: bool = False, -) -> ort.SessionOptions | None: +def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None: """Get ONNX Runtime session options with appropriate settings. Args: - is_complex_model: Whether the model needs basic optimization to avoid graph fusion issues. + model_type: Model being loaded, used to pin its graph optimization level. Returns: - SessionOptions with appropriate optimization level, or None for default settings. + SessionOptions with a pinned optimization level, or None for default settings. """ - if is_complex_model: - sess_options = ort.SessionOptions() - sess_options.graph_optimization_level = ( - ort.GraphOptimizationLevel.ORT_ENABLE_BASIC - ) - return sess_options + # Import here to avoid circular imports + from frigate.embeddings.types import EnrichmentModelTypeEnum - return None + if model_type == EnrichmentModelTypeEnum.jina_v2.value: + # below EXTENDED the CUDA EP returns an identical vector for every image, + # and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error + level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED + elif model_type == EnrichmentModelTypeEnum.jina_v1.value: + # aggressive optimizations create or expect nodes that don't exist + level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC + else: + return None + + sess_options = ort.SessionOptions() + sess_options.graph_optimization_level = level + return sess_options # Import OpenVINO only when needed to avoid circular dependencies @@ -79,7 +88,11 @@ def is_openvino_gpu_npu_available() -> bool: available_devices = get_openvino_available_devices() # Check for GPU, NPU, or other acceleration devices (excluding CPU) acceleration_devices = ["GPU", "MYRIAD", "NPU", "GNA", "HDDL"] - return any(device in available_devices for device in acceleration_devices) + return any( + avail_dev == accel_dev or avail_dev.startswith(accel_dev + ".") + for avail_dev in available_devices + for accel_dev in acceleration_devices + ) class BaseModelRunner(ABC): @@ -108,21 +121,6 @@ class BaseModelRunner(ABC): class ONNXModelRunner(BaseModelRunner): """Run ONNX models using ONNX Runtime.""" - @staticmethod - def is_cpu_complex_model(model_type: str) -> bool: - """Check if model needs basic optimization level to avoid graph fusion issues. - - Some models (like Jina-CLIP) have issues with aggressive optimizations like - SimplifiedLayerNormFusion that create or expect nodes that don't exist. - """ - # Import here to avoid circular imports - from frigate.embeddings.types import EnrichmentModelTypeEnum - - return model_type in [ - EnrichmentModelTypeEnum.jina_v1.value, - EnrichmentModelTypeEnum.jina_v2.value, - ] - @staticmethod def is_migraphx_complex_model(model_type: str) -> bool: # Import here to avoid circular imports @@ -131,10 +129,7 @@ class ONNXModelRunner(BaseModelRunner): return model_type in [ EnrichmentModelTypeEnum.paddleocr.value, - EnrichmentModelTypeEnum.yolov9_license_plate.value, - EnrichmentModelTypeEnum.jina_v1.value, EnrichmentModelTypeEnum.jina_v2.value, - EnrichmentModelTypeEnum.facenet.value, ModelTypeEnum.rfdetr.value, ModelTypeEnum.dfine.value, ] @@ -204,15 +199,20 @@ class CudaGraphRunner(BaseModelRunner): EnrichmentModelTypeEnum.yolov9_license_plate.value, ] + # ORT performs two regular runs before it starts capturing, but on some + # driver / cuDNN combinations the arena still has to extend on the run that + # captures, and cudaMalloc is not allowed during capture. Running with + # capture disabled first keeps those allocations outside of the capture. + GRAPH_FREE_WARMUP_RUNS = 2 + def __init__(self, session: ort.InferenceSession, cuda_device_id: int): self._session = session self._cuda_device_id = cuda_device_id - self._captured = False + self._prepared = False self._io_binding: ort.IOBinding | None = None self._input_name: str | None = None self._output_names: list[str] | None = None self._input_ortvalue: ort.OrtValue | None = None - self._output_ortvalues: ort.OrtValue | None = None def get_input_names(self) -> list[str]: """Get input names for the model.""" @@ -222,35 +222,41 @@ class CudaGraphRunner(BaseModelRunner): """Get the input width of the model.""" return self._session.get_inputs()[0].shape[3] + def _prepare(self, input_name: str, tensor_input: np.ndarray) -> None: + """Bind CUDA buffers and warm the session up with capture disabled.""" + self._io_binding = self._session.io_binding() + self._input_name = input_name + self._output_names = [o.name for o in self._session.get_outputs()] + + self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy( + tensor_input, "cuda", self._cuda_device_id + ) + self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue) + + for name in self._output_names: + # Bind outputs to CUDA and allow ORT to allocate appropriately + self._io_binding.bind_output(name, "cuda", self._cuda_device_id) + + # gpu_graph_id -1 disables capture and replay for the run + warmup_options = ort.RunOptions() + warmup_options.add_run_config_entry("gpu_graph_id", "-1") + + for _ in range(self.GRAPH_FREE_WARMUP_RUNS): + self._session.run_with_iobinding(self._io_binding, warmup_options) + + self._prepared = True + def run(self, input: dict[str, Any]): # Extract the single tensor input (assuming one input) input_name = list(input.keys())[0] - tensor_input = input[input_name] - tensor_input = np.ascontiguousarray(tensor_input) + tensor_input = np.ascontiguousarray(input[input_name]) - if not self._captured: - # Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device - self._io_binding = self._session.io_binding() - self._input_name = input_name - self._output_names = [o.name for o in self._session.get_outputs()] + if not self._prepared: + self._prepare(input_name, tensor_input) + else: + # Replay using updated input + self._input_ortvalue.update_inplace(tensor_input) - self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy( - tensor_input, "cuda", self._cuda_device_id - ) - self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue) - - for name in self._output_names: - # Bind outputs to CUDA and allow ORT to allocate appropriately - self._io_binding.bind_output(name, "cuda", self._cuda_device_id) - - # First IOBinding run to allocate, execute, and capture CUDA Graph - ro = ort.RunOptions() - self._session.run_with_iobinding(self._io_binding, ro) - self._captured = True - return self._io_binding.copy_outputs_to_cpu() - - # Replay using updated input, copy results to CPU - self._input_ortvalue.update_inplace(tensor_input) ro = ort.RunOptions() self._session.run_with_iobinding(self._io_binding, ro) return self._io_binding.copy_outputs_to_cpu() @@ -281,6 +287,13 @@ class OpenVINOModelRunner(BaseModelRunner): EnrichmentModelTypeEnum.arcface.value, ] + @staticmethod + def is_detection_model(model_type: str) -> bool: + # Import here to avoid circular imports + from frigate.detectors.detector_config import ModelTypeEnum + + return model_type in [m.value for m in ModelTypeEnum] + def __init__(self, model_path: str, device: str, model_type: str, **kwargs): self.model_path = model_path self.device = device @@ -309,22 +322,32 @@ class OpenVINOModelRunner(BaseModelRunner): # Apply performance optimization self.ov_core.set_property(device, {"PERF_COUNT": "NO"}) - if device in ["GPU", "AUTO"]: + if device in ["GPU", "AUTO", "NPU"]: self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"}) - # Compile model - self.compiled_model = self.ov_core.compile_model( - model=model_path, device_name=device - ) + if device in ["GPU", "AUTO"]: + try: + self.ov_core.set_property("GPU", {"GPU_QUEUE_THROTTLE": "LOW"}) + except Exception as e: + logger.debug(f"GPU_QUEUE_THROTTLE not supported: {e}") + + if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type): + try: + self.ov_core.set_property(device, {"NPU_TURBO": "YES"}) + except Exception as e: + logger.debug(f"NPU_TURBO not supported by driver: {e}") + + # Compile model under the shared lock + with _OPENVINO_LOCK: + self.compiled_model = self.ov_core.compile_model( + model=model_path, device_name=device + ) + + # Create reusable inference request + self.infer_request = self.compiled_model.create_infer_request() - # Create reusable inference request - self.infer_request = self.compiled_model.create_infer_request() self.input_tensor: ov.Tensor | None = None - # Thread lock to prevent concurrent inference (needed for JinaV2 which shares - # one runner between text and vision embeddings called from different threads) - self._inference_lock = threading.Lock() - if not self.complex_model: try: input_shape = self.compiled_model.inputs[0].get_shape() @@ -368,9 +391,11 @@ class OpenVINOModelRunner(BaseModelRunner): Returns: List of output tensors """ - # Lock prevents concurrent access to infer_request - # Needed for JinaV2: genai thread (text) + embeddings thread (vision) - with self._inference_lock: + # Shared lock serializes inference across every OpenVINO runner in this + # process — both the shared-runner JinaV2 case (genai text thread + + # embeddings vision thread) and distinct runners running on separate + # threads (e.g. the ArcFace face-model build vs the LPR detector). + with _OPENVINO_LOCK: from frigate.embeddings.types import EnrichmentModelTypeEnum if self.model_type in [EnrichmentModelTypeEnum.arcface.value]: @@ -474,7 +499,7 @@ class RKNNModelRunner(BaseModelRunner): except ImportError: logger.error("RKNN Lite not available") - raise ImportError("RKNN Lite not available") + raise ImportError("RKNN Lite not available") from None except Exception as e: logger.error(f"Error loading RKNN model: {e}") raise @@ -609,9 +634,7 @@ def get_optimized_runner( return ONNXModelRunner( ort.InferenceSession( model_path, - sess_options=get_ort_session_options( - ONNXModelRunner.is_cpu_complex_model(model_type) - ), + sess_options=get_ort_session_options(model_type), providers=providers, provider_options=options, ), diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index aa92f28f41..52d75ff8f7 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -3,7 +3,7 @@ import json import logging import os from enum import Enum -from typing import Any, Dict, Optional, Tuple +from typing import Any import requests from pydantic import BaseModel, ConfigDict, Field @@ -45,43 +45,68 @@ class ModelTypeEnum(str, Enum): class ModelConfig(BaseModel): - path: Optional[str] = Field(None, title="Custom Object detection model path.") - labelmap_path: Optional[str] = Field( - None, title="Label map for custom object detector." + path: str | None = Field( + None, + title="Custom object detector model path", + description="Path to a custom detection model file (or plus:// for Frigate+ models).", ) - width: int = Field(default=320, title="Object detection model input width.") - height: int = Field(default=320, title="Object detection model input height.") - labelmap: Dict[int, str] = Field( - default_factory=dict, title="Labelmap customization." + labelmap_path: str | None = Field( + None, + title="Label map for custom object detector", + description="Path to a labelmap file that maps numeric classes to string labels for the detector.", ) - attributes_map: Dict[str, list[str]] = Field( + width: int = Field( + default=320, + title="Object detection model input width", + description="Width of the model input tensor in pixels.", + ) + height: int = Field( + default=320, + title="Object detection model input height", + description="Height of the model input tensor in pixels.", + ) + labelmap: dict[int, str] = Field( + default_factory=dict, + title="Labelmap customization", + description="Overrides or remapping entries to merge into the standard labelmap.", + ) + attributes_map: dict[str, list[str]] = Field( default=DEFAULT_ATTRIBUTE_LABEL_MAP, - title="Map of object labels to their attribute labels.", + title="Map of object labels to their attribute labels", + description="Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", ) input_tensor: InputTensorEnum = Field( - default=InputTensorEnum.nhwc, title="Model Input Tensor Shape" + default=InputTensorEnum.nhwc, + title="Model Input Tensor Shape", + description="Tensor format expected by the model: 'nhwc' or 'nchw'.", ) input_pixel_format: PixelFormatEnum = Field( - default=PixelFormatEnum.rgb, title="Model Input Pixel Color Format" + default=PixelFormatEnum.rgb, + title="Model Input Pixel Color Format", + description="Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", ) input_dtype: InputDTypeEnum = Field( - default=InputDTypeEnum.int, title="Model Input D Type" + default=InputDTypeEnum.int, + title="Model Input D Type", + description="Data type of the model input tensor (for example 'float32').", ) model_type: ModelTypeEnum = Field( - default=ModelTypeEnum.ssd, title="Object Detection Model Type" + default=ModelTypeEnum.ssd, + title="Object Detection Model Type", + description="Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.", ) - _merged_labelmap: Optional[Dict[int, str]] = PrivateAttr() - _colormap: Dict[int, Tuple[int, int, int]] = PrivateAttr() + _merged_labelmap: dict[int, str] | None = PrivateAttr() + _colormap: dict[int, tuple[int, int, int]] = PrivateAttr() _all_attributes: list[str] = PrivateAttr() _all_attribute_logos: list[str] = PrivateAttr() _model_hash: str = PrivateAttr() @property - def merged_labelmap(self) -> Dict[int, str]: + def merged_labelmap(self) -> dict[int, str]: return self._merged_labelmap @property - def colormap(self) -> Dict[int, Tuple[int, int, int]]: + def colormap(self) -> dict[int, tuple[int, int, int]]: return self._colormap @property @@ -146,7 +171,7 @@ class ModelConfig(BaseModel): with open(model_info_path, "w") as f: json.dump(model_info, f) else: - with open(model_info_path, "r") as f: + with open(model_info_path) as f: model_info: dict[str, Any] = json.load(f) if detector and detector not in model_info["supportedDetectors"]: @@ -210,12 +235,20 @@ class ModelConfig(BaseModel): class BaseDetectorConfig(BaseModel): # the type field must be defined in all subclasses - type: str = Field(default="cpu", title="Detector Type") - model: Optional[ModelConfig] = Field( - default=None, title="Detector specific model configuration." + type: str = Field( + default="cpu", + title="Detector Type", + description="Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino').", ) - model_path: Optional[str] = Field( - default=None, title="Detector specific model path." + model: ModelConfig | None = Field( + default=None, + title="Detector specific model configuration", + description="Detector-specific model configuration options (path, input size, etc.).", + ) + model_path: str | None = Field( + default=None, + title="Detector specific model path", + description="File path to the detector model binary if required by the chosen detector.", ) model_config = ConfigDict( extra="allow", arbitrary_types_allowed=True, protected_namespaces=() diff --git a/frigate/detectors/detector_types.py b/frigate/detectors/detector_types.py index 418fcd625c..42129c5945 100644 --- a/frigate/detectors/detector_types.py +++ b/frigate/detectors/detector_types.py @@ -2,10 +2,9 @@ import importlib import logging import pkgutil from enum import Enum -from typing import Union +from typing import Annotated, Union from pydantic import Field -from typing_extensions import Annotated from . import plugins from .detection_api import DetectionApi @@ -37,6 +36,6 @@ class StrEnum(str, Enum): DetectorTypeEnum = StrEnum("DetectorTypeEnum", {k: k for k in api_types}) DetectorConfig = Annotated[ - Union[tuple(BaseDetectorConfig.__subclasses__())], + Union[tuple(BaseDetectorConfig.__subclasses__())], # noqa: UP007 Field(discriminator="type"), ] diff --git a/frigate/detectors/detector_utils.py b/frigate/detectors/detector_utils.py index d732de8717..d8930b2ae8 100644 --- a/frigate/detectors/detector_utils.py +++ b/frigate/detectors/detector_utils.py @@ -6,7 +6,7 @@ import numpy as np try: from tflite_runtime.interpreter import Interpreter, load_delegate except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter, load_delegate + from ai_edge_litert.interpreter import Interpreter, load_delegate logger = logging.getLogger(__name__) diff --git a/frigate/detectors/plugins/axengine.py b/frigate/detectors/plugins/axengine.py new file mode 100644 index 0000000000..43a1fffe4d --- /dev/null +++ b/frigate/detectors/plugins/axengine.py @@ -0,0 +1,97 @@ +import logging +import os.path +import re +import urllib.request +from typing import Literal + +from pydantic import ConfigDict + +from frigate.const import MODEL_CACHE_DIR +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum +from frigate.util.model import post_process_yolo + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "axengine" + +supported_models = { + ModelTypeEnum.yologeneric: "frigate-yolov9-.*$", +} + +model_cache_dir = os.path.join(MODEL_CACHE_DIR, "axengine_cache/") + + +class AxengineDetectorConfig(BaseDetectorConfig): + """AXERA AX650N/AX8850N NPU detector running compiled .axmodel files via the AXEngine runtime.""" + + model_config = ConfigDict( + title="AXEngine NPU", + ) + + type: Literal[DETECTOR_KEY] + + +class Axengine(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, config: AxengineDetectorConfig): + try: + import axengine as axe + except ModuleNotFoundError: + raise ImportError("AXEngine is not installed.") from None + + logger.info("__init__ axengine") + super().__init__(config) + self.height = config.model.height + self.width = config.model.width + model_path = config.model.path or "frigate-yolov9-tiny" + model_props = self.parse_model_input(model_path) + self.session = axe.InferenceSession(model_props["path"]) + + def __del__(self): + pass + + def parse_model_input(self, model_path): + model_props = {} + model_props["preset"] = True + + model_matched = False + + for model_type, pattern in supported_models.items(): + if re.match(pattern, model_path): + model_matched = True + model_props["model_type"] = model_type + + if model_matched: + model_props["filename"] = model_path + ".axmodel" + model_props["path"] = model_cache_dir + model_props["filename"] + + if not os.path.isfile(model_props["path"]): + self.download_model(model_props["filename"]) + else: + supported_models_str = ", ".join(model[1:-1] for model in supported_models) + raise Exception( + f"Model {model_path} is unsupported. Provide your own model or choose one of the following: {supported_models_str}" + ) + return model_props + + def download_model(self, filename): + if not os.path.isdir(model_cache_dir): + os.mkdir(model_cache_dir) + + HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co") + urllib.request.urlretrieve( + f"{HF_ENDPOINT}/AXERA-TECH/frigate-resource/resolve/axmodel/{filename}", + model_cache_dir + filename, + ) + + def detect_raw(self, tensor_input): + results = None + results = self.session.run(None, {"images": tensor_input}) + if self.detector_config.model.model_type == ModelTypeEnum.yologeneric: + return post_process_yolo(results, self.width, self.height) + else: + raise ValueError( + f'Model type "{self.detector_config.model.model_type}" is currently not supported.' + ) diff --git a/frigate/detectors/plugins/cpu_tfl.py b/frigate/detectors/plugins/cpu_tfl.py index 00351f5192..d8cfe33e0d 100644 --- a/frigate/detectors/plugins/cpu_tfl.py +++ b/frigate/detectors/plugins/cpu_tfl.py @@ -1,7 +1,7 @@ import logging +from typing import Literal -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -12,7 +12,7 @@ from ..detector_utils import tflite_detect_raw, tflite_init try: from tflite_runtime.interpreter import Interpreter except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter + from ai_edge_litert.interpreter import Interpreter logger = logging.getLogger(__name__) @@ -21,8 +21,18 @@ DETECTOR_KEY = "cpu" class CpuDetectorConfig(BaseDetectorConfig): + """CPU TFLite detector that runs TensorFlow Lite models on the host CPU without hardware acceleration. Not recommended.""" + + model_config = ConfigDict( + title="CPU", + ) + type: Literal[DETECTOR_KEY] - num_threads: int = Field(default=3, title="Number of detection threads") + num_threads: int = Field( + default=3, + title="Number of detection threads", + description="The number of threads used for CPU-based inference.", + ) class CpuTfl(DetectionApi): diff --git a/frigate/detectors/plugins/deepstack.py b/frigate/detectors/plugins/deepstack.py index e00a4e70d2..e87b07c444 100644 --- a/frigate/detectors/plugins/deepstack.py +++ b/frigate/detectors/plugins/deepstack.py @@ -1,11 +1,11 @@ import io import logging +from typing import Literal import numpy as np import requests from PIL import Image -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -16,12 +16,28 @@ DETECTOR_KEY = "deepstack" class DeepstackDetectorConfig(BaseDetectorConfig): + """DeepStack/CodeProject.AI detector that sends images to a remote DeepStack HTTP API for inference. Not recommended.""" + + model_config = ConfigDict( + title="DeepStack", + ) + type: Literal[DETECTOR_KEY] api_url: str = Field( - default="http://localhost:80/v1/vision/detection", title="DeepStack API URL" + default="http://localhost:80/v1/vision/detection", + title="DeepStack API URL", + description="The URL of the DeepStack API.", + ) + api_timeout: float = Field( + default=0.1, + title="DeepStack API timeout (in seconds)", + description="Maximum time allowed for a DeepStack API request.", + ) + api_key: str = Field( + default="", + title="DeepStack API key (if required)", + description="Optional API key for authenticated DeepStack services.", ) - api_timeout: float = Field(default=0.1, title="DeepStack API timeout (in seconds)") - api_key: str = Field(default="", title="DeepStack API key (if required)") class DeepStack(DetectionApi): diff --git a/frigate/detectors/plugins/degirum.py b/frigate/detectors/plugins/degirum.py deleted file mode 100644 index 28a13389f9..0000000000 --- a/frigate/detectors/plugins/degirum.py +++ /dev/null @@ -1,139 +0,0 @@ -import logging -import queue - -import numpy as np -from pydantic import Field -from typing_extensions import Literal - -from frigate.detectors.detection_api import DetectionApi -from frigate.detectors.detector_config import BaseDetectorConfig - -logger = logging.getLogger(__name__) -DETECTOR_KEY = "degirum" - - -### DETECTOR CONFIG ### -class DGDetectorConfig(BaseDetectorConfig): - type: Literal[DETECTOR_KEY] - location: str = Field(default=None, title="Inference Location") - zoo: str = Field(default=None, title="Model Zoo") - token: str = Field(default=None, title="DeGirum Cloud Token") - - -### ACTUAL DETECTOR ### -class DGDetector(DetectionApi): - type_key = DETECTOR_KEY - - def __init__(self, detector_config: DGDetectorConfig): - try: - import degirum as dg - except ModuleNotFoundError: - raise ImportError("Unable to import DeGirum detector.") - - self._queue = queue.Queue() - self._zoo = dg.connect( - detector_config.location, detector_config.zoo, detector_config.token - ) - - logger.debug(f"Models in zoo: {self._zoo.list_models()}") - - self.dg_model = self._zoo.load_model( - detector_config.model.path, - ) - - # Setting input image format to raw reduces preprocessing time - self.dg_model.input_image_format = "RAW" - - # Prioritize the most powerful hardware available - self.select_best_device_type() - # Frigate handles pre processing as long as these are all set - input_shape = self.dg_model.input_shape[0] - self.model_height = input_shape[1] - self.model_width = input_shape[2] - - # Passing in dummy frame so initial connection latency happens in - # init function and not during actual prediction - frame = np.zeros( - (detector_config.model.width, detector_config.model.height, 3), - dtype=np.uint8, - ) - # Pass in frame to overcome first frame latency - self.dg_model(frame) - self.prediction = self.prediction_generator() - - def select_best_device_type(self): - """ - Helper function that selects fastest hardware available per model runtime - """ - types = self.dg_model.supported_device_types - - device_map = { - "OPENVINO": ["GPU", "NPU", "CPU"], - "HAILORT": ["HAILO8L", "HAILO8"], - "N2X": ["ORCA1", "CPU"], - "ONNX": ["VITIS_NPU", "CPU"], - "RKNN": ["RK3566", "RK3568", "RK3588"], - "TENSORRT": ["DLA", "GPU", "DLA_ONLY"], - "TFLITE": ["ARMNN", "EDGETPU", "CPU"], - } - - runtime = types[0].split("/")[0] - # Just create an array of format {runtime}/{hardware} for every hardware - # in the value for appropriate key in device_map - self.dg_model.device_type = [ - f"{runtime}/{hardware}" for hardware in device_map[runtime] - ] - - def prediction_generator(self): - """ - Generator for all incoming frames. By using this generator, we don't have to keep - reconnecting our websocket on every "predict" call. - """ - logger.debug("Prediction generator was called") - with self.dg_model as model: - while 1: - logger.info(f"q size before calling get: {self._queue.qsize()}") - data = self._queue.get(block=True) - logger.info(f"q size after calling get: {self._queue.qsize()}") - logger.debug( - f"Data we're passing into model predict: {data}, shape of data: {data.shape}" - ) - result = model.predict(data) - logger.debug(f"Prediction result: {result}") - yield result - - def detect_raw(self, tensor_input): - # Reshaping tensor to work with pysdk - truncated_input = tensor_input.reshape(tensor_input.shape[1:]) - logger.debug(f"Detect raw was called for tensor input: {tensor_input}") - - # add tensor_input to input queue - self._queue.put(truncated_input) - logger.debug(f"Queue size after adding truncated input: {self._queue.qsize()}") - - # define empty detection result - detections = np.zeros((20, 6), np.float32) - # grab prediction - res = next(self.prediction) - - # If we have an empty prediction, return immediately - if len(res.results) == 0 or len(res.results[0]) == 0: - return detections - - i = 0 - for result in res.results: - if i >= 20: - break - - detections[i] = [ - result["category_id"], - float(result["score"]), - result["bbox"][1] / self.model_height, - result["bbox"][0] / self.model_width, - result["bbox"][3] / self.model_height, - result["bbox"][2] / self.model_width, - ] - i += 1 - - logger.debug(f"Detections output: {detections}") - return detections diff --git a/frigate/detectors/plugins/edgetpu_tfl.py b/frigate/detectors/plugins/edgetpu_tfl.py index 2b94fde397..96681abb1a 100644 --- a/frigate/detectors/plugins/edgetpu_tfl.py +++ b/frigate/detectors/plugins/edgetpu_tfl.py @@ -1,19 +1,20 @@ import logging import math import os +from typing import Literal import cv2 import numpy as np -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum +from frigate.util.model import xyxy_to_xywh_for_nms try: from tflite_runtime.interpreter import Interpreter, load_delegate except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter, load_delegate + from ai_edge_litert.interpreter import Interpreter, load_delegate logger = logging.getLogger(__name__) @@ -21,8 +22,18 @@ DETECTOR_KEY = "edgetpu" class EdgeTpuDetectorConfig(BaseDetectorConfig): + """EdgeTPU detector that runs TensorFlow Lite models compiled for Coral EdgeTPU using the EdgeTPU delegate.""" + + model_config = ConfigDict( + title="EdgeTPU", + ) + type: Literal[DETECTOR_KEY] - device: str = Field(default=None, title="Device Type") + device: str = Field( + default=None, + title="Device Type", + description="The device to use for EdgeTPU inference (e.g. 'usb', 'pci').", + ) class EdgeTpuTfl(DetectionApi): @@ -287,7 +298,7 @@ class EdgeTpuTfl(DetectionApi): # until after filtering out redundant boxes # Shift the logit scores to be non-negative (required by cv2) indices = cv2.dnn.NMSBoxes( - bboxes=boxes_filtered_decoded, + bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded), scores=max_scores_filtered_shiftedpositive, score_threshold=( self.min_logit_value + self.logit_shift_to_positive_values diff --git a/frigate/detectors/plugins/hailo8l.py b/frigate/detectors/plugins/hailo8l.py index cafc809c97..63a3c9a2a8 100755 --- a/frigate/detectors/plugins/hailo8l.py +++ b/frigate/detectors/plugins/hailo8l.py @@ -4,12 +4,11 @@ import subprocess import threading import urllib.request from functools import partial -from typing import Dict, List, Optional, Tuple +from typing import Literal import cv2 import numpy as np -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi @@ -83,8 +82,8 @@ class HailoAsyncInference: input_store: RequestStore, output_store: ResponseStore, batch_size: int = 1, - input_type: Optional[str] = None, - output_type: Optional[Dict[str, str]] = None, + input_type: str | None = None, + output_type: dict[str, str] | None = None, send_original_frame: bool = False, ) -> None: # when importing hailo it activates the driver @@ -125,9 +124,9 @@ class HailoAsyncInference: def callback( self, completion_info, - bindings_list: List, - input_batch: List, - request_ids: List[int], + bindings_list: list, + input_batch: list, + request_ids: list[int], ): if completion_info.exception: logger.error(f"Inference error: {completion_info.exception}") @@ -163,7 +162,7 @@ class HailoAsyncInference: } return configured_infer_model.create_bindings(output_buffers=output_buffers) - def get_input_shape(self) -> Tuple[int, ...]: + def get_input_shape(self) -> tuple[int, ...]: return self.hef.get_input_vstream_infos()[0].shape def run(self) -> None: @@ -304,7 +303,7 @@ class HailoDetector(DetectionApi): urllib.request.urlretrieve(url, destination) logger.debug(f"Downloaded model to {destination}") except Exception as e: - raise RuntimeError(f"Failed to download model from {url}: {str(e)}") + raise RuntimeError(f"Failed to download model from {url}: {str(e)}") from e def check_and_prepare(self) -> str: if not os.path.exists(self.cache_dir): @@ -350,7 +349,7 @@ class HailoDetector(DetectionApi): if not self.inference_thread.is_alive(): raise RuntimeError( "HailoRT inference thread has stopped, restart required." - ) + ) from None return np.zeros((20, 6), dtype=np.float32) @@ -410,5 +409,15 @@ class HailoDetector(DetectionApi): # ----------------- HailoDetectorConfig Class ----------------- # class HailoDetectorConfig(BaseDetectorConfig): + """Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware.""" + + model_config = ConfigDict( + title="Hailo-8/Hailo-8L", + ) + type: Literal[DETECTOR_KEY] - device: str = Field(default="PCIe", title="Device Type") + device: str = Field( + default="PCIe", + title="Device Type", + description="The device to use for Hailo inference (e.g. 'PCIe', 'M.2').", + ) diff --git a/frigate/detectors/plugins/memryx.py b/frigate/detectors/plugins/memryx.py index a93888f8a2..9e40fe5653 100644 --- a/frigate/detectors/plugins/memryx.py +++ b/frigate/detectors/plugins/memryx.py @@ -5,11 +5,11 @@ import shutil import urllib.request import zipfile from queue import Queue +from typing import Literal import cv2 import numpy as np -from pydantic import BaseModel, Field -from typing_extensions import Literal +from pydantic import BaseModel, ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import ( @@ -17,6 +17,7 @@ from frigate.detectors.detector_config import ( ModelTypeEnum, ) from frigate.util.file import FileLock +from frigate.util.model import xyxy_to_xywh_for_nms logger = logging.getLogger(__name__) @@ -30,8 +31,18 @@ class ModelConfig(BaseModel): class MemryXDetectorConfig(BaseDetectorConfig): + """MemryX MX3 detector that runs compiled DFP models on MemryX accelerators.""" + + model_config = ConfigDict( + title="MemryX", + ) + type: Literal[DETECTOR_KEY] - device: str = Field(default="PCIe", title="Device Path") + device: str = Field( + default="PCIe", + title="Device Path", + description="The device to use for MemryX inference (e.g. 'PCIe').", + ) class MemryXDetector(DetectionApi): @@ -51,7 +62,7 @@ class MemryXDetector(DetectionApi): except ModuleNotFoundError: raise ImportError( "MemryX SDK is not installed. Install it and set up MIX environment." - ) + ) from None return # Initialize stop_event as None, will be set later by set_stop_event() @@ -307,7 +318,7 @@ class MemryXDetector(DetectionApi): f"Failed to remove downloaded zip {zip_path}: {e}" ) - def send_input(self, connection_id, tensor_input: np.ndarray): + def send_input(self, connection_id, tensor_input: np.ndarray) -> None: """Pre-process (if needed) and send frame to MemryX input queue""" if tensor_input is None: raise ValueError("[send_input] No image data provided for inference") @@ -571,7 +582,7 @@ class MemryXDetector(DetectionApi): # Convert coordinates to integers x_min, y_min, x_max, y_max = map(int, [x_min, y_min, x_max, y_max]) - # Append valid detections [class_id, confidence, x, y, width, height] + # Append valid detections [class_id, confidence, x_min, y_min, x_max, y_max] detections.append([class_id, confidence, x_min, y_min, x_max, y_max]) final_detections = np.zeros((20, 6), np.float32) @@ -585,7 +596,7 @@ class MemryXDetector(DetectionApi): detections = np.array(detections, dtype=np.float32) # Apply Non-Maximum Suppression (NMS) - bboxes = detections[:, 2:6].tolist() # (x_min, y_min, width, height) + bboxes = xyxy_to_xywh_for_nms(detections[:, 2:6]) scores = detections[:, 1].tolist() # Confidence scores indices = cv2.dnn.NMSBoxes(bboxes, scores, 0.45, 0.5) diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index 6c9e510cef..cbf189916b 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -1,13 +1,15 @@ import logging +from typing import Literal import numpy as np -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detection_runners import get_optimized_runner from frigate.detectors.detector_config import ( BaseDetectorConfig, + InputDTypeEnum, + InputTensorEnum, ModelTypeEnum, ) from frigate.util.model import ( @@ -23,8 +25,18 @@ DETECTOR_KEY = "onnx" class ONNXDetectorConfig(BaseDetectorConfig): + """ONNX detector for running ONNX models; will use available acceleration backends (CUDA/ROCm/OpenVINO) when available.""" + + model_config = ConfigDict( + title="ONNX", + ) + type: Literal[DETECTOR_KEY] - device: str = Field(default="AUTO", title="Device Type") + device: str = Field( + default="AUTO", + title="Device Type", + description="The device to use for ONNX inference (e.g. 'AUTO', 'CPU', 'GPU').", + ) class ONNXDetector(DetectionApi): @@ -49,8 +61,34 @@ class ONNXDetector(DetectionApi): if self.onnx_model_type == ModelTypeEnum.yolox: self.calculate_grids_strides() + self._warmup(detector_config) logger.info(f"ONNX: {path} loaded") + def _warmup(self, detector_config: ONNXDetectorConfig) -> None: + """Run a warmup inference to front-load one-time compilation costs. + + Some GPU backends have a slow first inference: CUDA may need PTX JIT + compilation on newer architectures (e.g. NVIDIA 50-series / Blackwell), + and MIGraphX compiles the model graph on first run. Running it here + (during detector creation) keeps the watchdog start_time at 0.0 so the + process won't be killed. + """ + if detector_config.model.input_tensor == InputTensorEnum.nchw: + shape = (1, 3, detector_config.model.height, detector_config.model.width) + else: + shape = (1, detector_config.model.height, detector_config.model.width, 3) + + if detector_config.model.input_dtype in ( + InputDTypeEnum.float, + InputDTypeEnum.float_denorm, + ): + dtype = np.float32 + else: + dtype = np.uint8 + + logger.info("ONNX: warming up detector (may take a while on first run)...") + self.detect_raw(np.zeros(shape, dtype=dtype)) + def detect_raw(self, tensor_input: np.ndarray): if self.onnx_model_type == ModelTypeEnum.dfine: tensor_output = self.runner.run( diff --git a/frigate/detectors/plugins/openvino.py b/frigate/detectors/plugins/openvino.py index bda5c8871c..50f040dbac 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -1,9 +1,9 @@ import logging +from typing import Literal import numpy as np import openvino as ov -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detection_runners import OpenVINOModelRunner @@ -20,8 +20,18 @@ DETECTOR_KEY = "openvino" class OvDetectorConfig(BaseDetectorConfig): + """OpenVINO detector for AMD and Intel CPUs, Intel GPUs and Intel VPU hardware.""" + + model_config = ConfigDict( + title="OpenVINO", + ) + type: Literal[DETECTOR_KEY] - device: str = Field(default=None, title="Device Type") + device: str = Field( + default=None, + title="Device Type", + description="The device to use for OpenVINO inference (e.g. 'CPU', 'GPU', 'NPU').", + ) class OvDetector(DetectionApi): @@ -42,6 +52,12 @@ class OvDetector(DetectionApi): self.h = detector_config.model.height self.w = detector_config.model.width + logger.info( + "Loading OpenVINO model %s on device %s", + detector_config.model.path, + detector_config.device, + ) + self.runner = OpenVINOModelRunner( model_path=detector_config.model.path, device=detector_config.device, @@ -210,12 +226,12 @@ class OvDetector(DetectionApi): conf_mask = (image_pred[:, 4] * class_conf.squeeze() >= 0.3).squeeze() # Detections ordered as (x1, y1, x2, y2, obj_conf, class_conf, class_pred) - detections = np.concatenate( + predictions = np.concatenate( (image_pred[:, :5], class_conf, class_pred), axis=1 ) - detections = detections[conf_mask] + predictions = predictions[conf_mask] - ordered = detections[detections[:, 5].argsort()[::-1]][:20] + ordered = predictions[predictions[:, 5].argsort()[::-1]][:20] for i, object_detected in enumerate(ordered): detections[i] = self.process_yolo( diff --git a/frigate/detectors/plugins/rknn.py b/frigate/detectors/plugins/rknn.py index c16df507ec..f87344463c 100644 --- a/frigate/detectors/plugins/rknn.py +++ b/frigate/detectors/plugins/rknn.py @@ -6,13 +6,13 @@ from typing import Literal import cv2 import numpy as np -from pydantic import Field +from pydantic import ConfigDict, Field from frigate.const import MODEL_CACHE_DIR, SUPPORTED_RK_SOCS from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detection_runners import RKNNModelRunner from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum -from frigate.util.model import post_process_yolo +from frigate.util.model import post_process_yolo, xyxy_to_xywh_for_nms from frigate.util.rknn_converter import auto_convert_model logger = logging.getLogger(__name__) @@ -29,8 +29,20 @@ model_cache_dir = os.path.join(MODEL_CACHE_DIR, "rknn_cache/") class RknnDetectorConfig(BaseDetectorConfig): + """RKNN detector for Rockchip NPUs; runs compiled RKNN models on Rockchip hardware.""" + + model_config = ConfigDict( + title="RKNN", + ) + type: Literal[DETECTOR_KEY] - num_cores: int = Field(default=0, ge=0, le=3, title="Number of NPU cores to use.") + num_cores: int = Field( + default=0, + ge=0, + le=3, + title="Number of NPU cores to use.", + description="The number of NPU cores to use (0 for auto).", + ) class Rknn(DetectionApi): @@ -78,7 +90,7 @@ class Rknn(DetectionApi): with open("/proc/device-tree/compatible") as file: soc = file.read().split(",")[-1].strip("\x00") except FileNotFoundError: - raise Exception("Make sure to run docker in privileged mode.") + raise Exception("Make sure to run docker in privileged mode.") from None if soc not in SUPPORTED_RK_SOCS: raise Exception( @@ -273,7 +285,7 @@ class Rknn(DetectionApi): # run nms indices = cv2.dnn.NMSBoxes( - bboxes=boxes, + bboxes=xyxy_to_xywh_for_nms(boxes), scores=scores, score_threshold=0.4, nms_threshold=0.4, diff --git a/frigate/detectors/plugins/synaptics.py b/frigate/detectors/plugins/synaptics.py index 6181b16d70..6d0ac7f521 100644 --- a/frigate/detectors/plugins/synaptics.py +++ b/frigate/detectors/plugins/synaptics.py @@ -1,8 +1,9 @@ import logging import os +from typing import Literal import numpy as np -from typing_extensions import Literal +from pydantic import ConfigDict from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import ( @@ -27,6 +28,12 @@ DETECTOR_KEY = "synaptics" class SynapDetectorConfig(BaseDetectorConfig): + """Synaptics NPU detector for models in .synap format using the Synap SDK on Synaptics hardware.""" + + model_config = ConfigDict( + title="Synaptics", + ) + type: Literal[DETECTOR_KEY] diff --git a/frigate/detectors/plugins/teflon_tfl.py b/frigate/detectors/plugins/teflon_tfl.py index 7e29d66306..7dd44e6e89 100644 --- a/frigate/detectors/plugins/teflon_tfl.py +++ b/frigate/detectors/plugins/teflon_tfl.py @@ -1,6 +1,7 @@ import logging +from typing import Literal -from typing_extensions import Literal +from pydantic import ConfigDict from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -18,6 +19,12 @@ DETECTOR_KEY = "teflon_tfl" class TeflonDetectorConfig(BaseDetectorConfig): + """Teflon delegate detector for TFLite using Mesa Teflon delegate library to accelerate inference on supported GPUs.""" + + model_config = ConfigDict( + title="Teflon", + ) + type: Literal[DETECTOR_KEY] diff --git a/frigate/detectors/plugins/tensorrt.py b/frigate/detectors/plugins/tensorrt.py index bf0eb6fa8e..6b39212804 100644 --- a/frigate/detectors/plugins/tensorrt.py +++ b/frigate/detectors/plugins/tensorrt.py @@ -14,8 +14,9 @@ try: except ModuleNotFoundError: TRT_SUPPORT = False -from pydantic import Field -from typing_extensions import Literal +from typing import Literal + +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -46,11 +47,19 @@ if TRT_SUPPORT: class TensorRTDetectorConfig(BaseDetectorConfig): + """TensorRT detector for Nvidia Jetson devices using serialized TensorRT engines for accelerated inference.""" + + model_config = ConfigDict( + title="TensorRT", + ) + type: Literal[DETECTOR_KEY] - device: int = Field(default=0, title="GPU Device Index") + device: int = Field( + default=0, title="GPU Device Index", description="The GPU device index to use." + ) -class HostDeviceMem(object): +class HostDeviceMem: """Simple helper data class that's a little nicer to use than a 2-tuple.""" def __init__(self, host_mem, device_mem, nbytes, size): diff --git a/frigate/detectors/plugins/zmq_ipc.py b/frigate/detectors/plugins/zmq_ipc.py index cd397aefa9..cc9a538c81 100644 --- a/frigate/detectors/plugins/zmq_ipc.py +++ b/frigate/detectors/plugins/zmq_ipc.py @@ -1,12 +1,11 @@ import json import logging import os -from typing import Any, List +from typing import Any, Literal import numpy as np import zmq -from pydantic import Field -from typing_extensions import Literal +from pydantic import ConfigDict, Field from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -17,14 +16,28 @@ DETECTOR_KEY = "zmq" class ZmqDetectorConfig(BaseDetectorConfig): + """ZMQ IPC detector that offloads inference to an external process via a ZeroMQ IPC endpoint.""" + + model_config = ConfigDict( + title="ZMQ IPC", + ) + type: Literal[DETECTOR_KEY] endpoint: str = Field( - default="ipc:///tmp/cache/zmq_detector", title="ZMQ IPC endpoint" + default="ipc:///tmp/cache/zmq_detector", + title="ZMQ IPC endpoint", + description="The ZMQ endpoint to connect to.", ) request_timeout_ms: int = Field( - default=200, title="ZMQ request timeout in milliseconds" + default=200, + title="ZMQ request timeout in milliseconds", + description="Timeout for ZMQ requests in milliseconds.", + ) + linger_ms: int = Field( + default=0, + title="ZMQ socket linger in milliseconds", + description="Socket linger period in milliseconds.", ) - linger_ms: int = Field(default=0, title="ZMQ socket linger in milliseconds") class ZmqIpcDetector(DetectionApi): @@ -260,7 +273,7 @@ class ZmqIpcDetector(DetectionApi): } return json.dumps(header).encode("utf-8") - def _decode_response(self, frames: List[bytes]) -> np.ndarray: + def _decode_response(self, frames: list[bytes]) -> np.ndarray: try: if len(frames) == 1: # Single-frame raw float32 (20x6) diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 0a854fcfad..d9b45e7c65 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -4,10 +4,11 @@ import base64 import json import logging import os +import sys import threading from json.decoder import JSONDecodeError from multiprocessing.synchronize import Event as MpEvent -from typing import Any, Union +from typing import Any import regex from pathvalidate import ValidationError, sanitize_filename @@ -20,6 +21,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.models import Event from frigate.util.builtin import serialize from frigate.util.classification import kickoff_model_training +from frigate.util.path import safe_join from frigate.util.process import FrigateProcess from .maintainer import EmbeddingMaintainer @@ -32,7 +34,7 @@ class EmbeddingProcess(FrigateProcess): def __init__( self, config: FrigateConfig, - metrics: DataProcessorMetrics | None, + metrics: DataProcessorMetrics, stop_event: MpEvent, ) -> None: super().__init__( @@ -52,6 +54,14 @@ class EmbeddingProcess(FrigateProcess): self.stop_event, ) maintainer.start() + maintainer.join() + + # If the maintainer thread exited but no shutdown was requested, it + # crashed. Surface as a non-zero exit so the watchdog restarts us + # instead of treating the silent thread death as a clean shutdown. + if not self.stop_event.is_set(): + logger.error("Embeddings maintainer thread exited unexpectedly") + sys.exit(1) class EmbeddingsContext: @@ -64,7 +74,7 @@ class EmbeddingsContext: # load stats from disk stats_file = os.path.join(CONFIG_DIR, ".search_stats.json") try: - with open(stats_file, "r") as f: + with open(stats_file) as f: data = json.loads(f.read()) self.thumb_stats.from_dict(data["thumb_stats"]) self.desc_stats.from_dict(data["desc_stats"]) @@ -89,7 +99,7 @@ class EmbeddingsContext: self.requestor.stop() def search_thumbnail( - self, query: Union[Event, str], event_ids: list[str] = None + self, query: Event | str, event_ids: list[str] = None ) -> list[tuple[str, float]]: if query.__class__ == Event: cursor = self.db.execute_sql( @@ -205,14 +215,14 @@ class EmbeddingsContext: ) def get_face_ids(self, name: str) -> list[str]: - sql_query = f""" + sql_query = """ SELECT id FROM vec_descriptions - WHERE id LIKE '%{name}%' + WHERE id LIKE ? """ - return self.db.execute_sql(sql_query).fetchall() + return self.db.execute_sql(sql_query, (f"%{name}%",)).fetchall() def reprocess_face(self, face_file: str) -> dict[str, Any]: return self.requestor.send_data( @@ -225,11 +235,16 @@ class EmbeddingsContext: ) def delete_face_ids(self, face: str, ids: list[str]) -> None: - folder = os.path.join(FACE_DIR, face) - for id in ids: - file_path = os.path.join(folder, id) + folder = safe_join(FACE_DIR, face) - if os.path.isfile(file_path): + if folder is None: + logger.warning("Not deleting faces for invalid name %s", face) + return + + for id in ids: + file_path = safe_join(folder, id) + + if file_path and os.path.isfile(file_path): os.unlink(file_path) if face != "train" and len(os.listdir(folder)) == 0: @@ -246,7 +261,7 @@ class EmbeddingsContext: sanitized_old_name = sanitize_filename(old_name, replacement_text="_") sanitized_new_name = sanitize_filename(new_name, replacement_text="_") except ValidationError as e: - raise ValueError(f"Invalid face name: {str(e)}") + raise ValueError(f"Invalid face name: {str(e)}") from e if not regex.match(valid_name_pattern, old_name): raise ValueError(f"Invalid old face name: {old_name}") diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index 8d7bcd235e..91144c3fa5 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -28,6 +28,7 @@ from frigate.types import ModelStatusTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize from frigate.util.file import get_event_thumbnail_bytes +from .genai_embedding import GenAIEmbedding from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding from .onnx.jina_v2_embedding import JinaV2Embedding @@ -73,6 +74,7 @@ class Embeddings: config: FrigateConfig, db: SqliteVecQueueDatabase, metrics: DataProcessorMetrics, + genai_manager=None, ) -> None: self.config = config self.db = db @@ -104,7 +106,27 @@ class Embeddings: }, ) - if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + model_cfg = self.config.semantic_search.model + + if not isinstance(model_cfg, SemanticSearchModelEnum): + # GenAI provider + embeddings_client = ( + genai_manager.embeddings_client if genai_manager else None + ) + if not embeddings_client: + raise ValueError( + f"semantic_search.model is '{model_cfg}' (GenAI provider) but " + "no embeddings client is configured. Ensure the GenAI provider " + "has 'embeddings' in its roles." + ) + self.embedding = GenAIEmbedding(embeddings_client) + self.text_embedding = lambda input_data: self.embedding( + input_data, embedding_type="text" + ) + self.vision_embedding = lambda input_data: self.embedding( + input_data, embedding_type="vision" + ) + elif model_cfg == SemanticSearchModelEnum.jinav2: # Single JinaV2Embedding instance for both text and vision self.embedding = JinaV2Embedding( model_size=self.config.semantic_search.model_size, @@ -118,7 +140,8 @@ class Embeddings: self.vision_embedding = lambda input_data: self.embedding( input_data, embedding_type="vision" ) - else: # Default to jinav1 + else: + # Default to jinav1 self.text_embedding = JinaV1TextEmbedding( model_size=config.semantic_search.model_size, requestor=self.requestor, @@ -136,8 +159,11 @@ class Embeddings: self.metrics.text_embeddings_eps.value = self.text_eps.eps() def get_model_definitions(self): - # Version-specific models - if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + model_cfg = self.config.semantic_search.model + if not isinstance(model_cfg, SemanticSearchModelEnum): + # GenAI provider: no ONNX models to download + models = [] + elif model_cfg == SemanticSearchModelEnum.jinav2: models = [ "jinaai/jina-clip-v2-tokenizer", "jinaai/jina-clip-v2-model_fp16.onnx" @@ -240,7 +266,7 @@ class Embeddings: ) duration = datetime.datetime.now().timestamp() - start - self.text_inference_speed.update(duration / len(valid_ids)) + self.image_inference_speed.update(duration / len(valid_ids)) return embeddings @@ -312,11 +338,12 @@ class Embeddings: # Get total count of events to process total_events = Event.select().count() - batch_size = ( - 4 - if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2 - else 32 - ) + if not isinstance(self.config.semantic_search.model, SemanticSearchModelEnum): + batch_size = 1 + elif self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + batch_size = 4 + else: + batch_size = 32 current_page = 1 totals = { diff --git a/frigate/embeddings/genai_embedding.py b/frigate/embeddings/genai_embedding.py new file mode 100644 index 0000000000..d3637bb73b --- /dev/null +++ b/frigate/embeddings/genai_embedding.py @@ -0,0 +1,89 @@ +"""GenAI-backed embeddings for semantic search.""" + +import io +import logging +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +if TYPE_CHECKING: + from frigate.genai import GenAIClient + +logger = logging.getLogger(__name__) + +EMBEDDING_DIM = 768 + + +class GenAIEmbedding: + """Embedding adapter that delegates to a GenAI provider's embed API. + + Provides the same interface as JinaV2Embedding for semantic search: + __call__(inputs, embedding_type) -> list[np.ndarray]. Output embeddings are + normalized to 768 dimensions for Frigate's sqlite-vec schema. + """ + + def __init__(self, client: "GenAIClient") -> None: + self.client = client + + def __call__( + self, + inputs: list[str] | list[bytes] | list[Image.Image], + embedding_type: str = "text", + ) -> list[np.ndarray]: + """Generate embeddings for text or images. + + Args: + inputs: List of strings (text) or bytes/PIL images (vision). + embedding_type: "text" or "vision". + + Returns: + List of 768-dim numpy float32 arrays. + """ + if not inputs: + return [] + + if embedding_type == "text": + texts = [str(x) for x in inputs] + embeddings = self.client.embed(texts=texts) + elif embedding_type == "vision": + images: list[bytes] = [] + for inp in inputs: + if isinstance(inp, bytes): + images.append(inp) + elif isinstance(inp, Image.Image): + buf = io.BytesIO() + inp.convert("RGB").save(buf, format="JPEG") + images.append(buf.getvalue()) + else: + logger.warning( + "GenAIEmbedding: skipping unsupported vision input type %s", + type(inp).__name__, + ) + if not images: + return [] + embeddings = self.client.embed(images=images) + else: + raise ValueError( + f"Invalid embedding_type '{embedding_type}'. Must be 'text' or 'vision'." + ) + + result = [] + for emb in embeddings: + arr = np.asarray(emb, dtype=np.float32) + if arr.ndim > 1: + # Some providers return token-level embeddings; pool to one vector. + arr = arr.mean(axis=0) + arr = arr.flatten() + if arr.size != EMBEDDING_DIM: + if arr.size > EMBEDDING_DIM: + arr = arr[:EMBEDDING_DIM] + else: + arr = np.pad( + arr, + (0, EMBEDDING_DIM - arr.size), + mode="constant", + constant_values=0, + ) + result.append(arr) + return result diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index b632951d92..a91e162e7d 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -2,6 +2,7 @@ import base64 import datetime +import json import logging import threading from multiprocessing.synchronize import Event as MpEvent @@ -33,6 +34,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) +from frigate.config.classification import ObjectClassificationType from frigate.data_processing.common.license_plate.model import ( LicensePlateModelRunner, ) @@ -58,9 +60,14 @@ from frigate.data_processing.real_time.license_plate import ( ) from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum from frigate.db.sqlitevecq import SqliteVecQueueDatabase -from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum -from frigate.genai import get_genai_client +from frigate.events.types import ( + EventStateEnum, + EventTypeEnum, + RegenerateDescriptionEnum, +) +from frigate.genai import GenAIClientManager from frigate.models import Event, Recordings, ReviewSegment, Trigger +from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import serialize from frigate.util.file import get_event_thumbnail_bytes from frigate.util.image import SharedMemoryFrameManager @@ -71,6 +78,16 @@ logger = logging.getLogger(__name__) MAX_THUMBNAILS = 10 +GENAI_UPDATE_TOPICS = frozenset( + { + CameraConfigUpdateEnum.add.name, + CameraConfigUpdateEnum.objects.name, + CameraConfigUpdateEnum.object_genai.name, + CameraConfigUpdateEnum.review.name, + CameraConfigUpdateEnum.review_genai.name, + } +) + class EmbeddingMaintainer(threading.Thread): """Handle embedding queue and post event updates.""" @@ -78,7 +95,7 @@ class EmbeddingMaintainer(threading.Thread): def __init__( self, config: FrigateConfig, - metrics: DataProcessorMetrics | None, + metrics: DataProcessorMetrics, stop_event: MpEvent, ) -> None: super().__init__(name="embeddings_maintainer") @@ -91,14 +108,20 @@ 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.classification_config_subscriber = ConfigSubscriber( - "config/classification/custom/" - ) + self.enrichment_config_subscriber = ConfigSubscriber("config/") # Configure Frigate DB db = SqliteVecQueueDatabase( @@ -116,8 +139,10 @@ class EmbeddingMaintainer(threading.Thread): models = [Event, Recordings, ReviewSegment, Trigger] db.bind(models) + self.genai_manager = GenAIClientManager(config) + if config.semantic_search.enabled: - self.embeddings = Embeddings(config, db, metrics) + self.embeddings = Embeddings(config, db, metrics, self.genai_manager) # Check if we need to re-index events if config.semantic_search.reindex: @@ -144,7 +169,6 @@ class EmbeddingMaintainer(threading.Thread): self.frame_manager = SharedMemoryFrameManager() self.detected_license_plates: dict[str, dict[str, Any]] = {} - self.genai_client = get_genai_client(config) # model runners to share between realtime and post processors if self.config.lpr.enabled: @@ -186,6 +210,9 @@ class EmbeddingMaintainer(threading.Thread): ) for model_config in self.config.classification.custom.values(): + if not model_config.enabled: + continue + self.realtime_processors.append( CustomStateClassificationProcessor( self.config, model_config, self.requestor, self.metrics @@ -203,15 +230,6 @@ class EmbeddingMaintainer(threading.Thread): # post processors self.post_processors: list[PostProcessorApi] = [] - if self.genai_client is not None and any( - c.review.genai.enabled_in_config for c in self.config.cameras.values() - ): - self.post_processors.append( - ReviewDescriptionProcessor( - self.config, self.requestor, self.metrics, self.genai_client - ) - ) - if self.config.lpr.enabled: self.post_processors.append( LicensePlatePostProcessor( @@ -224,7 +242,7 @@ class EmbeddingMaintainer(threading.Thread): ) ) - if self.config.audio_transcription.enabled and any( + if any( c.enabled_in_config and c.audio_transcription.enabled for c in self.config.cameras.values() ): @@ -234,9 +252,9 @@ class EmbeddingMaintainer(threading.Thread): ) ) - semantic_trigger_processor: SemanticTriggerProcessor | None = None + self.semantic_trigger_processor: SemanticTriggerProcessor | None = None if self.config.semantic_search.enabled: - semantic_trigger_processor = SemanticTriggerProcessor( + self.semantic_trigger_processor = SemanticTriggerProcessor( db, self.config, self.requestor, @@ -244,43 +262,88 @@ class EmbeddingMaintainer(threading.Thread): metrics, self.embeddings, ) - self.post_processors.append(semantic_trigger_processor) + self.post_processors.append(self.semantic_trigger_processor) - if self.genai_client is not None and any( - c.objects.genai.enabled_in_config for c in self.config.cameras.values() - ): - self.post_processors.append( - ObjectDescriptionProcessor( - self.config, - self.embeddings, - self.requestor, - self.metrics, - self.genai_client, - semantic_trigger_processor, - ) - ) + self._sync_genai_processors() self.stop_event = stop_event # recordings data self.recordings_available_through: dict[str, float] = {} + def _sync_genai_processors(self) -> None: + """Create GenAI post processors for cameras that have GenAI enabled. + + Called at startup and again after camera config updates so enabling + GenAI on the first camera does not require a restart. Processors are + never removed once created. + + A profile can turn GenAI on without setting enabled_in_config, so both + flags are checked. + """ + cameras = self.config.cameras.values() + + if any( + c.review.genai.enabled or c.review.genai.enabled_in_config for c in cameras + ) and not any( + isinstance(p, ReviewDescriptionProcessor) for p in self.post_processors + ): + logger.debug("Initializing review description processor") + self.post_processors.append( + ReviewDescriptionProcessor( + self.config, + self.requestor, + self.metrics, + self.genai_manager, + ) + ) + + if any( + c.objects.genai.enabled or c.objects.genai.enabled_in_config + for c in cameras + ) and not any( + isinstance(p, ObjectDescriptionProcessor) for p in self.post_processors + ): + logger.debug("Initializing object description processor") + self.post_processors.append( + ObjectDescriptionProcessor( + self.config, + self.embeddings, + self.requestor, + self.metrics, + self.genai_manager, + self.semantic_trigger_processor, + ) + ) + + def _check_camera_config_updates(self) -> None: + """Apply camera config updates and register newly enabled processors.""" + updated_topics = self.config_updater.check_for_updates() + + if updated_topics.keys() & GENAI_UPDATE_TOPICS: + self._sync_genai_processors() + def run(self) -> None: """Maintain a SQLite-vec database for semantic search.""" while not self.stop_event.is_set(): - self.config_updater.check_for_updates() - self._check_classification_config_updates() + self._check_camera_config_updates() + self._check_enrichment_config_updates() self._process_requests() self._process_updates() self._process_recordings_updates() self._process_review_updates() self._process_frame_updates() + self._process_deferred_results() self._expire_dedicated_lpr() self._process_finalized() self._process_event_metadata() + # Shutdown deferred processors + for processor in self.realtime_processors: + processor.shutdown() + self.config_updater.stop() - self.classification_config_subscriber.stop() + self.enrichment_config_subscriber.stop() self.event_subscriber.stop() self.event_end_subscriber.stop() self.recordings_subscriber.stop() @@ -291,67 +354,104 @@ class EmbeddingMaintainer(threading.Thread): self.requestor.stop() logger.info("Exiting embeddings maintenance...") - def _check_classification_config_updates(self) -> None: - """Check for classification config updates and add/remove processors.""" - topic, model_config = self.classification_config_subscriber.check_for_update() + def _check_enrichment_config_updates(self) -> None: + """Check for enrichment config updates and delegate to processors.""" + topic, payload = self.enrichment_config_subscriber.check_for_update() - if topic: - model_name = topic.split("/")[-1] + if topic is None: + return - if model_config is None: - self.realtime_processors = [ - processor - for processor in self.realtime_processors - if not ( - isinstance( - processor, - ( - CustomStateClassificationProcessor, - CustomObjectClassificationProcessor, - ), - ) - and processor.model_config.name == model_name - ) - ] + # Custom classification add/remove requires managing the processor list + if topic.startswith("config/classification/custom/"): + self._handle_custom_classification_update(topic, payload) + return - logger.info( - f"Successfully removed classification processor for model: {model_name}" + if topic == "config/genai": + self.config.genai = payload + self.genai_manager.update_config(self.config) + + # Broadcast to all processors — each decides if the topic is relevant + for processor in self.realtime_processors: + processor.update_config(topic, payload) + + for processor in self.post_processors: + processor.update_config(topic, payload) + + def _remove_custom_classification_processor(self, model_name: str) -> None: + """Shut down and drop any running processor for a custom model.""" + remaining = [] + for processor in self.realtime_processors: + if ( + isinstance( + processor, + ( + CustomStateClassificationProcessor, + CustomObjectClassificationProcessor, + ), ) + and processor.model_config.name == model_name + ): + processor.shutdown() else: - self.config.classification.custom[model_name] = model_config + remaining.append(processor) + self.realtime_processors = remaining - # Check if processor already exists - for processor in self.realtime_processors: - if isinstance( - processor, - ( - CustomStateClassificationProcessor, - CustomObjectClassificationProcessor, - ), - ): - if processor.model_config.name == model_name: - logger.debug( - f"Classification processor for model {model_name} already exists, skipping" - ) - return + def _handle_custom_classification_update( + self, topic: str, model_config: Any + ) -> None: + """Handle add/remove of custom classification processors.""" + model_name = topic.split("/")[-1] - if model_config.state_config is not None: - processor = CustomStateClassificationProcessor( - self.config, model_config, self.requestor, self.metrics - ) - else: - processor = CustomObjectClassificationProcessor( - self.config, - model_config, - self.event_metadata_publisher, - self.requestor, - self.metrics, - ) + if model_config is None: + self._remove_custom_classification_processor(model_name) + logger.info( + f"Successfully removed classification processor for model: {model_name}" + ) + return - self.realtime_processors.append(processor) - logger.info( - f"Added classification processor for model: {model_name} (type: {type(processor).__name__})" + self.config.classification.custom[model_name] = model_config + + # A disabled model must not run; tear down any existing processor and + # do not register a new one. + if not model_config.enabled: + self._remove_custom_classification_processor(model_name) + logger.info(f"Disabled classification processor for model: {model_name}") + return + + for processor in self.realtime_processors: + if ( + isinstance( + processor, + ( + CustomStateClassificationProcessor, + CustomObjectClassificationProcessor, + ), ) + and processor.model_config.name == model_name + ): + processor.model_config = model_config + logger.debug( + f"Updated config for classification processor: {model_name}" + ) + return + + if model_config.state_config is not None: + processor = CustomStateClassificationProcessor( + self.config, model_config, self.requestor, self.metrics + ) + else: + processor = CustomObjectClassificationProcessor( + self.config, + model_config, + self.event_metadata_publisher, + self.requestor, + self.metrics, + ) + + self.realtime_processors.append(processor) + logger.info( + f"Added classification processor for model: {model_name} (type: {type(processor).__name__})" + ) def _process_requests(self) -> None: """Process embeddings requests""" @@ -392,7 +492,7 @@ class EmbeddingMaintainer(threading.Thread): logger.error(f"No processor handled the topic {topic}") return None except Exception as e: - logger.error(f"Unable to handle embeddings request {e}", exc_info=True) + logger.exception(f"Unable to handle embeddings request {e}") self.embeddings_responder.check_for_request(_handle_request) @@ -403,7 +503,7 @@ class EmbeddingMaintainer(threading.Thread): if update is None: return - source_type, _, camera, frame_name, data = update + source_type, event_type, camera, frame_name, data = update logger.debug( f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}" @@ -418,7 +518,9 @@ class EmbeddingMaintainer(threading.Thread): if self.config.semantic_search.enabled: self.embeddings.update_stats() - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return # no need to process updated objects if no processors are active if len(self.realtime_processors) == 0 and len(self.post_processors) == 0: @@ -451,6 +553,12 @@ class EmbeddingMaintainer(threading.Thread): for processor in self.post_processors: if isinstance(processor, ObjectDescriptionProcessor): + # skip end events — _process_finalized handles them via event_end_subscriber. + # processing them here can re-create tracked_events entries after cleanup + # when the event_subscriber queue is backlogged behind event_end_subscriber. + if event_type == EventStateEnum.end: + continue + processor.process_data( { "camera": camera, @@ -483,10 +591,16 @@ class EmbeddingMaintainer(threading.Thread): try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: + for processor in self.post_processors: + if isinstance(processor, ObjectDescriptionProcessor): + processor.cleanup_event(event_id) continue # Skip the event if not an object if event.data.get("type") != "object": + for processor in self.post_processors: + if isinstance(processor, ObjectDescriptionProcessor): + processor.cleanup_event(event_id) continue # Extract valid thumbnail @@ -636,13 +750,20 @@ class EmbeddingMaintainer(threading.Thread): if not camera or camera not in self.config.cameras: return - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return + dedicated_lpr_enabled = ( camera_config.type == CameraTypeEnum.lpr and "license_plate" not in camera_config.objects.track ) - if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0: + has_enabled_custom = any( + c.enabled for c in self.config.classification.custom.values() + ) + + if not dedicated_lpr_enabled and not has_enabled_custom: # no active features that use this data return @@ -674,6 +795,68 @@ class EmbeddingMaintainer(threading.Thread): self.frame_manager.close(frame_name) + def _process_deferred_results(self) -> None: + """Drain results from deferred processors and perform IPC side-effects.""" + for processor in self.realtime_processors: + results = processor.drain_results() + + for result in results: + if result.get("type") != "classification": + continue + + if result["processor"] == "state": + self.requestor.send_data( + f"{result['camera']}/classification/{result['model_name']}", + result["state"], + ) + elif result["processor"] == "object": + object_id = result["object_id"] + camera = result["camera"] + timestamp = result["timestamp"] + model_name = result["model_name"] + label = result["label"] + score = result["score"] + classification_type = result["classification_type"] + + if classification_type == ObjectClassificationType.sub_label: + self.event_metadata_publisher.publish( + (object_id, label, score), + EventMetadataTypeEnum.sub_label, + ) + self.requestor.send_data( + "tracked_object_update", + json.dumps( + { + "type": TrackedObjectUpdateTypesEnum.classification, + "id": object_id, + "camera": camera, + "timestamp": timestamp, + "model": model_name, + "sub_label": label, + "score": score, + } + ), + ) + elif classification_type == ObjectClassificationType.attribute: + self.event_metadata_publisher.publish( + (object_id, model_name, label, score), + EventMetadataTypeEnum.attribute.value, + ) + self.requestor.send_data( + "tracked_object_update", + json.dumps( + { + "type": TrackedObjectUpdateTypesEnum.classification, + "id": object_id, + "camera": camera, + "timestamp": timestamp, + "model": model_name, + "attribute": label, + "score": score, + } + ), + ) + def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None: """Embed the thumbnail for an event.""" if not self.config.semantic_search.enabled: diff --git a/frigate/embeddings/onnx/base_embedding.py b/frigate/embeddings/onnx/base_embedding.py index c0bd58475b..5b514d1f60 100644 --- a/frigate/embeddings/onnx/base_embedding.py +++ b/frigate/embeddings/onnx/base_embedding.py @@ -57,6 +57,12 @@ class BaseEmbedding(ABC): def _preprocess_inputs(self, raw_inputs: Any) -> Any: pass + @staticmethod + def _bgr_to_rgb(frame: Any) -> Any: + if isinstance(frame, np.ndarray) and frame.ndim == 3: + return np.ascontiguousarray(frame[:, :, ::-1]) + return frame + def _process_image(self, image, output: str = "RGB") -> Image.Image: if isinstance(image, str): if image.startswith("http"): diff --git a/frigate/embeddings/onnx/face_embedding.py b/frigate/embeddings/onnx/face_embedding.py index 04d756897c..31d2bd2066 100644 --- a/frigate/embeddings/onnx/face_embedding.py +++ b/frigate/embeddings/onnx/face_embedding.py @@ -17,7 +17,7 @@ from .base_embedding import BaseEmbedding try: from tflite_runtime.interpreter import Interpreter except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter + from ai_edge_litert.interpreter import Interpreter logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ class FaceNetEmbedding(BaseEmbedding): self.tensor_output_details = self.runner.get_output_details() def _preprocess_inputs(self, raw_inputs): - pil = self._process_image(raw_inputs[0]) + pil = self._process_image(self._bgr_to_rgb(raw_inputs[0])) # handle images larger than input size width, height = pil.size @@ -159,7 +159,7 @@ class ArcfaceEmbedding(BaseEmbedding): ) def _preprocess_inputs(self, raw_inputs): - pil = self._process_image(raw_inputs[0]) + pil = self._process_image(self._bgr_to_rgb(raw_inputs[0])) # handle images larger than input size width, height = pil.size diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 4af195990d..9af1bd26b2 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -2,17 +2,19 @@ import datetime import logging +import subprocess import threading import time from multiprocessing.managers import DictProxy from multiprocessing.synchronize import Event as MpEvent -from typing import Tuple +from typing import Any import numpy as np from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.inter_process import InterProcessRequestor -from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig +from frigate.config import CameraConfig, CameraInput, FrigateConfig +from frigate.config.camera.ffmpeg import CameraFfmpegConfig from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, @@ -35,21 +37,20 @@ from frigate.data_processing.real_time.audio_transcription import ( ) from frigate.ffmpeg_presets import parse_preset_input from frigate.log import LogPipe, suppress_stderr_during -from frigate.object_detection.base import load_labels -from frigate.util.builtin import get_ffmpeg_arg_list +from frigate.util.builtin import get_ffmpeg_arg_list, load_labels +from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.process import FrigateProcess -from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg try: from tflite_runtime.interpreter import Interpreter except ModuleNotFoundError: - from tensorflow.lite.python.interpreter import Interpreter + from ai_edge_litert.interpreter import Interpreter logger = logging.getLogger(__name__) -def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]: +def get_ffmpeg_command(ffmpeg: CameraFfmpegConfig) -> list[str]: ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0] input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + ( parse_preset_input(ffmpeg_input.input_args, 1) @@ -83,7 +84,6 @@ class AudioProcessor(FrigateProcess): def __init__( self, config: FrigateConfig, - cameras: list[CameraConfig], camera_metrics: DictProxy, stop_event: MpEvent, ): @@ -92,49 +92,100 @@ class AudioProcessor(FrigateProcess): ) self.camera_metrics = camera_metrics - self.cameras = cameras self.config = config + def __stop_audio_thread(self, camera: str) -> None: + thread = self.audio_threads.pop(camera, None) + if thread is None: + return + + thread.stop() + thread.join(10) + if thread.is_alive(): + self.logger.warning(f"Audio maintainer thread for {camera} is still alive") + else: + self.logger.info(f"Audio maintainer stopped for {camera}") + def run(self) -> None: self.pre_run_setup(self.config.logger) - audio_threads: list[AudioEventMaintainer] = [] + self.audio_threads: dict[str, AudioEventMaintainer] = {} threading.current_thread().name = "process:audio_manager" - if self.config.audio_transcription.enabled: - self.transcription_model_runner = AudioTranscriptionModelRunner( - self.config.audio_transcription.device, - self.config.audio_transcription.model_size, + if any( + c.enabled_in_config and c.audio_transcription.enabled + for c in self.config.cameras.values() + ): + self.transcription_model_runner: AudioTranscriptionModelRunner | None = ( + AudioTranscriptionModelRunner( + self.config.audio_transcription.device or "AUTO", + self.config.audio_transcription.model_size, + ) ) else: self.transcription_model_runner = None - if len(self.cameras) == 0: - return + config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.audio, + CameraConfigUpdateEnum.ffmpeg, + CameraConfigUpdateEnum.remove, + ], + ) - for camera in self.cameras: - audio_thread = AudioEventMaintainer( + def spawn_if_needed(camera: CameraConfig) -> None: + name = camera.name + if name is None or name in self.audio_threads: + return + if not camera.enabled or not camera.audio.enabled: + return + # ffmpeg update may not have arrived yet; wait for next poll + if not any("audio" in i.roles for i in camera.ffmpeg.inputs): + return + thread = AudioEventMaintainer( camera, self.config, self.camera_metrics, self.transcription_model_runner, - self.stop_event, + self.stop_event, # type: ignore[arg-type] ) - audio_threads.append(audio_thread) - audio_thread.start() + self.audio_threads[name] = thread + thread.start() + self.logger.info(f"Audio maintainer started for {name}") + + for camera in self.config.cameras.values(): + spawn_if_needed(camera) self.logger.info(f"Audio processor started (pid: {self.pid})") - while not self.stop_event.wait(): - pass + # poll for newly added/removed cameras or cameras flipped to + # audio.enabled at runtime + while not self.stop_event.wait(timeout=1.0): + updated_topics = config_subscriber.check_for_updates() - for thread in audio_threads: + # stop maintainers for removed cameras so their ffmpeg process is + # torn down and they stop touching camera_metrics (which the camera + # maintainer has already popped for the removed camera) + for removed_camera in updated_topics.get( + CameraConfigUpdateEnum.remove.name, [] + ): + self.__stop_audio_thread(removed_camera) + + for camera in self.config.cameras.values(): + spawn_if_needed(camera) + + config_subscriber.stop() + + for thread in self.audio_threads.values(): thread.join(1) if thread.is_alive(): self.logger.info(f"Waiting for thread {thread.name:s} to exit") thread.join(10) - for thread in audio_threads: + for thread in self.audio_threads.values(): if thread.is_alive(): self.logger.warning(f"Thread {thread.name} is still alive") @@ -156,13 +207,16 @@ class AudioEventMaintainer(threading.Thread): self.camera_config = camera self.camera_metrics = camera_metrics self.stop_event = stop_event + # per-camera stop signal so a single maintainer can be torn down at + # runtime (e.g. on camera removal) without stopping the whole process + self.camera_stop_event = threading.Event() self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads) self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),) self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2)) self.logger = logging.getLogger(f"audio.{self.camera_config.name}") self.ffmpeg_cmd = get_ffmpeg_command(self.camera_config.ffmpeg) self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio") - self.audio_listener = None + self.audio_listener: subprocess.Popen[Any] | None = None self.audio_transcription_model_runner = audio_transcription_model_runner self.transcription_processor = None self.transcription_thread = None @@ -171,7 +225,7 @@ class AudioEventMaintainer(threading.Thread): self.requestor = InterProcessRequestor() self.config_subscriber = CameraConfigUpdateSubscriber( None, - {self.camera_config.name: self.camera_config}, + {str(self.camera_config.name): self.camera_config}, [ CameraConfigUpdateEnum.audio, CameraConfigUpdateEnum.enabled, @@ -180,7 +234,10 @@ class AudioEventMaintainer(threading.Thread): ) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value) - if self.config.audio_transcription.enabled: + if ( + self.camera_config.audio_transcription.enabled + and self.audio_transcription_model_runner is not None + ): # init the transcription processor for this camera self.transcription_processor = AudioTranscriptionRealTimeProcessor( config=self.config, @@ -199,18 +256,23 @@ class AudioEventMaintainer(threading.Thread): self.transcription_thread.start() self.was_enabled = camera.enabled + self.was_audio_enabled = camera.audio.enabled - def detect_audio(self, audio) -> None: - if not self.camera_config.audio.enabled or self.stop_event.is_set(): + def detect_audio(self, audio: np.ndarray) -> None: + if ( + not self.camera_config.audio.enabled + or self.stop_event.is_set() + or self.camera_stop_event.is_set() + ): return - audio_as_float = audio.astype(np.float32) + audio_as_float: np.ndarray = audio.astype(np.float32) rms, dBFS = self.calculate_audio_levels(audio_as_float) self.camera_metrics[self.camera_config.name].audio_rms.value = rms self.camera_metrics[self.camera_config.name].audio_dBFS.value = dBFS - audio_detections: list[Tuple[str, float]] = [] + audio_detections: list[tuple[str, float]] = [] # only run audio detection when volume is above min_volume if rms >= self.camera_config.audio.min_volume: @@ -261,7 +323,7 @@ class AudioEventMaintainer(threading.Thread): else: self.transcription_processor.check_unload_model() - def calculate_audio_levels(self, audio_as_float: np.float32) -> Tuple[float, float]: + def calculate_audio_levels(self, audio_as_float: np.ndarray) -> tuple[float, float]: # Calculate RMS (Root-Mean-Square) which represents the average signal amplitude # Note: np.float32 isn't serializable, we must use np.float64 to publish the message rms = np.sqrt(np.mean(np.absolute(np.square(audio_as_float)))) @@ -296,6 +358,10 @@ class AudioEventMaintainer(threading.Thread): self.logpipe.dump() self.start_or_restart_ffmpeg() + if self.audio_listener is None or self.audio_listener.stdout is None: + log_and_restart() + return + try: chunk = self.audio_listener.stdout.read(self.chunk_size) @@ -316,11 +382,15 @@ class AudioEventMaintainer(threading.Thread): self.logger.error(f"Error reading audio data from ffmpeg process: {e}") log_and_restart() + def stop(self) -> None: + """Signal this maintainer to exit its run loop and clean up.""" + self.camera_stop_event.set() + def run(self) -> None: if self.camera_config.enabled: self.start_or_restart_ffmpeg() - while not self.stop_event.is_set(): + while not self.stop_event.is_set() and not self.camera_stop_event.is_set(): # check if there is an updated config self.config_subscriber.check_for_updates() @@ -341,7 +411,10 @@ class AudioEventMaintainer(threading.Thread): self.requestor.send_data( EXPIRE_AUDIO_ACTIVITY, self.camera_config.name ) - stop_ffmpeg(self.audio_listener, self.logger) + + if self.audio_listener: + stop_ffmpeg(self.audio_listener, self.logger) + self.audio_listener = None self.was_enabled = enabled continue @@ -350,6 +423,17 @@ class AudioEventMaintainer(threading.Thread): time.sleep(0.1) continue + audio_enabled = self.camera_config.audio.enabled + if audio_enabled != self.was_audio_enabled: + if not audio_enabled: + self.logger.debug( + f"Disabling audio detections for {self.camera_config.name}, ending events" + ) + self.requestor.send_data( + EXPIRE_AUDIO_ACTIVITY, self.camera_config.name + ) + self.was_audio_enabled = audio_enabled + self.read_audio() if self.audio_listener: @@ -367,7 +451,7 @@ class AudioEventMaintainer(threading.Thread): class AudioTfl: - def __init__(self, stop_event: threading.Event, num_threads=2): + def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None: self.stop_event = stop_event self.num_threads = num_threads self.labels = load_labels("/audio-labelmap.txt", prefill=521) @@ -382,7 +466,7 @@ class AudioTfl: self.tensor_input_details = self.interpreter.get_input_details() self.tensor_output_details = self.interpreter.get_output_details() - def _detect_raw(self, tensor_input): + def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray: self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input) self.interpreter.invoke() detections = np.zeros((20, 6), np.float32) @@ -410,8 +494,10 @@ class AudioTfl: return detections - def detect(self, tensor_input, threshold=AUDIO_MIN_CONFIDENCE): - detections = [] + def detect( + self, tensor_input: np.ndarray, threshold: float = AUDIO_MIN_CONFIDENCE + ) -> list[tuple[str, float, tuple[float, float, float, float]]]: + detections: list[tuple[str, float, tuple[float, float, float, float]]] = [] if self.stop_event.is_set(): return detections diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index d13a96dcfb..88b6a9eda5 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -29,7 +29,7 @@ class EventCleanup(threading.Thread): self.stop_event = stop_event self.db = db self.camera_keys = list(self.config.cameras.keys()) - self.removed_camera_labels: list[str] = None + self.removed_camera_labels: list[Event] | None = None self.camera_labels: dict[str, dict[str, Any]] = {} def get_removed_camera_labels(self) -> list[Event]: @@ -37,7 +37,7 @@ class EventCleanup(threading.Thread): if self.removed_camera_labels is None: self.removed_camera_labels = list( Event.select(Event.label) - .where(Event.camera.not_in(self.camera_keys)) + .where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc] .distinct() .execute() ) @@ -61,7 +61,7 @@ class EventCleanup(threading.Thread): ), } - return self.camera_labels[camera]["labels"] + return self.camera_labels[camera]["labels"] # type: ignore[no-any-return] def expire_snapshots(self) -> list[str]: ## Expire events from unlisted cameras based on the global config @@ -74,7 +74,9 @@ class EventCleanup(threading.Thread): # loop over object types in db for event in distinct_labels: # get expiration time for this label - expire_days = retain_config.objects.get(event.label, retain_config.default) + expire_days = retain_config.objects.get( + str(event.label), retain_config.default + ) expire_after = ( datetime.datetime.now() - datetime.timedelta(days=expire_days) @@ -87,7 +89,7 @@ class EventCleanup(threading.Thread): Event.thumbnail, ) .where( - Event.camera.not_in(self.camera_keys), + Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc] Event.start_time < expire_after, Event.label == event.label, Event.retain_indefinitely == False, @@ -95,7 +97,8 @@ class EventCleanup(threading.Thread): .namedtuples() .iterator() ) - logger.debug(f"{len(list(expired_events))} events can be expired") + expired_events = list(expired_events) + logger.debug(f"{len(expired_events)} events can be expired") # delete the media from disk for expired in expired_events: @@ -108,16 +111,16 @@ class EventCleanup(threading.Thread): # update the clips attribute for the db entry query = Event.select(Event.id).where( - Event.camera.not_in(self.camera_keys), + Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc] Event.start_time < expire_after, Event.label == event.label, Event.retain_indefinitely == False, ) - events_to_update = [] + events_to_update: list[str] = [] for event in query.iterator(): - events_to_update.append(event.id) + events_to_update.append(str(event.id)) if len(events_to_update) >= CHUNK_SIZE: logger.debug( f"Updating {update_params} for {len(events_to_update)} events" @@ -149,7 +152,7 @@ class EventCleanup(threading.Thread): for event in distinct_labels: # get expiration time for this label expire_days = retain_config.objects.get( - event.label, retain_config.default + str(event.label), retain_config.default ) expire_after = ( @@ -176,7 +179,7 @@ class EventCleanup(threading.Thread): # only snapshots are stored in /clips # so no need to delete mp4 files for event in expired_events: - events_to_update.append(event.id) + events_to_update.append(str(event.id)) deleted = delete_event_snapshot(event) if not deleted: @@ -213,14 +216,15 @@ class EventCleanup(threading.Thread): Event.camera, ) .where( - Event.camera.not_in(self.camera_keys), + Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc] Event.start_time < expire_after, Event.retain_indefinitely == False, ) .namedtuples() .iterator() ) - logger.debug(f"{len(list(expired_events))} events can be expired") + expired_events = list(expired_events) + logger.debug(f"{len(expired_events)} events can be expired") # delete the media from disk for expired in expired_events: media_name = f"{expired.camera}-{expired.id}" @@ -243,7 +247,7 @@ class EventCleanup(threading.Thread): # update the clips attribute for the db entry query = Event.select(Event.id).where( - Event.camera.not_in(self.camera_keys), + Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc] Event.start_time < expire_after, Event.retain_indefinitely == False, ) @@ -356,15 +360,16 @@ class EventCleanup(threading.Thread): logger.debug(f"Found {len(events_to_delete)} events that can be expired") if len(events_to_delete) > 0: - ids_to_delete = [e.id for e in events_to_delete] + ids_to_delete = [str(e.id) for e in events_to_delete] for i in range(0, len(ids_to_delete), CHUNK_SIZE): chunk = ids_to_delete[i : i + CHUNK_SIZE] logger.debug(f"Deleting {len(chunk)} events from the database") Event.delete().where(Event.id << chunk).execute() - if self.config.semantic_search.enabled: - self.db.delete_embeddings_description(event_ids=chunk) - self.db.delete_embeddings_thumbnail(event_ids=chunk) - logger.debug(f"Deleted {len(ids_to_delete)} embeddings") + # embeddings are always cleaned up, even when semantic search + # is disabled, so that they don't outlive their events + self.db.delete_embeddings_description(event_ids=chunk) + self.db.delete_embeddings_thumbnail(event_ids=chunk) + logger.debug(f"Deleted {len(chunk)} embeddings") logger.info("Exiting event cleanup...") diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index f6ab777c1c..169b2139ea 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -2,11 +2,12 @@ import logging import threading from multiprocessing import Queue from multiprocessing.synchronize import Event as MpEvent -from typing import Dict +from typing import Any from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.config import FrigateConfig from frigate.config.classification import ObjectClassificationType +from frigate.const import REPLAY_CAMERA_PREFIX from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event from frigate.util.builtin import to_relative_box @@ -14,7 +15,7 @@ from frigate.util.builtin import to_relative_box logger = logging.getLogger(__name__) -def should_update_db(prev_event: Event, current_event: Event) -> bool: +def should_update_db(prev_event: dict[str, Any], current_event: dict[str, Any]) -> bool: """If current_event has updated fields and (clip or snapshot).""" # If event is ending and was previously saved, always update to set end_time # This ensures events are properly ended even when alerts/detections are disabled @@ -46,7 +47,9 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool: return False -def should_update_state(prev_event: Event, current_event: Event) -> bool: +def should_update_state( + prev_event: dict[str, Any], current_event: dict[str, Any] +) -> bool: """If current event should update state, but not necessarily update the db.""" if prev_event["stationary"] != current_event["stationary"]: return True @@ -73,7 +76,7 @@ class EventProcessor(threading.Thread): super().__init__(name="event_processor") self.config = config self.timeline_queue = timeline_queue - self.events_in_process: Dict[str, Event] = {} + self.events_in_process: dict[str, dict[str, Any]] = {} self.stop_event = stop_event self.event_receiver = EventUpdateSubscriber() @@ -91,7 +94,7 @@ class EventProcessor(threading.Thread): if update == None: continue - source_type, event_type, camera, _, event_data = update + source_type, event_type, camera, _, event_data = update # type: ignore[misc] logger.debug( f"Event received: {source_type} {event_type} {camera} {event_data['id']}" @@ -139,52 +142,56 @@ class EventProcessor(threading.Thread): self, event_type: str, camera: str, - event_data: Event, + event_data: dict[str, Any], ) -> None: """handle tracked object event updates.""" updated_db = False if should_update_db(self.events_in_process[event_data["id"]], event_data): updated_db = True - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return + width = camera_config.detect.width height = camera_config.detect.height + + if width is None or height is None: + return + first_detector = list(self.config.detectors.values())[0] start_time = event_data["start_time"] end_time = ( None if event_data["end_time"] is None else event_data["end_time"] ) + snapshot = event_data["snapshot"] # score of the snapshot - score = ( - None - if event_data["snapshot"] is None - else event_data["snapshot"]["score"] - ) + score = None if snapshot is None else snapshot["score"] # detection region in the snapshot region = ( None - if event_data["snapshot"] is None + if snapshot is None else to_relative_box( width, height, - event_data["snapshot"]["region"], + snapshot["region"], ) ) # bounding box for the snapshot box = ( None - if event_data["snapshot"] is None + if snapshot is None else to_relative_box( width, height, - event_data["snapshot"]["box"], + snapshot["box"], ) ) attributes = ( None - if event_data["snapshot"] is None + if snapshot is None else [ { "box": to_relative_box( @@ -195,9 +202,14 @@ class EventProcessor(threading.Thread): "label": a["label"], "score": a["score"], } - for a in event_data["snapshot"]["attributes"] + for a in snapshot["attributes"] ] ) + snapshot_frame_time = None if snapshot is None else snapshot["frame_time"] + snapshot_area = None if snapshot is None else snapshot["area"] + snapshot_estimated_speed = ( + None if snapshot is None else snapshot["current_estimated_speed"] + ) # keep these from being set back to false because the event # may have started while recordings/snapshots/alerts/detections were enabled @@ -217,8 +229,12 @@ class EventProcessor(threading.Thread): Event.thumbnail: event_data.get("thumbnail"), Event.has_clip: event_data["has_clip"], Event.has_snapshot: event_data["has_snapshot"], - Event.model_hash: first_detector.model.model_hash, - Event.model_type: first_detector.model.model_type, + Event.model_hash: first_detector.model.model_hash + if first_detector.model + else None, + Event.model_type: first_detector.model.model_type + if first_detector.model + else None, Event.detector_type: first_detector.type, Event.data: { "box": box, @@ -226,6 +242,10 @@ class EventProcessor(threading.Thread): "score": score, "top_score": event_data["top_score"], "attributes": attributes, + "snapshot_clean": event_data.get("snapshot_clean", False), + "snapshot_frame_time": snapshot_frame_time, + "snapshot_area": snapshot_area, + "snapshot_estimated_speed": snapshot_estimated_speed, "average_estimated_speed": event_data["average_estimated_speed"], "velocity_angle": event_data["velocity_angle"], "type": "object", @@ -278,11 +298,15 @@ class EventProcessor(threading.Thread): if event_type == EventStateEnum.end: del self.events_in_process[event_data["id"]] - self.event_end_publisher.publish((event_data["id"], camera, updated_db)) + self.event_end_publisher.publish((event_data["id"], camera, updated_db)) # type: ignore[arg-type] def handle_external_detection( - self, event_type: EventStateEnum, event_data: Event + self, event_type: EventStateEnum, event_data: dict[str, Any] ) -> None: + # Skip replay cameras + if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX): + return + if event_type == EventStateEnum.start: event = { Event.id: event_data["id"], @@ -299,8 +323,11 @@ class EventProcessor(threading.Thread): "type": event_data["type"], "score": event_data["score"], "top_score": event_data["score"], + "snapshot_clean": event_data.get("snapshot_clean", False), }, } + if event_data.get("draw") is not None: + event[Event.data]["draw"] = event_data["draw"] if event_data.get("recognized_license_plate") is not None: event[Event.data]["recognized_license_plate"] = event_data[ "recognized_license_plate" diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 43272a6d1f..445cfd4df3 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -63,7 +63,7 @@ class LibvaGpuSelector: if not self._valid_gpus: return "" - if gpu <= len(self._valid_gpus): + if gpu < len(self._valid_gpus): return self._valid_gpus[gpu] else: logger.warning(f"Invalid GPU index {gpu}, using first valid GPU") @@ -120,10 +120,10 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[ PRESETS_HW_ACCEL_SCALE = { "preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}", - FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5", - "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p", - "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p", - FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5", + FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12", + "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", + "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", + FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12", "preset-jetson-h264": "-r {0}", # scaled in decoder "preset-jetson-h265": "-r {0}", # scaled in decoder FFMPEG_HWACCEL_RKMPP: "-r {0} -vf scale_rkrga=w={1}:h={2}:format=yuv420p:force_original_aspect_ratio=0,hwmap=mode=read,format=yuv420p", @@ -150,7 +150,11 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", - FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}", + # -vaapi_device is required in addition to -hwaccel_device: this is the only + # birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before + # the decoder creates a device, so hwupload cannot see an -hwaccel_device one. + # See https://github.com/AlexxIT/go2rtc/issues/1984 + FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -vaapi_device {3} -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v main -level:v 4.1 -async_depth:v 1 {2}", FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}", @@ -215,7 +219,7 @@ def parse_preset_hardware_acceleration_decode( width: int, height: int, gpu: int, -) -> list[str]: +) -> list[str] | None: """Return the correct preset if in preset format otherwise return None.""" if not isinstance(arg, str): return None @@ -242,18 +246,9 @@ def parse_preset_hardware_acceleration_scale( else: scale = PRESETS_HW_ACCEL_SCALE.get(arg, PRESETS_HW_ACCEL_SCALE["default"]) - if ( - ",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5" in scale - and os.environ.get("FFMPEG_DISABLE_GAMMA_EQUALIZER") is not None - ): - scale = scale.replace( - ",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5", - ":format=nv12,hwdownload,format=nv12,format=yuv420p", - ) - - scale = scale.format(fps, width, height).split(" ") - scale.extend(detect_args) - return scale + scale_args = scale.format(fps, width, height).split(" ") + scale_args.extend(detect_args) + return scale_args class EncodeTypeEnum(str, Enum): @@ -278,7 +273,7 @@ def parse_preset_hardware_acceleration_encode( arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE if not isinstance(arg, str): - return arg_map["default"].format(input, output) + return arg_map["default"].format(ffmpeg_path, input, output) # Not all jetsons have HW encoders, so fall back to default SW encoder if not if arg.startswith("preset-jetson-") and not os.path.exists("/dev/nvhost-msenc"): @@ -429,14 +424,14 @@ PRESETS_INPUT = { } -def parse_preset_input(arg: Any, detect_fps: int) -> list[str]: +def parse_preset_input(arg: Any, detect_fps: int) -> list[str] | None: """Return the correct preset if in preset format otherwise return None.""" if not isinstance(arg, str): return None if arg == "preset-http-jpeg-generic": input = PRESETS_INPUT[arg].copy() - input[len(_user_agent_args) + 1] = str(detect_fps) + input[1] = str(detect_fps) return input return PRESETS_INPUT.get(arg, None) @@ -539,7 +534,7 @@ PRESETS_RECORD_OUTPUT = { } -def parse_preset_output_record(arg: Any, force_record_hvc1: bool) -> list[str]: +def parse_preset_output_record(arg: Any, force_record_hvc1: bool) -> list[str] | None: """Return the correct preset if in preset format otherwise return None.""" if not isinstance(arg, str): return None diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index be1f6d1e79..4466e2a652 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -1,28 +1,49 @@ """Generative AI module for Frigate.""" -import datetime import importlib +import json import logging import os import re -from typing import Any, Optional +import time +from collections.abc import AsyncGenerator, Callable +from typing import Any -from playhouse.shortcuts import model_to_dict +import numpy as np +from pydantic import ValidationError -from frigate.config import CameraConfig, FrigateConfig, GenAIConfig, GenAIProviderEnum +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 +from frigate.util.builtin import has_non_finite_number logger = logging.getLogger(__name__) +__all__ = [ + "GenAIClient", + "GenAIClientManager", + "GenAIConfig", + "GenAIProviderEnum", + "PROVIDERS", + "load_providers", + "register_genai_provider", +] + PROVIDERS = {} -def register_genai_provider(key: GenAIProviderEnum): +def register_genai_provider(key: GenAIProviderEnum) -> Callable: """Register a GenAI provider.""" - def decorator(cls): + def decorator(cls: type) -> type: PROVIDERS[key] = cls return cls @@ -32,10 +53,48 @@ def register_genai_provider(key: GenAIProviderEnum): class GenAIClient: """Generative AI client for Frigate.""" - def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None: + # Minimum seconds between re-initialization attempts when the provider was + # offline at startup + REINIT_INTERVAL = 60.0 + + 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() + self._last_init_attempt = time.monotonic() + + def ensure_provider(self) -> bool: + """Ensure a provider is available, retrying initialization if needed. + + Providers can fail to initialize at startup when their backing service + isn't online yet (common when both are started together). This retries + ``_init_provider`` lazily — throttled to ``REINIT_INTERVAL`` — so the + client recovers on its own once the service is reachable, without a + config reload. + + Returns True if a provider is available. + """ + if self.provider is not None: + return True + + now = time.monotonic() + if now - self._last_init_attempt < self.REINIT_INTERVAL: + return False + + self._last_init_attempt = now + self.provider = self._init_provider() + if self.provider is not None: + logger.info( + "GenAI provider %s is now available", + self.genai_config.provider, + ) + return self.provider is not None def generate_review_description( self, @@ -47,86 +106,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 the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"]} security camera. - -## Normal Activity Patterns for This Property - -{activity_context_prompt} - -## Task Instructions - -Your task is to provide a clear, accurate description of the scene that: -1. States exactly what is happening based on observable actions and movements. -2. Evaluates the activity against the Normal and Suspicious Activity Indicators above. -3. Assigns a potential_threat_level (0, 1, or 2) based on the threat level indicators defined above, applying them consistently. - -**Use the activity patterns above as guidance to calibrate your assessment. Match the activity against both normal and suspicious indicators, then use your judgment based on the complete context.** - -## 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 Format - -Your response MUST be a flat JSON object with: -- `scene` (string): A narrative description of what happens across the sequence from start to finish, in chronological order. Start by describing how the sequence begins, then describe the progression of events. **Describe all significant movements and actions in the order they occur.** For example, if a vehicle arrives and then a person exits, describe both actions sequentially. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign. -- `title` (string): A concise, grammatically complete title in the format "[Subject] [action verb] [context]" that matches your scene description. Use names from "Objects in Scene" when you visually observe them. -- `shortSummary` (string): A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail. This should be a condensed version of the scene description above. -- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous. -- `potential_threat_level` (integer): 0, 1, or 2 as defined in "Normal Activity Patterns for This Property" above. Your threat level must be consistent with your scene description and the guidance above. -{get_concern_prompt()} - -## Sequence Details - -- 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. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses. - -**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.** - -**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.** -{get_objects_list()} - -## Important Notes -- Values must be plain strings, floats, or integers — no nested objects, no extra commentary. -- Only describe objects from the "Objects in Scene" list above. Do not hallucinate additional objects. -- When describing people or vehicles, use the exact names provided. -{get_language_prompt()} -""" logger.debug( f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}" ) @@ -140,7 +127,9 @@ Each line represents a detection state, not necessarily unique individuals. Pare ) as f: f.write(context_prompt) - response = self._send(context_prompt, thumbnails) + response_format = build_review_description_response_format(concerns) + + response = self._send(context_prompt, thumbnails, response_format) if debug_save and response: with open( @@ -158,20 +147,63 @@ Each line represents a detection state, not necessarily unique individuals. Pare try: metadata = ReviewMetadata.model_validate_json(clean_json) + except ValidationError as ve: + # Constraint violations (length, item count, ranges) are logged + # at debug and the response is kept anyway — a slightly + # off-spec answer is still usable, and dropping the whole + # response loses the narrative content the model produced. + for err in ve.errors(): + loc = ".".join(str(p) for p in err["loc"]) or "" + logger.debug( + "Review metadata soft validation: %s — %s (input: %r)", + loc, + err["msg"], + err.get("input"), + ) + try: + raw = json.loads(clean_json) + except json.JSONDecodeError as je: + logger.error("Failed to parse review description JSON: %s", je) + return None - # If any verified objects (contain parentheses with name), set to 0 - if any("(" in obj for obj in review_data["unified_objects"]): - metadata.potential_threat_level = 0 + # model_construct skips validation, so non-finite numbers that + # the validated path would have rejected have to be caught here + if has_non_finite_number(raw): + logger.error( + "Discarding review description containing non-finite numbers." + ) + return None - metadata.time = review_data["start"] - return metadata + # observations and confidence are required on the model; fill an empty default + # if the response omitted it so attribute access stays safe. + raw.setdefault("observations", []) + raw.setdefault("confidence", 0.0) + metadata = ReviewMetadata.model_construct(**raw) except Exception as e: - # rarely LLMs can fail to follow directions on output format - logger.warning( + logger.error( f"Failed to parse review description as the response did not match expected format. {e}" ) return None + + try: + # Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85) + if metadata.confidence > 1.0: + metadata.confidence = min(metadata.confidence / 100.0, 1.0) + + # If any verified objects (contain ← separator), set to 0 + if any("←" in obj for obj in review_data["unified_objects"]): + metadata.potential_threat_level = 0 + + metadata.title = metadata.title[0].upper() + metadata.title[1:] + metadata.time = review_data["start"] + return metadata + except Exception as e: + logger.error(f"Failed to post-process review metadata: {e}") + return None else: + logger.debug( + f"Invalid response received from GenAI provider for review description on {review_data['camera']}. Response: {response}", + ) return None def generate_review_summary( @@ -183,61 +215,9 @@ Each line represents a detection state, not necessarily unique individuals. Pare 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( @@ -266,13 +246,10 @@ Guidelines: camera_config: CameraConfig, thumbnails: list[bytes], event: Event, - ) -> Optional[str]: + ) -> str | None: """Generate a description for the frame.""" try: - prompt = camera_config.objects.genai.object_prompts.get( - 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 @@ -280,35 +257,196 @@ Guidelines: logger.debug(f"Sending images to genai provider with prompt: {prompt}") return self._send(prompt, thumbnails) - def _init_provider(self): + def _init_provider(self) -> Any: """Initialize the client.""" return None - def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: - """Submit a request to the provider.""" + def _send( + self, + prompt: str, + images: list[bytes], + response_format: dict | None = None, + enable_thinking: bool = False, + ) -> str | None: + """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 + def supports_vision(self) -> bool: + """Whether the model supports vision/image input. + + Defaults to True for cloud providers. Providers that can detect + capability at runtime (e.g. llama.cpp) should override this. + """ + return True + + @property + def supports_toggleable_thinking(self) -> bool: + """Whether the configured model exposes a per-request thinking toggle.""" + return False + + @property + def supports_embeddings(self) -> bool: + """Whether the configured model can generate embeddings via embed().""" + return False + + def list_models(self) -> list[str]: + """Return the list of model names available from this provider. + + Providers should override this to query their backend. + """ + return [] + def get_context_size(self) -> int: """Get the context window size for this provider in tokens.""" return 4096 + def estimate_image_tokens(self, width: int, height: int) -> float: + """Estimate prompt tokens consumed by a single image of the given dimensions. -def get_genai_client(config: FrigateConfig) -> Optional[GenAIClient]: - """Get the GenAI client.""" - if not config.genai.provider: - return None + Default heuristic: ~1 token per 1250 pixels. Providers that can measure or + know their model's exact image-token cost should override. + """ + return (width * height) / 1250 - load_providers() - provider = PROVIDERS.get(config.genai.provider) - if provider: - return provider(config.genai) + def embed( + self, + texts: list[str] | None = None, + images: list[bytes] | None = None, + ) -> list[np.ndarray]: + """Generate embeddings for text and/or images. - return None + Returns list of numpy arrays (one per input). Expected dimension is 768 + for Frigate semantic search compatibility. + + Providers that support embeddings should override this method. + """ + logger.warning( + "%s does not support embeddings. " + "This method should be overridden by the provider implementation.", + self.__class__.__name__, + ) + return [] + + def chat_with_tools( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """ + Send chat messages to LLM with optional tool definitions. + + This method handles conversation-style interactions with the LLM, + including function calling/tool usage capabilities. + + Args: + messages: List of message dictionaries. Each message should have: + - 'role': str - One of 'user', 'assistant', 'system', or 'tool' + - 'content': str - The message content + - 'tool_call_id': Optional[str] - For tool responses, the ID of the tool call + - 'name': Optional[str] - For tool messages, the tool name + tools: Optional list of tool definitions in OpenAI-compatible format. + Each tool should have 'type': 'function' and 'function' with: + - 'name': str - Tool name + - 'description': str - Tool description + - 'parameters': dict - JSON schema for parameters + tool_choice: How the model should handle tools: + - 'auto': Model decides whether to call tools + - 'none': Model must not call tools + - 'required': Model must call at least one tool + - Or a dict specifying a specific tool to call + 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 + - 'name': str - Tool name to call + - 'arguments': dict - Arguments for the tool call (parsed JSON) + - 'finish_reason': str - Reason generation stopped: + - 'stop': Normal completion + - 'tool_calls': LLM wants to call tools + - '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. + """ + # Base implementation - each provider should override this + logger.warning( + f"{self.__class__.__name__} does not support chat_with_tools. " + "This method should be overridden by the provider implementation." + ) + return { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + } + + async def chat_with_tools_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = 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(): - package_dir = os.path.dirname(__file__) - for filename in os.listdir(package_dir): +def load_providers() -> None: + 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) diff --git a/frigate/genai/azure-openai.py b/frigate/genai/azure-openai.py deleted file mode 100644 index eb08f77867..0000000000 --- a/frigate/genai/azure-openai.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Azure OpenAI Provider for Frigate AI.""" - -import base64 -import logging -from typing import 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 - -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): - """Initialize the client.""" - try: - parsed_url = urlparse(self.genai_config.base_url) - 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]) -> Optional[str]: - """Submit a request to Azure OpenAI.""" - encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] - try: - result = self.provider.chat.completions.create( - 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, - ) - except Exception as e: - logger.warning("Azure OpenAI returned an error: %s", str(e)) - return None - if len(result.choices) > 0: - return result.choices[0].message.content.strip() - return None - - def get_context_size(self) -> int: - """Get the context window size for Azure OpenAI.""" - return 128000 diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py deleted file mode 100644 index b700c33a4c..0000000000 --- a/frigate/genai/gemini.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Gemini Provider for Frigate AI.""" - -import logging -from typing import Optional - -from google import genai -from google.genai import errors, types - -from frigate.config import GenAIProviderEnum -from frigate.genai import GenAIClient, register_genai_provider - -logger = logging.getLogger(__name__) - - -@register_genai_provider(GenAIProviderEnum.gemini) -class GeminiClient(GenAIClient): - """Generative AI client for Frigate using Gemini.""" - - provider: genai.Client - - def _init_provider(self): - """Initialize the client.""" - # Merge provider_options into HttpOptions - http_options_dict = { - "timeout": int(self.timeout * 1000), # requires milliseconds - "retry_options": types.HttpRetryOptions( - attempts=3, - initial_delay=1.0, - max_delay=60.0, - exp_base=2.0, - jitter=1.0, - http_status_codes=[429, 500, 502, 503, 504], - ), - } - - if isinstance(self.genai_config.provider_options, dict): - http_options_dict.update(self.genai_config.provider_options) - - return genai.Client( - api_key=self.genai_config.api_key, - http_options=types.HttpOptions(**http_options_dict), - ) - - def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: - """Submit a request to Gemini.""" - contents = [ - types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images - ] + [prompt] - try: - # Merge runtime_options into generation_config if provided - generation_config_dict = {"candidate_count": 1} - generation_config_dict.update(self.genai_config.runtime_options) - - response = self.provider.models.generate_content( - model=self.genai_config.model, - contents=contents, - config=types.GenerateContentConfig( - **generation_config_dict, - ), - ) - except errors.APIError as e: - logger.warning("Gemini returned an error: %s", str(e)) - return None - except Exception as e: - logger.warning("An unexpected error occurred with Gemini: %s", str(e)) - return None - - try: - description = response.text.strip() - except (ValueError, AttributeError): - # No description was generated - return None - return description - - def get_context_size(self) -> int: - """Get the context window size for Gemini.""" - # Gemini Pro Vision has a 1M token context window - return 1000000 diff --git a/frigate/genai/manager.py b/frigate/genai/manager.py new file mode 100644 index 0000000000..1301f1b7a1 --- /dev/null +++ b/frigate/genai/manager.py @@ -0,0 +1,126 @@ +"""GenAI client manager for Frigate. + +Manages GenAI provider clients from Frigate config. Clients are created lazily +on first access so that providers whose roles are never used (e.g. chat when +no chat feature is active) are never initialized. +""" + +import logging +from typing import TYPE_CHECKING, Any + +from frigate.config import FrigateConfig +from frigate.config.camera.genai import GenAIConfig, GenAIRoleEnum + +if TYPE_CHECKING: + from frigate.genai import GenAIClient + +logger = logging.getLogger(__name__) + + +class GenAIClientManager: + """Manages GenAI provider clients from Frigate config.""" + + def __init__(self, config: FrigateConfig) -> None: + self._configs: dict[str, GenAIConfig] = {} + self._role_map: dict[GenAIRoleEnum, str] = {} + self._clients: dict[str, GenAIClient] = {} + self.update_config(config) + + def update_config(self, config: FrigateConfig) -> None: + """Store provider configs and build the role→name mapping. + + Called from __init__ and can be called again when config is reloaded. + Clients are not created here; they are instantiated lazily on first + access via a role property or list_models(). + """ + from frigate.genai import PROVIDERS, load_providers + + self._configs = {} + self._role_map = {} + self._clients = {} + + if not config.genai: + return + + load_providers() + + for name, genai_cfg in config.genai.items(): + if not genai_cfg.provider: + continue + if genai_cfg.provider not in PROVIDERS: + logger.warning( + "Unknown GenAI provider %s in config, skipping.", + genai_cfg.provider, + ) + continue + + self._configs[name] = genai_cfg + + for role in genai_cfg.roles: + self._role_map[role] = name + + def _get_client(self, name: str) -> "GenAIClient | None": + """Return the client for *name*, creating it on first access.""" + if name in self._clients: + client = self._clients[name] + client.ensure_provider() + return client + + from frigate.genai import PROVIDERS + + genai_cfg = self._configs.get(name) + if not genai_cfg: + return None + + if not genai_cfg.provider: + return None + + provider_cls = PROVIDERS.get(genai_cfg.provider) + if not provider_cls: + return None + + try: + client = provider_cls(genai_cfg) + except Exception as e: + logger.exception( + "Failed to create GenAI client for provider %s: %s", + genai_cfg.provider, + e, + ) + return None + + self._clients[name] = client + return client + + @property + def chat_client(self) -> "GenAIClient | None": + """Client configured for the chat role (e.g. chat with function calling).""" + name = self._role_map.get(GenAIRoleEnum.chat) + return self._get_client(name) if name else None + + @property + def description_client(self) -> "GenAIClient | None": + """Client configured for the descriptions role (e.g. review descriptions, object descriptions).""" + name = self._role_map.get(GenAIRoleEnum.descriptions) + return self._get_client(name) if name else None + + @property + def embeddings_client(self) -> "GenAIClient | None": + """Client configured for the embeddings role.""" + name = self._role_map.get(GenAIRoleEnum.embeddings) + return self._get_client(name) if name else None + + 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 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, + "supports_embeddings": client.supports_embeddings, + } + return result diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py deleted file mode 100644 index ab6d3c0b3a..0000000000 --- a/frigate/genai/ollama.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Ollama Provider for Frigate AI.""" - -import logging -from typing import Any, Optional - -from httpx import RemoteProtocolError, TimeoutException -from ollama import Client as ApiClient -from ollama import ResponseError - -from frigate.config import GenAIProviderEnum -from frigate.genai import GenAIClient, register_genai_provider - -logger = logging.getLogger(__name__) - - -@register_genai_provider(GenAIProviderEnum.ollama) -class OllamaClient(GenAIClient): - """Generative AI client for Frigate using Ollama.""" - - LOCAL_OPTIMIZED_OPTIONS = { - "options": { - "temperature": 0.5, - "repeat_penalty": 1.05, - "presence_penalty": 0.3, - }, - } - - provider: ApiClient - provider_options: dict[str, Any] - - def _init_provider(self): - """Initialize the client.""" - self.provider_options = { - **self.LOCAL_OPTIMIZED_OPTIONS, - **self.genai_config.provider_options, - } - - try: - client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout) - # ensure the model is available locally - response = client.show(self.genai_config.model) - if response.get("error"): - logger.error( - "Ollama error: %s", - response["error"], - ) - return None - return client - except Exception as e: - logger.warning("Error initializing Ollama: %s", str(e)) - return None - - def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: - """Submit a request to Ollama""" - if self.provider is None: - logger.warning( - "Ollama provider has not been initialized, a description will not be generated. Check your Ollama configuration." - ) - return None - try: - ollama_options = { - **self.provider_options, - **self.genai_config.runtime_options, - } - result = self.provider.generate( - self.genai_config.model, - prompt, - images=images if images else None, - **ollama_options, - ) - logger.debug( - f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}" - ) - return result["response"].strip() - except ( - TimeoutException, - ResponseError, - RemoteProtocolError, - ConnectionError, - ) as e: - logger.warning("Ollama returned an error: %s", str(e)) - return None - - def get_context_size(self) -> int: - """Get the context window size for Ollama.""" - return self.genai_config.provider_options.get("options", {}).get( - "num_ctx", 4096 - ) diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py deleted file mode 100644 index 1fb0dd8520..0000000000 --- a/frigate/genai/openai.py +++ /dev/null @@ -1,118 +0,0 @@ -"""OpenAI Provider for Frigate AI.""" - -import base64 -import logging -from typing import Optional - -from httpx import TimeoutException -from openai import OpenAI - -from frigate.config import GenAIProviderEnum -from frigate.genai import GenAIClient, register_genai_provider - -logger = logging.getLogger(__name__) - - -@register_genai_provider(GenAIProviderEnum.openai) -class OpenAIClient(GenAIClient): - """Generative AI client for Frigate using OpenAI.""" - - provider: OpenAI - context_size: Optional[int] = None - - def _init_provider(self): - """Initialize the client.""" - # 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 = { - k: v - for k, v in self.genai_config.provider_options.items() - if k != "context_size" - } - return OpenAI(api_key=self.genai_config.api_key, **provider_opts) - - def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: - """Submit a request to OpenAI.""" - encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] - messages_content = [] - for image in encoded_images: - messages_content.append( - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{image}", - "detail": "low", - }, - } - ) - messages_content.append( - { - "type": "text", - "text": prompt, - } - ) - try: - result = self.provider.chat.completions.create( - model=self.genai_config.model, - messages=[ - { - "role": "user", - "content": messages_content, - }, - ], - timeout=self.timeout, - **self.genai_config.runtime_options, - ) - if ( - result is not None - and hasattr(result, "choices") - and len(result.choices) > 0 - ): - return result.choices[0].message.content.strip() - return None - except (TimeoutException, Exception) as e: - logger.warning("OpenAI returned an error: %s", str(e)) - return None - - def get_context_size(self) -> int: - """Get the context window size for OpenAI.""" - if self.context_size is not None: - return self.context_size - - # First check provider_options for manually specified context size - # This is necessary for llama.cpp and other OpenAI-compatible servers - # that don't expose the configured runtime context size in the API response - if "context_size" in self.genai_config.provider_options: - self.context_size = self.genai_config.provider_options["context_size"] - logger.debug( - f"Using context size {self.context_size} from provider_options for model {self.genai_config.model}" - ) - return self.context_size - - try: - models = self.provider.models.list() - for model in models.data: - if model.id == self.genai_config.model: - if hasattr(model, "max_model_len") and model.max_model_len: - self.context_size = model.max_model_len - logger.debug( - f"Retrieved context size {self.context_size} for model {self.genai_config.model}" - ) - return self.context_size - - except Exception as e: - logger.debug( - f"Failed to fetch model context size from API: {e}, using default" - ) - - # Default to 128K for ChatGPT models, 8K for others - model_name = self.genai_config.model.lower() - if "gpt" in model_name: - self.context_size = 128000 - else: - self.context_size = 8192 - - logger.debug( - f"Using default context size {self.context_size} for model {self.genai_config.model}" - ) - return self.context_size diff --git a/frigate/genai/plugins/__init__.py b/frigate/genai/plugins/__init__.py new file mode 100644 index 0000000000..e6d66077d3 --- /dev/null +++ b/frigate/genai/plugins/__init__.py @@ -0,0 +1 @@ +"""GenAI provider plugins.""" diff --git a/frigate/genai/plugins/azure-openai.py b/frigate/genai/plugins/azure-openai.py new file mode 100644 index 0000000000..3599eb0dbd --- /dev/null +++ b/frigate/genai/plugins/azure-openai.py @@ -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 diff --git a/frigate/genai/plugins/gemini.py b/frigate/genai/plugins/gemini.py new file mode 100644 index 0000000000..4af29ff8c9 --- /dev/null +++ b/frigate/genai/plugins/gemini.py @@ -0,0 +1,713 @@ +"""Gemini Provider for Frigate AI.""" + +import base64 +import binascii +import json +import logging +from collections.abc import AsyncGenerator +from typing import Any + +from google import genai +from google.genai import errors, types +from google.genai.types import FunctionCallingConfigMode + +from frigate.config import GenAIProviderEnum +from frigate.genai import GenAIClient, register_genai_provider + +logger = logging.getLogger(__name__) + + +def _decode_thought_signature(value: Any) -> bytes | None: + """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: bytes | None) -> str | None: + """Encode bytes thought_signature as base64 so it survives JSON-friendly transport.""" + if not signature: + return None + return base64.b64encode(signature).decode("ascii") + + +def _decode_data_uri(url: str) -> tuple[str, bytes] | None: + """Decode a ``data:`` URI into ``(mime_type, bytes)``; None if not a data URI.""" + if not isinstance(url, str) or not url.startswith("data:"): + return None + try: + header, b64 = url.split(",", 1) + mime = header[len("data:") :].split(";")[0] or "image/jpeg" + return mime, base64.b64decode(b64) + except (ValueError, binascii.Error): + return None + + +def _parts_from_content(content: Any) -> list[types.Part]: + """Convert OpenAI-style message content (str or multimodal list) to Gemini parts.""" + if isinstance(content, list): + parts: list[types.Part] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + parts.append(types.Part.from_text(text=item.get("text") or "")) + elif item.get("type") == "image_url": + decoded = _decode_data_uri((item.get("image_url") or {}).get("url", "")) + if decoded is not None: + mime, data = decoded + parts.append(types.Part.from_bytes(data=data, mime_type=mime)) + # Gemini rejects empty parts; fall back to a single space. + return parts or [types.Part.from_text(text=" ")] + return [types.Part.from_text(text=content or "")] + + +def _stats_from_gemini_usage(usage: Any) -> dict[str, Any] | None: + """Build a stats dict from a Gemini usage_metadata object.""" + prompt_tokens = getattr(usage, "prompt_token_count", None) + completion_tokens = getattr(usage, "candidates_token_count", None) + if prompt_tokens is None and completion_tokens is None: + return None + stats: dict[str, Any] = {} + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + return stats or None + + +@register_genai_provider(GenAIProviderEnum.gemini) +class GeminiClient(GenAIClient): + """Generative AI client for Frigate using Gemini.""" + + provider: genai.Client + + def _init_provider(self) -> genai.Client: + """Initialize the client.""" + # Merge provider_options into HttpOptions + http_options_dict: dict[str, Any] = { + "timeout": int(self.timeout * 1000), # requires milliseconds + "retry_options": types.HttpRetryOptions( + attempts=3, + initial_delay=1.0, + max_delay=60.0, + exp_base=2.0, + jitter=1.0, + http_status_codes=[429, 500, 502, 503, 504], + ), + } + + if isinstance(self.genai_config.provider_options, dict): + http_options_dict.update(self.genai_config.provider_options) + + return genai.Client( + api_key=self.genai_config.api_key, + http_options=types.HttpOptions(**http_options_dict), + ) + + def _send( + self, + prompt: str, + images: list[bytes], + response_format: dict | None = None, + enable_thinking: bool = False, + ) -> str | None: + """Submit a request to Gemini.""" + contents = [prompt] + [ + types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images + ] + try: + # Merge runtime_options into generation_config if provided + generation_config_dict: dict[str, Any] = {"candidate_count": 1} + generation_config_dict.update(self.genai_config.runtime_options) + + if response_format and response_format.get("type") == "json_schema": + generation_config_dict["response_mime_type"] = "application/json" + schema = response_format.get("json_schema", {}).get("schema") + if schema: + generation_config_dict["response_schema"] = schema + + response = self.provider.models.generate_content( + model=self.genai_config.model, + contents=contents, # type: ignore[arg-type] + config=types.GenerateContentConfig( + **generation_config_dict, + ), + ) + except errors.APIError as e: + logger.warning("Gemini returned an error: %s", str(e)) + return None + except Exception as e: + logger.warning("An unexpected error occurred with Gemini: %s", str(e)) + return None + + try: + if response.text is None: + return None + description = response.text.strip() + except (ValueError, AttributeError): + # No description was generated + return None + return description + + def list_models(self) -> list[str]: + """Return available model names from Gemini.""" + try: + return sorted(m.name or "" for m in self.provider.models.list()) + except Exception as e: + logger.warning("Failed to list Gemini models: %s", e) + return [] + + def get_context_size(self) -> int: + """Get the context window size for Gemini.""" + # Gemini Pro Vision has a 1M token context window + return 1000000 + + def chat_with_tools( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """ + Send chat messages to Gemini with optional tool definitions. + + 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 + gemini_messages: list[types.Content] = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + # Map roles to Gemini format + if role == "system": + # Gemini doesn't have system role, prepend to first user message + if ( + gemini_messages + and gemini_messages[0].role == "user" + and gemini_messages[0].parts + ): + gemini_messages[0].parts[ + 0 + ].text = f"{content}\n\n{gemini_messages[0].parts[0].text}" + else: + gemini_messages.append( + types.Content( + role="user", parts=[types.Part.from_text(text=content)] + ) + ) + elif role == "assistant": + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + 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)) + elif role == "tool": + # Handle tool response + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) + gemini_messages.append( + types.Content( + role="user", + parts=[ + types.Part.from_function_response( + name=msg.get("name") + or msg.get("tool_call_id") + or "", + response=response_payload, + ) + ], + ) + ) + else: # user + gemini_messages.append( + types.Content(role="user", parts=_parts_from_content(content)) + ) + + # Convert tools to Gemini format + gemini_tools = None + if tools: + gemini_tools = [] + for tool in tools: + if tool.get("type") == "function": + func = tool.get("function", {}) + gemini_tools.append( + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=func.get("name", ""), + description=func.get("description", ""), + parameters=func.get("parameters", {}), + ) + ] + ) + ) + + # Configure tool choice + tool_config = None + if tool_choice: + if tool_choice == "none": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.NONE + ) + ) + elif tool_choice == "auto": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.AUTO + ) + ) + elif tool_choice == "required": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.ANY + ) + ) + + # Build request config + config_params: dict[str, Any] = {"candidate_count": 1} + + if gemini_tools: + config_params["tools"] = gemini_tools + + 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) + + response = self.provider.models.generate_content( + model=self.genai_config.model, + contents=gemini_messages, # type: ignore[arg-type] + config=types.GenerateContentConfig(**config_params), + ) + + # Check if response is valid + 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, reasoning, and tool calls from response + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + if part.text: + 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: + tool_calls = [] + + try: + arguments = ( + dict(part.function_call.args) + if part.function_call.args + else {} + ) + except Exception: + arguments = {} + + tool_calls.append( + { + "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: + from google.genai.types import FinishReason + + if candidate.finish_reason == FinishReason.STOP: + finish_reason = "stop" + elif candidate.finish_reason == FinishReason.MAX_TOKENS: + finish_reason = "length" + elif candidate.finish_reason in [ + FinishReason.SAFETY, + FinishReason.RECITATION, + ]: + finish_reason = "error" + elif tool_calls: + finish_reason = "tool_calls" + elif content: + finish_reason = "stop" + elif tool_calls: + finish_reason = "tool_calls" + elif content: + finish_reason = "stop" + + return { + "content": content, + "reasoning": reasoning, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + } + + except errors.APIError as e: + logger.warning("Gemini API error during chat_with_tools: %s", str(e)) + return { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + } + except Exception as e: + logger.warning( + "Gemini returned an error during chat_with_tools: %s", str(e) + ) + return { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + } + + async def chat_with_tools_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = 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 + gemini_messages: list[types.Content] = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + # Map roles to Gemini format + if role == "system": + # Gemini doesn't have system role, prepend to first user message + if ( + gemini_messages + and gemini_messages[0].role == "user" + and gemini_messages[0].parts + ): + gemini_messages[0].parts[ + 0 + ].text = f"{content}\n\n{gemini_messages[0].parts[0].text}" + else: + gemini_messages.append( + types.Content( + role="user", parts=[types.Part.from_text(text=content)] + ) + ) + elif role == "assistant": + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + 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)) + elif role == "tool": + # Handle tool response + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) + gemini_messages.append( + types.Content( + role="user", + parts=[ + types.Part.from_function_response( + name=msg.get("name") + or msg.get("tool_call_id") + or "", + response=response_payload, + ) + ], + ) + ) + else: # user + gemini_messages.append( + types.Content(role="user", parts=_parts_from_content(content)) + ) + + # Convert tools to Gemini format + gemini_tools = None + if tools: + gemini_tools = [] + for tool in tools: + if tool.get("type") == "function": + func = tool.get("function", {}) + gemini_tools.append( + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=func.get("name", ""), + description=func.get("description", ""), + parameters=func.get("parameters", {}), + ) + ] + ) + ) + + # Configure tool choice + tool_config = None + if tool_choice: + if tool_choice == "none": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.NONE + ) + ) + elif tool_choice == "auto": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.AUTO + ) + ) + elif tool_choice == "required": + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=FunctionCallingConfigMode.ANY + ) + ) + + # Build request config + config_params: dict[str, Any] = {"candidate_count": 1} + + if gemini_tools: + config_params["tools"] = gemini_tools + + 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_accum: list[dict[str, Any]] = [] + finish_reason = "stop" + usage_stats: dict[str, Any] | None = None + + stream = await self.provider.aio.models.generate_content_stream( + model=self.genai_config.model, + contents=gemini_messages, # type: ignore[arg-type] + config=types.GenerateContentConfig(**config_params), + ) + + async for chunk in stream: + chunk_usage = getattr(chunk, "usage_metadata", None) + if chunk_usage is not None: + maybe_stats = _stats_from_gemini_usage(chunk_usage) + if maybe_stats is not None: + usage_stats = maybe_stats + + if not chunk or not chunk.candidates: + continue + + candidate = chunk.candidates[0] + + # Check for finish reason + if hasattr(candidate, "finish_reason") and candidate.finish_reason: + from google.genai.types import FinishReason + + if candidate.finish_reason == FinishReason.STOP: + finish_reason = "stop" + elif candidate.finish_reason == FinishReason.MAX_TOKENS: + finish_reason = "length" + elif candidate.finish_reason in [ + FinishReason.SAFETY, + FinishReason.RECITATION, + ]: + finish_reason = "error" + + # Extract content, reasoning, and tool calls from chunk + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + if 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: + # Gemini streams complete function calls (not partial + # argument deltas), so each part is a distinct tool + # call. Append rather than accumulate by name — the + # latter concatenated parallel/repeated calls into one + # invalid arguments string (e.g. `{...}{...}`). + try: + arguments = ( + dict(part.function_call.args) + if part.function_call.args + else {} + ) + except Exception: + arguments = {} + + tool_calls_accum.append( + { + "id": part.function_call.name or "", + "name": part.function_call.name or "", + "arguments": arguments, + "thought_signature": getattr( + part, "thought_signature", None + ), + } + ) + + # 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 + if tool_calls_accum: + tool_calls_list = [ + { + "id": tc["id"], + "name": tc["name"], + "arguments": tc["arguments"] + if isinstance(tc["arguments"], dict) + else {}, + "thought_signature": _encode_thought_signature( + tc.get("thought_signature") + ), + } + for tc in tool_calls_accum + ] + finish_reason = "tool_calls" + + if usage_stats is not None: + yield ("stats", usage_stats) + + yield ( + "message", + { + "content": full_content, + "reasoning": full_reasoning, + "tool_calls": tool_calls_list, + "finish_reason": finish_reason, + }, + ) + + except errors.APIError as e: + logger.warning("Gemini API error during streaming: %s", str(e)) + yield ( + "message", + { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning( + "Gemini returned an error during chat_with_tools_stream: %s", str(e) + ) + yield ( + "message", + { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py new file mode 100644 index 0000000000..a217e0d898 --- /dev/null +++ b/frigate/genai/plugins/llama_cpp.py @@ -0,0 +1,979 @@ +"""llama.cpp Provider for Frigate AI.""" + +import base64 +import io +import json +import logging +from collections.abc import AsyncGenerator +from typing import Any, cast + +import httpx +import numpy as np +import requests +from PIL import Image + +from frigate.config import GenAIProviderEnum +from frigate.genai import GenAIClient, register_genai_provider +from frigate.genai.utils import parse_tool_calls_from_message + +logger = logging.getLogger(__name__) + + +def _stats_from_llama_cpp_chunk(data: dict[str, Any]) -> dict[str, Any] | None: + """Build a stats dict from a llama.cpp streaming chunk. + + Final-chunk `usage` carries authoritative token counts. Per-chunk + `timings` (enabled via timings_per_token) carries the running token + counts (prompt_n, predicted_n) and generation rate, so live updates + work mid-stream. + """ + usage = data.get("usage") or {} + timings = data.get("timings") or {} + prompt_tokens = usage.get("prompt_tokens") + completion_tokens = usage.get("completion_tokens") + predicted_ms = timings.get("predicted_ms") + tps = timings.get("predicted_per_second") + stats: dict[str, Any] = {} + + if not isinstance(prompt_tokens, int): + prompt_n = timings.get("prompt_n") + + if isinstance(prompt_n, int): + prompt_tokens = prompt_n + + if not isinstance(completion_tokens, int): + predicted_n = timings.get("predicted_n") + + if isinstance(predicted_n, int): + completion_tokens = predicted_n + + if not isinstance(prompt_tokens, int) and not isinstance(completion_tokens, int): + return None + + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + + if isinstance(predicted_ms, (int, float)) and predicted_ms > 0: + stats["completion_duration_ms"] = float(predicted_ms) + + if isinstance(tps, (int, float)) and tps > 0: + stats["tokens_per_second"] = float(tps) + + return stats or None + + +def _parse_launch_arg(args: list[str], flag: str) -> str | None: + """Return the value following `flag` in a positional argv list, or None.""" + try: + idx = args.index(flag) + except ValueError: + return None + if idx + 1 >= len(args): + return None + return args[idx + 1] + + +def _to_jpeg(img_bytes: bytes) -> bytes | None: + """Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" + try: + img = Image.open(io.BytesIO(img_bytes)) + if img.mode != "RGB": + img = img.convert("RGB") # type: ignore[assignment] + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85) + return buf.getvalue() + except Exception as e: + logger.warning("Failed to convert image to JPEG: %s", e) + return None + + +@register_genai_provider(GenAIProviderEnum.llamacpp) +class LlamaCppClient(GenAIClient): + """Generative AI client for Frigate using llama.cpp server.""" + + provider: str | None # base_url + provider_options: dict[str, Any] + _context_size: int | None + _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 + + @property + def supports_embeddings(self) -> bool: + """llama.cpp exposes an /embeddings endpoint for any loaded model.""" + return True + + def _auth_headers(self) -> dict | None: + """Bearer auth header when an API key is configured, else None.""" + if self.genai_config.api_key: + return {"Authorization": "Bearer " + self.genai_config.api_key} + + return None + + def _get(self, url: str, **kwargs: Any) -> requests.Response: + """GET with the configured auth headers injected.""" + return requests.get(url, headers=self._auth_headers(), **kwargs) + + def _post(self, url: str, **kwargs: Any) -> requests.Response: + """POST with the configured auth headers injected.""" + return requests.post(url, headers=self._auth_headers(), **kwargs) + + def _fetch_llama_props(self, base_url: str, model: str) -> dict[str, Any]: + """Fetch /props from a llama.cpp server, with llama-swap fallback. + + Raises the underlying RequestException if both endpoints fail; callers + decide how to surface the failure. + """ + try: + response = self._get( + f"{base_url}/props", + params={"model": model}, + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + except Exception: + response = self._get( + f"{base_url}/upstream/{model}/props", + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + + def _init_provider(self) -> str | None: + """Initialize the client and query model metadata from the server.""" + self.provider_options = { + **self.genai_config.provider_options, + } + self._context_size = None + 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__>" + + base_url = ( + self.genai_config.base_url.rstrip("/") + if self.genai_config.base_url + else None + ) + + if base_url is None: + return None + 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) + + if info is None: + return None + + self._context_size = info["context_size"] + 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, reasoning: %s", + configured_model, + self.get_context_size(), + self._supports_vision, + self._supports_audio, + self._supports_tools, + self._supports_reasoning, + ) + + return base_url + + def _get_model_info( + self, base_url: str, configured_model: str + ) -> dict[str, Any] | None: + """Resolve model metadata from /v1/models with /props fallback. + + Returns a dict of capability fields, or None if the server's model + registry was reachable and reported the configured model as missing. + A reachable-but-unparseable /v1/models is treated as soft-pass and + falls through to /props, matching prior behavior. + + After ggml-org/llama.cpp#22952, /v1/models exposes per-model + `architecture.input_modalities` (text/image/audio) — the primary + source. When proxied through llama-swap, the same entry carries + `status.args` (server launch argv) and, for the loaded model, + `meta.n_ctx`. /props remains the only source for `media_marker`, + which the server randomizes per startup unless LLAMA_MEDIA_MARKER + is set. + """ + info: dict[str, Any] = { + "context_size": None, + "supports_vision": False, + "supports_audio": False, + "supports_tools": False, + "supports_reasoning": False, + "media_marker": "<__media__>", + } + + model_entry: dict[str, Any] | None = None + try: + response = self._get(f"{base_url}/v1/models", timeout=10) + response.raise_for_status() + models_data = response.json() + + for model in models_data.get("data", []): + model_ids = {model.get("id")} + for alias in model.get("aliases", []): + model_ids.add(alias) + if configured_model in model_ids: + model_entry = model + break + + if model_entry is None: + available = [] + for m in models_data.get("data", []): + available.append(m.get("id", "unknown")) + for alias in m.get("aliases", []): + available.append(alias) + logger.error( + "Model '%s' not found on llama.cpp server. Available models: %s", + configured_model, + available, + ) + return None + except Exception as e: + logger.warning( + "Failed to query llama.cpp /v1/models endpoint: %s. " + "Model validation skipped.", + e, + ) + + if model_entry is not None: + architecture = model_entry.get("architecture") or {} + input_modalities = architecture.get("input_modalities") or [] + + if isinstance(input_modalities, list): + info["supports_vision"] = "image" in input_modalities + info["supports_audio"] = "audio" in input_modalities + + status = model_entry.get("status") or {} + launch_args = status.get("args") if isinstance(status, dict) else None + if not isinstance(launch_args, list): + launch_args = [] + + meta = model_entry.get("meta") if isinstance(model_entry, dict) else None + n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None + + if not n_ctx: + n_ctx = _parse_launch_arg(launch_args, "--ctx-size") + + if n_ctx: + try: + info["context_size"] = int(n_ctx) + except (TypeError, ValueError): + pass + + # Tool calling on llama-server requires --jinja. + if "--jinja" in launch_args: + info["supports_tools"] = True + + try: + props = self._fetch_llama_props(base_url, configured_model) + + if info["context_size"] is None: + default_settings = props.get("default_generation_settings", {}) + n_ctx = default_settings.get("n_ctx") + if n_ctx: + info["context_size"] = int(n_ctx) + + if not (info["supports_vision"] or info["supports_audio"]): + modalities = props.get("modalities", {}) + 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"]: + 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 + except Exception as e: + logger.warning( + "Failed to query llama.cpp /props endpoint: %s. " + "Image embeddings may fail if the server randomized its media marker.", + e, + ) + + return info + + def _send( + self, + prompt: str, + images: list[bytes], + response_format: dict | None = None, + enable_thinking: bool = False, + ) -> str | None: + """Submit a request to llama.cpp server.""" + if self.provider is None: + logger.warning( + "llama.cpp provider has not been initialized, a description will not be generated. Check your llama.cpp configuration." + ) + return None + + try: + content = [ + { + "type": "text", + "text": prompt, + } + ] + for image in images: + encoded_image = base64.b64encode(image).decode("utf-8") + content.append( + { + "type": "image_url", + "image_url": { # type: ignore[dict-item] + "url": f"data:image/jpeg;base64,{encoded_image}", + }, + } + ) + + # Build request payload with llama.cpp native options + payload: dict[str, Any] = { + "model": self.genai_config.model, + "messages": [ + { + "role": "user", + "content": content, + }, + ], + **self.provider_options, + } + + if response_format: + payload["response_format"] = response_format + + if self.supports_toggleable_thinking: + payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking} + + response = self._post( + f"{self.provider}/v1/chat/completions", + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + result = response.json() + + if ( + result is not None + and "choices" in result + and len(result["choices"]) > 0 + ): + choice = result["choices"][0] + if "message" in choice and "content" in choice["message"]: + return str(choice["message"]["content"].strip()) + return None + except Exception as e: + logger.warning("llama.cpp returned an error: %s", str(e)) + return None + + @property + def supports_vision(self) -> bool: + """Whether the loaded model supports vision/image input.""" + return self._supports_vision + + @property + def supports_audio(self) -> bool: + """Whether the loaded model supports audio input.""" + return self._supports_audio + + @property + def supports_tools(self) -> bool: + """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 ( + self.genai_config.base_url.rstrip("/") + if self.genai_config.base_url + else None + ) + if base_url is None: + return [] + try: + response = self._get(f"{base_url}/v1/models", timeout=10) + response.raise_for_status() + models = [] + for m in response.json().get("data", []): + models.append(m.get("id", "unknown")) + for alias in m.get("aliases", []): + models.append(alias) + return sorted(models) + except Exception as e: + logger.warning("Failed to list llama.cpp models: %s", e) + return [] + + def get_context_size(self) -> int: + """Get the context window size for llama.cpp. + + Resolution order: + 1. provider_options["context_size"] (user override) + 2. Value queried from llama.cpp server at init + 3. Default fallback of 4096 + """ + if "context_size" in self.provider_options: + return int(self.provider_options["context_size"]) + if self._context_size is not None: + return self._context_size + return 4096 + + def estimate_image_tokens(self, width: int, height: int) -> float: + """Probe the llama.cpp server to learn the model's image-token cost at the + requested dimensions. + + llama.cpp's image tokenization is a deterministic function of dimensions and + the loaded mmproj, so the result is cached per (width, height) for the + lifetime of the process. Falls back to the base pixel heuristic if the + server is unreachable or the response is malformed. + """ + if self.provider is None: + return super().estimate_image_tokens(width, height) + + cached = self._image_token_cache.get((width, height)) + + if cached is not None: + return cached + + try: + baseline = self._probe_baseline_tokens() + with_image = self._probe_image_prompt_tokens(width, height) + tokens = max(1, with_image - baseline) + except Exception as e: + logger.debug( + "llama.cpp image-token probe failed for %dx%d (%s); using heuristic", + width, + height, + e, + ) + return super().estimate_image_tokens(width, height) + + self._image_token_cache[(width, height)] = tokens + logger.debug( + "llama.cpp model '%s' uses ~%d tokens for %dx%d images", + self.genai_config.model, + tokens, + width, + height, + ) + return tokens + + def _probe_baseline_tokens(self) -> int: + """Return prompt_tokens for a minimal text-only request. Cached after first call.""" + if self._text_baseline_tokens is not None: + return self._text_baseline_tokens + + self._text_baseline_tokens = self._probe_prompt_tokens( + [{"type": "text", "text": "."}] + ) + return self._text_baseline_tokens + + def _probe_image_prompt_tokens(self, width: int, height: int) -> int: + """Return prompt_tokens for a single synthetic image plus minimal text.""" + img = Image.new("RGB", (width, height), (128, 128, 128)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=60) + encoded = base64.b64encode(buf.getvalue()).decode("utf-8") + return self._probe_prompt_tokens( + [ + {"type": "text", "text": "."}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{encoded}"}, + }, + ] + ) + + def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int: + """POST a 1-token chat completion and return reported prompt_tokens. + + Uses a generous timeout to absorb a cold model load on the first probe + when the server lazily loads models on demand (e.g. llama-swap). + """ + payload = { + "model": self.genai_config.model, + "messages": [{"role": "user", "content": content}], + "max_tokens": 1, + } + response = self._post( + f"{self.provider}/v1/chat/completions", + json=payload, + timeout=60, + ) + response.raise_for_status() + return int(response.json()["usage"]["prompt_tokens"]) + + def _build_payload( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: str | None, + stream: bool = False, + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """Build request payload for chat completions (sync or stream).""" + 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" + + payload: dict[str, Any] = { + "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, 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, + } + + @staticmethod + def _streamed_tool_calls_to_list( + tool_calls_by_index: dict[int, dict[str, Any]], + ) -> list[dict[str, Any]] | None: + """Convert streamed tool_calls index map to list of {id, name, arguments}.""" + if not tool_calls_by_index: + return None + result = [] + for idx in sorted(tool_calls_by_index.keys()): + t = tool_calls_by_index[idx] + args_str = t.get("arguments") or "{}" + try: + arguments = json.loads(args_str) + except json.JSONDecodeError: + arguments = {} + result.append( + { + "id": t.get("id", ""), + "name": t.get("name", ""), + "arguments": arguments, + } + ) + return result if result else None + + def _refresh_media_marker(self) -> bool: + """Re-fetch /props and update the cached media marker if it changed. + + The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER + is set), so a stale marker indicates a restart. Returns True iff the + marker was updated to a new value — used to gate a one-shot retry of + a failed embeddings request. + """ + if self.provider is None: + return False + try: + props = self._fetch_llama_props(self.provider, self.genai_config.model) + except Exception as e: + logger.warning("Failed to refresh llama.cpp media marker: %s", e) + return False + + marker = props.get("media_marker") + + if not isinstance(marker, str) or not marker or marker == self._media_marker: + return False + + logger.info("llama.cpp media marker changed (server restart); refreshed") + self._media_marker = marker + return True + + def embed( + self, + texts: list[str] | None = None, + images: list[bytes] | None = None, + ) -> list[np.ndarray]: + """Generate embeddings via llama.cpp /embeddings endpoint. + + Supports batch requests. Uses content format with prompt_string and + multimodal_data for images (PR #15108). Server must be started with + --embeddings and --mmproj for multimodal support. + """ + if self.provider is None: + logger.warning( + "llama.cpp provider has not been initialized. Check your llama.cpp configuration." + ) + return [] + + texts = texts or [] + images = images or [] + if not texts and not images: + return [] + + EMBEDDING_DIM = 768 + + encoded_images: list[str] = [] + for img in images: + # llama.cpp uses STB which does not support WebP; convert to JPEG + jpeg_bytes = _to_jpeg(img) + to_encode = jpeg_bytes if jpeg_bytes is not None else img + encoded_images.append(base64.b64encode(to_encode).decode("utf-8")) + + def build_content() -> list[dict[str, Any]]: + # prompt_string must contain the server's media marker placeholder + # for each image. The marker is randomized per server startup. + content: list[dict[str, Any]] = [] + for text in texts: + content.append({"prompt_string": text}) + for encoded in encoded_images: + content.append( + { + "prompt_string": f"{self._media_marker}\n", + "multimodal_data": [encoded], + } + ) + return content + + def post_embeddings() -> requests.Response: + return self._post( + f"{self.provider}/embeddings", + json={"model": self.genai_config.model, "content": build_content()}, + timeout=self.timeout, + ) + + try: + try: + response = post_embeddings() + response.raise_for_status() + except requests.exceptions.RequestException: + # The server may have restarted with a new media marker. + # Refresh from /props; only retry if the marker actually changed. + if not encoded_images or not self._refresh_media_marker(): + raise + response = post_embeddings() + response.raise_for_status() + result = response.json() + + items = result.get("data", result) if isinstance(result, dict) else result + if not isinstance(items, list): + logger.warning("llama.cpp embeddings returned unexpected format") + return [] + + embeddings = [] + for item in items: + emb = item.get("embedding") if isinstance(item, dict) else None + if emb is None: + logger.warning("llama.cpp embeddings item missing embedding field") + continue + arr = np.array(emb, dtype=np.float32) + if arr.ndim > 1: + # llama.cpp can return token-level embeddings; pool per item + arr = arr.mean(axis=0) + arr = arr.flatten() + orig_dim = arr.size + if orig_dim != EMBEDDING_DIM: + if orig_dim > EMBEDDING_DIM: + arr = arr[:EMBEDDING_DIM] + logger.debug( + "Truncated llama.cpp embedding from %d to %d dimensions", + orig_dim, + EMBEDDING_DIM, + ) + else: + arr = np.pad( + arr, + (0, EMBEDDING_DIM - orig_dim), + mode="constant", + constant_values=0, + ) + logger.debug( + "Padded llama.cpp embedding from %d to %d dimensions", + orig_dim, + EMBEDDING_DIM, + ) + embeddings.append(arr) + return embeddings + except requests.exceptions.Timeout: + logger.warning("llama.cpp embeddings request timed out") + return [] + except requests.exceptions.RequestException as e: + error_detail = str(e) + if hasattr(e, "response") and e.response is not None: + try: + error_detail = f"{str(e)} - Response: {e.response.text[:500]}" + except Exception: + pass + logger.warning("llama.cpp embeddings error: %s", error_detail) + return [] + except Exception as e: + logger.warning("Unexpected error in llama.cpp embeddings: %s", str(e)) + return [] + + def chat_with_tools( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """ + Send chat messages to llama.cpp server with optional tool definitions. + + Uses the OpenAI-compatible endpoint but passes through all native llama.cpp + parameters (like slot_id, temperature, etc.) via provider_options. + """ + if self.provider is None: + logger.warning( + "llama.cpp provider has not been initialized. Check your llama.cpp configuration." + ) + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + try: + payload = self._build_payload( + messages, + tools, + tool_choice, + stream=False, + enable_thinking=enable_thinking, + ) + response = self._post( + f"{self.provider}/v1/chat/completions", + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + result = response.json() + if result is None or "choices" not in result or len(result["choices"]) == 0: + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + return self._message_from_choice(result["choices"][0]) + except requests.exceptions.Timeout as e: + logger.warning("llama.cpp request timed out: %s", str(e)) + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + except requests.exceptions.RequestException as e: + error_detail = str(e) + if hasattr(e, "response") and e.response is not None: + try: + error_detail = f"{str(e)} - Response: {e.response.text[:500]}" + except Exception: + pass + logger.warning("llama.cpp returned an error: %s", error_detail) + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + except Exception as e: + logger.warning("Unexpected error in llama.cpp chat_with_tools: %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: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> AsyncGenerator[tuple[str, Any], None]: + """Stream chat with tools via OpenAI-compatible streaming API.""" + if self.provider is None: + logger.warning( + "llama.cpp provider has not been initialized. Check your llama.cpp configuration." + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + return + try: + 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" + + async with httpx.AsyncClient(timeout=float(self.timeout)) as client: + async with client.stream( + "POST", + f"{self.provider}/v1/chat/completions", + json=payload, + headers=self._auth_headers(), + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except json.JSONDecodeError: + continue + maybe_stats = _stats_from_llama_cpp_chunk(data) + if maybe_stats is not None: + yield ("stats", maybe_stats) + choices = data.get("choices") or [] + if not choices: + continue + 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"]) + for tc in delta.get("tool_calls") or []: + idx = tc.get("index", 0) + fn = tc.get("function") or {} + if idx not in tool_calls_by_index: + tool_calls_by_index[idx] = { + "id": tc.get("id", ""), + "name": tc.get("name") or fn.get("name", ""), + "arguments": "", + } + t = tool_calls_by_index[idx] + if tc.get("id"): + t["id"] = tc["id"] + name = tc.get("name") or fn.get("name") + if name: + t["name"] = name + arg = tc.get("arguments") or fn.get("arguments") + if arg is not None: + t["arguments"] += ( + arg if isinstance(arg, str) else json.dumps(arg) + ) + + 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" + yield ( + "message", + { + "content": full_content, + "reasoning": full_reasoning, + "tool_calls": tool_calls_list, + "finish_reason": finish_reason, + }, + ) + except httpx.HTTPStatusError as e: + logger.warning("llama.cpp streaming HTTP error: %s", e) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning( + "Unexpected error in llama.cpp chat_with_tools_stream: %s", str(e) + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/plugins/ollama.py b/frigate/genai/plugins/ollama.py new file mode 100644 index 0000000000..f323b0faa7 --- /dev/null +++ b/frigate/genai/plugins/ollama.py @@ -0,0 +1,565 @@ +"""Ollama Provider for Frigate AI.""" + +import base64 +import binascii +import json +import logging +from collections.abc import AsyncGenerator +from typing import Any + +from httpx import RemoteProtocolError, TimeoutException +from ollama import AsyncClient as OllamaAsyncClient +from ollama import Client as ApiClient +from ollama import ResponseError + +from frigate.config import GenAIProviderEnum +from frigate.genai import GenAIClient, register_genai_provider +from frigate.genai.utils import parse_tool_calls_from_message + +logger = logging.getLogger(__name__) + + +def _extract_ollama_stats(response: Any) -> dict[str, Any] | None: + """Build a stats dict from Ollama's response metadata. + + Ollama reports eval_count/eval_duration (generation) and + prompt_eval_count (context size). Durations are nanoseconds. + """ + if not response: + return None + if hasattr(response, "get"): + getter = response.get + else: + getter = lambda key: getattr(response, key, None) # noqa: E731 + + eval_count = getter("eval_count") + eval_duration_ns = getter("eval_duration") + prompt_eval_count = getter("prompt_eval_count") + if eval_count is None and prompt_eval_count is None: + return None + + stats: dict[str, Any] = {} + if isinstance(prompt_eval_count, int): + stats["prompt_tokens"] = prompt_eval_count + if isinstance(eval_count, int): + stats["completion_tokens"] = eval_count + if isinstance(eval_duration_ns, int) and eval_duration_ns > 0: + stats["completion_duration_ms"] = eval_duration_ns / 1_000_000 + if isinstance(eval_count, int) and eval_count > 0: + stats["tokens_per_second"] = eval_count / (eval_duration_ns / 1_000_000_000) + return stats or None + + +def _normalize_multimodal_content( + content: Any, +) -> tuple[str | None, list[bytes] | None]: + """Convert OpenAI-style multimodal content to Ollama's (text, images) shape. + + The chat API constructs user messages with content as a list of + ``{"type": "text"}`` and ``{"type": "image_url"}`` parts when a tool + returns a live frame. Ollama's SDK requires content to be a string and + images to be passed in a separate field, so we extract each. + """ + if not isinstance(content, list): + return content, None + + text_parts: list[str] = [] + images: list[bytes] = [] + for part in content: + if not isinstance(part, dict): + continue + part_type = part.get("type") + if part_type == "text": + text = part.get("text") + if text: + text_parts.append(str(text)) + elif part_type == "image_url": + url = (part.get("image_url") or {}).get("url", "") + if isinstance(url, str) and url.startswith("data:"): + try: + encoded = url.split(",", 1)[1] + images.append(base64.b64decode(encoded, validate=True)) + except (ValueError, IndexError, binascii.Error) as e: + logger.debug("Failed to decode multimodal image url: %s", e) + + return ("\n".join(text_parts) if text_parts else None), (images or None) + + +@register_genai_provider(GenAIProviderEnum.ollama) +class OllamaClient(GenAIClient): + """Generative AI client for Frigate using Ollama.""" + + LOCAL_OPTIMIZED_OPTIONS = { + "options": { + "temperature": 0.5, + "repeat_penalty": 1.05, + "presence_penalty": 0.3, + }, + } + + provider: ApiClient | None + provider_options: dict[str, Any] + _supports_thinking_cache: bool | None = 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: + return {"Authorization": "Bearer " + self.genai_config.api_key} + + return None + + def _init_provider(self) -> ApiClient | None: + """Initialize the client.""" + self.provider_options = { + **self.LOCAL_OPTIMIZED_OPTIONS, + **self.genai_config.provider_options, + } + + try: + client = ApiClient( + host=self.genai_config.base_url, + 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"): + logger.error( + "Ollama error: %s", + response["error"], + ) + return None + return client + except Exception as e: + logger.warning("Error initializing Ollama: %s", str(e)) + return None + + @staticmethod + def _clean_schema_for_ollama(schema: dict, *, _is_properties: bool = False) -> dict: + """Strip Pydantic metadata from a JSON schema for Ollama compatibility. + + Ollama's grammar-based constrained generation works best with minimal + schemas. Pydantic adds title/description/constraint fields that can + cause the grammar generator to silently skip required fields. + + Keys inside a ``properties`` dict are actual field names and must never + be stripped, even if they collide with a metadata key name (e.g. a + model field called ``title``). + """ + STRIP_KEYS = { + "title", + "description", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + } + result: dict[str, Any] = {} + for key, value in schema.items(): + if not _is_properties and key in STRIP_KEYS: + continue + if isinstance(value, dict): + result[key] = OllamaClient._clean_schema_for_ollama( + value, _is_properties=(key == "properties") + ) + elif isinstance(value, list): + result[key] = [ + OllamaClient._clean_schema_for_ollama(item) + if isinstance(item, dict) + else item + for item in value + ] + else: + result[key] = value + return result + + def _send( + self, + prompt: str, + images: list[bytes], + response_format: dict | None = None, + enable_thinking: bool = False, + ) -> str | None: + """Submit a request to Ollama""" + if self.provider is None: + logger.warning( + "Ollama provider has not been initialized, a description will not be generated. Check your Ollama configuration." + ) + return None + try: + ollama_options = { + **self.provider_options, + **self.genai_config.runtime_options, + } + if response_format and response_format.get("type") == "json_schema": + 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", + self.genai_config.model, + len(prompt), + len(images) if images else 0, + "format" in ollama_options, + {k: v for k, v in ollama_options.items() if k != "format"}, + ) + result = self.provider.generate( + self.genai_config.model, + prompt, + images=images if images else None, + **ollama_options, + ) + logger.debug( + "Ollama generate response: done=%s, done_reason=%s, eval_count=%s, " + "prompt_eval_count=%s, response_len=%s", + result.get("done"), + result.get("done_reason"), + result.get("eval_count"), + result.get("prompt_eval_count"), + len(result.get("response", "") or ""), + ) + response_text = str(result["response"]).strip() + if not response_text: + logger.warning( + "Ollama returned a blank response for model %s (done_reason=%s, " + "eval_count=%s). Check model output, ensure thinking is disabled.", + self.genai_config.model, + result.get("done_reason"), + result.get("eval_count"), + ) + return response_text + except ( + TimeoutException, + ResponseError, + RemoteProtocolError, + ConnectionError, + ) as e: + logger.warning("Ollama returned an error: %s", str(e)) + return None + + def list_models(self) -> list[str]: + """Return available model names from the Ollama server.""" + client = self.provider + if client is None: + # Provider init may have failed due to invalid model, but we can + # still list available models with a fresh client. + if not self.genai_config.base_url: + return [] + try: + client = ApiClient( + host=self.genai_config.base_url, + timeout=self.timeout, + headers=self._auth_headers(), + ) + except Exception: + return [] + try: + response = client.list() + return sorted( + m.get("name", m.get("model", "")) for m in response.get("models", []) + ) + except Exception as e: + logger.warning("Failed to list Ollama models: %s", e) + return [] + + def get_context_size(self) -> int: + """Get the context window size for Ollama.""" + return int( + self.genai_config.provider_options.get("options", {}).get("num_ctx", 4096) + ) + + def _build_request_params( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: str | None, + stream: bool = False, + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """Build request_messages and params for chat (sync or stream).""" + request_messages = [] + for msg in messages: + content, images = _normalize_multimodal_content(msg.get("content", "")) + msg_dict: dict[str, Any] = { + "role": msg.get("role"), + "content": content if content is not None else "", + } + if images: + msg_dict["images"] = images + if msg.get("tool_call_id"): + msg_dict["tool_call_id"] = msg["tool_call_id"] + if msg.get("name"): + msg_dict["name"] = msg["name"] + if msg.get("tool_calls"): + # Ollama requires tool call arguments as dicts, but the + # conversation format (OpenAI-style) stores them as JSON + # strings. Convert back to dicts for Ollama. + ollama_tool_calls = [] + for tc in msg["tool_calls"]: + func = tc.get("function") or {} + args = func.get("arguments") or {} + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, TypeError): + args = {} + ollama_tool_calls.append( + {"function": {"name": func.get("name", ""), "arguments": args}} + ) + msg_dict["tool_calls"] = ollama_tool_calls + request_messages.append(msg_dict) + + request_params: dict[str, Any] = { + "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]: + """Parse Ollama chat response into {content, tool_calls, finish_reason}.""" + if not response or "message" not in response: + logger.debug("Ollama response empty or missing 'message' key") + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + message = response["message"] + logger.debug( + "Ollama response message keys: %s, content_len=%s, thinking_len=%s, " + "tool_calls=%s, done=%s", + list(message.keys()) if hasattr(message, "keys") else "N/A", + len(message.get("content", "") or "") if message.get("content") else 0, + len(message.get("thinking", "") or "") if message.get("thinking") else 0, + bool(message.get("tool_calls")), + 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"): + finish_reason = ( + "tool_calls" if tool_calls else "stop" if content else "error" + ) + elif tool_calls: + finish_reason = "tool_calls" + elif content: + finish_reason = "stop" + return { + "content": content, + "reasoning": reasoning, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + } + + def chat_with_tools( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + if self.provider is None: + logger.warning( + "Ollama provider has not been initialized. Check your Ollama configuration." + ) + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + try: + request_params = self._build_request_params( + messages, + tools, + tool_choice, + stream=False, + enable_thinking=enable_thinking, + ) + response = self.provider.chat(**request_params) + return self._message_from_response(response) + except (TimeoutException, ResponseError, ConnectionError) as e: + logger.warning("Ollama returned an error: %s", str(e)) + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + except Exception as e: + logger.warning("Unexpected error in Ollama chat_with_tools: %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: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> AsyncGenerator[tuple[str, Any], None]: + """Stream chat with tools; yields content deltas then final message. + + When tools are provided, Ollama streaming does not include tool_calls + in the response chunks. To work around this, we use a non-streaming + call when tools are present to ensure tool calls are captured, then + emit the content as a single delta followed by the final message. + """ + if self.provider is None: + logger.warning( + "Ollama provider has not been initialized. Check your Ollama configuration." + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + return + try: + # Ollama does not return tool_calls in streaming mode, so fall + # back to a non-streaming call when tools are provided. + if tools: + logger.debug( + "Ollama: tools provided, using non-streaming call for tool support" + ) + request_params = self._build_request_params( + messages, + tools, + tool_choice, + stream=False, + enable_thinking=enable_thinking, + ) + async_client = OllamaAsyncClient( + host=self.genai_config.base_url, + timeout=self.timeout, + headers=self._auth_headers(), + ) + 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) + stats = _extract_ollama_stats(response) + if stats is not None: + yield ("stats", stats) + yield ("message", result) + return + + request_params = self._build_request_params( + messages, + tools, + tool_choice, + stream=True, + enable_thinking=enable_thinking, + ) + async_client = OllamaAsyncClient( + host=self.genai_config.base_url, + timeout=self.timeout, + 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) + async for chunk in stream: + 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) + yield ("content_delta", delta) + 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", + } + break + + stats = _extract_ollama_stats(final_chunk) + if stats is not None: + yield ("stats", stats) + + if final_message is not None: + yield ("message", final_message) + else: + yield ( + "message", + { + "content": "".join(content_parts).strip() or None, + "reasoning": "".join(reasoning_parts).strip() or None, + "tool_calls": None, + "finish_reason": "stop", + }, + ) + except (TimeoutException, ResponseError, ConnectionError) as e: + logger.warning("Ollama streaming error: %s", str(e)) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning( + "Unexpected error in Ollama chat_with_tools_stream: %s", str(e) + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/plugins/openai.py b/frigate/genai/plugins/openai.py new file mode 100644 index 0000000000..e89ab93922 --- /dev/null +++ b/frigate/genai/plugins/openai.py @@ -0,0 +1,482 @@ +"""OpenAI Provider for Frigate AI.""" + +import base64 +import json +import logging +from collections.abc import AsyncGenerator +from typing import Any + +from httpx import TimeoutException +from openai import OpenAI + +from frigate.config import GenAIProviderEnum +from frigate.genai import GenAIClient, register_genai_provider + +logger = logging.getLogger(__name__) + + +def _stats_from_openai_usage(usage: Any) -> dict[str, Any] | None: + """Build a stats dict from an OpenAI-compatible usage object.""" + if usage is None: + return None + prompt_tokens = getattr(usage, "prompt_tokens", None) + completion_tokens = getattr(usage, "completion_tokens", None) + if prompt_tokens is None and completion_tokens is None: + return None + stats: dict[str, Any] = {} + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + return stats or None + + +@register_genai_provider(GenAIProviderEnum.openai) +class OpenAIClient(GenAIClient): + """Generative AI client for Frigate using OpenAI.""" + + provider: OpenAI + context_size: int | None = None + + def _init_provider(self) -> OpenAI: + """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 = { + k: v + for k, v in self.genai_config.provider_options.items() + if k != "context_size" + } + + if self.genai_config.base_url: + provider_opts["base_url"] = self.genai_config.base_url + + return OpenAI(api_key=self.genai_config.api_key, **provider_opts) + + def _send( + self, + prompt: str, + images: list[bytes], + response_format: dict | None = None, + enable_thinking: bool = False, + ) -> str | None: + """Submit a request to OpenAI.""" + encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] + messages_content: list[dict] = [ + { + "type": "text", + "text": prompt, + } + ] + for image in encoded_images: + messages_content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image}", + "detail": "low", + }, + } + ) + try: + request_params = { + "model": self.genai_config.model, + "messages": [ + { + "role": "user", + "content": messages_content, + }, + ], + "timeout": self.timeout, + **self.genai_config.runtime_options, + } + if response_format: + # OpenAI strict mode requires additionalProperties: false on the schema + if response_format.get("type") == "json_schema" and response_format.get( + "json_schema", {} + ).get("strict"): + schema = response_format.get("json_schema", {}).get("schema") + if isinstance(schema, dict): + schema["additionalProperties"] = False + request_params["response_format"] = response_format + + result = self.provider.chat.completions.create(**request_params) + + if ( + result is not None + and hasattr(result, "choices") + and len(result.choices) > 0 + ): + message = result.choices[0].message + content = message.content + + if not content: + # When reasoning is enabled for some OpenAI backends the actual response + # is incorrectly placed in reasoning_content instead of content. + # This is buggy/incorrect behavior — reasoning should not be + # enabled for these models. + reasoning_content = getattr(message, "reasoning_content", None) + if reasoning_content: + logger.warning( + "Response content was empty but reasoning_content was provided; " + "reasoning appears to be enabled and should be disabled for this model." + ) + content = reasoning_content + + return str(content.strip()) if content else None + return None + except (TimeoutException, Exception) as e: + logger.warning("OpenAI returned an error: %s", str(e)) + return None + + def list_models(self) -> list[str]: + """Return available model IDs from the OpenAI-compatible API.""" + try: + return sorted(m.id for m in self.provider.models.list().data) + except Exception as e: + logger.warning("Failed to list OpenAI models: %s", e) + return [] + + def get_context_size(self) -> int: + """Get the context window size for OpenAI.""" + if self.context_size is not None: + return self.context_size + + # First check provider_options for manually specified context size + # This is necessary for llama.cpp and other OpenAI-compatible servers + # that don't expose the configured runtime context size in the API response + if "context_size" in self.genai_config.provider_options: + self.context_size = self.genai_config.provider_options["context_size"] + logger.debug( + f"Using context size {self.context_size} from provider_options for model {self.genai_config.model}" + ) + return self.context_size + + try: + models = self.provider.models.list() + for model in models.data: + if model.id == self.genai_config.model: + if hasattr(model, "max_model_len") and model.max_model_len: + self.context_size = model.max_model_len + logger.debug( + f"Retrieved context size {self.context_size} for model {self.genai_config.model}" + ) + return self.context_size + + except Exception as e: + logger.debug( + f"Failed to fetch model context size from API: {e}, using default" + ) + + # Default to 128K for ChatGPT models, 8K for others + model_name = self.genai_config.model.lower() + if "gpt" in model_name: + self.context_size = 128000 + else: + self.context_size = 8192 + + logger.debug( + f"Using default context size {self.context_size} for model {self.genai_config.model}" + ) + return self.context_size + + def chat_with_tools( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = None, + ) -> dict[str, Any]: + """ + Send chat messages to OpenAI with optional tool definitions. + + 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 + 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, + **self.genai_config.runtime_options, + } + + if tools: + request_params["tools"] = tools + if openai_tool_choice is not None: + request_params["tool_choice"] = openai_tool_choice + + if isinstance(self.genai_config.provider_options, dict): + excluded_options = {"context_size"} + provider_opts = { + k: v + for k, v in self.genai_config.provider_options.items() + if k not in excluded_options + } + request_params.update(provider_opts) + + result = self.provider.chat.completions.create(**request_params) + + 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 + 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: + 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, + "reasoning": reasoning, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + } + + except TimeoutException as e: + logger.warning("OpenAI request timed out: %s", str(e)) + return { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + } + except Exception as e: + logger.warning("OpenAI returned an error: %s", str(e)) + return { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + } + + async def chat_with_tools_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | None = "auto", + enable_thinking: bool | None = 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 + 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}, + **self.genai_config.runtime_options, + } + + if tools: + request_params["tools"] = tools + if openai_tool_choice is not None: + request_params["tool_choice"] = openai_tool_choice + + if isinstance(self.genai_config.provider_options, dict): + excluded_options = {"context_size"} + provider_opts = { + k: v + for k, v in self.genai_config.provider_options.items() + if k not in excluded_options + } + request_params.update(provider_opts) + + # 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: dict[str, Any] | None = None + + stream = self.provider.chat.completions.create(**request_params) + + 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 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) + 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 + full_reasoning = "".join(reasoning_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"] or "{}") + except (json.JSONDecodeError, ValueError): + logger.warning( + "Failed to parse streamed tool call arguments for %s", + tc["name"], + ) + parsed_args = {} + + # Downstream (ToolCall model) requires a dict; never leak a + # partial/invalid arguments string. + if not isinstance(parsed_args, dict): + parsed_args = {} + + 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, + "reasoning": full_reasoning, + "tool_calls": tool_calls_list, + "finish_reason": finish_reason, + }, + ) + + except TimeoutException as e: + logger.warning("OpenAI streaming request timed out: %s", str(e)) + yield ( + "message", + { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning("OpenAI streaming returned an error: %s", str(e)) + yield ( + "message", + { + "content": None, + "reasoning": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/prompts.py b/frigate/genai/prompts.py new file mode 100644 index 0000000000..33045606eb --- /dev/null +++ b/frigate/genai/prompts.py @@ -0,0 +1,758 @@ +"""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, Literal + +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": , "objects": [, ...]}. + 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: list[dict[str, Any]] | None = None, + embeddings_language: Literal["english", "multi"] = "multi", +) -> 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. When the + embeddings model only understands English (JinaV1), the `semantic_query` + description instructs the model to write the query in English. + """ + 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')." + + ( + " The configured embeddings model only understands " + "English, so always write semantic_query in English, " + "translating the user's description if they phrased it " + "in another language." + if embeddings_language == "english" + else "" + ) + ), + } + + 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_descriptors = [ + f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})" + for zone_name, zone_config in camera_config.zones.items() + ] + if not has_speed_zone: + has_speed_zone = any( + zone.distances for zone in camera_config.zones.values() + ) + if zone_descriptors: + cameras_info.append( + f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})" + ) + 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 or zones by their friendly name (e.g., 'Back Deck Camera', 'Front Walkway'), use the corresponding ID (e.g., 'back_deck_cam', 'front_walk') in tool calls. Tool results also identify zones by their ID, so when presenting cameras or zones back to the user, translate the ID to its friendly name." + ) + + 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:], 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}""" diff --git a/frigate/genai/utils.py b/frigate/genai/utils.py new file mode 100644 index 0000000000..fbd6f2110c --- /dev/null +++ b/frigate/genai/utils.py @@ -0,0 +1,83 @@ +"""Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing).""" + +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def parse_tool_calls_from_message( + message: dict[str, Any], +) -> list[dict[str, Any]] | None: + """ + Parse tool_calls from an OpenAI-style message dict. + + Message may have "tool_calls" as a list of: + {"id": str, "function": {"name": str, "arguments": str}, ...} + + Returns a list of {"id", "name", "arguments"} with arguments parsed as dict, + or None if no tool_calls. Used by Ollama and LlamaCpp (non-stream) responses. + """ + raw = message.get("tool_calls") + if not raw or not isinstance(raw, list): + return None + result = [] + for idx, tool_call in enumerate(raw): + function_data = tool_call.get("function") or {} + raw_arguments = function_data.get("arguments") or {} + if isinstance(raw_arguments, dict): + arguments = raw_arguments + elif isinstance(raw_arguments, str): + try: + arguments = json.loads(raw_arguments) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning( + "Failed to parse tool call arguments: %s, tool: %s", + e, + function_data.get("name", "unknown"), + ) + arguments = {} + else: + arguments = {} + result.append( + { + "id": tool_call.get("id", "") or f"call_{idx}", + "name": function_data.get("name", ""), + "arguments": arguments, + } + ) + return result if result else None + + +def build_assistant_message_for_conversation( + content: Any, + tool_calls_raw: list[dict[str, Any]] | None, +) -> dict[str, Any]: + """ + Build the assistant message dict in OpenAI format for appending to a conversation. + + tool_calls_raw: list of {"id", "name", "arguments"} (arguments as dict), or None. + """ + msg: dict[str, Any] = {"role": "assistant", "content": content} + if tool_calls_raw: + msg["tool_calls"] = [ + { + "id": tc["id"], + "type": "function", + "function": { + "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 + ] + return msg diff --git a/frigate/jobs/__init__.py b/frigate/jobs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py new file mode 100644 index 0000000000..393211ea99 --- /dev/null +++ b/frigate/jobs/debug_replay.py @@ -0,0 +1,497 @@ +"""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 +presence so the status bar can keep reading a single `active` flag from +/debug_replay/status for the entire session window — which is broader +than this job's lifetime. +""" + +import logging +import os +import subprocess as sp +import threading +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, cast + +from peewee import ModelSelect + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR +from frigate.jobs.export import JobStatePublisher +from frigate.jobs.job import Job +from frigate.jobs.manager import job_is_running, set_current_job +from frigate.models import Export, Recordings +from frigate.types import JobStatusTypesEnum +from frigate.util.ffmpeg import run_ffmpeg_with_progress + +if TYPE_CHECKING: + from frigate.debug_replay import DebugReplayManager + +logger = logging.getLogger(__name__) + +# Coalesce frequent ffmpeg progress callbacks so the WS isn't flooded. +PROGRESS_BROADCAST_MIN_INTERVAL = 1.0 + +JOB_TYPE = "debug_replay" + +STEP_PREPARING_CLIP = "preparing_clip" +STEP_STARTING_CAMERA = "starting_camera" + + +_active_runner: Optional["DebugReplayJobRunner"] = None +_runner_lock = threading.Lock() + + +def _set_active_runner(runner: Optional["DebugReplayJobRunner"]) -> None: + global _active_runner + with _runner_lock: + _active_runner = runner + + +def get_active_runner() -> Optional["DebugReplayJobRunner"]: + with _runner_lock: + return _active_runner + + +@dataclass +class DebugReplayJob(Job): + """Job state for a debug replay startup.""" + + job_type: str = JOB_TYPE + source_camera: str = "" + replay_camera_name: str = "" + start_ts: float = 0.0 + end_ts: float = 0.0 + current_step: str | None = None + progress_percent: float = 0.0 + + def to_dict(self) -> dict[str, Any]: + """Whitelisted payload for the job_state WS topic. + + Replay-specific fields land in results so the frontend's + generic Job type can be parameterised cleanly. + """ + return { + "id": self.id, + "job_type": self.job_type, + "status": self.status, + "start_time": self.start_time, + "end_time": self.end_time, + "error_message": self.error_message, + "results": { + "current_step": self.current_step, + "progress_percent": self.progress_percent, + "source_camera": self.source_camera, + "replay_camera_name": self.replay_camera_name, + "start_ts": self.start_ts, + "end_ts": self.end_ts, + }, + } + + +def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect: + """Return the Recordings query for the time range. + + Module-level so tests can patch it without instantiating a runner. + """ + query = ( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(start_ts, end_ts) + | Recordings.end_time.between(start_ts, end_ts) + | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) + ) + .where(Recordings.camera == source_camera) + .order_by(Recordings.start_time.asc()) + ) + return cast(ModelSelect, query) + + +class NoRecordingsError(ValueError): + """Raised when no recordings exist in the requested time range.""" + + +class DebugReplaySource(ABC): + """Abstract source for a debug replay session. + + Provides the camera identity and time range the replay represents, + validates that usable content exists, and supplies the ffmpeg input + args used to build the replay clip. + """ + + @property + @abstractmethod + def source_camera(self) -> str: + """Camera name the replay is derived from.""" + + @property + @abstractmethod + def start_ts(self) -> float: + """Unix timestamp marking the start of the replay range.""" + + @property + @abstractmethod + def end_ts(self) -> float: + """Unix timestamp marking the end of the replay range.""" + + @abstractmethod + def validate(self) -> None: + """Raise ValueError if the source has no usable content.""" + + @abstractmethod + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + """Return ffmpeg input args (including -i). May write temp files in working_dir.""" + + def cleanup(self, working_dir: str) -> None: + """Remove any temp files the source created in working_dir. Default no-op.""" + + +class RecordingDebugReplaySource(DebugReplaySource): + """Replay source backed by the Recordings table. + + 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, + internal_port: int, + ) -> None: + self._camera = source_camera + self._start_ts = start_ts + self._end_ts = end_ts + self._internal_port = internal_port + + @property + def source_camera(self) -> str: + return self._camera + + @property + def start_ts(self) -> float: + return self._start_ts + + @property + def end_ts(self) -> float: + return self._end_ts + + def validate(self) -> None: + if self._end_ts <= self._start_ts: + raise ValueError("End time must be after start time") + + if not query_recordings(self._camera, self._start_ts, self._end_ts).count(): + raise NoRecordingsError( + f"No recordings found for camera '{self._camera}' in the specified time range" + ) + + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + 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): + """Replay source backed by an existing Export. + + Uses the export's video file directly as the ffmpeg input — does not + require recordings to still exist for the time range. + """ + + def __init__(self, export: Export, duration: float) -> None: + self._camera = cast(str, export.camera) + # Export.date is declared DateTimeField but Frigate writes raw unix + # timestamps to the column. + self._start_ts = float(cast(Any, export.date)) + self._video_path = cast(str, export.video_path) + self._duration = duration + + @property + def source_camera(self) -> str: + return self._camera + + @property + def start_ts(self) -> float: + return self._start_ts + + @property + def end_ts(self) -> float: + return self._start_ts + self._duration + + def validate(self) -> None: + if not os.path.exists(self._video_path): + raise ValueError(f"Export video file not found: {self._video_path}") + + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + return ["-i", self._video_path] + + +class DebugReplayJobRunner(threading.Thread): + """Worker thread that drives the startup job to completion. + + Owns the live ffmpeg Popen reference for cancellation. Cancellation + is two-step (threading.Event + proc.terminate()) so the runner + both knows it should stop and is unblocked from its blocking subprocess + wait. + """ + + def __init__( + self, + job: DebugReplayJob, + source: DebugReplaySource, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + replay_manager: "DebugReplayManager", + publisher: JobStatePublisher | None = None, + ) -> None: + super().__init__(daemon=True, name=f"debug_replay_{job.id}") + self.job = job + self.source = source + self.frigate_config = frigate_config + self.config_publisher = config_publisher + self.replay_manager = replay_manager + self.publisher = publisher if publisher is not None else JobStatePublisher() + self._cancel_event = threading.Event() + self._active_process: sp.Popen | None = None + self._proc_lock = threading.Lock() + self._last_broadcast_monotonic: float = 0.0 + + def cancel(self) -> None: + """Request cancellation. Idempotent.""" + self._cancel_event.set() + with self._proc_lock: + proc = self._active_process + if proc is not None: + try: + proc.terminate() + except Exception as exc: + logger.warning("Failed to terminate ffmpeg subprocess: %s", exc) + + def is_cancelled(self) -> bool: + return self._cancel_event.is_set() + + def _record_proc(self, proc: sp.Popen) -> None: + with self._proc_lock: + self._active_process = proc + # Race: cancel arrived between Popen and _record_proc. + if self._cancel_event.is_set(): + try: + proc.terminate() + except Exception: + pass + + def _broadcast(self, force: bool = False) -> None: + now = time.monotonic() + if ( + not force + and now - self._last_broadcast_monotonic < PROGRESS_BROADCAST_MIN_INTERVAL + ): + return + self._last_broadcast_monotonic = now + + try: + self.publisher.publish(self.job.to_dict()) + except Exception as err: + logger.warning("Publisher raised during job state broadcast: %s", err) + + def run(self) -> None: + replay_name = self.job.replay_camera_name + os.makedirs(REPLAY_DIR, exist_ok=True) + clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4") + + self.job.status = JobStatusTypesEnum.running + self.job.start_time = time.time() + self.job.current_step = STEP_PREPARING_CLIP + self._broadcast(force=True) + + try: + input_args = self.source.ffmpeg_input_args(REPLAY_DIR) + + ffmpeg_cmd = [ + self.frigate_config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + *input_args, + "-c", + "copy", + "-movflags", + "+faststart", + clip_path, + ] + + logger.info( + "Generating replay clip for %s (%.1f - %.1f)", + self.job.source_camera, + self.job.start_ts, + self.job.end_ts, + ) + + def _on_progress(percent: float) -> None: + self.job.progress_percent = percent + self._broadcast() + + try: + returncode, stderr = run_ffmpeg_with_progress( + ffmpeg_cmd, + expected_duration_seconds=max( + 0.0, self.job.end_ts - self.job.start_ts + ), + on_progress=_on_progress, + process_started=self._record_proc, + use_low_priority=True, + ) + finally: + with self._proc_lock: + self._active_process = None + + if self._cancel_event.is_set(): + self._finalize_cancelled(clip_path) + return + + if returncode != 0: + raise RuntimeError(f"FFmpeg failed: {stderr[-500:]}") + + if not os.path.exists(clip_path): + raise RuntimeError("Clip file was not created") + + self.job.current_step = STEP_STARTING_CAMERA + self.job.progress_percent = 100.0 + self._broadcast(force=True) + + if self._cancel_event.is_set(): + self._finalize_cancelled(clip_path) + return + + self.replay_manager.publish_camera( + source_camera=self.job.source_camera, + replay_name=replay_name, + clip_path=clip_path, + frigate_config=self.frigate_config, + config_publisher=self.config_publisher, + ) + self.replay_manager.mark_session_ready(clip_path) + + self.job.status = JobStatusTypesEnum.success + self.job.end_time = time.time() + self._broadcast(force=True) + logger.info( + "Debug replay started: %s -> %s", + self.job.source_camera, + replay_name, + ) + except Exception as exc: + logger.exception("Debug replay startup failed") + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = str(exc) + self.job.end_time = time.time() + self._broadcast(force=True) + self.replay_manager.clear_session() + _remove_silent(clip_path) + finally: + self.source.cleanup(REPLAY_DIR) + _set_active_runner(None) + + def _finalize_cancelled(self, clip_path: str) -> None: + logger.info("Debug replay startup cancelled") + self.job.status = JobStatusTypesEnum.cancelled + self.job.end_time = time.time() + self._broadcast(force=True) + # The caller of cancel_debug_replay_job (DebugReplayManager.stop) owns + # session cleanup — db rows, filesystem artifacts, clear_session. We + # only clean up the partial concat output we created. + _remove_silent(clip_path) + + +def _remove_silent(path: str) -> None: + try: + if os.path.exists(path): + os.remove(path) + except OSError: + pass + + +def start_debug_replay_job( + *, + source: DebugReplaySource, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + replay_manager: "DebugReplayManager", +) -> str: + """Validate, create job, start runner. Returns the job id. + + Raises ValueError for an invalid source (camera missing, source has + no usable content) and RuntimeError if a session is already active. + """ + if job_is_running(JOB_TYPE) or replay_manager.active: + raise RuntimeError("A replay session is already active") + + if source.source_camera not in frigate_config.cameras: + raise ValueError(f"Camera '{source.source_camera}' not found") + + source.validate() + + replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}" + replay_manager.mark_starting( + source_camera=source.source_camera, + replay_camera_name=replay_name, + start_ts=source.start_ts, + end_ts=source.end_ts, + ) + + job = DebugReplayJob( + source_camera=source.source_camera, + replay_camera_name=replay_name, + start_ts=source.start_ts, + end_ts=source.end_ts, + ) + set_current_job(job) + + runner = DebugReplayJobRunner( + job=job, + source=source, + frigate_config=frigate_config, + config_publisher=config_publisher, + replay_manager=replay_manager, + ) + _set_active_runner(runner) + runner.start() + + return job.id + + +def cancel_debug_replay_job() -> bool: + """Signal the active runner to cancel. + + Returns True if a runner was signalled, False if no job was active. + """ + runner = get_active_runner() + if runner is None: + return False + runner.cancel() + return True + + +def wait_for_runner(timeout: float = 2.0) -> bool: + """Join the active runner. Returns True if the runner ended in time.""" + runner = get_active_runner() + if runner is None: + return True + runner.join(timeout=timeout) + return not runner.is_alive() diff --git a/frigate/jobs/export.py b/frigate/jobs/export.py new file mode 100644 index 0000000000..1c88ec5849 --- /dev/null +++ b/frigate/jobs/export.py @@ -0,0 +1,508 @@ +"""Export job management with queued background execution.""" + +import logging +import os +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from queue import Full, Queue +from typing import Any + +from peewee import DoesNotExist + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.config.camera.record import ChaptersEnum +from frigate.const import UPDATE_JOB_STATE +from frigate.jobs.job import Job +from frigate.models import Export +from frigate.record.export import PlaybackSourceEnum, RecordingExporter +from frigate.types import JobStatusTypesEnum + +logger = logging.getLogger(__name__) + +# Maximum number of jobs that can sit in the queue waiting to run. +# Prevents a runaway client from unbounded memory growth. +MAX_QUEUED_EXPORT_JOBS = 100 + +# Minimum interval between progress broadcasts. FFmpeg can emit progress +# events many times per second; we coalesce them so the WebSocket isn't +# flooded with redundant updates. +PROGRESS_BROADCAST_MIN_INTERVAL = 1.0 + +# Delay before removing a completed job from the in-memory map. Gives the +# frontend a chance to receive the final state via WebSocket before SWR +# polling takes over. +COMPLETED_JOB_CLEANUP_DELAY = 5.0 + + +class ExportQueueFullError(RuntimeError): + """Raised when the export queue is at capacity.""" + + +@dataclass +class ExportJob(Job): + """Job state for export operations.""" + + job_type: str = "export" + camera: str = "" + name: str | None = None + image_path: str | None = None + export_case_id: str | None = None + request_start_time: float = 0.0 + request_end_time: float = 0.0 + playback_source: str = PlaybackSourceEnum.recordings.value + ffmpeg_input_args: str | None = None + ffmpeg_output_args: str | None = None + cpu_fallback: bool = False + chapters: ChaptersEnum | None = None + current_step: str = "queued" + progress_percent: float = 0.0 + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for API responses. + + Only exposes fields that are part of the public ExportJobModel schema. + Internal execution details (image_path, ffmpeg args, cpu_fallback) are + intentionally omitted so they don't leak through the API. + """ + return { + "id": self.id, + "job_type": self.job_type, + "status": self.status, + "camera": self.camera, + "name": self.name, + "export_case_id": self.export_case_id, + "request_start_time": self.request_start_time, + "request_end_time": self.request_end_time, + "start_time": self.start_time, + "end_time": self.end_time, + "error_message": self.error_message, + "results": self.results, + "current_step": self.current_step, + "progress_percent": self.progress_percent, + } + + +class ExportQueueWorker(threading.Thread): + """Worker that executes queued exports.""" + + def __init__(self, manager: "ExportJobManager", worker_index: int) -> None: + super().__init__( + daemon=True, + name=f"export_queue_worker_{worker_index}", + ) + self.manager = manager + + def run(self) -> None: + while True: + job = self.manager.queue.get() + + try: + self.manager.run_job(job) + except Exception: + logger.exception( + "Export queue worker failed while processing %s", job.id + ) + finally: + self.manager.queue.task_done() + + +class JobStatePublisher: + """Publishes a single job state payload to the dispatcher. + + Each call opens a short-lived :py:class:`InterProcessRequestor`, sends + the payload, and closes the socket. The short-lived design avoids + REQ/REP state corruption that would arise from sharing a single REQ + socket across the API thread and worker threads (REQ sockets must + strictly alternate send/recv). + + With the 1s broadcast throttle in place, socket creation overhead is + negligible. The class also exists so tests can substitute a no-op + instance instead of stubbing ZMQ — see ``BaseTestHttp.setUp``. + """ + + def publish(self, payload: dict[str, Any]) -> None: + try: + requestor = InterProcessRequestor() + except Exception as err: + logger.warning("Failed to open job state requestor: %s", err) + return + + try: + requestor.send_data(UPDATE_JOB_STATE, payload) + except Exception as err: + logger.debug("Job state broadcast failed: %s", err) + finally: + try: + requestor.stop() + except Exception: + pass + + +class ExportJobManager: + """Concurrency-limited manager for queued export jobs.""" + + def __init__( + self, + config: FrigateConfig, + max_concurrent: int, + max_queued: int = MAX_QUEUED_EXPORT_JOBS, + publisher: JobStatePublisher | None = None, + ) -> None: + self.config = config + self.max_concurrent = max(1, max_concurrent) + self.queue: Queue[ExportJob] = Queue(maxsize=max(1, max_queued)) + self.jobs: dict[str, ExportJob] = {} + self.lock = threading.Lock() + self.workers: list[ExportQueueWorker] = [] + self.started = False + self.publisher = publisher if publisher is not None else JobStatePublisher() + self._last_broadcast_monotonic: float = 0.0 + self._broadcast_throttle_lock = threading.Lock() + + def _broadcast_all_jobs(self, force: bool = False) -> None: + """Publish aggregate export job state via the job_state WS topic. + + When ``force`` is False, broadcasts within + ``PROGRESS_BROADCAST_MIN_INTERVAL`` of the previous one are skipped + to avoid flooding the WebSocket with rapid progress updates. + ``force`` bypasses the throttle and is used for status transitions + (enqueue/start/finish) where the frontend needs the latest state. + """ + now = time.monotonic() + with self._broadcast_throttle_lock: + if ( + not force + and now - self._last_broadcast_monotonic + < PROGRESS_BROADCAST_MIN_INTERVAL + ): + return + self._last_broadcast_monotonic = now + + with self.lock: + active = [ + j + for j in self.jobs.values() + if j.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running) + ] + + any_running = any(j.status == JobStatusTypesEnum.running for j in active) + payload: dict[str, Any] = { + "job_type": "export", + "status": "running" if any_running else "queued", + "results": {"jobs": [j.to_dict() for j in active]}, + } + + try: + self.publisher.publish(payload) + except Exception as err: + logger.warning("Publisher raised during job state broadcast: %s", err) + + def _make_progress_callback(self, job: ExportJob) -> Callable[[str, float], None]: + """Build a callback the exporter can invoke during execution.""" + + def on_progress(step: str, percent: float) -> None: + job.current_step = step + job.progress_percent = percent + self._broadcast_all_jobs() + + return on_progress + + def _schedule_job_cleanup(self, job_id: str) -> None: + """Drop a completed job from ``self.jobs`` after a short delay.""" + + def cleanup() -> None: + with self.lock: + self.jobs.pop(job_id, None) + + timer = threading.Timer(COMPLETED_JOB_CLEANUP_DELAY, cleanup) + timer.daemon = True + timer.start() + + def ensure_started(self) -> None: + """Ensure worker threads are started exactly once.""" + with self.lock: + if self.started: + self._restart_dead_workers_locked() + return + + for index in range(self.max_concurrent): + worker = ExportQueueWorker(self, index) + worker.start() + self.workers.append(worker) + + self.started = True + + def _restart_dead_workers_locked(self) -> None: + for index, worker in enumerate(self.workers): + if worker.is_alive(): + continue + + logger.error( + "Export queue worker %s died unexpectedly, restarting", worker.name + ) + replacement = ExportQueueWorker(self, index) + replacement.start() + self.workers[index] = replacement + + def enqueue(self, job: ExportJob) -> str: + """Queue a job for background execution. + + Raises ExportQueueFullError if the queue is at capacity. + """ + self.ensure_started() + + try: + self.queue.put_nowait(job) + except Full as err: + raise ExportQueueFullError( + "Export queue is full; try again once current exports finish" + ) from err + + with self.lock: + self.jobs[job.id] = job + + self._broadcast_all_jobs(force=True) + + return job.id + + def get_job(self, job_id: str) -> ExportJob | None: + """Get a job by ID.""" + with self.lock: + return self.jobs.get(job_id) + + def list_active_jobs(self) -> list[ExportJob]: + """List queued and running jobs.""" + with self.lock: + return [ + job + for job in self.jobs.values() + if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running) + ] + + def cancel_queued_jobs_for_case(self, case_id: str) -> list[ExportJob]: + """Cancel queued export jobs assigned to a deleted case.""" + cancelled_jobs: list[ExportJob] = [] + + with self.lock: + with self.queue.mutex: + retained_jobs: list[ExportJob] = [] + + while self.queue.queue: + job = self.queue.queue.popleft() + + if ( + job.export_case_id == case_id + and job.status == JobStatusTypesEnum.queued + ): + job.status = JobStatusTypesEnum.cancelled + job.end_time = time.time() + cancelled_jobs.append(job) + continue + + retained_jobs.append(job) + + self.queue.queue.extend(retained_jobs) + + if cancelled_jobs: + self.queue.unfinished_tasks = max( + 0, + self.queue.unfinished_tasks - len(cancelled_jobs), + ) + if self.queue.unfinished_tasks == 0: + self.queue.all_tasks_done.notify_all() + self.queue.not_full.notify_all() + + return cancelled_jobs + + def available_slots(self) -> int: + """Approximate number of additional jobs that could be queued right now. + + Uses Queue.qsize() which is best-effort; callers should treat the + result as advisory since another thread could enqueue between + checking and enqueueing. + """ + return max(0, self.queue.maxsize - self.queue.qsize()) + + def run_job(self, job: ExportJob) -> None: + """Execute a queued export job.""" + job.status = JobStatusTypesEnum.running + job.start_time = time.time() + self._broadcast_all_jobs(force=True) + + exporter = RecordingExporter( + self.config, + job.id, + job.camera, + job.name, + job.image_path, + int(job.request_start_time), + int(job.request_end_time), + PlaybackSourceEnum(job.playback_source), + job.export_case_id, + job.ffmpeg_input_args, + job.ffmpeg_output_args, + job.cpu_fallback, + job.chapters, + on_progress=self._make_progress_callback(job), + ) + + try: + exporter.run() + export = Export.get_or_none(Export.id == job.id) + if export is None: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export failed" + elif export.in_progress: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export did not complete" + else: + job.status = JobStatusTypesEnum.success + job.results = { + "export_id": export.id, + "export_case_id": export.export_case_id, + "video_path": export.video_path, + "thumb_path": export.thumb_path, + } + except DoesNotExist: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export not found" + except Exception as err: + logger.exception("Export job %s failed: %s", job.id, err) + job.status = JobStatusTypesEnum.failed + job.error_message = str(err) + finally: + job.end_time = time.time() + self._broadcast_all_jobs(force=True) + self._schedule_job_cleanup(job.id) + + +_job_manager: ExportJobManager | None = None +_job_manager_lock = threading.Lock() + + +def _get_max_concurrent(config: FrigateConfig) -> int: + return int(config.record.export.max_concurrent) + + +def reap_stale_exports() -> None: + """Sweep Export rows stuck with in_progress=True from previous sessions. + + On Frigate startup no export job is alive yet, so any in_progress=True + row must be a leftover from a previous session that crashed, was killed + mid-export, or returned early from RecordingExporter.run() without + flipping the flag. For each stale row we either: + + - delete the row (and any thumb) if the video file is missing or empty, + since there is nothing worth recovering + - flip in_progress to False if the video file exists on disk and is + non-empty, treating it as a completed export the user can manage + through the normal UI + + Must only be called when the export job manager is certain to have no + active jobs — i.e., at Frigate startup, before any worker runs. + + All exceptions are caught and logged; the caller does not need to wrap + this in a try/except. A failure on a single row will not stop the rest + of the sweep, and a failure in the top-level query will log and return. + """ + try: + stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 + except Exception: + logger.exception("Failed to query stale in-progress exports") + return + + if not stale_exports: + logger.debug("No stale in-progress exports found on startup") + return + + flipped = 0 + deleted = 0 + errored = 0 + + for export in stale_exports: + try: + video_path = export.video_path + has_usable_file = False + + if video_path: + try: + has_usable_file = os.path.getsize(video_path) > 0 + except OSError: + has_usable_file = False + + if has_usable_file: + # Unassign from any case on recovery: the user should + # re-triage a recovered export rather than have it silently + # reappear inside a case they curated. + Export.update( + {Export.in_progress: False, Export.export_case: None} + ).where(Export.id == export.id).execute() + flipped += 1 + logger.info( + "Recovered stale in-progress export %s (file intact on disk)", + export.id, + ) + continue + + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + if video_path: + Path(video_path).unlink(missing_ok=True) + Export.delete().where(Export.id == export.id).execute() + deleted += 1 + logger.info( + "Deleted stale in-progress export %s (no usable file on disk)", + export.id, + ) + except Exception: + errored += 1 + logger.exception("Failed to reap stale export %s", export.id) + + logger.info( + "Stale export cleanup complete: %d recovered, %d deleted, %d errored", + flipped, + deleted, + errored, + ) + + +def get_export_job_manager(config: FrigateConfig) -> ExportJobManager: + """Get or create the singleton export job manager.""" + global _job_manager + + with _job_manager_lock: + if _job_manager is None: + _job_manager = ExportJobManager(config, _get_max_concurrent(config)) + _job_manager.ensure_started() + return _job_manager + + +def start_export_job(config: FrigateConfig, job: ExportJob) -> str: + """Queue an export job and return its ID.""" + return get_export_job_manager(config).enqueue(job) + + +def get_export_job(config: FrigateConfig, job_id: str) -> ExportJob | None: + """Get a queued or completed export job by ID.""" + return get_export_job_manager(config).get_job(job_id) + + +def list_active_export_jobs(config: FrigateConfig) -> list[ExportJob]: + """List queued and running export jobs.""" + return get_export_job_manager(config).list_active_jobs() + + +def cancel_queued_export_jobs_for_case( + config: FrigateConfig, case_id: str +) -> list[ExportJob]: + """Cancel queued export jobs that still point at a deleted case.""" + return get_export_job_manager(config).cancel_queued_jobs_for_case(case_id) + + +def available_export_queue_slots(config: FrigateConfig) -> int: + """Approximate number of additional export jobs that could be queued now.""" + return get_export_job_manager(config).available_slots() diff --git a/frigate/jobs/job.py b/frigate/jobs/job.py new file mode 100644 index 0000000000..c40087d0cd --- /dev/null +++ b/frigate/jobs/job.py @@ -0,0 +1,21 @@ +"""Generic base class for long-running background jobs.""" + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass +class Job: + """Base class for long-running background jobs.""" + + id: str = field(default_factory=lambda: __import__("uuid").uuid4().__str__()[:12]) + job_type: str = "" # Must be set by subclasses + status: str = "queued" # queued, running, success, failed, cancelled + results: dict[str, Any] | None = None + start_time: float | None = None + end_time: float | None = None + error_message: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for WebSocket transmission.""" + return asdict(self) diff --git a/frigate/jobs/manager.py b/frigate/jobs/manager.py new file mode 100644 index 0000000000..c2fb44af38 --- /dev/null +++ b/frigate/jobs/manager.py @@ -0,0 +1,69 @@ +"""Generic job management for long-running background tasks.""" + +import threading + +from frigate.jobs.job import Job +from frigate.types import JobStatusTypesEnum + +# Global state and locks for enforcing single concurrent job per job type +_job_locks: dict[str, threading.Lock] = {} +_current_jobs: dict[str, Job | None] = {} +# Keep completed jobs for retrieval, keyed by (job_type, job_id) +_completed_jobs: dict[tuple[str, str], Job] = {} + + +def _get_lock(job_type: str) -> threading.Lock: + """Get or create a lock for the specified job type.""" + if job_type not in _job_locks: + _job_locks[job_type] = threading.Lock() + return _job_locks[job_type] + + +def set_current_job(job: Job) -> None: + """Set the current job for a given job type.""" + lock = _get_lock(job.job_type) + with lock: + # Store the previous job if it was completed + old_job = _current_jobs.get(job.job_type) + if old_job and old_job.status in ( + JobStatusTypesEnum.success, + JobStatusTypesEnum.failed, + JobStatusTypesEnum.cancelled, + ): + _completed_jobs[(job.job_type, old_job.id)] = old_job + _current_jobs[job.job_type] = job + + +def clear_current_job(job_type: str, job_id: str | None = None) -> None: + """Clear the current job for a given job type, optionally checking the ID.""" + lock = _get_lock(job_type) + with lock: + if job_type in _current_jobs: + current = _current_jobs[job_type] + if current is None or (job_id is None or current.id == job_id): + _current_jobs[job_type] = None + + +def get_current_job(job_type: str) -> Job | None: + """Get the current running/queued job for a given job type, if any.""" + lock = _get_lock(job_type) + with lock: + return _current_jobs.get(job_type) + + +def get_job_by_id(job_type: str, job_id: str) -> Job | None: + """Get job by ID. Checks current job first, then completed jobs.""" + lock = _get_lock(job_type) + with lock: + # Check if it's the current job + current = _current_jobs.get(job_type) + if current and current.id == job_id: + return current + # Check if it's a completed job + return _completed_jobs.get((job_type, job_id)) + + +def job_is_running(job_type: str) -> bool: + """Check if a job of the given type is currently running or queued.""" + job = get_current_job(job_type) + return job is not None and job.status in ("queued", "running") diff --git a/frigate/jobs/media_sync.py b/frigate/jobs/media_sync.py new file mode 100644 index 0000000000..1cd8209869 --- /dev/null +++ b/frigate/jobs/media_sync.py @@ -0,0 +1,154 @@ +"""Media sync job management with background execution.""" + +import logging +import os +import threading +from dataclasses import dataclass, field +from datetime import datetime +from typing import cast + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.const import CONFIG_DIR, UPDATE_JOB_STATE +from frigate.jobs.job import Job +from frigate.jobs.manager import ( + get_current_job, + get_job_by_id, + job_is_running, + set_current_job, +) +from frigate.types import JobStatusTypesEnum +from frigate.util.media import sync_all_media, write_orphan_report + +logger = logging.getLogger(__name__) + + +@dataclass +class MediaSyncJob(Job): + """In-memory job state for media sync operations.""" + + job_type: str = "media_sync" + dry_run: bool = False + media_types: list[str] = field(default_factory=lambda: ["all"]) + force: bool = False + verbose: bool = False + + +class MediaSyncRunner(threading.Thread): + """Thread-based runner for media sync jobs.""" + + def __init__(self, job: MediaSyncJob) -> None: + super().__init__(daemon=True, name="media_sync") + self.job = job + self.requestor = InterProcessRequestor() + + def run(self) -> None: + """Execute the media sync job and broadcast status updates.""" + try: + # Update job status to running + self.job.status = JobStatusTypesEnum.running + self.job.start_time = datetime.now().timestamp() + self._broadcast_status() + + # Execute sync with provided parameters + logger.debug( + f"Starting media sync job {self.job.id}: " + f"media_types={self.job.media_types}, " + f"dry_run={self.job.dry_run}, " + f"force={self.job.force}" + ) + + results = sync_all_media( + dry_run=self.job.dry_run, + media_types=self.job.media_types, + force=self.job.force, + ) + + # Write verbose report if requested + if self.job.verbose: + report_dir = os.path.join(CONFIG_DIR, "media_sync") + os.makedirs(report_dir, exist_ok=True) + report_path = os.path.join(report_dir, f"{self.job.id}.txt") + write_orphan_report( + results, + report_path, + job_id=self.job.id, + dry_run=self.job.dry_run, + ) + logger.info( + "Media sync verbose orphan report written to %s", report_path + ) + + # Store results and mark as complete + self.job.results = results.to_dict() + self.job.status = JobStatusTypesEnum.success + self.job.end_time = datetime.now().timestamp() + + logger.debug(f"Media sync job {self.job.id} completed successfully") + self._broadcast_status() + + except Exception as e: + logger.exception(f"Media sync job {self.job.id} failed: {e}") + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = str(e) + self.job.end_time = datetime.now().timestamp() + self._broadcast_status() + + finally: + if self.requestor: + self.requestor.stop() + + def _broadcast_status(self) -> None: + """Broadcast job status update via IPC to all WebSocket subscribers.""" + try: + self.requestor.send_data( + UPDATE_JOB_STATE, + self.job.to_dict(), + ) + except Exception as e: + logger.warning(f"Failed to broadcast media sync status: {e}") + + +def start_media_sync_job( + dry_run: bool = False, + media_types: list[str] | None = None, + force: bool = False, + verbose: bool = False, +) -> str | None: + """Start a new media sync job if none is currently running. + + Returns job ID on success, None if job already running. + """ + # Check if a job is already running + if job_is_running("media_sync"): + current = get_current_job("media_sync") + logger.warning( + f"Media sync job {current.id if current else 'unknown'} is already running. Rejecting new request." + ) + return None + + # Create and start new job + job = MediaSyncJob( + dry_run=dry_run, + media_types=media_types or ["all"], + force=force, + verbose=verbose, + ) + + logger.debug(f"Creating new media sync job: {job.id}") + set_current_job(job) + + # Start the background runner + runner = MediaSyncRunner(job) + runner.start() + + return job.id + + +def get_current_media_sync_job() -> MediaSyncJob | None: + """Get the current running/queued media sync job, if any.""" + return cast(MediaSyncJob | None, get_current_job("media_sync")) + + +def get_media_sync_job_by_id(job_id: str) -> MediaSyncJob | None: + """Get media sync job by ID. Currently only tracks the current job.""" + return cast(MediaSyncJob | None, get_job_by_id("media_sync", job_id)) diff --git a/frigate/jobs/motion_search.py b/frigate/jobs/motion_search.py new file mode 100644 index 0000000000..15cd104f7b --- /dev/null +++ b/frigate/jobs/motion_search.py @@ -0,0 +1,957 @@ +"""Motion search job management with background execution and parallel verification.""" + +import logging +import os +import threading +import time +from collections.abc import Callable, Generator, Iterable +from concurrent.futures import Future, ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, cast + +import cv2 +import numpy as np + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.const import UPDATE_JOB_STATE +from frigate.jobs.job import Job +from frigate.jobs.manager import ( + get_job_by_id, + set_current_job, +) +from frigate.jobs.motion_search_batch import ( + build_segment_time_map, + coalesce_runs, + stream_time_to_absolute, +) +from frigate.jobs.motion_search_decode import ( + iter_vod_frames, + keyframe_sampling_eligible, + probe_video_dimensions, + probe_vod_keyframe_pts, + resolve_motion_decode_args, +) +from frigate.models import Recordings +from frigate.types import JobStatusTypesEnum + +logger = logging.getLogger(__name__) + +# Constants +HEATMAP_GRID_SIZE = 16 +# Max wall-clock span of one VOD run request (seconds). Bounds per-request size +# and gives streaming/cancel/early-exit granularity. +MAX_RUN_SECONDS = 600.0 +# Treat segments within this many seconds end-to-start as time-contiguous. +RUN_GAP_EPSILON = 1.0 +# Longest-side pixels for the ROI downscale before motion detection. +SCALE_TARGET = 400 +# Minimum wall seconds between intra-run progress broadcasts. +PROGRESS_BROADCAST_INTERVAL = 1.0 +# Output frame rate for the fixed-cadence fallback used on long-GOP cameras +# (where keyframe sampling is too sparse). Keyframe cameras ignore this. +FALLBACK_SAMPLE_FPS = 2.0 + + +@dataclass +class MotionSearchMetrics: + """Metrics collected during motion search execution.""" + + segments_scanned: int = 0 + segments_processed: int = 0 + metadata_inactive_segments: int = 0 + heatmap_roi_skip_segments: int = 0 + fallback_full_range_segments: int = 0 + frames_decoded: int = 0 + wall_time_seconds: float = 0.0 + segments_with_errors: int = 0 + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return asdict(self) + + +@dataclass +class MotionSearchResult: + """A single search result with timestamp and change info.""" + + timestamp: float + change_percentage: float + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return asdict(self) + + +@dataclass +class MotionSearchJob(Job): + """Job state for motion search operations.""" + + job_type: str = "motion_search" + camera: str = "" + start_time_range: float = 0.0 + end_time_range: float = 0.0 + polygon_points: list[list[float]] = field(default_factory=list) + threshold: int = 30 + min_area: float = 5.0 + parallel: bool = False + max_results: int = 25 + + # Track progress + total_frames_processed: int = 0 + + # Live progress (ride the existing to_dict() websocket broadcast) + scanning_timestamp: float | None = None + progress: float = 0.0 + + # Metrics for observability + metrics: MotionSearchMetrics | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for WebSocket transmission.""" + d = asdict(self) + if self.metrics: + d["metrics"] = self.metrics.to_dict() + return d + + +def create_polygon_mask( + polygon_points: list[list[float]], frame_width: int, frame_height: int +) -> np.ndarray: + """Create a binary mask from normalized polygon coordinates.""" + motion_points = np.array( + [[int(p[0] * frame_width), int(p[1] * frame_height)] for p in polygon_points], + dtype=np.int32, + ) + mask = np.zeros((frame_height, frame_width), dtype=np.uint8) + cv2.fillPoly(mask, [motion_points], (255,)) + return mask + + +def compute_roi_crop_and_scale( + polygon_points: list[list[float]], + frame_width: int, + frame_height: int, + scale_target: int, +) -> tuple[tuple[int, int, int, int], tuple[int, int]]: + """Compute the ROI crop box and never-upscale scaled dimensions. + + Returns ((crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)) in pixels. + The crop is the polygon's bounding box in frame pixels; the scaled size fits + the crop's longest side to ``scale_target`` without ever enlarging it. + """ + xs = [p[0] for p in polygon_points] + ys = [p[1] for p in polygon_points] + # nv12 (4:2:0) hwdownload requires even crop offsets and even crop/scale + # dimensions; otherwise ffmpeg rounds the chroma planes and the raw byte + # stream stops matching the expected frame size. Force even values, and the + # mask is built from these same values so the two stay aligned. + crop_x = int(min(xs) * frame_width) + crop_y = int(min(ys) * frame_height) + crop_x -= crop_x % 2 + crop_y -= crop_y % 2 + crop_w = max(2, int(max(xs) * frame_width) - crop_x) + crop_h = max(2, int(max(ys) * frame_height) - crop_y) + crop_w -= crop_w % 2 + crop_h -= crop_h % 2 + + longest = max(crop_w, crop_h) + factor = min(1.0, scale_target / longest) + scaled_w = max(2, round(crop_w * factor)) + scaled_h = max(2, round(crop_h * factor)) + scaled_w -= scaled_w % 2 + scaled_h -= scaled_h % 2 + return (crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h) + + +def build_scaled_roi_mask( + polygon_points: list[list[float]], + frame_width: int, + frame_height: int, + crop: tuple[int, int, int, int], + scaled: tuple[int, int], +) -> np.ndarray: + """Rasterize the polygon mask at the scaled ROI size. + + Builds the full-resolution mask, crops it to the ROI box, and nearest- + neighbor resizes it to the scaled dimensions so it lines up exactly with the + frames ffmpeg crops and scales. + """ + crop_w, crop_h, crop_x, crop_y = crop + scaled_w, scaled_h = scaled + full_mask = create_polygon_mask(polygon_points, frame_width, frame_height) + cropped = full_mask[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w] + return cv2.resize(cropped, (scaled_w, scaled_h), interpolation=cv2.INTER_NEAREST) + + +def detect_motion_scaled( + frames: Iterable[tuple[int, np.ndarray]], + mask: np.ndarray, + threshold: int, + min_area: float, + timestamp_fn: Callable[[int], float], +) -> list[MotionSearchResult]: + """Detect motion across pre-cropped, pre-scaled gray frames. + + ``frames`` yields (absolute_frame_index, gray_roi_frame); ``mask`` is the + scaled ROI mask. ``min_area`` is a percentage of the masked ROI. Mirrors the + full-res detection math (absdiff -> blur -> threshold -> dilate -> contours) + on the already-reduced frames. + """ + results: list[MotionSearchResult] = [] + mask_area = np.count_nonzero(mask) + if mask_area == 0: + return results + min_area_pixels = int((min_area / 100.0) * mask_area) + + prev: np.ndarray | None = None + for frame_idx, gray in frames: + masked = cv2.bitwise_and(gray, gray, mask=mask) + if prev is not None: + diff = cv2.absdiff(prev, masked) + diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0) + _, thresh = cv2.threshold(diff_blurred, threshold, 255, cv2.THRESH_BINARY) + thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload] + thresh_masked = cv2.bitwise_and(thresh_dilated, thresh_dilated, mask=mask) + change_pixels = cv2.countNonZero(thresh_masked) + if change_pixels > min_area_pixels: + contours, _ = cv2.findContours( + thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + total_change_area = sum( + cv2.contourArea(c) + for c in contours + if cv2.contourArea(c) >= min_area_pixels + ) + if total_change_area > 0: + change_percentage = (total_change_area / mask_area) * 100 + results.append( + MotionSearchResult( + timestamp=timestamp_fn(frame_idx), + change_percentage=round(change_percentage, 2), + ) + ) + prev = masked + return results + + +def compute_roi_bbox_normalized( + polygon_points: list[list[float]], +) -> tuple[float, float, float, float]: + """Compute the bounding box of the ROI in normalized coordinates (0-1). + + Returns (x_min, y_min, x_max, y_max) in normalized coordinates. + """ + if not polygon_points: + return (0.0, 0.0, 1.0, 1.0) + + x_coords = [p[0] for p in polygon_points] + y_coords = [p[1] for p in polygon_points] + return (min(x_coords), min(y_coords), max(x_coords), max(y_coords)) + + +def heatmap_overlaps_roi( + heatmap: object, roi_bbox: tuple[float, float, float, float] +) -> bool: + """Check if a sparse motion heatmap has any overlap with the ROI bounding box. + + Args: + heatmap: Sparse dict mapping cell index (str) to intensity (1-255). + roi_bbox: (x_min, y_min, x_max, y_max) in normalized coordinates (0-1). + + Returns: + True if there is overlap (any active cell in the ROI region). + """ + if not isinstance(heatmap, dict): + # Invalid heatmap, assume overlap to be safe + return True + + x_min, y_min, x_max, y_max = roi_bbox + + # Convert normalized coordinates to grid cells (0-15) + grid_x_min = max(0, int(x_min * HEATMAP_GRID_SIZE)) + grid_y_min = max(0, int(y_min * HEATMAP_GRID_SIZE)) + grid_x_max = min(HEATMAP_GRID_SIZE - 1, int(x_max * HEATMAP_GRID_SIZE)) + grid_y_max = min(HEATMAP_GRID_SIZE - 1, int(y_max * HEATMAP_GRID_SIZE)) + + # Check each cell in the ROI bbox + for y in range(grid_y_min, grid_y_max + 1): + for x in range(grid_x_min, grid_x_max + 1): + idx = str(y * HEATMAP_GRID_SIZE + x) + if idx in heatmap: + return True + + return False + + +def segment_passes_activity_gate(recording: Recordings) -> bool: + """Check if a segment passes the activity gate. + + Returns True if any of motion, objects, or regions is non-zero/non-null. + Returns True if all are null (old segments without data). + """ + motion: Any = recording.motion + objects: Any = recording.objects + regions: Any = recording.regions + + # Old segments without metadata - pass through (conservative) + if motion is None and objects is None and regions is None: + return True + + # Pass if any activity indicator is positive + return bool(motion) or bool(objects) or bool(regions) + + +def segment_passes_heatmap_gate( + recording: Recordings, roi_bbox: tuple[float, float, float, float] +) -> bool: + """Check if a segment passes the heatmap overlap gate. + + Returns True if: + - No heatmap is stored (old segments). + - The heatmap overlaps with the ROI bbox. + """ + heatmap = getattr(recording, "motion_heatmap", None) + if heatmap is None: + # No heatmap stored, fall back to activity gate + return True + + return heatmap_overlaps_roi(heatmap, roi_bbox) + + +def resolve_internal_port(config: FrigateConfig) -> int: + """Return the unauthenticated internal nginx port for VOD requests.""" + listen = config.networking.listen.internal + if isinstance(listen, str): + return int(listen.split(":")[-1]) + return int(listen) + + +def build_vod_url(internal_port: int, camera: str, start: float, end: float) -> str: + """Build the internal VOD HLS URL for a camera time range.""" + return ( + f"http://127.0.0.1:{internal_port}/vod/{camera}" + f"/start/{start}/end/{end}/index.m3u8" + ) + + +class MotionSearchRunner(threading.Thread): + """Thread-based runner for motion search jobs with parallel verification.""" + + def __init__( + self, + job: MotionSearchJob, + config: FrigateConfig, + cancel_event: threading.Event, + ) -> None: + super().__init__(daemon=True, name=f"motion_search_{job.id}") + self.job = job + self.config = config + self.cancel_event = cancel_event + self.internal_stop_event = threading.Event() + self.requestor = InterProcessRequestor() + self.metrics = MotionSearchMetrics() + self.job.metrics = self.metrics + + # Worker cap: min(4, cpu_count) + cpu_count = os.cpu_count() or 1 + self.max_workers = min(4, cpu_count) + + # Resolved once per job in _execute_search + self.ffmpeg_path: str = "ffmpeg" + self.ffprobe_path: str = "ffprobe" + self.decode_args: list[str] = [] + # Keyframe sampling decision, decided once per job from the first run's + # GOP. The fallback cadence is a fixed rate (see FALLBACK_SAMPLE_FPS). + self.use_keyframe: bool = True + self.fps_rate: float = FALLBACK_SAMPLE_FPS + # ROI crop/scale + scaled mask, computed once from the VOD-stream + # dimensions (which can differ from the detect resolution). + self.crop: tuple[int, int, int, int] = (0, 0, 0, 0) + self.scaled: tuple[int, int] = (0, 0) + self.scaled_mask: np.ndarray = np.zeros((0, 0), dtype=np.uint8) + self.channels: int = 1 + self.internal_port: int = 5000 + self._last_progress_broadcast: float = 0.0 + + def run(self) -> None: + """Execute the motion search job.""" + try: + self.job.status = JobStatusTypesEnum.running + self.job.start_time = datetime.now().timestamp() + self._broadcast_status() + + results = self._execute_search() + + if self.cancel_event.is_set(): + self.job.status = JobStatusTypesEnum.cancelled + else: + self.job.status = JobStatusTypesEnum.success + self.job.results = { + "results": [r.to_dict() for r in results], + "total_frames_processed": self.job.total_frames_processed, + } + + self.job.end_time = datetime.now().timestamp() + self.metrics.wall_time_seconds = self.job.end_time - self.job.start_time + self.job.metrics = self.metrics + + logger.debug( + "Motion search job %s completed: status=%s, results=%d, frames=%d", + self.job.id, + self.job.status, + len(results), + self.job.total_frames_processed, + ) + self._broadcast_status() + + except Exception as e: + logger.exception("Motion search job %s failed: %s", self.job.id, e) + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = str(e) + self.job.end_time = datetime.now().timestamp() + self.metrics.wall_time_seconds = self.job.end_time - ( + self.job.start_time or 0 + ) + self.job.metrics = self.metrics + self._broadcast_status() + + finally: + if self.requestor: + self.requestor.stop() + + def _broadcast_status(self) -> None: + """Broadcast job status update via IPC to WebSocket subscribers.""" + if self.job.status == JobStatusTypesEnum.running and self.job.start_time: + self.metrics.wall_time_seconds = ( + datetime.now().timestamp() - self.job.start_time + ) + + try: + self.requestor.send_data(UPDATE_JOB_STATE, self.job.to_dict()) + except Exception as e: + logger.warning("Failed to broadcast motion search status: %s", e) + + def _should_stop(self) -> bool: + """Check if processing should stop due to cancellation or internal limits.""" + return self.cancel_event.is_set() or self.internal_stop_event.is_set() + + def _execute_search(self) -> list[MotionSearchResult]: + """Main search execution logic.""" + camera_name = self.job.camera + camera_config = self.config.cameras.get(camera_name) + if not camera_config: + raise ValueError(f"Camera {camera_name} not found") + + frame_width = camera_config.detect.width + frame_height = camera_config.detect.height + + if frame_width is None or frame_height is None: + raise ValueError(f"Camera {camera_name} detect dimensions not configured") + + self.ffmpeg_path = camera_config.ffmpeg.ffmpeg_path + self.ffprobe_path = camera_config.ffmpeg.ffprobe_path + + # Create polygon mask + polygon_mask = create_polygon_mask( + self.job.polygon_points, frame_width, frame_height + ) + + if np.count_nonzero(polygon_mask) == 0: + logger.warning("Polygon mask is empty for job %s", self.job.id) + return [] + + # Compute ROI bbox in normalized coordinates for heatmap gate + roi_bbox = compute_roi_bbox_normalized(self.job.polygon_points) + + # Query recordings + recordings = list( + Recordings.select() + .where( + ( + Recordings.start_time.between( + self.job.start_time_range, self.job.end_time_range + ) + ) + | ( + Recordings.end_time.between( + self.job.start_time_range, self.job.end_time_range + ) + ) + | ( + (self.job.start_time_range > Recordings.start_time) + & (self.job.end_time_range < Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .order_by(Recordings.start_time.asc()) + ) + + if not recordings: + logger.debug("No recordings found for motion search job %s", self.job.id) + return [] + + logger.debug( + "Motion search job %s: queried %d recording segments for camera %s " + "(range %.1f - %.1f)", + self.job.id, + len(recordings), + camera_name, + self.job.start_time_range, + self.job.end_time_range, + ) + + self.metrics.segments_scanned = len(recordings) + + # Apply activity and heatmap gates + filtered_recordings = [] + for recording in recordings: + if not segment_passes_activity_gate(recording): + self.metrics.metadata_inactive_segments += 1 + self.metrics.segments_processed += 1 + logger.debug( + "Motion search job %s: segment %s skipped by activity gate " + "(motion=%s, objects=%s, regions=%s)", + self.job.id, + recording.id, + recording.motion, + recording.objects, + recording.regions, + ) + continue + if not segment_passes_heatmap_gate(recording, roi_bbox): + self.metrics.heatmap_roi_skip_segments += 1 + self.metrics.segments_processed += 1 + logger.debug( + "Motion search job %s: segment %s skipped by heatmap gate " + "(heatmap present=%s, roi_bbox=%s)", + self.job.id, + recording.id, + recording.motion_heatmap is not None, + roi_bbox, + ) + continue + filtered_recordings.append(recording) + + self._broadcast_status() + + # Fallback: if all segments were filtered out, scan all segments + # This allows motion search to find things the detector missed + if not filtered_recordings and recordings: + logger.info( + "All %d segments filtered by gates, falling back to full scan", + len(recordings), + ) + self.metrics.fallback_full_range_segments = len(recordings) + filtered_recordings = recordings + + logger.debug( + "Motion search job %s: %d/%d segments passed gates " + "(activity_skipped=%d, heatmap_skipped=%d)", + self.job.id, + len(filtered_recordings), + len(recordings), + self.metrics.metadata_inactive_segments, + self.metrics.heatmap_roi_skip_segments, + ) + + # Resolve decode backend (allowlisted hwaccel or software), coalesce the + # gate-passing segments into time-contiguous runs, and probe the first + # run's VOD stream once for dimensions + keyframe layout. VOD output is + # what we decode, so crop/scale/mask are computed against it. + self.internal_port = resolve_internal_port(self.config) + self.decode_args = resolve_motion_decode_args(camera_config) + ffprobe_path = self.ffprobe_path + + runs = coalesce_runs(filtered_recordings, MAX_RUN_SECONDS, RUN_GAP_EPSILON) + if not runs: + return [] + + first_run = runs[0] + first_url = build_vod_url( + self.internal_port, + camera_name, + float(first_run[0].start_time), + float(first_run[-1].end_time), + ) + dims = probe_video_dimensions(ffprobe_path, first_url) + if dims is None: + raise ValueError(f"Could not probe VOD dimensions for camera {camera_name}") + rec_width, rec_height, _rec_fps = dims + + self.crop, self.scaled = compute_roi_crop_and_scale( + self.job.polygon_points, rec_width, rec_height, SCALE_TARGET + ) + self.scaled_mask = build_scaled_roi_mask( + self.job.polygon_points, rec_width, rec_height, self.crop, self.scaled + ) + self.channels = 1 # always gray output + + # Decide keyframe vs fixed-cadence sampling once from the first run's GOP + # (keyframe structure is a per-camera constant). + first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url) + self.use_keyframe = keyframe_sampling_eligible(first_pts) + + logger.debug( + "Motion search job %s: %d runs, sampling=%s, hwaccel=%s, vod=%dx%d", + self.job.id, + len(runs), + "keyframe" if self.use_keyframe else "cadence", + bool(self.decode_args), + rec_width, + rec_height, + ) + + return self._search_runs(runs) + + def _emit_progress(self, abs_ts: float) -> None: + """Throttled intra-run progress broadcast (scanning cursor).""" + now = time.monotonic() + if now - self._last_progress_broadcast < PROGRESS_BROADCAST_INTERVAL: + return + self._last_progress_broadcast = now + self.job.scanning_timestamp = abs_ts + self._broadcast_status() + + def _detect_with_progress( + self, + indexed_frames: list[tuple[int, np.ndarray]], + timestamp_fn: Callable[[int], float], + ) -> list[MotionSearchResult]: + """Run detection while firing throttled progress as frames are scanned.""" + + def _gen() -> Generator[tuple[int, np.ndarray], None, None]: + for i, frame in indexed_frames: + if not self._should_stop(): + self._emit_progress(timestamp_fn(i)) + yield i, frame + + return detect_motion_scaled( + _gen(), + self.scaled_mask, + self.job.threshold, + self.job.min_area, + timestamp_fn, + ) + + def _process_run( + self, run: list[Recordings] + ) -> tuple[list[MotionSearchResult], int]: + """Decode one run's VOD stream and detect motion. + + Keyframe mode compares every decoded keyframe (free recall, since they + are all decoded anyway) paired with its probed PTS; if the decoded and + probed counts disagree (the decoder ignored ``-skip_frame nokey`` or the + stream is corrupt) this run re-runs in the fixed-cadence fallback. + Returns ``(results, frame_count)``. + """ + run_start: float = run[0].start_time # type: ignore[assignment] + run_end: float = run[-1].end_time # type: ignore[assignment] + vod_url = build_vod_url(self.internal_port, self.job.camera, run_start, run_end) + time_map = build_segment_time_map(run) + + if self.use_keyframe: + kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url) + frames = list( + iter_vod_frames( + self.ffmpeg_path, + vod_url, + self.scaled[0], + self.scaled[1], + self.channels, + self.decode_args, + self.crop, + self.scaled, + True, + self._should_stop, + skip_nonkey=True, + fps_rate=None, + ) + ) + if kf_pts and len(frames) == len(kf_pts): + abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts] + indexed = list(enumerate(frames)) + + def _ts_kf(i: int) -> float: + return abs_times[i] + + results = self._detect_with_progress(indexed, _ts_kf) + return results, len(frames) + + logger.debug( + "Keyframe count mismatch (%d decoded vs %d probed), using cadence", + len(frames), + len(kf_pts), + ) + + return self._process_run_cadence(vod_url, time_map) + + def _process_run_cadence( + self, vod_url: str, time_map: list[tuple[float, float, float]] + ) -> tuple[list[MotionSearchResult], int]: + """Fixed-cadence fallback: fps-filtered VOD decode, evenly spaced times.""" + frames = list( + iter_vod_frames( + self.ffmpeg_path, + vod_url, + self.scaled[0], + self.scaled[1], + self.channels, + self.decode_args, + self.crop, + self.scaled, + True, + self._should_stop, + skip_nonkey=False, + fps_rate=self.fps_rate, + ) + ) + indexed = list(enumerate(frames)) + + def _ts_fps(i: int) -> float: + return stream_time_to_absolute(time_map, i / self.fps_rate) + + results = self._detect_with_progress(indexed, _ts_fps) + return results, len(frames) + + def _merge_run( + self, + run: list[Recordings], + run_results: list[MotionSearchResult], + frames: int, + state: dict[str, Any], + ) -> bool: + """Fold one run's output into the running results; stream + dedup. + + Returns True once ``max_results`` deduped hits have accumulated. + """ + state["completed_runs"] += 1 + state["all_results"].extend(run_results) + state["total_frames"] += frames + self.job.total_frames_processed = state["total_frames"] + self.metrics.frames_decoded = state["total_frames"] + self.metrics.segments_processed += len(run) + self.job.progress = state["completed_runs"] / state["total_runs"] + + state["all_results"].sort(key=lambda r: r.timestamp) + deduped = self._deduplicate_results(state["all_results"])[ + : self.job.max_results + ] + self.job.results = { + "results": [r.to_dict() for r in deduped], + "total_frames_processed": state["total_frames"], + } + self._broadcast_status() + return len(deduped) >= self.job.max_results + + def _search_runs(self, runs: list[list[Recordings]]) -> list[MotionSearchResult]: + """Decode runs (parallel pool when enabled), merge in order, stream.""" + state: dict[str, Any] = { + "all_results": [], + "total_frames": 0, + "completed_runs": 0, + "total_runs": len(runs), + } + self.job.results = {"results": [], "total_frames_processed": 0} + + logger.debug( + "Motion search job %s: searching %d runs (parallel=%s, workers=%d)", + self.job.id, + len(runs), + self.job.parallel, + self.max_workers, + ) + + if self.job.parallel and len(runs) > 1: + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures: dict[Future, int] = {} + for idx, run in enumerate(runs): + if self._should_stop(): + break + futures[executor.submit(self._process_run, run)] = idx + + completed: dict[int, tuple[list[MotionSearchResult], int]] = {} + next_idx = 0 + for future in as_completed(futures): + if self._should_stop(): + break + run_idx = futures[future] + try: + completed[run_idx] = future.result() + except Exception as e: + self.metrics.segments_with_errors += 1 + logger.warning("Error processing run %d: %s", run_idx, e) + completed[run_idx] = ([], 0) + + while next_idx in completed: + run_results, frames = completed.pop(next_idx) + if self._merge_run(runs[next_idx], run_results, frames, state): + self.internal_stop_event.set() + for pending in futures: + pending.cancel() + break + next_idx += 1 + + if self.internal_stop_event.is_set(): + break + else: + for run in runs: + if self._should_stop(): + break + try: + run_results, frames = self._process_run(run) + except Exception as e: + self.metrics.segments_with_errors += 1 + self.metrics.segments_processed += len(run) + self._broadcast_status() + logger.warning("Error processing run: %s", e) + continue + if self._merge_run(run, run_results, frames, state): + break + + all_results: list[MotionSearchResult] = state["all_results"] + self.job.total_frames_processed = state["total_frames"] + self.metrics.frames_decoded = state["total_frames"] + self.job.progress = 1.0 + + logger.debug( + "Motion search job %s: complete, %d raw results, %d frames, %d errors", + self.job.id, + len(all_results), + state["total_frames"], + self.metrics.segments_with_errors, + ) + + all_results.sort(key=lambda r: r.timestamp) + return self._deduplicate_results(all_results)[: self.job.max_results] + + def _deduplicate_results( + self, results: list[MotionSearchResult], min_gap: float = 1.0 + ) -> list[MotionSearchResult]: + """Deduplicate results that are too close together.""" + if not results: + return results + + deduplicated: list[MotionSearchResult] = [] + last_timestamp = 0.0 + + for result in results: + if result.timestamp - last_timestamp >= min_gap: + deduplicated.append(result) + last_timestamp = result.timestamp + + return deduplicated + + +# Module-level state for managing per-camera jobs +_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {} +_jobs_lock = threading.Lock() + + +def stop_all_motion_search_jobs() -> None: + """Cancel all running motion search jobs for clean shutdown.""" + with _jobs_lock: + for job_id, (job, cancel_event) in _motion_search_jobs.items(): + if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running): + cancel_event.set() + logger.debug("Signalling motion search job %s to stop", job_id) + + +def start_motion_search_job( + config: FrigateConfig, + camera_name: str, + start_time: float, + end_time: float, + polygon_points: list[list[float]], + threshold: int = 30, + min_area: float = 5.0, + parallel: bool = False, + max_results: int = 25, +) -> str: + """Start a new motion search job. + + Returns the job ID. + """ + job = MotionSearchJob( + camera=camera_name, + start_time_range=start_time, + end_time_range=end_time, + polygon_points=polygon_points, + threshold=threshold, + min_area=min_area, + parallel=parallel, + max_results=max_results, + ) + + cancel_event = threading.Event() + + with _jobs_lock: + _motion_search_jobs[job.id] = (job, cancel_event) + + set_current_job(job) + + runner = MotionSearchRunner(job, config, cancel_event) + runner.start() + + logger.debug( + "Started motion search job %s for camera %s: " + "time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, " + "parallel=%s, max_results=%d, polygon_points=%d vertices", + job.id, + camera_name, + start_time, + end_time, + threshold, + min_area, + parallel, + max_results, + len(polygon_points), + ) + return job.id + + +def get_motion_search_job(job_id: str) -> MotionSearchJob | None: + """Get a motion search job by ID.""" + with _jobs_lock: + job_entry = _motion_search_jobs.get(job_id) + if job_entry: + return job_entry[0] + # Check completed jobs via manager + return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id)) + + +def cancel_motion_search_job(job_id: str) -> bool: + """Cancel a motion search job. + + Returns True if cancellation was initiated, False if job not found. + """ + with _jobs_lock: + job_entry = _motion_search_jobs.get(job_id) + if not job_entry: + return False + + job, cancel_event = job_entry + + if job.status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running): + # Already finished + return True + + cancel_event.set() + job.status = JobStatusTypesEnum.cancelled + job_payload = job.to_dict() + logger.info("Cancelled motion search job %s", job_id) + + requestor: InterProcessRequestor | None = None + try: + requestor = InterProcessRequestor() + requestor.send_data(UPDATE_JOB_STATE, job_payload) + except Exception as e: + logger.warning( + "Failed to broadcast cancelled motion search job %s: %s", job_id, e + ) + finally: + if requestor: + requestor.stop() + + return True diff --git a/frigate/jobs/motion_search_batch.py b/frigate/jobs/motion_search_batch.py new file mode 100644 index 0000000000..f916da5e0a --- /dev/null +++ b/frigate/jobs/motion_search_batch.py @@ -0,0 +1,75 @@ +"""Pure helpers for VOD-batched motion search. + +Coalescing gate-passing segments into time-contiguous runs, mapping a frame's +VOD stream time back to an absolute timestamp, and thinning sample times to a +target interval. No I/O or ffmpeg here so the tricky math stays unit-testable. +""" + +from bisect import bisect_right +from typing import Any + + +def coalesce_runs( + segments: list[Any], max_seconds: float, epsilon: float +) -> list[list[Any]]: + """Group gate-passing segments into time-contiguous runs. + + A run extends while each segment's ``start_time`` is within ``epsilon`` of + the previous segment's ``end_time`` (no recording gap) and the run's total + span stays at or below ``max_seconds``. A gap or the cap starts a new run. + Each segment must expose ``start_time`` / ``end_time``. + """ + runs: list[list[Any]] = [] + current: list[Any] = [] + for seg in segments: + if not current: + current = [seg] + continue + prev_end = float(current[-1].end_time) + run_start = float(current[0].start_time) + contiguous = abs(float(seg.start_time) - prev_end) <= epsilon + within_cap = (float(seg.end_time) - run_start) <= max_seconds + if contiguous and within_cap: + current.append(seg) + else: + runs.append(current) + current = [seg] + if current: + runs.append(current) + return runs + + +def build_segment_time_map( + run: list[Any], +) -> list[tuple[float, float, float]]: + """Build a (stream_offset, abs_start, duration) row per segment in a run. + + ``stream_offset`` is the segment's start in continuous VOD stream time (the + cumulative sum of preceding segment durations); ``abs_start`` is its absolute + ``start_time``. Built from each segment's own duration; for a gap-free run + this makes stream time equal ``run_start + offset``. + """ + rows: list[tuple[float, float, float]] = [] + offset = 0.0 + for seg in run: + duration = float(seg.end_time) - float(seg.start_time) + rows.append((offset, float(seg.start_time), duration)) + offset += duration + return rows + + +def stream_time_to_absolute( + time_map: list[tuple[float, float, float]], stream_time: float +) -> float: + """Map a VOD stream time to an absolute timestamp via the run's table. + + Binary-searches the segment whose stream range contains ``stream_time`` and + returns ``abs_start + (stream_time - stream_offset)``. Times past the last + segment map into the last segment (clamped at the run edge). + """ + offsets = [row[0] for row in time_map] + idx = bisect_right(offsets, stream_time) - 1 + if idx < 0: + idx = 0 + stream_offset, abs_start, _duration = time_map[idx] + return abs_start + (stream_time - stream_offset) diff --git a/frigate/jobs/motion_search_decode.py b/frigate/jobs/motion_search_decode.py new file mode 100644 index 0000000000..4b1d518013 --- /dev/null +++ b/frigate/jobs/motion_search_decode.py @@ -0,0 +1,382 @@ +"""Hardware-accelerated ffmpeg decode for motion search. + +Decodes a recording run's VOD/HLS stream with an ffmpeg subprocess, optionally +selecting only keyframes, and streams raw frames over a pipe for the motion +math. Output is the requested ``pix_fmt`` (gray or ``bgr24``) with optional +crop/scale applied in the filter graph so downstream pixels are unchanged. +""" + +import json +import logging +import subprocess as sp +import tempfile +from collections.abc import Callable, Generator +from typing import IO + +import numpy as np + +from frigate.config import CameraConfig +from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode +from frigate.util.services import auto_detect_hwaccel + +logger = logging.getLogger(__name__) + +# Output-format surfaces that download cleanly to nv12 via the fixed +# ``hwdownload,format=nv12`` step the decode path appends. Other surfaces +# (drm_prime from rkmpp, vulkan, amf) need a different download step, so motion +# search decodes them in software to keep results byte-identical rather than risk +# a wrong-but-valid-sized frame the zero-frame fallback gate would not catch. +_NV12_OUTPUT_FORMATS = frozenset({"vaapi", "cuda", "qsv"}) + + +def _hwaccel_output_format(decode_args: list[str]) -> str | None: + """Return the ``-hwaccel_output_format`` value in ffmpeg args, or None.""" + try: + idx = decode_args.index("-hwaccel_output_format") + except ValueError: + return None + return decode_args[idx + 1] if idx + 1 < len(decode_args) else None + + +def resolve_motion_decode_args(camera_config: CameraConfig) -> list[str]: + """Resolve the ffmpeg hwaccel decode args for a camera's recordings. + + ``auto`` is resolved via ``auto_detect_hwaccel`` and the preset is expanded + by ``parse_preset_hardware_acceleration_decode`` (the same table the live + pipeline uses). Acceleration is kept only when the decoded surface downloads + cleanly to nv12 -- decided by reading ``-hwaccel_output_format`` back from the + resolved args rather than a separate preset allowlist that could drift from + ``PRESETS_HW_ACCEL_DECODE``. Anything else (custom args, a software-only + preset, or an nv12-incompatible surface) returns an empty list, meaning + software decode, so results stay byte-identical. + """ + raw = camera_config.ffmpeg.hwaccel_args + preset = auto_detect_hwaccel() if raw == "auto" else raw + + # Custom args (a list) decode in software so results stay byte-identical. + if not isinstance(preset, str): + return [] + + decode_args = parse_preset_hardware_acceleration_decode( + preset, + camera_config.detect.fps, + camera_config.detect.width or 0, + camera_config.detect.height or 0, + camera_config.ffmpeg.gpu, + ) + if not decode_args: + return [] + + if _hwaccel_output_format(decode_args) not in _NV12_OUTPUT_FORMATS: + return [] + + return decode_args + + +def _read_exact(stream: IO[bytes], size: int) -> bytes | None: + """Read exactly ``size`` bytes from a pipe, or None at clean EOF. + + Pipe reads can return fewer bytes than requested, so loop until the frame + is complete. A short read at the start of a frame means end-of-stream. + """ + buf = bytearray() + while len(buf) < size: + chunk = stream.read(size - len(buf)) + if not chunk: + return None + buf.extend(chunk) + return bytes(buf) + + +def _terminate(proc: sp.Popen[bytes]) -> None: + """Stop an ffmpeg decode process promptly.""" + # Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then + # sees a broken pipe), then signal it. The resulting ffmpeg write error is + # harmless and goes to the captured stderr. + if proc.stdout is not None: + try: + proc.stdout.close() + except OSError: + pass + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except sp.TimeoutExpired: + proc.kill() + proc.wait() + + +KEYFRAME_MAX_GAP_SECONDS = 2.0 + + +def keyframe_sampling_eligible( + keyframe_pts: list[float], max_gap: float = KEYFRAME_MAX_GAP_SECONDS +) -> bool: + """True if keyframes are dense and regular enough for keyframe-only sampling. + + Requires at least two keyframes and no gap longer than ``max_gap`` seconds, so + a multi-second motion event necessarily spans a sampled keyframe. + """ + if len(keyframe_pts) < 2: + return False + gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])] + return max(gaps) <= max_gap + + +VOD_PROTOCOL_ARGS = ["-protocol_whitelist", "pipe,file,http,tcp"] + + +def build_vod_decode_command( + ffmpeg_path: str, + vod_url: str, + decode_args: list[str], + crop: tuple[int, int, int, int] | None, + scale: tuple[int, int] | None, + gray: bool, + *, + skip_nonkey: bool, + fps_rate: float | None, +) -> list[str]: + """Build the ffmpeg argv to decode a VOD HLS URL. + + ``skip_nonkey`` adds ``-skip_frame nokey`` (keyframe-only). ``fps_rate`` adds + an ``fps`` filter for the fixed-cadence fallback. They are mutually + exclusive: keyframe mode passes ``skip_nonkey=True``/``fps_rate=None``; the + fallback passes ``skip_nonkey=False`` with a rate. + """ + filters: list[str] = [] + # With hwaccel the decoded frames are GPU surfaces; pull them back to system + # memory before the CPU fps/crop/scale filters and the rawvideo encoder. + if decode_args: + filters.append("hwdownload") + filters.append("format=nv12") + if fps_rate is not None: + filters.append(f"fps={fps_rate}") + if crop is not None: + cw, ch, cx, cy = crop + filters.append(f"crop={cw}:{ch}:{cx}:{cy}") + if scale is not None: + sw, sh = scale + filters.append(f"scale={sw}:{sh}") + + pix_fmt = "gray" if gray else "bgr24" + cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "error"] + if skip_nonkey: + cmd += ["-skip_frame", "nokey"] + cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"] + if filters: + cmd += ["-vf", ",".join(filters)] + cmd += ["-vsync", "0", "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:"] + return cmd + + +def _run_vod_decode( + ffmpeg_path: str, + vod_url: str, + out_width: int, + out_height: int, + channels: int, + decode_args: list[str], + crop: tuple[int, int, int, int] | None, + scale: tuple[int, int] | None, + gray: bool, + should_stop: Callable[[], bool], + *, + skip_nonkey: bool, + fps_rate: float | None, + software_retry: bool, +) -> Generator[np.ndarray, None, None]: + """Run one VOD decode, yielding raw frames; retry in software if empty.""" + cmd = build_vod_decode_command( + ffmpeg_path, + vod_url, + decode_args, + crop, + scale, + gray, + skip_nonkey=skip_nonkey, + fps_rate=fps_rate, + ) + frame_size = out_width * out_height * channels + stderr_file = tempfile.SpooledTemporaryFile(max_size=65536) + proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=stderr_file) + assert proc.stdout is not None + + count = 0 + try: + while True: + if should_stop(): + break + buf = _read_exact(proc.stdout, frame_size) + if buf is None: + break + if channels == 1: + frame = np.frombuffer(buf, dtype=np.uint8).reshape( + (out_height, out_width) + ) + else: + frame = np.frombuffer(buf, dtype=np.uint8).reshape( + (out_height, out_width, channels) + ) + count += 1 + yield frame + finally: + _terminate(proc) + stderr_file.close() + + if count == 0 and software_retry and not should_stop(): + logger.warning("Hardware VOD decode produced no frames, retrying in software") + yield from _run_vod_decode( + ffmpeg_path, + vod_url, + out_width, + out_height, + channels, + [], + crop, + scale, + gray, + should_stop, + skip_nonkey=skip_nonkey, + fps_rate=fps_rate, + software_retry=False, + ) + + +def iter_vod_frames( + ffmpeg_path: str, + vod_url: str, + out_width: int, + out_height: int, + channels: int, + decode_args: list[str], + crop: tuple[int, int, int, int] | None, + scale: tuple[int, int] | None, + gray: bool, + should_stop: Callable[[], bool], + *, + skip_nonkey: bool, + fps_rate: float | None, +) -> Generator[np.ndarray, None, None]: + """Decode a VOD HLS URL and yield raw frames in order. + + Pair keyframe-mode output with probed keyframe PTS; pair fallback output with + a fixed cadence. Falls back once to software decode if a hwaccel decode yields + no frames. + """ + yield from _run_vod_decode( + ffmpeg_path, + vod_url, + out_width, + out_height, + channels, + decode_args, + crop, + scale, + gray, + should_stop, + skip_nonkey=skip_nonkey, + fps_rate=fps_rate, + software_retry=bool(decode_args), + ) + + +def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]: + """Return keyframe presentation timestamps (VOD stream time) in order. + + Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on + any failure so the caller can fall back. + """ + cmd = [ + ffprobe_path, + "-v", + "error", + *VOD_PROTOCOL_ARGS, + "-i", + vod_url, + "-select_streams", + "v:0", + "-show_packets", + "-show_entries", + "packet=pts_time,flags", + "-of", + "json", + ] + try: + completed = sp.run(cmd, capture_output=True, text=True, timeout=120) + except (OSError, sp.SubprocessError): + logger.warning("ffprobe failed for VOD keyframe probe") + return [] + + if completed.returncode != 0 or not completed.stdout: + return [] + + try: + packets = json.loads(completed.stdout).get("packets", []) + except json.JSONDecodeError: + return [] + + pts: list[float] = [] + for pkt in packets: + flags = pkt.get("flags", "") + pts_time = pkt.get("pts_time") + if flags.startswith("K") and pts_time is not None: + try: + pts.append(float(pts_time)) + except ValueError: + continue + return sorted(pts) + + +def probe_video_dimensions( + ffprobe_path: str, recording_path: str +) -> tuple[int, int, float] | None: + """Return (width, height, fps) for a recording's video stream, or None. + + Reads stream metadata via ffprobe (no decode). The record stream resolution + can differ from the camera's detect resolution, so this is probed once per + job against a real segment. + """ + cmd = [ + ffprobe_path, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height,avg_frame_rate", + "-of", + "json", + recording_path, + ] + try: + completed = sp.run(cmd, capture_output=True, text=True, timeout=30) + except (OSError, sp.SubprocessError): + return None + + if completed.returncode != 0 or not completed.stdout: + return None + + try: + streams = json.loads(completed.stdout).get("streams", []) + except json.JSONDecodeError: + return None + + if not streams: + return None + + stream = streams[0] + width = int(stream.get("width", 0) or 0) + height = int(stream.get("height", 0) or 0) + rate = stream.get("avg_frame_rate", "0/0") or "0/0" + try: + num, _, den = rate.partition("/") + fps = float(num) / float(den) if float(den) != 0 else 0.0 + except (ValueError, ZeroDivisionError): + fps = 0.0 + + if width <= 0 or height <= 0: + return None + + return width, height, fps diff --git a/frigate/jobs/vlm_watch.py b/frigate/jobs/vlm_watch.py new file mode 100644 index 0000000000..1e6a542c21 --- /dev/null +++ b/frigate/jobs/vlm_watch.py @@ -0,0 +1,449 @@ +"""VLM watch job: continuously monitors a camera and notifies when a condition is met.""" + +import base64 +import json +import logging +import re +import threading +import time +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any + +import cv2 + +from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.const import UPDATE_JOB_STATE +from frigate.jobs.job import Job +from frigate.types import JobStatusTypesEnum + +logger = logging.getLogger(__name__) + +# Polling interval bounds (seconds) +_MIN_INTERVAL = 1 +_MAX_INTERVAL = 300 + +# Minimum seconds between VLM iterations when woken by detections (no zone filter) +_DETECTION_COOLDOWN_WITHOUT_ZONE = 10 + +# Max user/assistant turn pairs to keep in conversation history +_MAX_HISTORY = 10 + + +@dataclass +class VLMWatchJob(Job): + """Job state for a VLM watch monitor.""" + + job_type: str = "vlm_watch" + camera: str = "" + condition: str = "" + max_duration_minutes: int = 60 + labels: list = field(default_factory=list) + zones: list = field(default_factory=list) + last_reasoning: str = "" + notification_message: str = "" + iteration_count: int = 0 + username: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class VLMWatchRunner(threading.Thread): + """Background thread that polls a camera with the vision client until a condition is met.""" + + def __init__( + self, + job: VLMWatchJob, + config: FrigateConfig, + cancel_event: threading.Event, + frame_processor: Any, + genai_manager: Any, + dispatcher: Any, + ) -> None: + super().__init__(daemon=True, name=f"vlm_watch_{job.id}") + self.job = job + self.config = config + self.cancel_event = cancel_event + self.frame_processor = frame_processor + self.genai_manager = genai_manager + self.dispatcher = dispatcher + self.requestor = InterProcessRequestor() + self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value) + self.conversation: list[dict[str, Any]] = [] + + def run(self) -> None: + self.job.status = JobStatusTypesEnum.running + self.job.start_time = time.time() + self._broadcast_status() + self.conversation = [{"role": "system", "content": self._build_system_prompt()}] + + max_end_time = self.job.start_time + self.job.max_duration_minutes * 60 + + try: + while not self.cancel_event.is_set(): + if time.time() > max_end_time: + logger.debug( + "VLM watch job %s timed out after %d minutes", + self.job.id, + self.job.max_duration_minutes, + ) + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = f"Monitor timed out after {self.job.max_duration_minutes} minutes" + break + + next_run_in = self._run_iteration() + + if self.job.status == JobStatusTypesEnum.success: + break + + self._wait_for_trigger(next_run_in) + + except Exception as e: + logger.exception("VLM watch job %s failed: %s", self.job.id, e) + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = str(e) + + finally: + if self.job.status == JobStatusTypesEnum.running: + self.job.status = JobStatusTypesEnum.cancelled + self.job.end_time = time.time() + self._broadcast_status() + try: + self.detection_subscriber.stop() + except Exception: + pass + try: + self.requestor.stop() + except Exception: + pass + + def _run_iteration(self) -> float: + """Run one VLM analysis iteration. Returns seconds until next run.""" + chat_client = self.genai_manager.chat_client + if chat_client is None or not chat_client.supports_vision: + logger.warning( + "VLM watch job %s: no chat client with vision support available", + self.job.id, + ) + return 30 + + frame = self.frame_processor.get_current_frame(self.job.camera, {}) + if frame is None: + logger.debug( + "VLM watch job %s: frame unavailable for camera %s", + self.job.id, + self.job.camera, + ) + self.job.last_reasoning = "Camera frame unavailable" + return 10 + + # Downscale frame to 480p max height + h, w = frame.shape[:2] + if h > 480: + scale = 480.0 / h + frame = cv2.resize( + frame, (int(w * scale), 480), interpolation=cv2.INTER_AREA + ) + + _, enc = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) + b64 = base64.b64encode(enc.tobytes()).decode() + + timestamp = datetime.now().strftime("%H:%M:%S") + self.conversation.append( + { + "role": "user", + "content": [ + {"type": "text", "text": f"Frame captured at {timestamp}."}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{b64}"}, + }, + ], + } + ) + + response = chat_client.chat_with_tools( + messages=self.conversation, + tools=None, + tool_choice=None, + ) + response_str = response.get("content") or "" + + if not response_str: + logger.warning( + "VLM watch job %s: empty response from vision client", self.job.id + ) + # Remove the user message we just added so we don't leave a dangling turn + self.conversation.pop() + return 30 + + logger.debug("VLM watch job %s response: %s", self.job.id, response_str) + + self.conversation.append({"role": "assistant", "content": response_str}) + + # Keep system prompt + last _MAX_HISTORY user/assistant pairs + max_msgs = 1 + _MAX_HISTORY * 2 + if len(self.conversation) > max_msgs: + self.conversation = [self.conversation[0]] + self.conversation[ + -(max_msgs - 1) : + ] + + try: + clean = re.sub( + r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response_str) + ) + parsed = json.loads(clean) + condition_met = bool(parsed.get("condition_met", False)) + next_run_in = max( + _MIN_INTERVAL, + min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))), + ) + reasoning = str(parsed.get("reasoning", "")) + notification_message = str(parsed.get("notification_message", "")) + except (json.JSONDecodeError, ValueError, TypeError) as e: + logger.warning( + "VLM watch job %s: failed to parse VLM response: %s", self.job.id, e + ) + return 30 + + self.job.last_reasoning = reasoning + self.job.notification_message = notification_message + self.job.iteration_count += 1 + self._broadcast_status() + + if condition_met: + logger.debug( + "VLM watch job %s: condition met on camera %s — %s", + self.job.id, + self.job.camera, + reasoning, + ) + self._send_notification(notification_message or reasoning) + self.job.status = JobStatusTypesEnum.success + return 0 + + return next_run_in + + def _wait_for_trigger(self, max_wait: float) -> None: + """Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera. + + With zones configured, a matching detection wakes immediately (events + are already filtered). Without zones, detections are frequent so a + cooldown is enforced: messages are continuously drained to prevent + queue backup, but the loop only exits once a match has been seen + *and* the cooldown period has elapsed. + """ + now = time.time() + deadline = now + max_wait + use_cooldown = not self.job.zones + earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0 + triggered = False + + while not self.cancel_event.is_set(): + remaining = deadline - time.time() + if remaining <= 0: + break + + if triggered and time.time() >= earliest_wake: + break + + result = self.detection_subscriber.check_for_update( + timeout=min(1.0, remaining) + ) + if result is None: + continue + topic, payload = result + if topic is None or payload is None: + continue + # payload = (camera, frame_name, frame_time, tracked_objects, motion_boxes, regions) + cam = payload[0] + tracked_objects = payload[3] + logger.debug( + "VLM watch job %s: detection event cam=%s (want %s), objects=%s", + self.job.id, + cam, + self.job.camera, + [ + {"label": o.get("label"), "zones": o.get("current_zones")} + for o in (tracked_objects or []) + ], + ) + if cam != self.job.camera or not tracked_objects: + continue + if self._detection_matches_filters(tracked_objects): + if not use_cooldown: + logger.debug( + "VLM watch job %s: woken early by detection event on %s", + self.job.id, + self.job.camera, + ) + break + + if not triggered: + logger.debug( + "VLM watch job %s: detection match on %s, draining for %.0fs", + self.job.id, + self.job.camera, + max(0, earliest_wake - time.time()), + ) + triggered = True + + def _detection_matches_filters(self, tracked_objects: list) -> bool: + """Return True if any tracked object passes the label and zone filters.""" + labels = self.job.labels + zones = self.job.zones + for obj in tracked_objects: + label_ok = not labels or obj.get("label") in labels + zone_ok = not zones or bool(set(obj.get("current_zones", [])) & set(zones)) + if label_ok and zone_ok: + return True + return False + + def _build_system_prompt(self) -> str: + focus_text = "" + if self.job.labels or self.job.zones: + parts = [] + if self.job.labels: + parts.append(f"object types: {', '.join(self.job.labels)}") + if self.job.zones: + parts.append(f"zones: {', '.join(self.job.zones)}") + focus_text = f"\nFocus on {' and '.join(parts)}.\n" + + return ( + f'You are monitoring a security camera. Your task: determine when "{self.job.condition}" occurs.\n' + f"{focus_text}\n" + f"You will receive a sequence of frames over time. Use the conversation history to understand " + f"what is stationary vs. actively changing.\n\n" + f"For each frame respond with JSON only:\n" + f'{{"condition_met": , "next_run_in": , "reasoning": "", "notification_message": ""}}\n\n' + f"Guidelines for notification_message:\n" + f"- Only required when condition_met is true.\n" + f"- Write a short, natural notification a user would want to receive on their phone.\n" + f'- Example: "Your package has been delivered to the front porch."\n\n' + f"Guidelines for next_run_in:\n" + f"- Scene is empty / nothing of interest visible: 60-300.\n" + f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. " + f"They may be moving toward the zone.\n" + f"- Condition is actively forming (object approaching zone or threshold): 1-5.\n" + f"- Set condition_met to true only when you are confident the condition is currently met.\n" + f"- Keep reasoning to 1-2 sentences." + ) + + def _send_notification(self, message: str) -> None: + """Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users.""" + payload = { + "camera": self.job.camera, + "condition": self.job.condition, + "message": message, + "reasoning": self.job.last_reasoning, + "job_id": self.job.id, + } + + if self.dispatcher: + try: + self.dispatcher.publish("camera_monitoring", json.dumps(payload)) + except Exception as e: + logger.warning( + "VLM watch job %s: failed to publish alert: %s", self.job.id, e + ) + + def _broadcast_status(self) -> None: + try: + self.requestor.send_data(UPDATE_JOB_STATE, self.job.to_dict()) + except Exception as e: + logger.warning( + "VLM watch job %s: failed to broadcast status: %s", self.job.id, e + ) + + +# Module-level singleton (only one watch job at a time) +_current_job: VLMWatchJob | None = None +_cancel_event: threading.Event | None = None +_job_lock = threading.Lock() + + +def start_vlm_watch_job( + camera: str, + condition: str, + max_duration_minutes: int, + config: FrigateConfig, + frame_processor: Any, + genai_manager: Any, + dispatcher: Any, + labels: list[str] | None = None, + zones: list[str] | None = None, + username: str = "", +) -> str: + """Start a new VLM watch job. Returns the job ID. + + Raises RuntimeError if a job is already running. + """ + global _current_job, _cancel_event + + with _job_lock: + if _current_job is not None and _current_job.status in ( + JobStatusTypesEnum.queued, + JobStatusTypesEnum.running, + ): + raise RuntimeError( + f"A VLM watch job is already running (id={_current_job.id}). " + "Cancel it before starting a new one." + ) + + job = VLMWatchJob( + camera=camera, + condition=condition, + max_duration_minutes=max_duration_minutes, + labels=labels or [], + zones=zones or [], + username=username, + ) + cancel_ev = threading.Event() + _current_job = job + _cancel_event = cancel_ev + + runner = VLMWatchRunner( + job=job, + config=config, + cancel_event=cancel_ev, + frame_processor=frame_processor, + genai_manager=genai_manager, + dispatcher=dispatcher, + ) + runner.start() + + logger.debug( + "Started VLM watch job %s: camera=%s, condition=%r, max_duration=%dm", + job.id, + camera, + condition, + max_duration_minutes, + ) + return job.id + + +def stop_vlm_watch_job() -> bool: + """Cancel the current VLM watch job. Returns True if a job was cancelled.""" + global _current_job, _cancel_event + + with _job_lock: + if _current_job is None or _current_job.status not in ( + JobStatusTypesEnum.queued, + JobStatusTypesEnum.running, + ): + return False + + if _cancel_event: + _cancel_event.set() + + _current_job.status = JobStatusTypesEnum.cancelled + logger.debug("Cancelled VLM watch job %s", _current_job.id) + return True + + +def get_vlm_watch_job() -> VLMWatchJob | None: + """Return the current (or most recent) VLM watch job.""" + return _current_job diff --git a/frigate/log.py b/frigate/log.py index cd475a4bdb..3c5f8ec8b8 100644 --- a/frigate/log.py +++ b/frigate/log.py @@ -6,13 +6,14 @@ import os import sys import threading from collections import deque +from collections.abc import Callable, Generator from contextlib import contextmanager from enum import Enum from functools import wraps from logging.handlers import QueueHandler, QueueListener from multiprocessing.managers import SyncManager from queue import Empty, Queue -from typing import Any, Callable, Deque, Generator, Optional +from typing import Any from frigate.util.builtin import clean_camera_user_pass @@ -47,8 +48,8 @@ class LogLevel(str, Enum): critical = "critical" -log_listener: Optional[QueueListener] = None -log_queue: Optional[Queue] = None +log_listener: QueueListener | None = None +log_queue: Queue | None = None def setup_logging(manager: SyncManager) -> None: @@ -118,7 +119,7 @@ class LogPipe(threading.Thread): super().__init__(daemon=False) self.logger = logging.getLogger(log_name) self.level = level - self.deque: Deque[str] = deque(maxlen=100) + self.deque: deque[str] = deque(maxlen=100) self.fdRead, self.fdWrite = os.pipe() self.pipeReader = os.fdopen(self.fdRead) self.start() diff --git a/frigate/models.py b/frigate/models.py index 93f6cb54fe..d927a12c83 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -78,6 +78,15 @@ class Recordings(Model): dBFS = IntegerField(null=True) segment_size = FloatField(default=0) # this should be stored as MB regions = IntegerField(null=True) + motion_heatmap = JSONField(null=True) # 16x16 grid, 256 values (0-255) + + +class ExportCase(Model): + id = CharField(null=False, primary_key=True, max_length=30) + name = CharField(index=True, max_length=100) + description = TextField(null=True) + created_at = DateTimeField() + updated_at = DateTimeField() class Export(Model): @@ -88,6 +97,12 @@ class Export(Model): video_path = CharField(unique=True) thumb_path = CharField(unique=True) in_progress = BooleanField() + export_case = ForeignKeyField( + ExportCase, + null=True, + backref="exports", + column_name="export_case_id", + ) class ReviewSegment(Model): diff --git a/frigate/motion/__init__.py b/frigate/motion/__init__.py index 1f6785d5da..e6ead35bb7 100644 --- a/frigate/motion/__init__.py +++ b/frigate/motion/__init__.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Tuple from numpy import ndarray @@ -10,13 +9,13 @@ class MotionDetector(ABC): @abstractmethod def __init__( self, - frame_shape: Tuple[int, int, int], + frame_shape: tuple[int, int, int], config: MotionConfig, fps: int, - improve_contrast, - threshold, - contour_area, - ): + improve_contrast: bool, + threshold: int, + contour_area: int | None, + ) -> None: pass @abstractmethod @@ -25,7 +24,7 @@ class MotionDetector(ABC): pass @abstractmethod - def is_calibrating(self): + def is_calibrating(self) -> bool: """Return if motion is recalibrating.""" pass @@ -35,6 +34,6 @@ class MotionDetector(ABC): pass @abstractmethod - def stop(self): + def stop(self) -> None: """Stop any ongoing work and processes.""" pass diff --git a/frigate/motion/frigate_motion.py b/frigate/motion/frigate_motion.py index fd362de346..8a067e1da9 100644 --- a/frigate/motion/frigate_motion.py +++ b/frigate/motion/frigate_motion.py @@ -1,7 +1,9 @@ +from typing import Any + import cv2 import numpy as np -from frigate.config import MotionConfig +from frigate.config.config import RuntimeMotionConfig from frigate.motion import MotionDetector from frigate.util.image import grab_cv2_contours @@ -9,26 +11,27 @@ from frigate.util.image import grab_cv2_contours class FrigateMotionDetector(MotionDetector): def __init__( self, - frame_shape, - config: MotionConfig, + frame_shape: tuple[int, ...], + config: RuntimeMotionConfig, fps: int, - improve_contrast, - threshold, - contour_area, - ): + improve_contrast: Any, + threshold: Any, + contour_area: Any, + ) -> None: self.config = config self.frame_shape = frame_shape - self.resize_factor = frame_shape[0] / config.frame_height + frame_height = config.frame_height or frame_shape[0] + self.resize_factor = frame_shape[0] / frame_height self.motion_frame_size = ( - config.frame_height, - config.frame_height * frame_shape[1] // frame_shape[0], + frame_height, + frame_height * frame_shape[1] // frame_shape[0], ) self.avg_frame = np.zeros(self.motion_frame_size, np.float32) self.avg_delta = np.zeros(self.motion_frame_size, np.float32) self.motion_frame_count = 0 self.frame_counter = 0 resized_mask = cv2.resize( - config.mask, + config.rasterized_mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR, ) @@ -38,10 +41,10 @@ class FrigateMotionDetector(MotionDetector): self.threshold = threshold self.contour_area = contour_area - def is_calibrating(self): + def is_calibrating(self) -> bool: return False - def detect(self, frame): + def detect(self, frame: np.ndarray) -> list: motion_boxes = [] gray = frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] @@ -99,7 +102,7 @@ class FrigateMotionDetector(MotionDetector): # dilate the thresholded image to fill in holes, then find contours # on thresholded image - thresh_dilated = cv2.dilate(thresh, None, iterations=2) + thresh_dilated = cv2.dilate(thresh, None, iterations=2) # type: ignore[call-overload] contours = cv2.findContours( thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) diff --git a/frigate/motion/improved_motion.py b/frigate/motion/improved_motion.py index b081d37919..598eeacd1d 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -5,7 +5,7 @@ import numpy as np from scipy.ndimage import gaussian_filter from frigate.camera import PTZMetrics -from frigate.config import MotionConfig +from frigate.config.config import RuntimeMotionConfig from frigate.motion import MotionDetector from frigate.util.image import grab_cv2_contours @@ -15,22 +15,23 @@ logger = logging.getLogger(__name__) class ImprovedMotionDetector(MotionDetector): def __init__( self, - frame_shape, - config: MotionConfig, + frame_shape: tuple[int, ...], + config: RuntimeMotionConfig, fps: int, - ptz_metrics: PTZMetrics = None, - name="improved", - blur_radius=1, - interpolation=cv2.INTER_NEAREST, - contrast_frame_history=50, - ): + ptz_metrics: PTZMetrics | None = None, + name: str = "improved", + blur_radius: int = 1, + interpolation: int = cv2.INTER_NEAREST, + contrast_frame_history: int = 50, + ) -> None: self.name = name self.config = config self.frame_shape = frame_shape - self.resize_factor = frame_shape[0] / config.frame_height + frame_height = config.frame_height or frame_shape[0] + self.resize_factor = frame_shape[0] / frame_height self.motion_frame_size = ( - config.frame_height, - config.frame_height * frame_shape[1] // frame_shape[0], + frame_height, + frame_height * frame_shape[1] // frame_shape[0], ) self.avg_frame = np.zeros(self.motion_frame_size, np.float32) self.motion_frame_count = 0 @@ -44,20 +45,20 @@ class ImprovedMotionDetector(MotionDetector): self.contrast_values[:, 1:2] = 255 self.contrast_values_index = 0 self.ptz_metrics = ptz_metrics - self.last_stop_time = None + self.last_stop_time: float | None = None - def is_calibrating(self): + def is_calibrating(self) -> bool: return self.calibrating - def detect(self, frame): - motion_boxes = [] + def detect(self, frame: np.ndarray) -> list[tuple[int, int, int, int]]: + motion_boxes: list[tuple[int, int, int, int]] = [] if not self.config.enabled: return motion_boxes # if ptz motor is moving from autotracking, quickly return # a single box that is 80% of the frame - if ( + if self.ptz_metrics is not None and ( self.ptz_metrics.autotracker_enabled.value and not self.ptz_metrics.motor_stopped.is_set() ): @@ -130,19 +131,19 @@ class ImprovedMotionDetector(MotionDetector): # dilate the thresholded image to fill in holes, then find contours # on thresholded image - thresh_dilated = cv2.dilate(thresh, None, iterations=1) + thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload] contours = cv2.findContours( thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) contours = grab_cv2_contours(contours) # loop over the contours - total_contour_area = 0 + total_contour_area: float = 0 for c in contours: # if the contour is big enough, count it as motion contour_area = cv2.contourArea(c) total_contour_area += contour_area - if contour_area > self.config.contour_area: + if contour_area > (self.config.contour_area or 0): x, y, w, h = cv2.boundingRect(c) motion_boxes.append( ( @@ -159,7 +160,7 @@ class ImprovedMotionDetector(MotionDetector): # check if the motor has just stopped from autotracking # if so, reassign the average to the current frame so we begin with a new baseline - if ( + if self.ptz_metrics is not None and ( # ensure we only do this for cameras with autotracking enabled self.ptz_metrics.autotracker_enabled.value and self.ptz_metrics.motor_stopped.is_set() @@ -176,11 +177,32 @@ class ImprovedMotionDetector(MotionDetector): motion_boxes = [] pct_motion = 0 + # skip motion entirely if the scene change percentage exceeds configured + # threshold. this is useful to ignore lighting storms, IR mode switches, + # etc. rather than registering them as brief motion and then recalibrating. + # note: skipping means the frame is dropped and **no recording will be + # created**, which could hide a legitimate object if the camera is actively + # auto‑tracking. the alternative is to allow motion and accept a small + # recording that can be reviewed in the timeline. disabled by default (None). + if ( + self.config.skip_motion_threshold is not None + and pct_motion > self.config.skip_motion_threshold + ): + # force a recalibration so we transition to the new background + self.calibrating = True + return [] + # once the motion is less than 5% and the number of contours is < 4, assume its calibrated if pct_motion < 0.05 and len(motion_boxes) <= 4: self.calibrating = False - # if calibrating or the motion contours are > 80% of the image area (lightning, ir, ptz) recalibrate + # if calibrating or the motion contours are > 80% of the image area + # (lightning, ir, ptz) recalibrate. the lightning threshold does **not** + # stop motion detection entirely; it simply halts additional processing for + # the current frame once the percentage crosses the threshold. this helps + # reduce false positive object detections and CPU usage during high‑motion + # events. recordings continue to be generated because users expect data + # while a PTZ camera is moving. if self.calibrating or pct_motion > self.config.lightning_threshold: self.calibrating = True @@ -233,7 +255,7 @@ class ImprovedMotionDetector(MotionDetector): def update_mask(self) -> None: resized_mask = cv2.resize( - self.config.mask, + self.config.rasterized_mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_AREA, ) diff --git a/frigate/mypy.ini b/frigate/mypy.ini index 5bad10f497..3c643236fb 100644 --- a/frigate/mypy.ini +++ b/frigate/mypy.ini @@ -22,50 +22,43 @@ warn_unreachable = true no_implicit_reexport = true [mypy-frigate.*] +ignore_errors = false + +# Third-party code imported from https://github.com/ufal/whisper_streaming +[mypy-frigate.data_processing.real_time.whisper_online] ignore_errors = true -[mypy-frigate.__main__] -ignore_errors = false -disallow_untyped_calls = false +# TODO: Remove ignores for these modules as they are updated with type annotations. -[mypy-frigate.app] -ignore_errors = false -disallow_untyped_calls = false +[mypy-frigate.api.*] +ignore_errors = true -[mypy-frigate.const] -ignore_errors = false +[mypy-frigate.config.*] +ignore_errors = true -[mypy-frigate.comms.*] -ignore_errors = false +[mypy-frigate.debug_replay] +ignore_errors = true -[mypy-frigate.events] -ignore_errors = false +[mypy-frigate.detectors.*] +ignore_errors = true -[mypy-frigate.log] -ignore_errors = false +[mypy-frigate.embeddings.*] +ignore_errors = true -[mypy-frigate.models] -ignore_errors = false +[mypy-frigate.http] +ignore_errors = true -[mypy-frigate.plus] -ignore_errors = false +[mypy-frigate.ptz.*] +ignore_errors = true -[mypy-frigate.stats] -ignore_errors = false +[mypy-frigate.stats.*] +ignore_errors = true -[mypy-frigate.track.*] -ignore_errors = false +[mypy-frigate.test.*] +ignore_errors = true -[mypy-frigate.types] -ignore_errors = false +[mypy-frigate.util.*] +ignore_errors = true -[mypy-frigate.version] -ignore_errors = false - -[mypy-frigate.watchdog] -ignore_errors = false -disallow_untyped_calls = false - - -[mypy-frigate.service_manager.*] -ignore_errors = false +[mypy-frigate.video.*] +ignore_errors = true diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index d2a54afbc5..bc7910e4d0 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from collections import deque from multiprocessing import Queue, Value from multiprocessing.synchronize import Event as MpEvent +from typing import Any import numpy as np import zmq @@ -34,26 +35,25 @@ logger = logging.getLogger(__name__) class ObjectDetector(ABC): @abstractmethod - def detect(self, tensor_input, threshold: float = 0.4): + def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list: pass class BaseLocalDetector(ObjectDetector): def __init__( self, - detector_config: BaseDetectorConfig = None, - labels: str = None, - stop_event: MpEvent = None, - ): + detector_config: BaseDetectorConfig | None = None, + labels: str | None = None, + stop_event: MpEvent | None = None, + ) -> None: self.fps = EventsPerSecond() if labels is None: - self.labels = {} + self.labels: dict[int, str] = {} else: self.labels = load_labels(labels) - if detector_config: + if detector_config and detector_config.model: self.input_transform = tensor_transform(detector_config.model.input_tensor) - self.dtype = detector_config.model.input_dtype else: self.input_transform = None @@ -77,10 +77,10 @@ class BaseLocalDetector(ObjectDetector): return tensor_input - def detect(self, tensor_input: np.ndarray, threshold=0.4): + def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list: detections = [] - raw_detections = self.detect_raw(tensor_input) + raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined] for d in raw_detections: if int(d[0]) < 0 or int(d[0]) >= len(self.labels): @@ -96,28 +96,28 @@ class BaseLocalDetector(ObjectDetector): class LocalObjectDetector(BaseLocalDetector): - def detect_raw(self, tensor_input: np.ndarray): + def detect_raw(self, tensor_input: np.ndarray) -> np.ndarray: tensor_input = self._transform_input(tensor_input) - return self.detect_api.detect_raw(tensor_input=tensor_input) + return self.detect_api.detect_raw(tensor_input=tensor_input) # type: ignore[no-any-return] class AsyncLocalObjectDetector(BaseLocalDetector): - def async_send_input(self, tensor_input: np.ndarray, connection_id: str): + def async_send_input(self, tensor_input: np.ndarray, connection_id: str) -> None: tensor_input = self._transform_input(tensor_input) - return self.detect_api.send_input(connection_id, tensor_input) + self.detect_api.send_input(connection_id, tensor_input) - def async_receive_output(self): + def async_receive_output(self) -> Any: return self.detect_api.receive_output() class DetectorRunner(FrigateProcess): def __init__( self, - name, + name: str, detection_queue: Queue, cameras: list[str], - avg_speed: Value, - start_time: Value, + avg_speed: Any, + start_time: Any, config: FrigateConfig, detector_config: BaseDetectorConfig, stop_event: MpEvent, @@ -129,11 +129,11 @@ class DetectorRunner(FrigateProcess): self.start_time = start_time self.config = config self.detector_config = detector_config - self.outputs: dict = {} + self.outputs: dict[str, Any] = {} - def create_output_shm(self, name: str): + def create_output_shm(self, name: str) -> None: out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) - out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) + out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) self.outputs[name] = {"shm": out_shm, "np": out_np} def run(self) -> None: @@ -155,8 +155,8 @@ class DetectorRunner(FrigateProcess): connection_id, ( 1, - self.detector_config.model.height, - self.detector_config.model.width, + self.detector_config.model.height, # type: ignore[union-attr] + self.detector_config.model.width, # type: ignore[union-attr] 3, ), ) @@ -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: @@ -187,11 +188,11 @@ class DetectorRunner(FrigateProcess): class AsyncDetectorRunner(FrigateProcess): def __init__( self, - name, + name: str, detection_queue: Queue, cameras: list[str], - avg_speed: Value, - start_time: Value, + avg_speed: Any, + start_time: Any, config: FrigateConfig, detector_config: BaseDetectorConfig, stop_event: MpEvent, @@ -203,15 +204,15 @@ class AsyncDetectorRunner(FrigateProcess): self.start_time = start_time self.config = config self.detector_config = detector_config - self.outputs: dict = {} + self.outputs: dict[str, Any] = {} self._frame_manager: SharedMemoryFrameManager | None = None self._publisher: ObjectDetectorPublisher | None = None self._detector: AsyncLocalObjectDetector | None = None - self.send_times = deque() + self.send_times: deque[float] = deque() - def create_output_shm(self, name: str): + def create_output_shm(self, name: str) -> None: out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) - out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) + out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) self.outputs[name] = {"shm": out_shm, "np": out_np} def _detect_worker(self) -> None: @@ -222,12 +223,13 @@ class AsyncDetectorRunner(FrigateProcess): except queue.Empty: continue + assert self._frame_manager is not None input_frame = self._frame_manager.get( connection_id, ( 1, - self.detector_config.model.height, - self.detector_config.model.width, + self.detector_config.model.height, # type: ignore[union-attr] + self.detector_config.model.width, # type: ignore[union-attr] 3, ), ) @@ -238,11 +240,13 @@ class AsyncDetectorRunner(FrigateProcess): # mark start time and send to accelerator self.send_times.append(time.perf_counter()) + assert self._detector is not None self._detector.async_send_input(input_frame, connection_id) def _result_worker(self) -> None: logger.info("Starting Result Worker Thread") while not self.stop_event.is_set(): + assert self._detector is not None connection_id, detections = self._detector.async_receive_output() # Handle timeout case (queue.Empty) - just continue @@ -256,6 +260,7 @@ class AsyncDetectorRunner(FrigateProcess): duration = time.perf_counter() - ts # release input buffer + assert self._frame_manager is not None self._frame_manager.close(connection_id) if connection_id not in self.outputs: @@ -264,6 +269,7 @@ class AsyncDetectorRunner(FrigateProcess): # write results and publish if detections is not None: self.outputs[connection_id]["np"][:] = detections[:] + assert self._publisher is not None self._publisher.publish(connection_id) # update timers @@ -330,11 +336,14 @@ class ObjectDetectProcess: self.stop_event = stop_event self.start_or_restart() - def stop(self): + def stop(self) -> None: # if the process has already exited on its own, just return if self.detect_process and self.detect_process.exitcode: return + if self.detect_process is None: + return + logging.info("Waiting for detection process to exit gracefully...") self.detect_process.join(timeout=30) if self.detect_process.exitcode is None: @@ -343,8 +352,8 @@ class ObjectDetectProcess: self.detect_process.join() logging.info("Detection process has exited...") - def start_or_restart(self): - self.detection_start.value = 0.0 + def start_or_restart(self) -> None: + self.detection_start.value = 0.0 # type: ignore[attr-defined] if (self.detect_process is not None) and self.detect_process.is_alive(): self.stop() @@ -389,17 +398,19 @@ class RemoteObjectDetector: self.detection_queue = detection_queue self.stop_event = stop_event self.shm = UntrackedSharedMemory(name=self.name, create=False) - self.np_shm = np.ndarray( + self.np_shm: np.ndarray = np.ndarray( (1, model_config.height, model_config.width, 3), dtype=np.uint8, buffer=self.shm.buf, ) self.out_shm = UntrackedSharedMemory(name=f"out-{self.name}", create=False) - self.out_np_shm = np.ndarray((20, 6), dtype=np.float32, buffer=self.out_shm.buf) + self.out_np_shm: np.ndarray = np.ndarray( + (20, 6), dtype=np.float32, buffer=self.out_shm.buf + ) self.detector_subscriber = ObjectDetectorSubscriber(name) - def detect(self, tensor_input, threshold=0.4): - detections = [] + def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list: + detections: list = [] if self.stop_event.is_set(): return detections @@ -431,7 +442,7 @@ class RemoteObjectDetector: self.fps.update() return detections - def cleanup(self): + def cleanup(self) -> None: self.detector_subscriber.stop() self.shm.unlink() self.out_shm.unlink() diff --git a/frigate/object_detection/util.py b/frigate/object_detection/util.py index ea8bd4226c..4e351d66a1 100644 --- a/frigate/object_detection/util.py +++ b/frigate/object_detection/util.py @@ -13,10 +13,10 @@ class RequestStore: A thread-safe hash-based response store that handles creating requests. """ - def __init__(self): + def __init__(self) -> None: self.request_counter = 0 self.request_counter_lock = threading.Lock() - self.input_queue = queue.Queue() + self.input_queue: queue.Queue[tuple[int, ndarray]] = queue.Queue() def __get_request_id(self) -> int: with self.request_counter_lock: @@ -45,17 +45,19 @@ class ResponseStore: their request's result appears. """ - def __init__(self): - self.responses = {} # Maps request_id -> (original_input, infer_results) + def __init__(self) -> None: + self.responses: dict[ + int, ndarray + ] = {} # Maps request_id -> (original_input, infer_results) self.lock = threading.Lock() self.cond = threading.Condition(self.lock) - def put(self, request_id: int, response: ndarray): + def put(self, request_id: int, response: ndarray) -> None: with self.cond: self.responses[request_id] = response self.cond.notify_all() - def get(self, request_id: int, timeout=None) -> ndarray: + def get(self, request_id: int, timeout: float | None = None) -> ndarray: with self.cond: if not self.cond.wait_for( lambda: request_id in self.responses, timeout=timeout @@ -65,7 +67,9 @@ class ResponseStore: return self.responses.pop(request_id) -def tensor_transform(desired_shape: InputTensorEnum): +def tensor_transform( + desired_shape: InputTensorEnum, +) -> tuple[int, int, int, int] | None: # Currently this function only supports BHWC permutations if desired_shape == InputTensorEnum.nhwc: return None diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index eb23c25736..a38669cbf9 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -4,14 +4,13 @@ import datetime import glob import logging import math -import multiprocessing as mp import os import queue import subprocess as sp import threading -import time import traceback -from typing import Any, Optional +from multiprocessing.synchronize import Event as MpEvent +from typing import Any import cv2 import numpy as np @@ -19,6 +18,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -62,8 +62,10 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]: if round(a_w / a_h, 2) != round(width / height, 2): canvas_width = int(width // 4 * 4) canvas_height = int((canvas_width / a_w * a_h) // 4 * 4) - logger.warning( - f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}" + logger.error( + f"Birdseye resolution {width}x{height} is not a supported aspect ratio " + f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. " + f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)" ) return (canvas_width, canvas_height) @@ -74,25 +76,25 @@ class Canvas: self, canvas_width: int, canvas_height: int, - scaling_factor: int, + scaling_factor: float, ) -> None: self.scaling_factor = scaling_factor gcd = math.gcd(canvas_width, canvas_height) self.aspect = get_standard_aspect_ratio( - (canvas_width / gcd), (canvas_height / gcd) + int(canvas_width / gcd), int(canvas_height / gcd) ) self.width = canvas_width - self.height = (self.width * self.aspect[1]) / self.aspect[0] - self.coefficient_cache: dict[int, int] = {} + self.height: float = (self.width * self.aspect[1]) / self.aspect[0] + self.coefficient_cache: dict[int, float] = {} self.aspect_cache: dict[str, tuple[int, int]] = {} - def get_aspect(self, coefficient: int) -> tuple[int, int]: + def get_aspect(self, coefficient: float) -> tuple[float, float]: return (self.aspect[0] * coefficient, self.aspect[1] * coefficient) - def get_coefficient(self, camera_count: int) -> int: + def get_coefficient(self, camera_count: int) -> float: return self.coefficient_cache.get(camera_count, self.scaling_factor) - def set_coefficient(self, camera_count: int, coefficient: int) -> None: + def set_coefficient(self, camera_count: int, coefficient: float) -> None: self.coefficient_cache[camera_count] = coefficient def get_camera_aspect( @@ -105,7 +107,7 @@ class Canvas: gcd = math.gcd(camera_width, camera_height) camera_aspect = get_standard_aspect_ratio( - camera_width / gcd, camera_height / gcd + int(camera_width / gcd), int(camera_height / gcd) ) self.aspect_cache[cam_name] = camera_aspect return camera_aspect @@ -116,7 +118,7 @@ class FFMpegConverter(threading.Thread): self, ffmpeg: FfmpegConfig, input_queue: queue.Queue, - stop_event: mp.Event, + stop_event: MpEvent, in_width: int, in_height: int, out_width: int, @@ -128,7 +130,7 @@ class FFMpegConverter(threading.Thread): self.camera = "birdseye" self.input_queue = input_queue self.stop_event = stop_event - self.bd_pipe = None + self.bd_pipe: int | None = None if birdseye_rtsp: self.recreate_birdseye_pipe() @@ -181,7 +183,8 @@ class FFMpegConverter(threading.Thread): os.close(stdin) self.reading_birdseye = False - def __write(self, b) -> None: + def __write(self, b: bytes) -> None: + assert self.process.stdin is not None self.process.stdin.write(b) if self.bd_pipe: @@ -200,13 +203,13 @@ class FFMpegConverter(threading.Thread): return - def read(self, length): + def read(self, length: int) -> Any: try: - return self.process.stdout.read1(length) + return self.process.stdout.read1(length) # type: ignore[union-attr] except ValueError: return False - def exit(self): + def exit(self) -> None: if self.bd_pipe: os.close(self.bd_pipe) @@ -233,16 +236,18 @@ class BroadcastThread(threading.Thread): self, camera: str, converter: FFMpegConverter, - websocket_server, - stop_event: mp.Event, + websocket_server: Any, + stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config - def run(self): + def run(self) -> None: while not self.stop_event.is_set(): buf = self.converter.read(65536) if buf: @@ -255,6 +260,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -270,20 +276,16 @@ class BirdsEyeFrameManager: def __init__( self, config: FrigateConfig, - stop_event: mp.Event, + stop_event: MpEvent, ): self.config = config - self.mode = config.birdseye.mode width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height) self.frame_shape = (height, width) self.yuv_shape = (height * 3 // 2, width) - self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8) + self.frame: np.ndarray = np.ndarray(self.yuv_shape, dtype=np.uint8) self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor) self.stop_event = stop_event - self.inactivity_threshold = config.birdseye.inactivity_threshold - - if config.birdseye.layout.max_cameras: - self.last_refresh_time = 0 + self.last_refresh_time: float = 0 # initialize the frame as black and with the Frigate logo self.blank_frame = np.zeros(self.yuv_shape, np.uint8) @@ -307,27 +309,36 @@ class BirdsEyeFrameManager: birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED) if birdseye_logo is not None: - transparent_layer = birdseye_logo[:, :, 3] + if birdseye_logo.ndim == 2: + # Grayscale image (no channels) — use directly as luminance + transparent_layer = birdseye_logo + elif birdseye_logo.shape[2] >= 4: + # RGBA — use alpha channel as luminance + transparent_layer = birdseye_logo[:, :, 3] + else: + # RGB or other format without alpha — convert to grayscale + transparent_layer = cv2.cvtColor(birdseye_logo, cv2.COLOR_BGR2GRAY) y_offset = height // 2 - transparent_layer.shape[0] // 2 x_offset = width // 2 - transparent_layer.shape[1] // 2 self.blank_frame[ - y_offset : y_offset + transparent_layer.shape[1], - x_offset : x_offset + transparent_layer.shape[0], + y_offset : y_offset + transparent_layer.shape[0], + x_offset : x_offset + transparent_layer.shape[1], ] = transparent_layer else: logger.warning("Unable to read Frigate logo") self.frame[:] = self.blank_frame - self.cameras = {} + self.cameras: dict[str, Any] = {} for camera in self.config.cameras.keys(): self.add_camera(camera) - self.camera_layout = [] - self.active_cameras = set() + self.camera_layout: list[Any] = [] + self.active_cameras: set[str] = set() + self.layout_camera_order: list[str] = [] self.last_output_time = 0.0 - def add_camera(self, cam: str): + def add_camera(self, cam: str) -> None: """Add a camera to self.cameras with the correct structure.""" settings = self.config.cameras[cam] # precalculate the coordinates for all the channels @@ -357,16 +368,28 @@ class BirdsEyeFrameManager: }, } - def remove_camera(self, cam: str): + def remove_camera(self, cam: str) -> None: """Remove a camera from self.cameras.""" if cam in self.cameras: del self.cameras[cam] - def clear_frame(self): + def sort_cameras(self, cameras: set[str]) -> list[str]: + """Sort cameras by birdseye order, falling back to name when tied.""" + return sorted( + cameras, + key=lambda camera: (self.config.cameras[camera].birdseye.order, camera), + ) + + def clear_frame(self) -> None: logger.debug("Clearing the birdseye frame") self.frame[:] = self.blank_frame - def copy_to_position(self, position, camera=None, frame: np.ndarray = None): + def copy_to_position( + self, + position: Any, + camera: str | None = None, + frame: np.ndarray | None = None, + ) -> None: if camera is None: frame = None channel_dims = None @@ -385,7 +408,9 @@ class BirdsEyeFrameManager: channel_dims, ) - def camera_active(self, mode, object_box_count, motion_box_count): + def camera_active( + self, mode: Any, object_box_count: int, motion_box_count: int + ) -> bool: if mode == BirdseyeModeEnum.continuous: return True @@ -395,6 +420,8 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True + return False + def get_camera_coordinates(self) -> dict[str, dict[str, int]]: """Return the coordinates of each camera in the current layout.""" coordinates = {} @@ -409,7 +436,7 @@ class BirdsEyeFrameManager: } return coordinates - def update_frame(self, frame: Optional[np.ndarray] = None) -> tuple[bool, bool]: + def update_frame(self, frame: np.ndarray | None = None) -> tuple[bool, bool]: """ Update birdseye, optionally with a new frame. Returns (frame_changed, layout_changed) to indicate if the frame or layout changed. @@ -420,12 +447,13 @@ class BirdsEyeFrameManager: [ cam for cam, cam_data in self.cameras.items() - if self.config.cameras[cam].birdseye.enabled + if cam in self.config.cameras + and self.config.cameras[cam].birdseye.enabled and self.config.cameras[cam].enabled_in_config and self.config.cameras[cam].enabled and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] - < self.inactivity_threshold + < self.config.birdseye.inactivity_threshold ] ) logger.debug(f"Active cameras: {active_cameras}") @@ -446,7 +474,7 @@ class BirdsEyeFrameManager: - self.cameras[active_camera]["last_active_frame"] ), ) - active_cameras = limited_active_cameras[:max_cameras] + active_cameras = set(limited_active_cameras[:max_cameras]) max_camera_refresh = True self.last_refresh_time = now @@ -462,6 +490,7 @@ class BirdsEyeFrameManager: # if the layout needs to be cleared self.camera_layout = [] self.active_cameras = set() + self.layout_camera_order = [] self.clear_frame() frame_changed = True layout_changed = True @@ -480,21 +509,21 @@ class BirdsEyeFrameManager: else: reset_layout = True + sorted_active_cameras = self.sort_cameras(active_cameras) + + if not reset_layout and sorted_active_cameras != self.layout_camera_order: + logger.debug("Birdseye camera order changed") + reset_layout = True + if reset_layout: logger.debug("Resetting Birdseye layout...") self.clear_frame() self.active_cameras = active_cameras + self.layout_camera_order = sorted_active_cameras layout_changed = True # Layout is changing due to reset # this also converts added_cameras from a set to a list since we need # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) + active_cameras_to_add = sorted_active_cameras if len(active_cameras) == 1: # show single camera as fullscreen camera = active_cameras_to_add[0] @@ -505,7 +534,7 @@ class BirdsEyeFrameManager: # center camera view in canvas and ensure that it fits if scaled_width < self.canvas.width: - coefficient = 1 + coefficient: float = 1 x_offset = int((self.canvas.width - scaled_width) / 2) else: coefficient = self.canvas.width / scaled_width @@ -552,7 +581,7 @@ class BirdsEyeFrameManager: calculating = False self.canvas.set_coefficient(len(active_cameras), coefficient) - self.camera_layout = layout_candidate + self.camera_layout = layout_candidate or [] frame_changed = True # Draw the layout @@ -572,10 +601,12 @@ class BirdsEyeFrameManager: self, cameras_to_add: list[str], coefficient: float, - ) -> tuple[Any]: + ) -> list[list[Any]] | None: """Calculate the optimal layout for 2+ cameras.""" - def map_layout(camera_layout: list[list[Any]], row_height: int): + def map_layout( + camera_layout: list[list[Any]], row_height: int + ) -> tuple[int, int, list[list[Any]] | None]: """Map the calculated layout.""" candidate_layout = [] starting_x = 0 @@ -723,8 +754,11 @@ class BirdsEyeFrameManager: Update birdseye for a specific camera with new frame data. Returns (frame_changed, layout_changed) to indicate if the frame or layout changed. """ - # don't process if birdseye is disabled for this camera - camera_config = self.config.cameras[camera] + # don't process if camera was removed or birdseye is disabled + camera_config = self.config.cameras.get(camera) + if camera_config is None: + return False, False + force_update = False # disabling birdseye is a little tricky @@ -753,8 +787,9 @@ class BirdsEyeFrameManager: frame_changed, layout_changed = self.update_frame(frame) except Exception: frame_changed, layout_changed = False, False - self.active_cameras = [] + self.active_cameras = set() self.camera_layout = [] + self.layout_camera_order = [] print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame @@ -769,36 +804,43 @@ class Birdseye: def __init__( self, config: FrigateConfig, - stop_event: mp.Event, - websocket_server, + stop_event: MpEvent, + websocket_server: Any, ) -> None: self.config = config - self.input = queue.Queue(maxsize=10) + canvas_width, canvas_height = get_canvas_shape( + config.birdseye.width, config.birdseye.height + ) + self.input: queue.Queue[bytes] = queue.Queue(maxsize=10) self.converter = FFMpegConverter( config.ffmpeg, self.input, stop_event, - config.birdseye.width, - config.birdseye.height, - config.birdseye.width, - config.birdseye.height, + canvas_width, + canvas_height, + canvas_width, + canvas_height, config.birdseye.quality, config.birdseye.restream, ) self.broadcaster = BroadcastThread( - "birdseye", self.converter, websocket_server, stop_event + "birdseye", + self.converter, + websocket_server, + stop_event, + config, ) self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event) self.frame_manager = SharedMemoryFrameManager() self.stop_event = stop_event self.requestor = InterProcessRequestor() self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps - self._idle_interval: Optional[float] = ( + self._idle_interval: float | None = ( (1.0 / self.idle_fps) if self.idle_fps > 0 else None ) if config.birdseye.restream: - self.birdseye_buffer = self.frame_manager.create( + self.birdseye_buffer: Any = self.frame_manager.create( "birdseye", self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1], ) @@ -854,7 +896,7 @@ class Birdseye: coordinates = self.birdseye_manager.get_camera_coordinates() self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates) if self._idle_interval: - now = time.monotonic() + now = datetime.datetime.now().timestamp() is_idle = len(self.birdseye_manager.camera_layout) == 0 if ( is_idle diff --git a/frigate/output/camera.py b/frigate/output/camera.py index 2311ec659e..88d16ed4b4 100644 --- a/frigate/output/camera.py +++ b/frigate/output/camera.py @@ -1,12 +1,14 @@ """Handle outputting individual cameras via jsmpeg.""" import logging -import multiprocessing as mp import queue import subprocess as sp import threading +from multiprocessing.synchronize import Event as MpEvent +from typing import Any -from frigate.config import CameraConfig, FfmpegConfig +from frigate.config import CameraConfig, FfmpegConfig, FrigateConfig +from frigate.output.ws_auth import ws_has_camera_access logger = logging.getLogger(__name__) @@ -17,7 +19,7 @@ class FFMpegConverter(threading.Thread): camera: str, ffmpeg: FfmpegConfig, input_queue: queue.Queue, - stop_event: mp.Event, + stop_event: MpEvent, in_width: int, in_height: int, out_width: int, @@ -64,16 +66,17 @@ class FFMpegConverter(threading.Thread): start_new_session=True, ) - def __write(self, b) -> None: + def __write(self, b: bytes) -> None: + assert self.process.stdin is not None self.process.stdin.write(b) - def read(self, length): + def read(self, length: int) -> Any: try: - return self.process.stdout.read1(length) + return self.process.stdout.read1(length) # type: ignore[union-attr] except ValueError: return False - def exit(self): + def exit(self) -> None: self.process.terminate() try: @@ -98,16 +101,18 @@ class BroadcastThread(threading.Thread): self, camera: str, converter: FFMpegConverter, - websocket_server, - stop_event: mp.Event, + websocket_server: Any, + stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config - def run(self): + def run(self) -> None: while not self.stop_event.is_set(): buf = self.converter.read(65536) if buf: @@ -120,6 +125,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -133,15 +139,19 @@ class BroadcastThread(threading.Thread): class JsmpegCamera: def __init__( - self, config: CameraConfig, stop_event: mp.Event, websocket_server + self, + config: CameraConfig, + frigate_config: FrigateConfig, + stop_event: MpEvent, + websocket_server: Any, ) -> None: self.config = config - self.input = queue.Queue(maxsize=config.detect.fps) + self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps) width = int( config.live.height * (config.frame_shape[1] / config.frame_shape[0]) ) self.converter = FFMpegConverter( - config.name, + config.name or "", config.ffmpeg, self.input, stop_event, @@ -152,13 +162,17 @@ class JsmpegCamera: config.live.quality, ) self.broadcaster = BroadcastThread( - config.name, self.converter, websocket_server, stop_event + config.name or "", + self.converter, + websocket_server, + stop_event, + frigate_config, ) self.converter.start() self.broadcaster.start() - def write_frame(self, frame_bytes) -> None: + def write_frame(self, frame_bytes: bytes) -> None: try: self.input.put_nowait(frame_bytes) except queue.Full: diff --git a/frigate/output/output.py b/frigate/output/output.py index a444150007..0793c1cd15 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -15,6 +15,7 @@ from ws4py.server.wsgirefserver import ( ) from ws4py.server.wsgiutils import WebSocketWSGIApplication +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.ws import WebSocket from frigate.config import FrigateConfig @@ -22,10 +23,16 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) -from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED +from frigate.const import ( + CACHE_DIR, + CLIPS_DIR, + PROCESS_PRIORITY_MED, + REPLAY_CAMERA_PREFIX, +) from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame from frigate.util.process import FrigateProcess @@ -55,6 +62,12 @@ def check_disabled_camera_update( # last camera update was more than 1 second ago # need to send empty data to birdseye because current # frame is now out of date + cam_width = config.cameras[camera].detect.width + cam_height = config.cameras[camera].detect.height + + if cam_width is None or cam_height is None: + raise ValueError(f"Camera {camera} detect dimensions not configured") + if birdseye and offline_time < 10: # we only need to send blank frames to birdseye at the beginning of a camera being offline birdseye.write_data( @@ -62,10 +75,7 @@ def check_disabled_camera_update( [], [], now, - get_blank_yuv_frame( - config.cameras[camera].detect.width, - config.cameras[camera].detect.height, - ), + get_blank_yuv_frame(cam_width, cam_height), ) if not has_enabled_camera and birdseye: @@ -79,6 +89,32 @@ class OutputProcess(FrigateProcess): ) self.config = config + def is_debug_replay_camera(self, camera: str) -> bool: + return camera.startswith(REPLAY_CAMERA_PREFIX) + + def add_camera( + self, + camera: str, + websocket_server: WSGIServer, + jsmpeg_cameras: dict[str, JsmpegCamera], + preview_recorders: dict[str, PreviewRecorder], + preview_write_times: dict[str, float], + birdseye: Birdseye | None, + ) -> None: + camera_config = self.config.cameras[camera] + jsmpeg_cameras[camera] = JsmpegCamera( + camera_config, self.config, self.stop_event, websocket_server + ) + preview_recorders[camera] = PreviewRecorder(camera_config) + preview_write_times[camera] = 0 + + if ( + birdseye is not None + and self.config.birdseye.enabled + and camera_config.birdseye.enabled + ): + birdseye.add_camera(camera) + def run(self) -> None: self.pre_run_setup(self.config.logger) @@ -107,6 +143,7 @@ class OutputProcess(FrigateProcess): CameraConfigUpdateEnum.record, ], ) + birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Birdseye | None = None @@ -118,14 +155,17 @@ class OutputProcess(FrigateProcess): move_preview_frames("cache") for camera, cam_config in self.config.cameras.items(): - if not cam_config.enabled_in_config: + if not cam_config.enabled_in_config or self.is_debug_replay_camera(camera): continue - jsmpeg_cameras[camera] = JsmpegCamera( - cam_config, self.stop_event, websocket_server + self.add_camera( + camera, + websocket_server, + jsmpeg_cameras, + preview_recorders, + preview_write_times, + birdseye, ) - preview_recorders[camera] = PreviewRecorder(cam_config) - preview_write_times[camera] = 0 if self.config.birdseye.enabled: birdseye = Birdseye(self.config, self.stop_event, websocket_server) @@ -133,26 +173,36 @@ class OutputProcess(FrigateProcess): websocket_thread.start() while not self.stop_event.is_set(): + update_topic, birdseye_config = ( + birdseye_config_subscriber.check_for_update() + ) + + if update_topic is not None and birdseye_config is not None: + # only the global-only fields are applied here; the per-camera + # enabled and mode arrive on config/cameras//birdseye, + # already resolved against yaml by the config parse + self.config.birdseye = birdseye_config + logger.debug("Applied dynamic birdseye config update") + # check if there is an updated config updates = config_subscriber.check_for_updates() if CameraConfigUpdateEnum.add in updates: for camera in updates["add"]: - jsmpeg_cameras[camera] = JsmpegCamera( - self.config.cameras[camera], self.stop_event, websocket_server - ) - preview_recorders[camera] = PreviewRecorder( - self.config.cameras[camera] - ) - preview_write_times[camera] = 0 + if not self.is_debug_replay_camera(camera): + self.add_camera( + camera, + websocket_server, + jsmpeg_cameras, + preview_recorders, + preview_write_times, + birdseye, + ) - if ( - self.config.birdseye.enabled - and self.config.cameras[camera].birdseye.enabled - ): - birdseye.add_camera(camera) - - (topic, data) = detection_subscriber.check_for_update(timeout=1) + _result = detection_subscriber.check_for_update(timeout=1) + if _result is None: + continue + (topic, data) = _result now = datetime.datetime.now().timestamp() if now - last_disabled_cam_check > 5: @@ -162,7 +212,7 @@ class OutputProcess(FrigateProcess): self.config, birdseye, preview_recorders, preview_write_times ) - if not topic: + if not topic or data is None: continue ( @@ -174,7 +224,11 @@ class OutputProcess(FrigateProcess): _, ) = data - if not self.config.cameras[camera].enabled: + if ( + camera not in self.config.cameras + or not self.config.cameras[camera].enabled + or self.is_debug_replay_camera(camera) + ): continue frame = frame_manager.get( @@ -206,17 +260,23 @@ class OutputProcess(FrigateProcess): # send camera frame to ffmpeg process if websockets are connected if any( ws.environ["PATH_INFO"].endswith(camera) + and ws_has_camera_access(ws, camera, self.config) for ws in websocket_server.manager ): # write to the converter for the camera if clients are listening to the specific camera jsmpeg_cameras[camera].write_frame(frame.tobytes()) # send output data to birdseye if websocket is connected or restreaming - if self.config.birdseye.enabled and ( - self.config.birdseye.restream - or any( - ws.environ["PATH_INFO"].endswith("birdseye") - for ws in websocket_server.manager + if ( + self.config.birdseye.enabled + and birdseye is not None + and ( + self.config.birdseye.restream + or any( + ws.environ["PATH_INFO"].endswith("birdseye") + and ws_has_camera_access(ws, "birdseye", self.config) + for ws in websocket_server.manager + ) ) ): birdseye.write_data( @@ -232,9 +292,12 @@ class OutputProcess(FrigateProcess): move_preview_frames("clips") while True: - (topic, data) = detection_subscriber.check_for_update(timeout=0) + _cleanup_result = detection_subscriber.check_for_update(timeout=0) + if _cleanup_result is None: + break + (topic, data) = _cleanup_result - if not topic: + if not topic or data is None: break ( @@ -263,6 +326,7 @@ class OutputProcess(FrigateProcess): birdseye.stop() config_subscriber.stop() + birdseye_config_subscriber.stop() websocket_server.manager.close_all() websocket_server.manager.stop() websocket_server.manager.join() @@ -271,17 +335,34 @@ class OutputProcess(FrigateProcess): logger.info("exiting output process...") -def move_preview_frames(loc: str): +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") - try: - if loc == "clips": - shutil.move(preview_cache, preview_holdover) - elif loc == "cache": - if not os.path.exists(preview_holdover): - return + if loc == "clips": + src = preview_cache + dst = preview_holdover + elif loc == "cache": + src = preview_holdover + dst = preview_cache + else: + return - shutil.move(preview_holdover, preview_cache) + try: + if not os.path.exists(src): + return + + shutil.move(src, dst) + + 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, + ) diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 6dfd909047..f07521fb8d 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -22,7 +22,6 @@ from frigate.ffmpeg_presets import ( parse_preset_hardware_acceleration_encode, ) from frigate.models import Previews -from frigate.track.object_processing import TrackedObject from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop logger = logging.getLogger(__name__) @@ -47,6 +46,15 @@ PREVIEW_QUALITY_BIT_RATES = { RecordQualityEnum.high: 9864, RecordQualityEnum.very_high: 10096, } +# the -qmax param for ffmpeg prevents the encoder from overly compressing frames while still trying to hit the bitrate target +# lower values are higher quality. This is especially important for iniitial frames in the segment +PREVIEW_QMAX_PARAM = { + RecordQualityEnum.very_low: "", + RecordQualityEnum.low: "", + RecordQualityEnum.medium: "", + RecordQualityEnum.high: " -qmax 25", + RecordQualityEnum.very_high: " -qmax 25", +} def get_cache_image_name(camera: str, frame_time: float) -> str: @@ -57,6 +65,53 @@ def get_cache_image_name(camera: str, frame_time: float) -> str: ) +def get_most_recent_preview_frame( + camera: str, before: float | None = None +) -> str | None: + """Get the most recent preview frame for a camera.""" + if not os.path.exists(PREVIEW_CACHE_DIR): + return None + + try: + # files are named preview_{camera}-{timestamp}.webp + # we want the largest timestamp that is less than or equal to before + preview_files = [ + f + for f in os.listdir(PREVIEW_CACHE_DIR) + if f.startswith(f"preview_{camera}-") + and f.endswith(f".{PREVIEW_FRAME_TYPE}") + ] + + if not preview_files: + return None + + # sort by timestamp in descending order + # filenames are like preview_front-1712345678.901234.webp + preview_files.sort(reverse=True) + + if before is None: + return os.path.join(PREVIEW_CACHE_DIR, preview_files[0]) + + for file_name in preview_files: + try: + # Extract timestamp: preview_front-1712345678.901234.webp + # Split by dash and extension + timestamp_part = file_name.split("-")[-1].split( + f".{PREVIEW_FRAME_TYPE}" + )[0] + timestamp = float(timestamp_part) + + if timestamp <= before: + return os.path.join(PREVIEW_CACHE_DIR, file_name) + except (ValueError, IndexError): + continue + + return None + except Exception as e: + logger.error(f"Error searching for most recent preview frame: {e}") + return None + + class FFMpegConverter(threading.Thread): """Convert a list of still frames into a vfr mp4.""" @@ -80,7 +135,7 @@ class FFMpegConverter(threading.Thread): config.ffmpeg.ffmpeg_path, "default", input="-f concat -y -protocol_whitelist pipe,file -safe 0 -threads 1 -i /dev/stdin", - output=f"-threads 1 -g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}", + output=f"-threads 1 -g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]}{PREVIEW_QMAX_PARAM[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}", type=EncodeTypeEnum.preview, ) @@ -93,17 +148,19 @@ class FFMpegConverter(threading.Thread): if t_idx == item_count - 1: # last frame does not get a duration playlist.append( - f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" + f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type] ) continue playlist.append( - f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" + f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type] ) playlist.append( f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}" ) + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + try: p = sp.run( self.ffmpeg_cmd.split(" "), @@ -145,30 +202,33 @@ class FFMpegConverter(threading.Thread): # unlink files from cache # don't delete last frame as it will be used as first frame in next segment for t in self.frame_times[0:-1]: - Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) + Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) # type: ignore[arg-type] class PreviewRecorder: def __init__(self, config: CameraConfig) -> None: self.config = config - self.start_time = 0 - self.last_output_time = 0 + self.camera_name: str = config.name or "" + self.start_time: float = 0 + self.last_output_time: float = 0 self.offline = False - self.output_frames = [] + self.output_frames: list[float] = [] - if config.detect.width > config.detect.height: + if config.detect.width is None or config.detect.height is None: + raise ValueError("Detect width and height must be set for previews.") + + self.detect_width: int = config.detect.width + self.detect_height: int = config.detect.height + + if self.detect_width > self.detect_height: self.out_height = PREVIEW_HEIGHT self.out_width = ( - int((config.detect.width / config.detect.height) * self.out_height) - // 4 - * 4 + int((self.detect_width / self.detect_height) * self.out_height) // 4 * 4 ) else: self.out_width = PREVIEW_HEIGHT self.out_height = ( - int((config.detect.height / config.detect.width) * self.out_width) - // 4 - * 4 + int((self.detect_height / self.detect_width) * self.out_width) // 4 * 4 ) # create communication for finished previews @@ -191,10 +251,9 @@ class PreviewRecorder: "v2": v2, } - # end segment at end of hour + # end segment at end of hour (use UTC to avoid DST issues) self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) + (datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1)) .replace(minute=0, second=0, microsecond=0) .timestamp() ) @@ -206,14 +265,13 @@ class PreviewRecorder: # check for existing items in cache start_ts = ( - datetime.datetime.now() - .astimezone(datetime.timezone.utc) + datetime.datetime.now(datetime.UTC) .replace(minute=0, second=0, microsecond=0) .timestamp() ) - file_start = f"preview_{config.name}" - start_file = f"{file_start}-{start_ts}.webp" + file_start = f"preview_{config.name}-" + start_file = f"{file_start}{start_ts}.webp" for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))): if not file.startswith(file_start): @@ -241,14 +299,16 @@ class PreviewRecorder: def reset_frame_cache(self, frame_time: float) -> None: self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) + ( + datetime.datetime.fromtimestamp(frame_time, tz=datetime.UTC) + + datetime.timedelta(hours=1) + ) .replace(minute=0, second=0, microsecond=0) .timestamp() ) self.start_time = frame_time self.last_output_time = frame_time - self.output_frames: list[float] = [] + self.output_frames = [] def should_write_frame( self, @@ -288,7 +348,9 @@ class PreviewRecorder: def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None: # resize yuv frame - small_frame = np.zeros((self.out_height * 3 // 2, self.out_width), np.uint8) + small_frame: np.ndarray = np.zeros( + (self.out_height * 3 // 2, self.out_width), np.uint8 + ) copy_yuv_to_position( small_frame, (0, 0), @@ -301,14 +363,17 @@ class PreviewRecorder: small_frame, cv2.COLOR_YUV2BGR_I420, ) - cv2.imwrite( - get_cache_image_name(self.config.name, frame_time), + cache_path = get_cache_image_name(self.camera_name, frame_time) + + if not cv2.imwrite( + cache_path, small_frame, [ int(cv2.IMWRITE_WEBP_QUALITY), PREVIEW_QUALITY_WEBP[self.config.record.preview.quality], ], - ) + ): + logger.error("Failed to write preview frame to %s", cache_path) def write_data( self, @@ -342,7 +407,7 @@ class PreviewRecorder: ).start() else: logger.debug( - f"Not saving preview for {self.config.name} because there are no saved frames." + f"Not saving preview for {self.camera_name} because there are no saved frames." ) self.reset_frame_cache(frame_time) @@ -362,9 +427,7 @@ class PreviewRecorder: if not self.offline: self.write_frame_to_cache( frame_time, - get_blank_yuv_frame( - self.config.detect.width, self.config.detect.height - ), + get_blank_yuv_frame(self.detect_width, self.detect_height), ) self.offline = True @@ -377,9 +440,9 @@ class PreviewRecorder: return old_frame_path = get_cache_image_name( - self.config.name, self.output_frames[-1] + self.camera_name, self.output_frames[-1] ) - new_frame_path = get_cache_image_name(self.config.name, frame_time) + new_frame_path = get_cache_image_name(self.camera_name, frame_time) shutil.copy(old_frame_path, new_frame_path) # save last frame to ensure consistent duration @@ -393,13 +456,12 @@ class PreviewRecorder: self.reset_frame_cache(frame_time) def stop(self) -> None: - self.config_subscriber.stop() self.requestor.stop() def get_active_objects( - frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject] -) -> list[TrackedObject]: + frame_time: float, camera_config: CameraConfig, all_objects: list[dict[str, Any]] +) -> list[dict[str, Any]]: """get active objects for detection.""" return [ o diff --git a/frigate/output/ws_auth.py b/frigate/output/ws_auth.py new file mode 100644 index 0000000000..33ec4e4980 --- /dev/null +++ b/frigate/output/ws_auth.py @@ -0,0 +1,43 @@ +"""Authorization helpers for JSMPEG websocket clients.""" + +from typing import Any + +from frigate.config import FrigateConfig +from frigate.models import User + + +def _get_valid_ws_roles(ws: Any, config: FrigateConfig) -> list[str]: + role_header = ws.environ.get("HTTP_REMOTE_ROLE", "") + roles = [ + role.strip() + for role in role_header.split(config.proxy.separator) + if role.strip() + ] + return [role for role in roles if role in config.auth.roles] + + +def ws_has_camera_access(ws: Any, camera_name: str, config: FrigateConfig) -> bool: + """Return True when a websocket client is authorized for the camera path.""" + roles = _get_valid_ws_roles(ws, config) + + if not roles: + return False + + roles_dict = config.auth.roles + + # Birdseye is a composite stream, so only users with unrestricted access + # should receive it. + if camera_name == "birdseye": + return any(role == "admin" or not roles_dict.get(role) for role in roles) + + all_camera_names = set(config.cameras.keys()) + + for role in roles: + if role == "admin" or not roles_dict.get(role): + return True + + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + if camera_name in allowed_cameras: + return True + + return False diff --git a/frigate/plus.py b/frigate/plus.py index 197b6e48d0..d528aa1757 100644 --- a/frigate/plus.py +++ b/frigate/plus.py @@ -4,7 +4,7 @@ import logging import os import re from pathlib import Path -from typing import Any, List +from typing import Any import cv2 import requests @@ -105,9 +105,9 @@ class PlusApi: def upload_image(self, image: ndarray, camera: str) -> str: r = self._get("image/signed_urls") - presigned_urls = r.json() if not r.ok: raise Exception("Unable to get signed urls") + presigned_urls = r.json() # resize and submit original files = {"file": get_jpg_bytes(image, 1920, 85)} @@ -140,8 +140,8 @@ class PlusApi: def add_false_positive( self, plus_id: str, - region: List[float], - bbox: List[float], + region: list[float], + bbox: list[float], score: float, label: str, model_hash: str, @@ -184,7 +184,7 @@ class PlusApi: def add_annotation( self, plus_id: str, - bbox: List[float], + bbox: list[float], label: str, difficult: bool = False, ) -> None: diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 6e86ecbf2c..e9bbda9889 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -20,6 +20,10 @@ from norfair.camera_motion import ( from frigate.camera import PTZMetrics from frigate.comms.dispatcher import Dispatcher from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( AUTOTRACKING_MAX_AREA_RATIO, AUTOTRACKING_MAX_MOVE_METRICS, @@ -48,6 +52,22 @@ def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time): ) +def transform_is_finite(coord_transformations) -> bool: + """Return True if a norfair coordinate transform contains only finite values. + + A near-singular homography (common when the motion estimator can't find + enough stable features during zoom on a low-texture scene) can produce + inf/nan matrix entries. norfair accumulates the homography across frames, so + a single bad transform poisons every subsequent one and propagates nan into + the tracker's distance function, crashing the camera process. + """ + for attr in ("homography_matrix", "inverse_homography_matrix", "movement_vector"): + value = getattr(coord_transformations, attr, None) + if value is not None and not np.all(np.isfinite(value)): + return False + return True + + class PtzMotionEstimator: def __init__(self, config: CameraConfig, ptz_metrics: PTZMetrics) -> None: self.frame_manager = SharedMemoryFrameManager() @@ -116,7 +136,9 @@ class PtzMotionEstimator: mask[y1:y2, x1:x2] = 0 # merge camera config motion mask with detections. Norfair function needs 0,1 mask - mask = np.bitwise_and(mask, self.camera_config.motion.mask).clip(max=1) + mask = np.bitwise_and(mask, self.camera_config.motion.rasterized_mask).clip( + max=1 + ) # Norfair estimator function needs color so it can convert it right back to gray frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA) @@ -133,6 +155,19 @@ class PtzMotionEstimator: ) self.coord_transformations = None + # A degenerate homography can yield non-finite transform values that + # norfair would accumulate and feed to the tracker as nan estimates. + # Drop the bad transform and request a reset so the estimator rebuilds + # a fresh reference frame instead of poisoning every following frame. + if self.coord_transformations is not None and not transform_is_finite( + self.coord_transformations + ): + logger.warning( + f"Autotracker: motion estimator produced a non-finite transform for {camera} at frame time {frame_time}, resetting" + ) + self.coord_transformations = None + self.ptz_metrics.reset.set() + try: logger.debug( f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0, 0]])}" @@ -163,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread): def run(self): while not self.stop_event.wait(1): - for camera, camera_config in self.config.cameras.items(): + self.ptz_autotracker.check_for_updates() + + for camera, camera_config in list(self.config.cameras.items()): if not camera_config.enabled: continue @@ -180,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread): self.ptz_autotracker.tracked_object[camera] = None self.ptz_autotracker.tracked_object_history[camera].clear() + self.ptz_autotracker.config_subscriber.stop() logger.info("Exiting autotracker...") @@ -213,6 +251,16 @@ class PtzAutoTracker: self.zoom_time: dict[str, float] = {} self.zoom_factor: dict[str, object] = {} + self.config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.autotracking, + CameraConfigUpdateEnum.onvif, + ], + ) + # if cam is set to autotrack, onvif should be set up for camera, camera_config in self.config.cameras.items(): if not camera_config.enabled: @@ -229,6 +277,29 @@ class PtzAutoTracker: # Wait for the coroutine to complete future.result() + def check_for_updates(self) -> None: + """Apply camera config updates and mirror autotracking state to ptz metrics. + + The camera processes read autotracker_enabled rather than the config, so it + has to follow every path that can change autotracking, not just the mqtt + toggle that writes it directly. + """ + updates = self.config_subscriber.check_for_updates() + + for cameras in updates.values(): + for camera in cameras: + camera_config = self.config.cameras.get(camera) + metrics = self.ptz_metrics.get(camera) + + # a camera added at runtime gets its metrics from the maintainer on + # another thread, which seeds them from this same config value + if camera_config is None or metrics is None: + continue + + metrics.autotracker_enabled.value = ( + camera_config.onvif.autotracking.enabled + ) + async def _autotracker_setup(self, camera_config: CameraConfig, camera: str): logger.debug(f"{camera}: Autotracker init") @@ -725,7 +796,7 @@ class PtzAutoTracker: try: # Asynchronously wait for move data with a timeout move_data = await asyncio.wait_for(move_queue.get(), timeout=0.1) - except asyncio.TimeoutError: + except TimeoutError: continue async with self.move_queue_locks[camera]: @@ -899,7 +970,7 @@ class PtzAutoTracker: # Check direction difference velocities = np.round(velocities) invalid_dirs = False - if not np.any(np.linalg.norm(velocities, axis=1)): + if np.all(np.linalg.norm(velocities, axis=1)): cosine_sim = np.dot(velocities[0], velocities[1]) / ( np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1]) ) @@ -917,8 +988,8 @@ class PtzAutoTracker: if invalid: logger.debug( - f"{camera}: Invalid velocity: {tuple(np.round(velocities, 2).flatten().astype(int))}: Invalid because: " - + ", ".join( + f"{camera}: Invalid velocity: {tuple(np.round(velocities, 2).flatten().astype(int))}: Invalid because: %s", + ", ".join( [ var_name for var_name, is_invalid in [ @@ -930,7 +1001,7 @@ class PtzAutoTracker: ] if is_invalid ] - ) + ), ) # invalid velocity return False, np.zeros((4,)) @@ -1065,7 +1136,7 @@ class PtzAutoTracker: f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}" ) logger.debug( - f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[0])}, y threshold: {velocity_threshold_y}" + f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}" ) logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}") logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}") @@ -1329,10 +1400,12 @@ 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: - if not self.autotracker_init[camera]: + if not self.autotracker_init.get(camera): future = asyncio.run_coroutine_threadsafe( self._autotracker_setup(camera_config, camera), self.onvif.loop ) @@ -1450,9 +1523,11 @@ class PtzAutoTracker: } async def camera_maintenance(self, camera): - # bail and don't check anything if we're calibrating or tracking an object + # bail and don't check anything if we're not set up yet, calibrating, or + # tracking an object. a camera enabled at runtime has no autotracker_init + # entry until autotrack_object sets it up if ( - not self.autotracker_init[camera] + not self.autotracker_init.get(camera) or self.calibrating[camera] or self.tracked_object[camera] is not None ): diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 488dbd278c..6b7e56022d 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -15,6 +15,10 @@ from zeep.exceptions import Fault, TransportError from frigate.camera import PTZMetrics from frigate.config import FrigateConfig, ZoomingModeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.util.builtin import find_by_key logger = logging.getLogger(__name__) @@ -65,7 +69,14 @@ class OnvifController: self.camera_configs[cam_name] = cam self.status_locks[cam_name] = asyncio.Lock() + self.config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [CameraConfigUpdateEnum.onvif], + ) + asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop) + asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop) def _run_event_loop(self) -> None: """Run the event loop in a separate thread.""" @@ -80,6 +91,52 @@ class OnvifController: for cam_name in self.camera_configs: await self._init_single_camera(cam_name) + async def _poll_config_updates(self) -> None: + """Poll for ONVIF config updates and re-initialize cameras as needed.""" + while True: + await asyncio.sleep(1) + try: + updates = self.config_subscriber.check_for_updates() + for update_type, cameras in updates.items(): + if update_type == CameraConfigUpdateEnum.onvif.name: + for cam_name in cameras: + await self._reinit_camera(cam_name) + except Exception: + logger.error("Error checking for ONVIF config updates") + + async def _close_camera(self, cam_name: str) -> None: + """Close the ONVIF client session for a camera.""" + cam_state = self.cams.get(cam_name) + if cam_state and "onvif" in cam_state: + try: + await cam_state["onvif"].close() + except Exception: + logger.debug(f"Error closing ONVIF session for {cam_name}") + + async def _reinit_camera(self, cam_name: str) -> None: + """Re-initialize a camera after config change.""" + logger.info(f"Re-initializing ONVIF for {cam_name} due to config change") + + # close existing session before re-init + await self._close_camera(cam_name) + + cam = self.config.cameras.get(cam_name) + if not cam or not cam.onvif.host: + # ONVIF removed from config, clean up + self.cams.pop(cam_name, None) + self.camera_configs.pop(cam_name, None) + self.failed_cams.pop(cam_name, None) + return + + # update stored config and reset state + self.camera_configs[cam_name] = cam + if cam_name not in self.status_locks: + self.status_locks[cam_name] = asyncio.Lock() + self.cams.pop(cam_name, None) + self.failed_cams.pop(cam_name, None) + + await self._init_single_camera(cam_name) + async def _init_single_camera(self, cam_name: str) -> bool: """Initialize a single camera by name. @@ -95,21 +152,12 @@ class OnvifController: cam = self.camera_configs[cam_name] try: - user = cam.onvif.user - password = cam.onvif.password - - if user is not None and isinstance(user, bytes): - user = user.decode("utf-8") - - if password is not None and isinstance(password, bytes): - password = password.decode("utf-8") - self.cams[cam_name] = { "onvif": ONVIFCamera( cam.onvif.host, cam.onvif.port, - user, - password, + cam.onvif.user, + cam.onvif.password, wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"), adjust_time=cam.onvif.ignore_time_mismatch, encrypt=not cam.onvif.tls_insecure, @@ -118,6 +166,7 @@ class OnvifController: "active": False, "features": [], "presets": {}, + "profiles": [], } return True except (Fault, ONVIFError, TransportError, Exception) as e: @@ -161,22 +210,60 @@ class OnvifController: ) return False + # build list of valid PTZ profiles + valid_profiles = [ + p + for p in profiles + if p.VideoEncoderConfiguration + and p.PTZConfiguration + and ( + p.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace is not None + or p.PTZConfiguration.DefaultContinuousZoomVelocitySpace is not None + ) + ] + + # store available profiles for API response and log for debugging + self.cams[camera_name]["profiles"] = [ + {"name": getattr(p, "Name", None) or p.token, "token": p.token} + for p in valid_profiles + ] + for p in valid_profiles: + logger.debug( + "Onvif profile for %s: name='%s', token='%s'", + camera_name, + getattr(p, "Name", None), + p.token, + ) + + configured_profile = self.config.cameras[camera_name].onvif.profile profile = None - for _, onvif_profile in enumerate(profiles): - if ( - onvif_profile.VideoEncoderConfiguration - and onvif_profile.PTZConfiguration - and ( - onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace - is not None - or onvif_profile.PTZConfiguration.DefaultContinuousZoomVelocitySpace - is not None + + if configured_profile is not None: + # match by exact token first, then by name + for p in valid_profiles: + if p.token == configured_profile: + profile = p + break + if profile is None: + for p in valid_profiles: + if getattr(p, "Name", None) == configured_profile: + profile = p + break + if profile is None: + available = [ + f"name='{getattr(p, 'Name', None)}', token='{p.token}'" + for p in valid_profiles + ] + logger.error( + "Onvif profile '%s' not found for camera %s. Available profiles: %s", + configured_profile, + camera_name, + available, ) - ): - # use the first profile that has a valid ptz configuration - profile = onvif_profile - logger.debug(f"Selected Onvif profile for {camera_name}: {profile}") - break + return False + else: + # use the first profile that has a valid ptz configuration + profile = valid_profiles[0] if valid_profiles else None if profile is None: logger.error( @@ -184,6 +271,8 @@ class OnvifController: ) return False + logger.debug(f"Selected Onvif profile for {camera_name}: {profile}") + # get the PTZ config for the profile try: configs = profile.PTZConfiguration @@ -218,48 +307,93 @@ class OnvifController: move_request.ProfileToken = profile.token self.cams[camera_name]["move_request"] = move_request - # extra setup for autotracking cameras - if ( - self.config.cameras[camera_name].onvif.autotracking.enabled_in_config - and self.config.cameras[camera_name].onvif.autotracking.enabled - ): + # get PTZ configuration options for feature detection and relative movement + ptz_config = None + fov_space_id = None + + try: request = ptz.create_type("GetConfigurationOptions") request.ConfigurationToken = profile.PTZConfiguration.token ptz_config = await ptz.GetConfigurationOptions(request) - logger.debug(f"Onvif config for {camera_name}: {ptz_config}") - - service_capabilities_request = ptz.create_type("GetServiceCapabilities") - self.cams[camera_name]["service_capabilities_request"] = ( - service_capabilities_request + logger.debug( + f"Onvif PTZ configuration options for {camera_name}: {ptz_config}" + ) + except (Fault, ONVIFError, TransportError, Exception) as e: + logger.debug( + f"Unable to get PTZ configuration options for {camera_name}: {e}" ) - fov_space_id = next( - ( - i - for i, space in enumerate( - ptz_config.Spaces.RelativePanTiltTranslationSpace - ) - if "TranslationSpaceFov" in space["URI"] - ), - None, - ) - - # status request for autotracking and filling ptz-parameters - status_request = ptz.create_type("GetStatus") - status_request.ProfileToken = profile.token - self.cams[camera_name]["status_request"] = status_request + # detect FOV translation space for relative movement + if ptz_config is not None: try: - status = await ptz.GetStatus(status_request) - logger.debug(f"Onvif status config for {camera_name}: {status}") - except Exception as e: - logger.warning(f"Unable to get status from camera: {camera_name}: {e}") - status = None + fov_space_id = next( + ( + i + for i, space in enumerate( + ptz_config.Spaces.RelativePanTiltTranslationSpace + ) + if "TranslationSpaceFov" in space["URI"] + ), + None, + ) + except (AttributeError, TypeError): + fov_space_id = None - # autotracking relative panning/tilting needs a relative zoom value set to 0 - # if camera supports relative movement + autotracking_config = self.config.cameras[camera_name].onvif.autotracking + autotracking_enabled = ( + autotracking_config.enabled_in_config and autotracking_config.enabled + ) + + # these are local and cost nothing to build, and autotracking can be enabled + # after a camera is initialized, so always create them rather than baking the + # current config value into init state + status_request = ptz.create_type("GetStatus") + status_request.ProfileToken = profile.token + self.cams[camera_name]["status_request"] = status_request + + service_capabilities_request = ptz.create_type("GetServiceCapabilities") + self.cams[camera_name]["service_capabilities_request"] = ( + service_capabilities_request + ) + + # setup relative move request when FOV relative movement is supported + if ( + fov_space_id is not None + and configs.DefaultRelativePanTiltTranslationSpace is not None + ): + # one-off GetStatus to seed Translation field + status = None + try: + one_off_status_request = ptz.create_type("GetStatus") + one_off_status_request.ProfileToken = profile.token + status = await ptz.GetStatus(one_off_status_request) + logger.debug(f"Onvif status for {camera_name}: {status}") + except Exception as e: + logger.warning(f"Unable to get status from camera {camera_name}: {e}") + + rel_move_request = ptz.create_type("RelativeMove") + rel_move_request.ProfileToken = profile.token + logger.debug(f"{camera_name}: Relative move request: {rel_move_request}") + + fov_uri = ptz_config["Spaces"]["RelativePanTiltTranslationSpace"][ + fov_space_id + ]["URI"] + + if rel_move_request.Translation is None: + if status is not None: + # seed from current position + rel_move_request.Translation = status.Position + rel_move_request.Translation.PanTilt.space = fov_uri + else: + # fallback: construct Translation explicitly + rel_move_request.Translation = { + "PanTilt": {"x": 0, "y": 0, "space": fov_uri} + } + + # configure zoom on relative move request if ( - self.config.cameras[camera_name].onvif.autotracking.zooming - != ZoomingModeEnum.disabled + autotracking_enabled + and autotracking_config.zooming != ZoomingModeEnum.disabled ): zoom_space_id = next( ( @@ -271,60 +405,43 @@ class OnvifController: ), None, ) - - # setup relative moving request for autotracking - move_request = ptz.create_type("RelativeMove") - move_request.ProfileToken = profile.token - logger.debug(f"{camera_name}: Relative move request: {move_request}") - if move_request.Translation is None and fov_space_id is not None: - move_request.Translation = status.Position - move_request.Translation.PanTilt.space = ptz_config["Spaces"][ - "RelativePanTiltTranslationSpace" - ][fov_space_id]["URI"] - - # try setting relative zoom translation space - try: - if ( - self.config.cameras[camera_name].onvif.autotracking.zooming - != ZoomingModeEnum.disabled - ): + try: if zoom_space_id is not None: - move_request.Translation.Zoom.space = ptz_config["Spaces"][ + rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][ "RelativeZoomTranslationSpace" ][zoom_space_id]["URI"] - else: - if ( - move_request["Translation"] is not None - and "Zoom" in move_request["Translation"] - ): - del move_request["Translation"]["Zoom"] - if ( - move_request["Speed"] is not None - and "Zoom" in move_request["Speed"] - ): - del move_request["Speed"]["Zoom"] - logger.debug( - f"{camera_name}: Relative move request after deleting zoom: {move_request}" + except Exception as e: + autotracking_config.zooming = ZoomingModeEnum.disabled + logger.warning( + f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}" ) - except Exception as e: - self.config.cameras[ - camera_name - ].onvif.autotracking.zooming = ZoomingModeEnum.disabled - logger.warning( - f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}" + else: + # remove zoom fields from relative move request + if ( + rel_move_request["Translation"] is not None + and "Zoom" in rel_move_request["Translation"] + ): + del rel_move_request["Translation"]["Zoom"] + if ( + rel_move_request["Speed"] is not None + and "Zoom" in rel_move_request["Speed"] + ): + del rel_move_request["Speed"]["Zoom"] + logger.debug( + f"{camera_name}: Relative move request after deleting zoom: {rel_move_request}" ) - if move_request.Speed is None: - move_request.Speed = configs.DefaultPTZSpeed if configs else None + if rel_move_request.Speed is None: + rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None logger.debug( - f"{camera_name}: Relative move request after setup: {move_request}" + f"{camera_name}: Relative move request after setup: {rel_move_request}" ) - self.cams[camera_name]["relative_move_request"] = move_request + self.cams[camera_name]["relative_move_request"] = rel_move_request - # setup absolute moving request for autotracking zooming - move_request = ptz.create_type("AbsoluteMove") - move_request.ProfileToken = profile.token - self.cams[camera_name]["absolute_move_request"] = move_request + # setup absolute move request + abs_move_request = ptz.create_type("AbsoluteMove") + abs_move_request.ProfileToken = profile.token + self.cams[camera_name]["absolute_move_request"] = abs_move_request # setup existing presets try: @@ -334,15 +451,15 @@ class OnvifController: presets = [] for preset in presets: - # Ensure preset name is a Unicode string and handle UTF-8 characters correctly preset_name = getattr(preset, "Name") or f"preset {preset['token']}" - - if isinstance(preset_name, bytes): - preset_name = preset_name.decode("utf-8") - - # Convert to lowercase while preserving UTF-8 characters - preset_name_lower = preset_name.lower() - self.cams[camera_name]["presets"][preset_name_lower] = preset["token"] + # Some cameras (e.g. Reolink) return UTF-8 bytes that zeep decodes + # as latin-1, producing mojibake. Detect that and repair it by + # round-tripping through latin-1 -> utf-8. + try: + preset_name = preset_name.encode("latin-1").decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + pass + self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"] # get list of supported features supported_features = [] @@ -358,48 +475,48 @@ class OnvifController: if configs.DefaultRelativeZoomTranslationSpace: supported_features.append("zoom-r") - if ( - self.config.cameras[camera_name].onvif.autotracking.enabled_in_config - and self.config.cameras[camera_name].onvif.autotracking.enabled - ): + if ptz_config is not None: try: - # get camera's zoom limits from onvif config self.cams[camera_name]["relative_zoom_range"] = ( ptz_config.Spaces.RelativeZoomTranslationSpace[0] ) except Exception as e: - if ( - self.config.cameras[camera_name].onvif.autotracking.zooming - == ZoomingModeEnum.relative - ): - self.config.cameras[ - camera_name - ].onvif.autotracking.zooming = ZoomingModeEnum.disabled + if autotracking_config.zooming == ZoomingModeEnum.relative: + autotracking_config.zooming = ZoomingModeEnum.disabled logger.warning( f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}" ) if configs.DefaultAbsoluteZoomPositionSpace: supported_features.append("zoom-a") - if ( - self.config.cameras[camera_name].onvif.autotracking.enabled_in_config - and self.config.cameras[camera_name].onvif.autotracking.enabled - ): + if ptz_config is not None: try: - # get camera's zoom limits from onvif config self.cams[camera_name]["absolute_zoom_range"] = ( ptz_config.Spaces.AbsoluteZoomPositionSpace[0] ) self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits except Exception as e: - if self.config.cameras[camera_name].onvif.autotracking.zooming: - self.config.cameras[ - camera_name - ].onvif.autotracking.zooming = ZoomingModeEnum.disabled + if autotracking_config.zooming != ZoomingModeEnum.disabled: + autotracking_config.zooming = ZoomingModeEnum.disabled logger.warning( f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}" ) + # disable autotracking zoom if required ranges are unavailable + if autotracking_config.zooming != ZoomingModeEnum.disabled: + if autotracking_config.zooming == ZoomingModeEnum.relative: + if "relative_zoom_range" not in self.cams[camera_name]: + autotracking_config.zooming = ZoomingModeEnum.disabled + logger.warning( + f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable" + ) + if autotracking_config.zooming == ZoomingModeEnum.absolute: + if "absolute_zoom_range" not in self.cams[camera_name]: + autotracking_config.zooming = ZoomingModeEnum.disabled + logger.warning( + f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable" + ) + if ( self.cams[camera_name]["video_source_token"] is not None and imaging is not None @@ -416,10 +533,9 @@ class OnvifController: except (Fault, ONVIFError, TransportError, Exception) as e: logger.debug(f"Focus not supported for {camera_name}: {e}") + # detect FOV relative movement support if ( - self.config.cameras[camera_name].onvif.autotracking.enabled_in_config - and self.config.cameras[camera_name].onvif.autotracking.enabled - and fov_space_id is not None + fov_space_id is not None and configs.DefaultRelativePanTiltTranslationSpace is not None ): supported_features.append("pt-r-fov") @@ -509,14 +625,18 @@ class OnvifController: return self.cams[camera_name]["active"] = True - self.ptz_metrics[camera_name].motor_stopped.clear() - logger.debug( - f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}" - ) - self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[ - camera_name - ].frame_time.value - self.ptz_metrics[camera_name].stop_time.value = 0 + + # only track start_time for autotracking + if self.ptz_metrics[camera_name].autotracker_enabled.value: + self.ptz_metrics[camera_name].motor_stopped.clear() + logger.debug( + f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}" + ) + self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[ + camera_name + ].frame_time.value + self.ptz_metrics[camera_name].stop_time.value = 0 + move_request = self.cams[camera_name]["relative_move_request"] # function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera. @@ -548,11 +668,8 @@ class OnvifController: move_request.Translation.PanTilt.x = pan move_request.Translation.PanTilt.y = tilt - if ( - "zoom-r" in self.cams[camera_name]["features"] - and self.config.cameras[camera_name].onvif.autotracking.zooming - == ZoomingModeEnum.relative - ): + # include zoom if requested and camera supports relative zoom + if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]: move_request.Speed = { "PanTilt": { "x": speed, @@ -560,7 +677,7 @@ class OnvifController: }, "Zoom": {"x": speed}, } - move_request.Translation.Zoom.x = zoom + move_request["Translation"]["Zoom"] = {"x": zoom} await self.cams[camera_name]["ptz"].RelativeMove(move_request) @@ -568,19 +685,12 @@ class OnvifController: move_request.Translation.PanTilt.x = 0 move_request.Translation.PanTilt.y = 0 - if ( - "zoom-r" in self.cams[camera_name]["features"] - and self.config.cameras[camera_name].onvif.autotracking.zooming - == ZoomingModeEnum.relative - ): - move_request.Translation.Zoom.x = 0 + if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]: + del move_request["Translation"]["Zoom"] self.cams[camera_name]["active"] = False async def _move_to_preset(self, camera_name: str, preset: str) -> None: - if isinstance(preset, bytes): - preset = preset.decode("utf-8") - preset = preset.lower() if preset not in self.cams[camera_name]["presets"]: @@ -717,8 +827,18 @@ class OnvifController: elif command == OnvifCommandEnum.preset: await self._move_to_preset(camera_name, param) elif command == OnvifCommandEnum.move_relative: - _, pan, tilt = param.split("_") - await self._move_relative(camera_name, float(pan), float(tilt), 0, 1) + parts = param.split("_") + if len(parts) == 3: + _, pan, tilt = parts + zoom = 0.0 + elif len(parts) == 4: + _, pan, tilt, zoom = parts + else: + logger.error(f"Invalid move_relative params: {param}") + return + await self._move_relative( + camera_name, float(pan), float(tilt), float(zoom), 1 + ) elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out): await self._zoom(camera_name, command) elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out): @@ -741,7 +861,7 @@ class OnvifController: try: # Wait with a timeout to prevent blocking indefinitely future.result(timeout=10) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Command {command} timed out for camera {camera_name}") except Exception as e: logger.error( @@ -773,6 +893,7 @@ class OnvifController: "name": camera_name, "features": self.cams[camera_name]["features"], "presets": list(self.cams[camera_name]["presets"].keys()), + "profiles": self.cams[camera_name].get("profiles", []), } if camera_name not in self.cams.keys() and camera_name in self.config.cameras: @@ -970,6 +1091,7 @@ class OnvifController: return logger.info("Exiting ONVIF controller...") + self.config_subscriber.stop() def stop_and_cleanup(): try: diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 0e401e8fff..71097f1d95 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -7,15 +7,15 @@ import os import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path +from typing import Any from playhouse.sqlite_ext import SqliteExtDatabase from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus -from frigate.record.util import remove_empty_directories, sync_recordings from frigate.util.builtin import clear_and_unlink -from frigate.util.time import get_tomorrow_at_time +from frigate.util.media import remove_empty_directories logger = logging.getLogger(__name__) @@ -61,7 +61,9 @@ class RecordingCleanup(threading.Thread): db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);") db.close() - def expire_review_segments(self, config: CameraConfig, now: datetime) -> None: + def expire_review_segments( + self, config: CameraConfig, now: datetime.datetime + ) -> set[Path]: """Delete review segments that are expired""" alert_expire_date = ( now - datetime.timedelta(days=config.record.alerts.retain.days) @@ -69,7 +71,7 @@ class RecordingCleanup(threading.Thread): detection_expire_date = ( now - datetime.timedelta(days=config.record.detections.retain.days) ).timestamp() - expired_reviews: ReviewSegment = ( + expired_reviews = ( ReviewSegment.select(ReviewSegment.id, ReviewSegment.thumb_path) .where(ReviewSegment.camera == config.name) .where( @@ -85,9 +87,12 @@ class RecordingCleanup(threading.Thread): .namedtuples() ) + maybe_empty_dirs = set() thumbs_to_delete = list(map(lambda x: x[1], expired_reviews)) for thumb_path in thumbs_to_delete: - Path(thumb_path).unlink(missing_ok=True) + thumb_path = Path(thumb_path) + thumb_path.unlink(missing_ok=True) + maybe_empty_dirs.add(thumb_path.parent) max_deletes = 100000 deleted_reviews_list = list(map(lambda x: x[0], expired_reviews)) @@ -100,18 +105,20 @@ class RecordingCleanup(threading.Thread): << deleted_reviews_list[i : i + max_deletes] ).execute() + return maybe_empty_dirs + def expire_existing_camera_recordings( self, continuous_expire_date: float, motion_expire_date: float, config: CameraConfig, - reviews: ReviewSegment, - ) -> None: + reviews: list[Any], + ) -> set[Path]: """Delete recordings for existing camera based on retention config.""" # Get the timestamp for cutoff of retained days # Get recordings to check for expiration - recordings: Recordings = ( + recordings = ( Recordings.select( Recordings.id, Recordings.start_time, @@ -137,18 +144,19 @@ class RecordingCleanup(threading.Thread): .iterator() ) + maybe_empty_dirs = set() + # loop over recordings and see if they overlap with any non-expired reviews # TODO: expire segments based on segment stats according to config review_start = 0 deleted_recordings = set() kept_recordings: list[tuple[float, float]] = [] - recording: Recordings for recording in recordings: keep = False mode = None # Now look for a reason to keep this recording segment for idx in range(review_start, len(reviews)): - review: ReviewSegment = reviews[idx] + review = reviews[idx] severity = review.severity pre_capture = config.record.get_review_pre_capture(severity) post_capture = config.record.get_review_post_capture(severity) @@ -191,8 +199,10 @@ class RecordingCleanup(threading.Thread): ) or (mode == RetainModeEnum.active_objects and recording.objects == 0) ): - Path(recording.path).unlink(missing_ok=True) + recording_path = Path(recording.path) + recording_path.unlink(missing_ok=True) deleted_recordings.add(recording.id) + maybe_empty_dirs.add(recording_path.parent) else: kept_recordings.append((recording.start_time, recording.end_time)) @@ -206,7 +216,7 @@ class RecordingCleanup(threading.Thread): Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() - previews: list[Previews] = ( + previews = ( Previews.select( Previews.id, Previews.start_time, @@ -253,8 +263,10 @@ class RecordingCleanup(threading.Thread): # Delete previews without any relevant recordings if not keep: - Path(preview.path).unlink(missing_ok=True) + preview_path = Path(preview.path) + preview_path.unlink(missing_ok=True) deleted_previews.add(preview.id) + maybe_empty_dirs.add(preview_path.parent) # expire previews logger.debug(f"Expiring {len(deleted_previews)} previews") @@ -266,7 +278,9 @@ class RecordingCleanup(threading.Thread): Previews.id << deleted_previews_list[i : i + max_deletes] ).execute() - def expire_recordings(self) -> None: + return maybe_empty_dirs + + def expire_recordings(self) -> set[Path]: """Delete recordings based on retention config.""" logger.debug("Start expire recordings.") logger.debug("Start deleted cameras.") @@ -278,23 +292,27 @@ class RecordingCleanup(threading.Thread): expire_before = ( datetime.datetime.now() - datetime.timedelta(days=expire_days) ).timestamp() - no_camera_recordings: Recordings = ( + no_camera_recordings = ( Recordings.select( Recordings.id, Recordings.path, ) .where( - Recordings.camera.not_in(list(self.config.cameras.keys())), + Recordings.camera.not_in(list(self.config.cameras.keys())), # type: ignore[call-arg, arg-type, misc] Recordings.end_time < expire_before, ) .namedtuples() .iterator() ) + maybe_empty_dirs = set() + deleted_recordings = set() for recording in no_camera_recordings: - Path(recording.path).unlink(missing_ok=True) + recording_path = Path(recording.path) + recording_path.unlink(missing_ok=True) deleted_recordings.add(recording.id) + maybe_empty_dirs.add(recording_path.parent) logger.debug(f"Expiring {len(deleted_recordings)} recordings") # delete up to 100,000 at a time @@ -311,7 +329,7 @@ class RecordingCleanup(threading.Thread): logger.debug(f"Start camera: {camera}.") now = datetime.datetime.now() - self.expire_review_segments(config, now) + maybe_empty_dirs |= self.expire_review_segments(config, now) continuous_expire_date = ( now - datetime.timedelta(days=config.record.continuous.days) ).timestamp() @@ -325,7 +343,7 @@ class RecordingCleanup(threading.Thread): ).timestamp() # Get all the reviews to check against - reviews: ReviewSegment = ( + reviews = ( ReviewSegment.select( ReviewSegment.start_time, ReviewSegment.end_time, @@ -333,15 +351,17 @@ class RecordingCleanup(threading.Thread): ) .where( ReviewSegment.camera == camera, - # need to ensure segments for all reviews starting - # before the expire date are included - ReviewSegment.start_time < motion_expire_date, + # candidate recordings can extend up to continuous_expire_date + # (the no-motion no-audio branch of the recordings query), + # so reviews must cover that full range to avoid deleting + # segments that overlap recent alerts/detections. + ReviewSegment.start_time < continuous_expire_date, ) .order_by(ReviewSegment.start_time) .namedtuples() ) - self.expire_existing_camera_recordings( + maybe_empty_dirs |= self.expire_existing_camera_recordings( continuous_expire_date, motion_expire_date, config, reviews ) logger.debug(f"End camera: {camera}.") @@ -349,16 +369,14 @@ class RecordingCleanup(threading.Thread): logger.debug("End all cameras.") logger.debug("End expire recordings.") + return maybe_empty_dirs + def run(self) -> None: + if self.config.safe_mode: logger.info("Safe mode enabled, skipping recording cleanup") return - # on startup sync recordings with disk if enabled - if self.config.record.sync_recordings: - sync_recordings(limited=False) - next_sync = get_tomorrow_at_time(3) - # Expire tmp clips every minute, recordings and clean directories every hour. for counter in itertools.cycle(range(self.config.record.expire_interval)): if self.stop_event.wait(60): @@ -367,16 +385,8 @@ class RecordingCleanup(threading.Thread): self.clean_tmp_previews() - if ( - self.config.record.sync_recordings - and datetime.datetime.now().astimezone(datetime.timezone.utc) - > next_sync - ): - sync_recordings(limited=True) - next_sync = get_tomorrow_at_time(3) - if counter == 0: self.clean_tmp_clips() - self.expire_recordings() - remove_empty_directories(RECORD_DIR) + maybe_empty_dirs = self.expire_recordings() + remove_empty_directories(Path(RECORD_DIR), maybe_empty_dirs) self.truncate_wal() diff --git a/frigate/record/export.py b/frigate/record/export.py index 28a72d05ea..00addda124 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -4,15 +4,16 @@ import datetime import logging import os import random +import re import shutil import string import subprocess as sp import threading +from collections.abc import Callable from enum import Enum from pathlib import Path -from typing import Optional -import pytz +import pytz # type: ignore[import-untyped] from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -23,28 +24,177 @@ from frigate.const import ( EXPORT_DIR, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, - PROCESS_PRIORITY_LOW, ) from frigate.ffmpeg_presets import ( EncodeTypeEnum, parse_preset_hardware_acceleration_encode, ) -from frigate.models import Export, Previews, Recordings +from frigate.models import Export, Previews, Recordings, ReviewSegment +from frigate.util.ffmpeg import run_ffmpeg_with_progress from frigate.util.time import is_current_hour logger = logging.getLogger(__name__) -TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" +DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS = "-an" +DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" +TIMELAPSE_DATA_INPUT_ARGS = "-skip_frame nokey" + +# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS). +# Captures the floating-point factor so we can scale expected duration. +SETPTS_FACTOR_RE = re.compile(r"setpts=([0-9]*\.?[0-9]+)\*PTS") + +# Allowlisted flags that take no value. +_VALUELESS_FLAGS = frozenset({"-an", "-sn", "-dn"}) + +# Allowlisted filter flags. Their value is validated as a filtergraph and may +# only reference filters in _SAFE_FILTERS. +_FILTER_FLAGS = frozenset({"-vf", "-af", "-filter"}) + +# Allowlisted flags that take exactly one value (encoder / muxer-safe options). +_VALUE_FLAGS = frozenset( + { + "-c", + "-codec", + "-b", + "-crf", + "-qp", + "-q", + "-qscale", + "-preset", + "-tune", + "-profile", + "-level", + "-pix_fmt", + "-r", + "-g", + "-keyint_min", + "-sc_threshold", + "-bf", + "-refs", + "-qmin", + "-qmax", + "-maxrate", + "-minrate", + "-bufsize", + "-movflags", + "-threads", + "-aspect", + "-fps_mode", + "-vsync", + "-skip_frame", + } +) + +_ALLOWED_FLAGS = _VALUELESS_FLAGS | _FILTER_FLAGS | _VALUE_FLAGS + +# Filters that cannot read files, load plugins, or open network sources. +_SAFE_FILTERS = frozenset( + { + "setpts", + "fps", + "scale", + "format", + "transpose", + "hflip", + "vflip", + "crop", + "pad", + "setsar", + "setdar", + } +) + +# Conservative shape for a non-filter flag value. Excludes "/" (paths / +# filtergraph division), whitespace, brackets, and a leading "-" so a value +# can never be a path or swallow a following flag. ":" is permitted for values +# like "16:9". +_SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9_.:+][A-Za-z0-9_.:+-]*$") + +# Substrings inside a filtergraph that indicate a file-reading filter option. +# "movie=" also matches "amovie=" as a substring. +_BLOCKED_FILTER_VALUE_MARKERS = ("movie=", "textfile=", "filename=", "fontfile=") -def lower_priority(): - os.nice(PROCESS_PRIORITY_LOW) +def _base_flag(token: str) -> str: + """Return a flag's base name, lowercased and without its stream specifier. + + e.g. "-c:v" -> "-c", "-filter:a:0" -> "-filter". + """ + return token.lower().split(":", 1)[0] -class PlaybackFactorEnum(str, Enum): - realtime = "realtime" - timelapse_25x = "timelapse_25x" +def _validate_filtergraph(value: str) -> tuple[bool, str]: + """Validate a filtergraph value, allowing only filters in _SAFE_FILTERS.""" + # None of the safe filters need any of these + if any(token in value for token in ("://", "..", "[", "]")): + return False, "Invalid filter graph in custom ffmpeg arguments" + + lowered = value.lower() + if any(marker in lowered for marker in _BLOCKED_FILTER_VALUE_MARKERS): + return False, "File-reading filters are not allowed in custom ffmpeg arguments" + + # Filters are separated by "," within a chain and ";" between chains. Safe + # filters never use unescaped "," or ";" in their arguments, so splitting on + # them to recover filter names cannot hide a disallowed filter. + for spec in re.split(r"[;,]", value): + spec = spec.strip() + if not spec: + continue + + name = spec.split("=", 1)[0].strip().lower() + if name not in _SAFE_FILTERS: + return False, f"Filter not allowed in custom ffmpeg arguments: {name}" + + return True, "" + + +def validate_ffmpeg_args(args: str) -> tuple[bool, str]: + """Validate user-provided custom export ffmpeg args with an allowlist. + + Every token must be an allowlisted flag or the value of one; filter values + may only reference safe filters; and no token may become a bare input or + output URL. This structurally prevents arbitrary file read/write, network + exfiltration/SSRF, and resource-exhaustion via the export endpoint. + + Admin users skip this validation entirely since they are trusted. + """ + if not args or not args.strip(): + return True, "" + + tokens = args.split() + i = 0 + while i < len(tokens): + token = tokens[i] + + # A bare (non-flag) token here would be parsed by ffmpeg as an input or + # output URL. Only the server sets inputs/outputs, never the user. + if not token.startswith("-"): + return False, f"Unexpected argument in custom ffmpeg arguments: {token}" + + base = _base_flag(token) + if base not in _ALLOWED_FLAGS: + return False, f"Forbidden ffmpeg argument: {token}" + + if base in _VALUELESS_FLAGS: + i += 1 + continue + + # Remaining flags consume exactly one value. + if i + 1 >= len(tokens): + return False, f"Missing value for ffmpeg argument: {token}" + + value = tokens[i + 1] + if base in _FILTER_FLAGS: + valid, message = _validate_filtergraph(value) + if not valid: + return False, message + elif not _SAFE_VALUE_RE.match(value): + return False, f"Invalid value for {token}: {value}" + + i += 2 + + return True, "" class PlaybackSourceEnum(str, Enum): @@ -60,13 +210,17 @@ class RecordingExporter(threading.Thread): config: FrigateConfig, id: str, camera: str, - name: Optional[str], - image: Optional[str], + name: str | None, + image: str | None, start_time: int, end_time: int, - playback_factor: PlaybackFactorEnum, playback_source: PlaybackSourceEnum, - chapters: Optional[ChaptersEnum] = None, + export_case_id: str | None = None, + ffmpeg_input_args: str | None = None, + ffmpeg_output_args: str | None = None, + cpu_fallback: bool = False, + chapters: ChaptersEnum | None = None, + on_progress: Callable[[str, float], None] | None = None, ) -> None: super().__init__() self.config = config @@ -76,23 +230,292 @@ class RecordingExporter(threading.Thread): self.user_provided_image = image self.start_time = start_time self.end_time = end_time - self.playback_factor = playback_factor self.playback_source = playback_source + self.export_case_id = export_case_id + self.ffmpeg_input_args = ffmpeg_input_args + self.ffmpeg_output_args = ffmpeg_output_args + self.cpu_fallback = cpu_fallback self.chapters = chapters + self.on_progress = on_progress # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) + def _emit_progress(self, step: str, percent: float) -> None: + """Invoke the progress callback if one was supplied.""" + if self.on_progress is None: + return + try: + self.on_progress(step, max(0.0, min(100.0, percent))) + except Exception: + logger.exception("Export progress callback failed") + + def _expected_output_duration_seconds(self) -> float: + """Compute the expected duration of the output video in seconds. + + Users often request a wide time range (e.g. a full hour) when only + a few minutes of recordings actually live on disk for that span, + so the requested range overstates the work and progress would + plateau very early. We sum the actual saved seconds from the + Recordings/Previews tables and use that as the input duration. + Timelapse exports then scale this by the setpts factor. + """ + requested_duration = max(0.0, float(self.end_time - self.start_time)) + + recorded = self._sum_source_duration_seconds() + input_duration = ( + recorded if recorded is not None and recorded > 0 else requested_duration + ) + + if not self.ffmpeg_output_args: + return input_duration + + match = SETPTS_FACTOR_RE.search(self.ffmpeg_output_args) + if match is None: + return input_duration + + try: + factor = float(match.group(1)) + except ValueError: + return input_duration + + if factor <= 0: + return input_duration + + return input_duration * factor + + def _sum_source_duration_seconds(self) -> float | None: + """Sum saved-video seconds inside [start_time, end_time]. + + Queries Recordings or Previews depending on the playback source, + clamps each segment to the requested range, and returns the total. + Returns ``None`` on any error so the caller can fall back to the + requested range duration without losing progress reporting. + """ + try: + if self.playback_source == PlaybackSourceEnum.recordings: + rows = ( + Recordings.select(Recordings.start_time, Recordings.end_time) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == self.camera) + .iterator() + ) + else: + rows = ( + Previews.select(Previews.start_time, Previews.end_time) + .where( + Previews.start_time.between(self.start_time, self.end_time) + | Previews.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Previews.start_time) + & (self.end_time < Previews.end_time) + ) + ) + .where(Previews.camera == self.camera) + .iterator() + ) + except Exception: + logger.exception( + "Failed to sum source duration for export %s", self.export_id + ) + return None + + total = 0.0 + try: + for row in rows: + clipped_start = max(float(row.start_time), float(self.start_time)) + clipped_end = min(float(row.end_time), float(self.end_time)) + if clipped_end > clipped_start: + total += clipped_end - clipped_start + except Exception: + logger.exception( + "Failed to read recording rows for export %s", self.export_id + ) + return None + + return total + + def _run_ffmpeg_with_progress( + self, + ffmpeg_cmd: list[str], + playlist_lines: str | list[str], + step: str = "encoding", + ) -> tuple[int, str]: + """Delegate to the shared helper, mapping percent → (step, percent). + + Returns ``(returncode, captured_stderr)``. + """ + if isinstance(playlist_lines, list): + stdin_payload = "\n".join(playlist_lines) + else: + stdin_payload = playlist_lines + + return run_ffmpeg_with_progress( + ffmpeg_cmd, + expected_duration_seconds=self._expected_output_duration_seconds(), + on_progress=lambda percent: self._emit_progress(step, percent), + stdin_payload=stdin_payload, + use_low_priority=True, + ) + def get_datetime_from_timestamp(self, timestamp: int) -> str: - # return in iso format + # return in iso format using the configured ui.timezone when set, + # so the auto-generated export name reflects local time rather + # than the container's UTC clock + tz_name = self.config.ui.timezone + if tz_name: + try: + tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + tz = None + if tz is not None: + return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( + "%Y-%m-%d %H:%M:%S" + ) return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") def _chapter_metadata_path(self) -> str: return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt") + def _build_chapter_metadata_file(self, recordings: list) -> str | None: + """Write an FFmpeg metadata file with chapters for review items in range. + + Chapter offsets are computed in *output time*: the VOD endpoint + concatenates recording clips back-to-back, so wall-clock gaps + between recordings collapse in the produced video. We walk the + same recording rows that feed the playlist and convert each + review item's wall-clock boundaries into output-time offsets. + Returns ``None`` when there are no recordings, no review items, + or any chapter would have zero output duration. + """ + if not recordings: + return None + + windows: list[tuple[float, float, float]] = [] + output_offset = 0.0 + for rec in recordings: + clipped_start = max(float(rec.start_time), float(self.start_time)) + clipped_end = min(float(rec.end_time), float(self.end_time)) + if clipped_end <= clipped_start: + continue + windows.append((clipped_start, clipped_end, output_offset)) + output_offset += clipped_end - clipped_start + + if not windows: + return None + + try: + review_rows = list( + ReviewSegment.select( + ReviewSegment.start_time, + ReviewSegment.end_time, + ReviewSegment.severity, + ReviewSegment.data, + ) + .where( + ReviewSegment.start_time.between(self.start_time, self.end_time) + | ReviewSegment.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > ReviewSegment.start_time) + & (self.end_time < ReviewSegment.end_time) + ) + ) + .where(ReviewSegment.camera == self.camera) + .order_by(ReviewSegment.start_time.asc()) + .iterator() + ) + except Exception: + logger.exception( + "Failed to query review segments for export %s", self.export_id + ) + return None + + if not review_rows: + return None + + total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0]) + last_recorded_end = windows[-1][1] + + def wall_to_output(t: float) -> float: + t = max(float(self.start_time), min(float(self.end_time), t)) + for w_start, w_end, w_offset in windows: + if t < w_start: + return w_offset + if t <= w_end: + return w_offset + (t - w_start) + return total_output + + chapter_blocks: list[str] = [] + for review in review_rows: + if review.start_time is None: + continue + # In-progress segments have a NULL end_time until the activity + # closes; clamp to the last recorded second so the chapter never + # extends past the actual video. + review_end = ( + float(review.end_time) + if review.end_time is not None + else last_recorded_end + ) + start_out = wall_to_output(float(review.start_time)) + end_out = wall_to_output(review_end) + + # Drop chapters that fall entirely in a recording gap, or are + # too short to be navigable in a player. + if end_out - start_out < 1.0: + continue + + data = review.data or {} + labels: list[str] = [] + for obj in data.get("objects") or []: + label = str(obj).split("-")[0] + if label and label not in labels: + labels.append(label) + + metadata = data.get("metadata") or {} + title = metadata.get("title") + + if not title: + title = str(review.severity).capitalize() + + if labels: + title = f"{title}: {', '.join(labels)}" + + chapter_blocks.append( + "[CHAPTER]\n" + "TIMEBASE=1/1000\n" + f"START={int(start_out * 1000)}\n" + f"END={int(end_out * 1000)}\n" + f"title={title}" + ) + + if not chapter_blocks: + return None + + meta_path = self._chapter_metadata_path() + try: + with open(meta_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + f.write("\n".join(chapter_blocks)) + f.write("\n") + except OSError: + logger.exception( + "Failed to write chapter metadata file for export %s", self.export_id + ) + return None + + return meta_path + def _build_recording_segment_chapter_metadata_file( self, recordings: list - ) -> Optional[str]: + ) -> str | None: """Write an FFmpeg metadata file with one chapter per recording segment. Each chapter's title is the segment's wallclock start time in @@ -108,14 +531,14 @@ class RecordingExporter(threading.Thread): return None tz_name = self.config.ui.timezone - tz: Optional[datetime.tzinfo] = None + tz: datetime.tzinfo | None = None if tz_name: try: tz = pytz.timezone(tz_name) except pytz.UnknownTimeZoneError: tz = None if tz is None: - tz = datetime.timezone.utc + tz = datetime.UTC chapter_blocks: list[str] = [] output_offset_ms = 0 @@ -169,13 +592,13 @@ class RecordingExporter(threading.Thread): if ( self.start_time - < datetime.datetime.now(datetime.timezone.utc) + < datetime.datetime.now(datetime.UTC) .replace(minute=0, second=0, microsecond=0) .timestamp() ): # has preview mp4 try: - preview: Previews = ( + preview = ( Previews.select( Previews.camera, Previews.path, @@ -198,16 +621,14 @@ class RecordingExporter(threading.Thread): except DoesNotExist: return "" - diff = self.start_time - preview.start_time - minutes = int(diff / 60) - seconds = int(diff % 60) + diff = max(0.0, float(self.start_time) - float(preview.start_time)) ffmpeg_cmd = [ - "/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support + "/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support "-hide_banner", "-loglevel", "warning", "-ss", - f"00:{minutes}:{seconds}", + f"{diff:.3f}", "-i", preview.path, "-frames", @@ -229,16 +650,22 @@ class RecordingExporter(threading.Thread): else: # need to generate from existing images preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{self.camera}" - start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}" - end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}" + file_start = f"preview_{self.camera}-" + start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}" selected_preview = None + # Preview frames are written at most 1-2 fps during activity + # and as little as one every 30s during quiet periods, so a + # short export window can contain zero frames. Track the most + # recent frame before the window as a fallback. + fallback_preview = None for file in sorted(os.listdir(preview_dir)): if not file.startswith(file_start): continue if file < start_file: + fallback_preview = os.path.join(preview_dir, file) continue if file > end_file: @@ -247,6 +674,9 @@ class RecordingExporter(threading.Thread): selected_preview = os.path.join(preview_dir, file) break + if not selected_preview: + selected_preview = fallback_preview + if not selected_preview: return "" @@ -254,96 +684,91 @@ class RecordingExporter(threading.Thread): return thumb_path - def get_record_export_command(self, video_path: str) -> list[str]: + def get_record_export_command( + self, video_path: str, use_hwaccel: bool = True + ) -> tuple[list[str], str | list[str]]: + # handle case where internal port is a string with ip:port + internal_port = self.config.networking.listen.internal + if type(internal_port) is str: + internal_port = int(internal_port.split(":")[-1]) + + recordings = list( + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == self.camera) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + + playlist_lines: list[str] = [] if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: - playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" + playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" ffmpeg_input = ( - f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_lines}" + f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}" ) else: - playlist_lines = [] - - # get full set of recordings - export_recordings = ( - Recordings.select( - Recordings.start_time, - Recordings.end_time, - ) - .where( - Recordings.start_time.between(self.start_time, self.end_time) - | Recordings.end_time.between(self.start_time, self.end_time) - | ( - (self.start_time > Recordings.start_time) - & (self.end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == self.camera) - .order_by(Recordings.start_time.asc()) - ) - - # Use pagination to process records in chunks + # Chunk the recording rows into pages so each playlist line + # references a bounded sub-range rather than the full export. page_size = 1000 - num_pages = (export_recordings.count() + page_size - 1) // page_size - - for page in range(1, num_pages + 1): - playlist = export_recordings.paginate(page, page_size) + for i in range(0, len(recordings), page_size): + chunk = recordings[i : i + page_size] playlist_lines.append( - f"file 'http://127.0.0.1:5000/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'" + f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'" ) ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" - # When chapters are requested, query the per-segment recording rows - # and write an FFmpeg metadata sidecar. Timelapse playback rescales - # time so chapter offsets would no longer match wallclock — restrict - # chapter injection to realtime playback. - chapter_args = "" - if ( - self.chapters == ChaptersEnum.recording_segments - and self.playback_factor == PlaybackFactorEnum.realtime - ): - recordings = list( - Recordings.select( - Recordings.start_time, - Recordings.end_time, - ) - .where( - Recordings.start_time.between(self.start_time, self.end_time) - | Recordings.end_time.between(self.start_time, self.end_time) - | ( - (self.start_time > Recordings.start_time) - & (self.end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == self.camera) - .order_by(Recordings.start_time.asc()) - .iterator() + if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None: + hwaccel_args = ( + self.config.cameras[self.camera].record.export.hwaccel_args + if use_hwaccel + else None ) - chapters_path = self._build_recording_segment_chapter_metadata_file( - recordings - ) - if chapters_path: - chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1" - - if self.playback_factor == PlaybackFactorEnum.realtime: - ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart" - ).split(" ") - elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.ffmpeg_path, - self.config.ffmpeg.hwaccel_args, - f"-an {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", + hwaccel_args, + f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(), + f"{self.ffmpeg_output_args} -movflags +faststart".strip(), EncodeTypeEnum.timelapse, ) ).split(" ") + else: + # Realtime/stream-copy export. Embed chapter metadata according to + # the camera's configured chapter mode: per-recording-segment + # timestamps or per-review-item titles. + if self.chapters == ChaptersEnum.recording_segments: + chapters_path = self._build_recording_segment_chapter_metadata_file( + recordings + ) + elif self.chapters == ChaptersEnum.review_items: + chapters_path = self._build_chapter_metadata_file(recordings) + else: + chapters_path = None + + chapter_args = ( + 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" + ).split(" ") # add metadata title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" creation_time = datetime.datetime.fromtimestamp( - self.start_time, tz=datetime.timezone.utc + self.start_time, tz=datetime.UTC ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") ffmpeg_cmd.extend( [ @@ -360,16 +785,18 @@ class RecordingExporter(threading.Thread): return ffmpeg_cmd, playlist_lines - def get_preview_export_command(self, video_path: str) -> list[str]: + def get_preview_export_command( + self, video_path: str, use_hwaccel: bool = True + ) -> tuple[list[str], list[str]]: playlist_lines = [] codec = "-c copy" if is_current_hour(self.start_time): # get list of current preview frames preview_dir = os.path.join(CACHE_DIR, "preview_frames") - file_start = f"preview_{self.camera}" - start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}" - end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}" + file_start = f"preview_{self.camera}-" + start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}" for file in sorted(os.listdir(preview_dir)): if not file.startswith(file_start): @@ -410,7 +837,6 @@ class RecordingExporter(threading.Thread): .iterator() ) - preview: Previews for preview in export_previews: playlist_lines.append(f"file '{preview.path}'") @@ -428,25 +854,30 @@ class RecordingExporter(threading.Thread): "-y -protocol_whitelist pipe,file,tcp -f concat -safe 0 -i /dev/stdin" ) - if self.playback_factor == PlaybackFactorEnum.realtime: - ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart" - ).split(" ") - elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: + if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None: + hwaccel_args = ( + self.config.cameras[self.camera].record.export.hwaccel_args + if use_hwaccel + else None + ) ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.ffmpeg_path, - self.config.ffmpeg.hwaccel_args, - f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", + hwaccel_args, + f"{self.ffmpeg_input_args} {TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}".strip(), + f"{self.ffmpeg_output_args} -movflags +faststart".strip(), EncodeTypeEnum.timelapse, ) ).split(" ") + else: + ffmpeg_cmd = ( + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart" + ).split(" ") # add metadata title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" creation_time = datetime.datetime.fromtimestamp( - self.start_time, tz=datetime.timezone.utc + self.start_time, tz=datetime.UTC ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") ffmpeg_cmd.extend( [ @@ -467,6 +898,7 @@ class RecordingExporter(threading.Thread): logger.debug( f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" ) + self._emit_progress("preparing", 0.0) export_name = ( self.user_provided_name or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}" @@ -481,17 +913,20 @@ class RecordingExporter(threading.Thread): video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4" thumb_path = self.save_thumbnail(self.export_id) - Export.insert( - { - Export.id: self.export_id, - Export.camera: self.camera, - Export.name: export_name, - Export.date: self.start_time, - Export.video_path: video_path, - Export.thumb_path: thumb_path, - Export.in_progress: True, - } - ).execute() + export_values = { + Export.id: self.export_id, + Export.camera: self.camera, + Export.name: export_name, + Export.date: self.start_time, + Export.video_path: video_path, + Export.thumb_path: thumb_path, + Export.in_progress: True, + } + + if self.export_case_id is not None: + export_values[Export.export_case] = self.export_case_id + + Export.insert(export_values).execute() try: if self.playback_source == PlaybackSourceEnum.recordings: @@ -501,26 +936,57 @@ class RecordingExporter(threading.Thread): except DoesNotExist: return - p = sp.run( - ffmpeg_cmd, - input="\n".join(playlist_lines), - encoding="ascii", - preexec_fn=lower_priority, - capture_output=True, + # When neither custom ffmpeg arg is set the default path uses + # `-c copy` (stream copy — no re-encoding). Report that as a + # distinct step so the UI doesn't mislabel a remux as encoding. + # The retry branch below always re-encodes because cpu_fallback + # requires custom args; it stays "encoding_retry". + is_stream_copy = ( + self.ffmpeg_input_args is None and self.ffmpeg_output_args is None ) + initial_step = "copying" if is_stream_copy else "encoding" + + returncode, stderr = self._run_ffmpeg_with_progress( + ffmpeg_cmd, playlist_lines, step=initial_step + ) + + # If export failed and cpu_fallback is enabled, retry without hwaccel + if ( + returncode != 0 + and self.cpu_fallback + and self.ffmpeg_input_args is not None + and self.ffmpeg_output_args is not None + ): + logger.warning( + f"Export with hardware acceleration failed, retrying without hwaccel for {self.export_id}" + ) + + if self.playback_source == PlaybackSourceEnum.recordings: + ffmpeg_cmd, playlist_lines = self.get_record_export_command( + video_path, use_hwaccel=False + ) + else: + ffmpeg_cmd, playlist_lines = self.get_preview_export_command( + video_path, use_hwaccel=False + ) + + returncode, stderr = self._run_ffmpeg_with_progress( + ffmpeg_cmd, playlist_lines, step="encoding_retry" + ) Path(self._chapter_metadata_path()).unlink(missing_ok=True) - if p.returncode != 0: + if returncode != 0: logger.error( f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" ) - logger.error(p.stderr) + logger.error(stderr) Path(video_path).unlink(missing_ok=True) Export.delete().where(Export.id == self.export_id).execute() Path(thumb_path).unlink(missing_ok=True) return else: + self._emit_progress("finalizing", 100.0) Export.update({Export.in_progress: False}).where( Export.id == self.export_id ).execute() @@ -528,7 +994,7 @@ class RecordingExporter(threading.Thread): logger.debug(f"Finished exporting {video_path}") -def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]): +def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]) -> None: Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) exports = [] diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index e36df78d0b..7f9dbc19da 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -11,7 +11,7 @@ import time from collections import defaultdict from multiprocessing.synchronize import Event as MpEvent from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any import numpy as np import psutil @@ -42,6 +42,8 @@ from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) +STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2 + class SegmentInfo: def __init__( @@ -50,11 +52,13 @@ class SegmentInfo: active_object_count: int, region_count: int, average_dBFS: int, + motion_heatmap: dict[str, int] | None = None, ) -> None: self.motion_count = motion_count self.active_object_count = active_object_count self.region_count = region_count self.average_dBFS = average_dBFS + self.motion_heatmap = motion_heatmap def should_discard_segment(self, retain_mode: RetainModeEnum) -> bool: keep = False @@ -96,7 +100,7 @@ class RecordingMaintainer(threading.Thread): self.stop_event = stop_event self.object_recordings_info: dict[str, list] = defaultdict(list) self.audio_recordings_info: dict[str, list] = defaultdict(list) - self.end_time_cache: dict[str, Tuple[datetime.datetime, float]] = {} + self.end_time_cache: dict[str, tuple[datetime.datetime, float]] = {} self.unexpected_cache_files_logged: bool = False async def move_files(self) -> None: @@ -123,7 +127,7 @@ class RecordingMaintainer(threading.Thread): start_time = datetime.datetime.strptime( date, CACHE_SEGMENT_FORMAT - ).astimezone(datetime.timezone.utc) + ).astimezone(datetime.UTC) if ( camera not in newest_cache_segments or start_time > newest_cache_segments[camera]["start_time"] @@ -183,7 +187,7 @@ class RecordingMaintainer(threading.Thread): # important that start_time is utc because recordings are stored and compared in utc start_time = datetime.datetime.strptime( date, CACHE_SEGMENT_FORMAT - ).astimezone(datetime.timezone.utc) + ).astimezone(datetime.UTC) grouped_recordings[camera].append( { @@ -264,7 +268,7 @@ class RecordingMaintainer(threading.Thread): # get all reviews with the end time after the start of the oldest cache file # or with end_time None - reviews: ReviewSegment = ( + reviews = ( ReviewSegment.select( ReviewSegment.start_time, ReviewSegment.end_time, @@ -287,18 +291,21 @@ class RecordingMaintainer(threading.Thread): ) # publish most recently available recording time and None if disabled + camera_cfg = self.config.cameras.get(camera) self.recordings_publisher.publish( ( camera, recordings[0]["start_time"].timestamp() - if self.config.cameras[camera].record.enabled + if camera_cfg and camera_cfg.record.enabled else None, None, ), RecordingsDataTypeEnum.saved.value, ) - recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks) + self._expire_stale_recordings_info(grouped_recordings) + + recordings_to_insert: list[dict[str, Any] | None] = await asyncio.gather(*tasks) # fire and forget recordings entries self.requestor.send_data( @@ -306,18 +313,32 @@ class RecordingMaintainer(threading.Thread): [r for r in recordings_to_insert if r is not None], ) + def _expire_stale_recordings_info( + self, grouped_recordings: defaultdict[str, list[dict[str, Any]]] + ) -> None: + expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL + for recordings_info in ( + self.object_recordings_info, + self.audio_recordings_info, + ): + for camera in list(recordings_info.keys()): + if camera in grouped_recordings: + continue + info = recordings_info[camera] + while info and info[0][0] < expire_before: + info.pop(0) + def drop_segment(self, cache_path: str) -> None: Path(cache_path).unlink(missing_ok=True) self.end_time_cache.pop(cache_path, None) async def validate_and_move_segment( - self, camera: str, reviews: list[ReviewSegment], recording: dict[str, Any] - ) -> Optional[Recordings]: + self, camera: str, reviews: Any, recording: dict[str, Any] + ) -> dict[str, Any] | None: cache_path: str = recording["cache_path"] start_time: datetime.datetime = recording["start_time"] - record_config = self.config.cameras[camera].record - # Just delete files if recordings are turned off + # Just delete files if camera removed or recordings are turned off if ( camera not in self.config.cameras or not self.config.cameras[camera].record.enabled @@ -368,6 +389,7 @@ class RecordingMaintainer(threading.Thread): ) record_config = self.config.cameras[camera].record + segment_stats: SegmentInfo | None = None highest = None if record_config.continuous.days > 0: @@ -389,7 +411,7 @@ class RecordingMaintainer(threading.Thread): if ( datetime.datetime.fromtimestamp( most_recently_processed_frame_time - ).astimezone(datetime.timezone.utc) + ).astimezone(datetime.UTC) >= end_time ): record_mode = ( @@ -397,9 +419,19 @@ class RecordingMaintainer(threading.Thread): if highest == "continuous" else RetainModeEnum.motion ) - return await self.move_segment( - camera, start_time, end_time, duration, cache_path, record_mode - ) + segment_stats = self.segment_stats(camera, start_time, end_time) + + # Here we only check if we should move the segment based on non-object recording retention + # we will always want to check for overlapping review items below before dropping the segment + if not segment_stats.should_discard_segment(record_mode): + return await self.move_segment( + camera, + start_time, + end_time, + duration, + cache_path, + segment_stats, + ) # we fell through the continuous / motion check, so we need to check the review items # if the cached segment overlaps with the review items: @@ -431,29 +463,96 @@ class RecordingMaintainer(threading.Thread): if review.severity == "alert" else record_config.detections.retain.mode ) - # move from cache to recordings immediately - return await self.move_segment( - camera, - start_time, - end_time, - duration, - cache_path, - record_mode, - ) - # if it doesn't overlap with an review item, go ahead and drop the segment - # if it ends more than the configured pre_capture for the camera - # BUT only if continuous/motion is NOT enabled (otherwise wait for processing) - elif highest is None: + + if segment_stats is None: + segment_stats = self.segment_stats(camera, start_time, end_time) + + if not segment_stats.should_discard_segment(record_mode): + # move from cache to recordings immediately + return await self.move_segment( + camera, + start_time, + end_time, + duration, + cache_path, + segment_stats, + ) + else: + self.drop_segment(cache_path) + return None + + # if it doesn't overlap with a review item, drop the segment once it + # ends more than event_pre_capture before the most recently processed + # frame. at this point we've already decided not to keep it for + # continuous/motion retention (either disabled or segment_stats said + # discard), so waiting longer just fills the cache. + else: camera_info = self.object_recordings_info[camera] most_recently_processed_frame_time = ( camera_info[-1][0] if len(camera_info) > 0 else 0 ) retain_cutoff = datetime.datetime.fromtimestamp( most_recently_processed_frame_time - record_config.event_pre_capture - ).astimezone(datetime.timezone.utc) + ).astimezone(datetime.UTC) + if end_time < retain_cutoff: self.drop_segment(cache_path) + return None + + def _compute_motion_heatmap( + self, camera: str, motion_boxes: list[tuple[int, int, int, int]] + ) -> dict[str, int] | None: + """Compute a 16x16 motion intensity heatmap from motion boxes. + + Returns a sparse dict mapping cell index (as string) to intensity (1-255). + Only cells with motion are included. + + Args: + camera: Camera name to get detect dimensions from. + motion_boxes: List of (x1, y1, x2, y2) pixel coordinates. + + Returns: + Sparse dict like {"45": 3, "46": 5}, or None if no boxes. + """ + if not motion_boxes: + return None + + camera_config = self.config.cameras.get(camera) + if not camera_config: + return None + + frame_width = camera_config.detect.width + frame_height = camera_config.detect.height + + if not frame_width or frame_width <= 0 or not frame_height or frame_height <= 0: + return None + + GRID_SIZE = 16 + counts: dict[int, int] = {} + + for box in motion_boxes: + if len(box) < 4: + continue + x1, y1, x2, y2 = box + + # Convert pixel coordinates to grid cells + grid_x1 = max(0, int((x1 / frame_width) * GRID_SIZE)) + grid_y1 = max(0, int((y1 / frame_height) * GRID_SIZE)) + grid_x2 = min(GRID_SIZE - 1, int((x2 / frame_width) * GRID_SIZE)) + grid_y2 = min(GRID_SIZE - 1, int((y2 / frame_height) * GRID_SIZE)) + + for y in range(grid_y1, grid_y2 + 1): + for x in range(grid_x1, grid_x2 + 1): + idx = y * GRID_SIZE + x + counts[idx] = min(255, counts.get(idx, 0) + 1) + + if not counts: + return None + + # Convert to string keys for JSON storage + return {str(k): v for k, v in counts.items()} + def segment_stats( self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime ) -> SegmentInfo: @@ -461,6 +560,8 @@ class RecordingMaintainer(threading.Thread): active_count = 0 region_count = 0 motion_count = 0 + all_motion_boxes: list[tuple[int, int, int, int]] = [] + for frame in self.object_recordings_info[camera]: # frame is after end time of segment if frame[0] > end_time.timestamp(): @@ -479,6 +580,8 @@ class RecordingMaintainer(threading.Thread): ) motion_count += len(frame[2]) region_count += len(frame[3]) + # Collect motion boxes for heatmap computation + all_motion_boxes.extend(frame[2]) audio_values = [] for frame in self.audio_recordings_info[camera]: @@ -498,8 +601,14 @@ class RecordingMaintainer(threading.Thread): average_dBFS = 0 if not audio_values else np.average(audio_values) + motion_heatmap = self._compute_motion_heatmap(camera, all_motion_boxes) + return SegmentInfo( - motion_count, active_count, region_count, round(average_dBFS) + motion_count, + active_count, + region_count, + round(average_dBFS), + motion_heatmap, ) async def move_segment( @@ -509,15 +618,8 @@ class RecordingMaintainer(threading.Thread): end_time: datetime.datetime, duration: float, cache_path: str, - store_mode: RetainModeEnum, - ) -> Optional[Recordings]: - segment_info = self.segment_stats(camera, start_time, end_time) - - # check if the segment shouldn't be stored - if segment_info.should_discard_segment(store_mode): - self.drop_segment(cache_path) - return - + segment_info: SegmentInfo, + ) -> dict[str, Any] | None: # directory will be in utc due to start_time being in utc directory = os.path.join( RECORD_DIR, @@ -525,8 +627,7 @@ class RecordingMaintainer(threading.Thread): camera, ) - if not os.path.exists(directory): - os.makedirs(directory) + os.makedirs(directory, exist_ok=True) # file will be in utc due to start_time being in utc file_name = f"{start_time.strftime('%M.%S.mp4')}" @@ -557,7 +658,8 @@ class RecordingMaintainer(threading.Thread): if p.returncode != 0: logger.error(f"Unable to convert {cache_path} to {file_path}") - logger.error((await p.stderr.read()).decode("ascii")) + if p.stderr: + logger.error((await p.stderr.read()).decode("ascii")) return None else: logger.debug( @@ -592,6 +694,7 @@ class RecordingMaintainer(threading.Thread): Recordings.regions.name: segment_info.region_count, Recordings.dBFS.name: segment_info.average_dBFS, Recordings.segment_size.name: segment_size, + Recordings.motion_heatmap.name: segment_info.motion_heatmap, } except Exception as e: logger.error(f"Unable to store recording segment {cache_path}") @@ -620,11 +723,16 @@ class RecordingMaintainer(threading.Thread): stale_frame_count_threshold = 10 # empty the object recordings info queue while True: - (topic, data) = self.detection_subscriber.check_for_update( + result = self.detection_subscriber.check_for_update( timeout=FAST_QUEUE_TIMEOUT ) - if not topic: + if not result: + break + + topic, data = result + + if not topic or not data: break if topic == DetectionTypeEnum.video.value: @@ -663,7 +771,8 @@ class RecordingMaintainer(threading.Thread): ) ) elif ( - topic == DetectionTypeEnum.api.value or DetectionTypeEnum.lpr.value + topic == DetectionTypeEnum.api.value + or topic == DetectionTypeEnum.lpr.value ): continue diff --git a/frigate/record/util.py b/frigate/record/util.py deleted file mode 100644 index 6a91c1aaf0..0000000000 --- a/frigate/record/util.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Recordings Utilities.""" - -import datetime -import logging -import os - -from peewee import DatabaseError, chunked - -from frigate.const import RECORD_DIR -from frigate.models import Recordings, RecordingsToDelete - -logger = logging.getLogger(__name__) - - -def remove_empty_directories(directory: str) -> None: - # list all directories recursively and sort them by path, - # longest first - paths = sorted( - [x[0] for x in os.walk(directory)], - key=lambda p: len(str(p)), - reverse=True, - ) - for path in paths: - # don't delete the parent - if path == directory: - continue - if len(os.listdir(path)) == 0: - os.rmdir(path) - - -def sync_recordings(limited: bool) -> None: - """Check the db for stale recordings entries that don't exist in the filesystem.""" - - def delete_db_entries_without_file(check_timestamp: float) -> bool: - """Delete db entries where file was deleted outside of frigate.""" - - if limited: - recordings = Recordings.select(Recordings.id, Recordings.path).where( - Recordings.start_time >= check_timestamp - ) - else: - # get all recordings in the db - recordings = Recordings.select(Recordings.id, Recordings.path) - - # Use pagination to process records in chunks - page_size = 1000 - num_pages = (recordings.count() + page_size - 1) // page_size - recordings_to_delete = set() - - for page in range(num_pages): - for recording in recordings.paginate(page, page_size): - if not os.path.exists(recording.path): - recordings_to_delete.add(recording.id) - - if len(recordings_to_delete) == 0: - return True - - logger.info( - f"Deleting {len(recordings_to_delete)} recording DB entries with missing files" - ) - - # convert back to list of dictionaries for insertion - recordings_to_delete = [ - {"id": recording_id} for recording_id in recordings_to_delete - ] - - if float(len(recordings_to_delete)) / max(1, recordings.count()) > 0.5: - logger.warning( - f"Deleting {(len(recordings_to_delete) / max(1, recordings.count()) * 100):.2f}% of recordings DB entries, could be due to configuration error. Aborting..." - ) - return False - - # create a temporary table for deletion - RecordingsToDelete.create_table(temporary=True) - - # insert ids to the temporary table - max_inserts = 1000 - for batch in chunked(recordings_to_delete, max_inserts): - RecordingsToDelete.insert_many(batch).execute() - - try: - # delete records in the main table that exist in the temporary table - query = Recordings.delete().where( - Recordings.id.in_(RecordingsToDelete.select(RecordingsToDelete.id)) - ) - query.execute() - except DatabaseError as e: - logger.error(f"Database error during recordings db cleanup: {e}") - - return True - - def delete_files_without_db_entry(files_on_disk: list[str]): - """Delete files where file is not inside frigate db.""" - files_to_delete = [] - - for file in files_on_disk: - if not Recordings.select().where(Recordings.path == file).exists(): - files_to_delete.append(file) - - if len(files_to_delete) == 0: - return True - - logger.info( - f"Deleting {len(files_to_delete)} recordings files with missing DB entries" - ) - - if float(len(files_to_delete)) / max(1, len(files_on_disk)) > 0.5: - logger.debug( - f"Deleting {(len(files_to_delete) / max(1, len(files_on_disk)) * 100):.2f}% of recordings DB entries, could be due to configuration error. Aborting..." - ) - return False - - for file in files_to_delete: - os.unlink(file) - - return True - - logger.debug("Start sync recordings.") - - # start checking on the hour 36 hours ago - check_point = datetime.datetime.now().replace( - minute=0, second=0, microsecond=0 - ).astimezone(datetime.timezone.utc) - datetime.timedelta(hours=36) - db_success = delete_db_entries_without_file(check_point.timestamp()) - - # only try to cleanup files if db cleanup was successful - if db_success: - if limited: - # get recording files from last 36 hours - hour_check = f"{RECORD_DIR}/{check_point.strftime('%Y-%m-%d/%H')}" - files_on_disk = { - os.path.join(root, file) - for root, _, files in os.walk(RECORD_DIR) - for file in files - if root > hour_check - } - else: - # get all recordings files on disk and put them in a set - files_on_disk = { - os.path.join(root, file) - for root, _, files in os.walk(RECORD_DIR) - for file in files - } - - delete_files_without_db_entry(files_on_disk) - - logger.debug("End sync recordings.") diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 917c0c5acd..ea892a18f9 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -11,7 +11,7 @@ import sys import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path -from typing import Any, Optional +from typing import Any import cv2 import numpy as np @@ -31,7 +31,7 @@ from frigate.const import ( ) from frigate.models import ReviewSegment from frigate.review.types import SeverityEnum -from frigate.track.object_processing import ManualEventState, TrackedObject +from frigate.track.object_processing import ManualEventState from frigate.util.image import SharedMemoryFrameManager, calculate_16_9_crop logger = logging.getLogger(__name__) @@ -69,7 +69,9 @@ class PendingReviewSegment: self.last_alert_time = frame_time # thumbnail - self._frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) + self._frame: np.ndarray[Any, Any] = np.zeros( + (THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8 + ) self.has_frame = False self.frame_active_count = 0 self.frame_path = os.path.join( @@ -77,8 +79,11 @@ class PendingReviewSegment: ) def update_frame( - self, camera_config: CameraConfig, frame, objects: list[TrackedObject] - ): + self, + camera_config: CameraConfig, + frame: np.ndarray, + objects: list[dict[str, Any]], + ) -> None: min_x = camera_config.frame_shape[1] min_y = camera_config.frame_shape[0] max_x = 0 @@ -110,11 +115,13 @@ class PendingReviewSegment: if self._frame is not None: self.thumb_time = datetime.datetime.now().timestamp() self.has_frame = True - cv2.imwrite( + Path(self.frame_path).parent.mkdir(parents=True, exist_ok=True) + if not cv2.imwrite( self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] - ) + ): + logger.error("Failed to write review thumbnail to %s", self.frame_path) - def save_full_frame(self, camera_config: CameraConfig, frame): + def save_full_frame(self, camera_config: CameraConfig, frame: np.ndarray) -> None: color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0]) self._frame = cv2.resize( @@ -123,9 +130,11 @@ class PendingReviewSegment: if self._frame is not None: self.has_frame = True - cv2.imwrite( + Path(self.frame_path).parent.mkdir(parents=True, exist_ok=True) + if not cv2.imwrite( self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] - ) + ): + logger.error("Failed to write review thumbnail to %s", self.frame_path) def get_data(self, ended: bool) -> dict: end_time = None @@ -165,13 +174,13 @@ class ActiveObjects: self, frame_time: float, camera_config: CameraConfig, - all_objects: list[TrackedObject], + all_objects: list[dict[str, Any]], ): self.camera_config = camera_config # get current categorization of objects to know if # these objects are currently being categorized - self.categorized_objects = { + self.categorized_objects: dict[str, list[dict[str, Any]]] = { "alerts": [], "detections": [], } @@ -250,7 +259,7 @@ class ActiveObjects: return False - def get_all_objects(self) -> list[TrackedObject]: + def get_all_objects(self) -> list[dict[str, Any]]: return ( self.categorized_objects["alerts"] + self.categorized_objects["detections"] ) @@ -262,7 +271,7 @@ class ReviewSegmentMaintainer(threading.Thread): def __init__(self, config: FrigateConfig, stop_event: MpEvent): super().__init__(name="review_segment_maintainer") self.config = config - self.active_review_segments: dict[str, Optional[PendingReviewSegment]] = {} + self.active_review_segments: dict[str, PendingReviewSegment | None] = {} self.frame_manager = SharedMemoryFrameManager() # create communication for review segments @@ -309,7 +318,7 @@ class ReviewSegmentMaintainer(threading.Thread): "reviews", json.dumps(review_update), ) - self.review_publisher.publish(review_update, segment.camera) + self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type] self.requestor.send_data( f"{segment.camera}/review_status", segment.severity.value.upper() ) @@ -318,8 +327,8 @@ class ReviewSegmentMaintainer(threading.Thread): self, segment: PendingReviewSegment, camera_config: CameraConfig, - frame, - objects: list[TrackedObject], + frame: np.ndarray | None, + objects: list[dict[str, Any]], prev_data: dict[str, Any], ) -> None: """Update segment.""" @@ -337,7 +346,7 @@ class ReviewSegmentMaintainer(threading.Thread): "reviews", json.dumps(review_update), ) - self.review_publisher.publish(review_update, segment.camera) + self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type] self.requestor.send_data( f"{segment.camera}/review_status", segment.severity.value.upper() ) @@ -346,7 +355,7 @@ class ReviewSegmentMaintainer(threading.Thread): self, segment: PendingReviewSegment, prev_data: dict[str, Any], - ) -> float: + ) -> Any: """End segment.""" final_data = segment.get_data(ended=True) end_time = final_data[ReviewSegment.end_time.name] @@ -360,24 +369,61 @@ class ReviewSegmentMaintainer(threading.Thread): "reviews", json.dumps(review_update), ) - self.review_publisher.publish(review_update, segment.camera) + self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type] self.requestor.send_data(f"{segment.camera}/review_status", "NONE") self.active_review_segments[segment.camera] = None return end_time - def forcibly_end_segment(self, camera: str) -> float: + def forcibly_end_segment(self, camera: str) -> Any: """Forcibly end the pending segment for a camera.""" segment = self.active_review_segments.get(camera) if segment: + if self.indefinite_events.get(camera): + self.indefinite_events[camera] = {} + now = datetime.datetime.now().timestamp() + + if segment.last_alert_time == sys.maxsize: + segment.last_alert_time = now + + if segment.last_detection_time == sys.maxsize: + segment.last_detection_time = now + prev_data = segment.get_data(False) return self._publish_segment_end(segment, prev_data) + return None + + def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None: + """Determine the review severity for a manual event label. + + Alert labels take precedence over detection labels, matching how + tracked objects are categorized. Labels in neither list default to + alerts so manual events keep their historical severity. + """ + review_config = self.config.cameras[camera].review + # label contains 'label: sub_label', only the label is categorized + label = label.split(": ")[0] + + if review_config.alerts.enabled and label in review_config.alerts.labels: + return SeverityEnum.alert + + if ( + review_config.detections.enabled + and review_config.detections.labels is not None + and label in review_config.detections.labels + ): + return SeverityEnum.detection + + if review_config.alerts.enabled: + return SeverityEnum.alert + + return None def update_existing_segment( self, segment: PendingReviewSegment, frame_name: str, frame_time: float, - objects: list[TrackedObject], + objects: list[dict[str, Any]], ) -> None: """Validate if existing review segment should continue.""" camera_config = self.config.cameras[segment.camera] @@ -394,7 +440,11 @@ class ReviewSegmentMaintainer(threading.Thread): if activity.has_activity_category(SeverityEnum.alert): # update current time for last alert activity - segment.last_alert_time = frame_time + if ( + segment.last_alert_time is None + or frame_time > segment.last_alert_time + ): + segment.last_alert_time = frame_time if segment.severity != SeverityEnum.alert: # if segment is not alert category but current activity is @@ -404,7 +454,11 @@ class ReviewSegmentMaintainer(threading.Thread): should_update_image = True if activity.has_activity_category(SeverityEnum.detection): - segment.last_detection_time = frame_time + if ( + segment.last_detection_time is None + or frame_time > segment.last_detection_time + ): + segment.last_detection_time = frame_time for object in activity.get_all_objects(): # Alert-level objects should always be added (they extend/upgrade the segment) @@ -484,8 +538,11 @@ class ReviewSegmentMaintainer(threading.Thread): except FileNotFoundError: return - if segment.severity == SeverityEnum.alert and frame_time > ( - segment.last_alert_time + camera_config.review.alerts.cutoff_time + if ( + segment.severity == SeverityEnum.alert + and segment.last_alert_time is not None + and frame_time + > (segment.last_alert_time + camera_config.review.alerts.cutoff_time) ): needs_new_detection = ( segment.last_detection_time > segment.last_alert_time @@ -508,23 +565,18 @@ class ReviewSegmentMaintainer(threading.Thread): new_zones.update(o["current_zones"]) if new_detections: - self.active_review_segments[activity.camera_config.name] = ( - PendingReviewSegment( - activity.camera_config.name, - end_time, - SeverityEnum.detection, - new_detections, - sub_labels={}, - audio=set(), - zones=list(new_zones), - ) + new_segment = PendingReviewSegment( + segment.camera, + end_time, + SeverityEnum.detection, + new_detections, + sub_labels={}, + audio=set(), + zones=list(new_zones), ) - self._publish_segment_start( - self.active_review_segments[activity.camera_config.name] - ) - self.active_review_segments[ - activity.camera_config.name - ].last_detection_time = last_detection_time + self.active_review_segments[segment.camera] = new_segment + self._publish_segment_start(new_segment) + new_segment.last_detection_time = last_detection_time elif segment.severity == SeverityEnum.detection and frame_time > ( segment.last_detection_time + camera_config.review.detections.cutoff_time @@ -536,7 +588,7 @@ class ReviewSegmentMaintainer(threading.Thread): camera: str, frame_name: str, frame_time: float, - objects: list[TrackedObject], + objects: list[dict[str, Any]], ) -> None: """Check if a new review segment should be created.""" camera_config = self.config.cameras[camera] @@ -573,7 +625,7 @@ class ReviewSegmentMaintainer(threading.Thread): zones.append(zone) if severity: - self.active_review_segments[camera] = PendingReviewSegment( + new_segment = PendingReviewSegment( camera, frame_time, severity, @@ -582,6 +634,7 @@ class ReviewSegmentMaintainer(threading.Thread): audio=set(), zones=zones, ) + self.active_review_segments[camera] = new_segment try: yuv_frame = self.frame_manager.get( @@ -592,11 +645,11 @@ class ReviewSegmentMaintainer(threading.Thread): logger.debug(f"Failed to get frame {frame_name} from SHM") return - self.active_review_segments[camera].update_frame( + new_segment.update_frame( camera_config, yuv_frame, activity.get_all_objects() ) self.frame_manager.close(frame_name) - self._publish_segment_start(self.active_review_segments[camera]) + self._publish_segment_start(new_segment) except FileNotFoundError: return @@ -613,9 +666,14 @@ class ReviewSegmentMaintainer(threading.Thread): for camera in updated_topics["enabled"]: self.forcibly_end_segment(camera) - (topic, data) = self.detection_subscriber.check_for_update(timeout=1) + result = self.detection_subscriber.check_for_update(timeout=1) - if not topic: + if not result: + continue + + topic, data = result + + if not topic or not data: continue if topic == DetectionTypeEnum.video.value: @@ -634,7 +692,10 @@ class ReviewSegmentMaintainer(threading.Thread): _, audio_detections, ) = data - elif topic == DetectionTypeEnum.api.value or DetectionTypeEnum.lpr.value: + elif ( + topic == DetectionTypeEnum.api.value + or topic == DetectionTypeEnum.lpr.value + ): ( camera, frame_time, @@ -644,6 +705,9 @@ class ReviewSegmentMaintainer(threading.Thread): if camera not in self.indefinite_events: self.indefinite_events[camera] = {} + if camera not in self.config.cameras: + continue + if ( not self.config.cameras[camera].enabled or not self.config.cameras[camera].record.enabled @@ -695,17 +759,26 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - if ( - topic == DetectionTypeEnum.api - and self.config.cameras[camera].review.alerts.enabled - ): - current_segment.severity = SeverityEnum.alert + if topic == DetectionTypeEnum.api: + severity = self.get_manual_event_severity( + camera, manual_info["label"] + ) + + if severity == SeverityEnum.alert: + current_segment.severity = SeverityEnum.alert + current_segment.last_alert_time = manual_info[ + "end_time" + ] + elif severity == SeverityEnum.detection: + current_segment.last_detection_time = manual_info[ + "end_time" + ] elif ( topic == DetectionTypeEnum.lpr and self.config.cameras[camera].review.detections.enabled ): current_segment.severity = SeverityEnum.detection - current_segment.last_alert_time = manual_info["end_time"] + current_segment.last_alert_time = manual_info["end_time"] elif manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( manual_info["label"] @@ -713,11 +786,14 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - if ( - topic == DetectionTypeEnum.api - and self.config.cameras[camera].review.alerts.enabled - ): - current_segment.severity = SeverityEnum.alert + if topic == DetectionTypeEnum.api: + if ( + self.get_manual_event_severity( + camera, manual_info["label"] + ) + == SeverityEnum.alert + ): + current_segment.severity = SeverityEnum.alert elif ( topic == DetectionTypeEnum.lpr and self.config.cameras[camera].review.detections.enabled @@ -789,42 +865,39 @@ class ReviewSegmentMaintainer(threading.Thread): detections, ) elif topic == DetectionTypeEnum.api: - if self.config.cameras[camera].review.alerts.enabled: - self.active_review_segments[camera] = PendingReviewSegment( + severity = self.get_manual_event_severity( + camera, manual_info["label"] + ) + + if severity: + api_segment = PendingReviewSegment( camera, frame_time, - SeverityEnum.alert, + severity, {manual_info["event_id"]: manual_info["label"]}, {}, [], set(), ) + self.active_review_segments[camera] = api_segment if manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( manual_info["label"] ) # temporarily make it so this event can not end - self.active_review_segments[ - camera - ].last_alert_time = sys.maxsize - self.active_review_segments[ - camera - ].last_detection_time = sys.maxsize + api_segment.last_alert_time = sys.maxsize + api_segment.last_detection_time = sys.maxsize elif manual_info["state"] == ManualEventState.complete: - self.active_review_segments[ - camera - ].last_alert_time = manual_info["end_time"] - self.active_review_segments[ - camera - ].last_detection_time = manual_info["end_time"] + api_segment.last_alert_time = manual_info["end_time"] + api_segment.last_detection_time = manual_info["end_time"] else: logger.warning( - f"Manual event API has been called for {camera}, but alerts are disabled. This manual event will not appear as an alert." + f"Manual event API has been called for {camera}, but alerts and detections are disabled. This manual event will not appear as an alert or detection." ) elif topic == DetectionTypeEnum.lpr: if self.config.cameras[camera].review.detections.enabled: - self.active_review_segments[camera] = PendingReviewSegment( + lpr_segment = PendingReviewSegment( camera, frame_time, SeverityEnum.detection, @@ -833,25 +906,18 @@ class ReviewSegmentMaintainer(threading.Thread): [], set(), ) + self.active_review_segments[camera] = lpr_segment if manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( manual_info["label"] ) # temporarily make it so this event can not end - self.active_review_segments[ - camera - ].last_alert_time = sys.maxsize - self.active_review_segments[ - camera - ].last_detection_time = sys.maxsize + lpr_segment.last_alert_time = sys.maxsize + lpr_segment.last_detection_time = sys.maxsize elif manual_info["state"] == ManualEventState.complete: - self.active_review_segments[ - camera - ].last_alert_time = manual_info["end_time"] - self.active_review_segments[ - camera - ].last_detection_time = manual_info["end_time"] + lpr_segment.last_alert_time = manual_info["end_time"] + lpr_segment.last_detection_time = manual_info["end_time"] else: logger.warning( f"Dedicated LPR camera API has been called for {camera}, but detections are disabled. LPR events will not appear as a detection." diff --git a/frigate/service_manager/multiprocessing.py b/frigate/service_manager/multiprocessing.py index 87bb4ffeea..88d497dd66 100644 --- a/frigate/service_manager/multiprocessing.py +++ b/frigate/service_manager/multiprocessing.py @@ -9,7 +9,6 @@ from abc import ABC, abstractmethod from asyncio.exceptions import TimeoutError from logging.handlers import QueueHandler from types import FrameType -from typing import Optional import frigate.log @@ -22,13 +21,13 @@ DEFAULT_STOP_TIMEOUT = 10 # seconds class BaseServiceProcess(Service, ABC): """A Service the manages a multiprocessing.Process.""" - _process: Optional[mp.Process] + _process: mp.Process | None def __init__( self, *, - name: Optional[str] = None, - manager: Optional[ServiceManager] = None, + name: str | None = None, + manager: ServiceManager | None = None, ) -> None: super().__init__(name=name, manager=manager) @@ -55,7 +54,7 @@ class BaseServiceProcess(Service, ABC): self, *, force: bool = False, - timeout: Optional[float] = None, + timeout: float | None = None, ) -> None: if timeout is None: timeout = DEFAULT_STOP_TIMEOUT @@ -85,7 +84,7 @@ class BaseServiceProcess(Service, ABC): self.manager.logger.info(f"{self.name} stopped") @property - def pid(self) -> Optional[int]: + def pid(self) -> int | None: return self._process.pid if self._process else None def _run(self) -> None: @@ -143,7 +142,7 @@ class ServiceProcess(BaseServiceProcess): faulthandler.enable() - def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: + def receiveSignal(signalNumber: int, frame: FrameType | None) -> None: # Get the stop_event through the dict to bypass lazy initialization. stop_event = self.__dict__.get("stop_event") if stop_event is not None: diff --git a/frigate/service_manager/multiprocessing_waiter.py b/frigate/service_manager/multiprocessing_waiter.py index 8acdf583c7..e356fac1d6 100644 --- a/frigate/service_manager/multiprocessing_waiter.py +++ b/frigate/service_manager/multiprocessing_waiter.py @@ -7,7 +7,7 @@ import threading from multiprocessing.connection import Connection from multiprocessing.connection import wait as mp_wait from socket import socket -from typing import Any, Optional, Union +from typing import Any logger = logging.getLogger(__name__) @@ -118,10 +118,10 @@ class MultiprocessingWaiter(threading.Thread): waiter_lock = threading.Lock() -waiter_thread: Optional[MultiprocessingWaiter] = None +waiter_thread: MultiprocessingWaiter | None = None -async def wait(object: Union[mp.Process, Connection, socket]) -> None: +async def wait(object: mp.Process | Connection | socket) -> None: """Wait for the supplied object to be ready. Under the hood, this uses multiprocessing.connection.wait() and a background thread manage the @@ -129,7 +129,7 @@ async def wait(object: Union[mp.Process, Connection, socket]) -> None: """ global waiter_thread, waiter_lock - sentinel: Union[Connection, socket, int] + sentinel: Connection | socket | int if isinstance(object, mp.Process): sentinel = object.sentinel elif isinstance(object, Connection) or isinstance(object, socket): diff --git a/frigate/service_manager/service.py b/frigate/service_manager/service.py index 89d766e9d6..34631b6210 100644 --- a/frigate/service_manager/service.py +++ b/frigate/service_manager/service.py @@ -5,12 +5,11 @@ import atexit import logging import threading from abc import ABC, abstractmethod +from collections.abc import Coroutine from contextvars import ContextVar from dataclasses import dataclass from functools import partial -from typing import Coroutine, Optional, Union, cast - -from typing_extensions import Self +from typing import Self, cast class Service(ABC): @@ -19,8 +18,8 @@ class Service(ABC): def __init__( self, *, - name: Optional[str] = None, - manager: Optional[ServiceManager] = None, + name: str | None = None, + manager: ServiceManager | None = None, ): if name: self.__dict__["name"] = name @@ -42,13 +41,13 @@ class Service(ABC): try: return self.__manager except AttributeError: - raise RuntimeError("Cannot access associated service manager") + raise RuntimeError("Cannot access associated service manager") from None def start( self, *, wait: bool = False, - wait_timeout: Optional[float] = None, + wait_timeout: float | None = None, ) -> Self: """Start this service. @@ -70,9 +69,9 @@ class Service(ABC): self, *, force: bool = False, - timeout: Optional[float] = None, + timeout: float | None = None, wait: bool = False, - wait_timeout: Optional[float] = None, + wait_timeout: float | None = None, ) -> Self: """Stop this service. @@ -97,9 +96,9 @@ class Service(ABC): self, *, force: bool = False, - stop_timeout: Optional[float] = None, + stop_timeout: float | None = None, wait: bool = False, - wait_timeout: Optional[float] = None, + wait_timeout: float | None = None, ) -> Self: """Restart this service. @@ -129,7 +128,7 @@ class Service(ABC): self, *, force: bool = False, - timeout: Optional[float] = None, + timeout: float | None = None, ) -> None: pass @@ -137,14 +136,14 @@ class Service(ABC): self, *, force: bool = False, - stop_timeout: Optional[float] = None, + stop_timeout: float | None = None, ) -> None: await self.on_stop(force=force, timeout=stop_timeout) await self.on_start() default_service_manager_lock = threading.Lock() -default_service_manager: Optional[ServiceManager] = None +default_service_manager: ServiceManager | None = None current_service_manager: ContextVar[ServiceManager] = ContextVar( "current_service_manager" @@ -162,8 +161,8 @@ class Command: """ coro: Coroutine - lock: Optional[asyncio.Lock] = None - done: Optional[threading.Event] = None + lock: asyncio.Lock | None = None + done: threading.Event | None = None class ServiceManager: @@ -189,7 +188,7 @@ class ServiceManager: _services_lock: threading.Lock # Commands will be queued with associated event loop. Queueing `None` signals shutdown. - _command_queue: asyncio.Queue[Union[Command, None]] + _command_queue: asyncio.Queue[Command | None] _event_loop: asyncio.AbstractEventLoop # The pending command counter is used to ensure all commands have been queued before shutdown. @@ -204,7 +203,7 @@ class ServiceManager: # Will be acquired to ensure the shutdown sentinel is sent only once. Never released. _shutdown_lock: threading.Lock - def __init__(self, *, name: Optional[str] = None): + def __init__(self, *, name: str | None = None): self._name = name if name is not None else (__package__ or __name__) self._logger = logging.getLogger(self.name) @@ -276,8 +275,8 @@ class ServiceManager: coro: Coroutine, *, wait: bool = False, - wait_timeout: Optional[float] = None, - lock: Optional[asyncio.Lock] = None, + wait_timeout: float | None = None, + lock: asyncio.Lock | None = None, ) -> None: """Run an async task in the service manager thread. @@ -299,7 +298,7 @@ class ServiceManager: cmd.done.wait(timeout=wait_timeout) def shutdown( - self, *, wait: bool = False, wait_timeout: Optional[float] = None + self, *, wait: bool = False, wait_timeout: float | None = None ) -> None: """Shutdown the service manager thread. @@ -321,7 +320,7 @@ class ServiceManager: if not self._manager_thread.is_alive(): raise RuntimeError(f"ServiceManager {self.name} is not running") - def _send_command(self, command: Union[Command, None]) -> None: + def _send_command(self, command: Command | None) -> None: self._ensure_running() async def queue_command() -> None: @@ -336,7 +335,7 @@ class ServiceManager: self._ensure_running() with self._services_lock: - name_conflict: Optional[Service] = next( + name_conflict: Service | None = next( ( existing for name, existing in self._services.items() diff --git a/frigate/stats/emitter.py b/frigate/stats/emitter.py index 42d4c16a88..7c2e328c73 100644 --- a/frigate/stats/emitter.py +++ b/frigate/stats/emitter.py @@ -6,7 +6,7 @@ import logging import threading import time from multiprocessing.synchronize import Event as MpEvent -from typing import Any, Optional +from typing import Any from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig @@ -32,7 +32,7 @@ class StatsEmitter(threading.Thread): self.config = config self.stats_tracking = stats_tracking self.stop_event = stop_event - self.hwaccel_errors: list[str] = [] + self.hwaccel_errors: dict[str, float] = {} self.stats_history: list[dict[str, Any]] = [] # create communication for stats @@ -49,21 +49,67 @@ class StatsEmitter(threading.Thread): self.stats_history.append(stats) return stats - def get_stats_history( - self, keys: Optional[list[str]] = None - ) -> list[dict[str, Any]]: - """Get stats history.""" + def get_stats_history(self, keys: list[str] | None = None) -> list[dict[str, Any]]: + """Get stats history. + + Supports dot-notation for nested keys to avoid returning large objects + when only specific subfields are needed. Handles two patterns: + + - Flat dict: "service.last_updated" returns {"service": {"last_updated": ...}} + - Dict-of-dicts: "cameras.camera_fps" returns each camera entry filtered + to only include "camera_fps" + """ if not keys: return self.stats_history + # Pre-parse keys into top-level keys and dot-notation fields + top_level_keys: list[str] = [] + nested_keys: dict[str, list[str]] = {} + + for k in keys: + if "." in k: + parent_key, child_key = k.split(".", 1) + nested_keys.setdefault(parent_key, []).append(child_key) + else: + top_level_keys.append(k) + selected_stats: list[dict[str, Any]] = [] for s in self.stats_history: - selected = {} + selected: dict[str, Any] = {} - for k in keys: + for k in top_level_keys: selected[k] = s.get(k) + for parent_key, child_keys in nested_keys.items(): + parent = s.get(parent_key) + + if not isinstance(parent, dict): + selected[parent_key] = parent + continue + + # Check if values are dicts (dict-of-dicts like cameras/detectors) + first_value = next(iter(parent.values()), None) + + if isinstance(first_value, dict): + # Filter each nested entry to only requested fields, + # omitting None values to preserve key-absence semantics + selected[parent_key] = { + entry_key: { + field: val + for field in child_keys + if (val := entry.get(field)) is not None + } + for entry_key, entry in parent.items() + } + else: + # Flat dict (like service) - pick individual fields + if parent_key not in selected: + selected[parent_key] = {} + + for child_key in child_keys: + selected[parent_key][child_key] = parent.get(child_key) + selected_stats.append(selected) return selected_stats diff --git a/frigate/stats/intel_gpu_info.py b/frigate/stats/intel_gpu_info.py new file mode 100644 index 0000000000..5bbb5c335b --- /dev/null +++ b/frigate/stats/intel_gpu_info.py @@ -0,0 +1,108 @@ +"""Resolve human-readable names for Intel GPUs via OpenVINO.""" + +import logging +import re + +logger = logging.getLogger(__name__) + + +class IntelGpuNameResolver: + """Build a pdev -> normalized device name map by enumerating OpenVINO GPUs. + + The lookup is performed once on first access and cached for the process + lifetime. OpenVINO exposes DEVICE_PCI_INFO (domain/bus/device/function) and + FULL_DEVICE_NAME for each GPU it can see, which is enough to associate the + name with the pdev string used by DRM fdinfo. + """ + + _names: dict[str, str] | None = None + + def get_names(self) -> dict[str, str]: + if self._names is not None: + return self._names + + names: dict[str, str] = {} + + try: + from openvino import Core + except ImportError: + logger.debug("OpenVINO unavailable; cannot resolve Intel GPU names") + self._names = names + return names + + try: + core = Core() + devices = core.available_devices + except Exception as exc: + logger.debug(f"OpenVINO Core initialization failed: {exc}") + self._names = names + return names + + cpu_name: str | None = None + if "CPU" in devices: + try: + cpu_name = self._strip_trademarks( + core.get_property("CPU", "FULL_DEVICE_NAME") + ) + except Exception as exc: + logger.debug(f"Failed to read CPU FULL_DEVICE_NAME: {exc}") + + for device in devices: + if not device.startswith("GPU"): + continue + + try: + pci = core.get_property(device, "DEVICE_PCI_INFO") + raw_name = core.get_property(device, "FULL_DEVICE_NAME") + device_type = core.get_property(device, "DEVICE_TYPE") + except Exception as exc: + logger.debug(f"Failed to read properties for {device}: {exc}") + continue + + pdev = self._format_pdev(pci) + if not pdev: + continue + + names[pdev] = self._resolve_name(raw_name, device_type, cpu_name) + + self._names = names + return names + + @staticmethod + def _format_pdev(pci) -> str | None: + try: + return f"{pci.domain:04x}:{pci.bus:02x}:{pci.device:02x}.{pci.function:x}" + except AttributeError: + return None + + @classmethod + def _resolve_name(cls, raw_name: str, device_type, cpu_name: str | None) -> str: + """Build a display name for a GPU. + + Modern integrated Intel GPUs are reported by OpenVINO with a generic + FULL_DEVICE_NAME like "Intel(R) Graphics (iGPU)" that gives no model + information. Since the iGPU is part of the CPU on these platforms, fall + back to the CPU name (which OpenVINO does report specifically) and + suffix it with "iGPU" so it's clear what the entry is. + """ + is_integrated = "INTEGRATED" in str(device_type).upper() + + if is_integrated and cpu_name: + short_cpu = re.sub(r"^Intel\s+", "", cpu_name) + return f"{short_cpu} iGPU" + + return cls._normalize_name(raw_name) + + @classmethod + def _normalize_name(cls, name: str) -> str: + cleaned = cls._strip_trademarks(name) + cleaned = re.sub(r"\s*\((?:i|d)GPU\)\s*$", "", cleaned, flags=re.IGNORECASE) + return " ".join(cleaned.split()) + + @staticmethod + def _strip_trademarks(name: str) -> str: + cleaned = re.sub(r"\(R\)|\(TM\)", "", name) + return " ".join(cleaned.split()) + + +intel_gpu_name_resolver = IntelGpuNameResolver() diff --git a/frigate/stats/prometheus.py b/frigate/stats/prometheus.py index 67d8d03d83..9797d569e1 100644 --- a/frigate/stats/prometheus.py +++ b/frigate/stats/prometheus.py @@ -1,6 +1,6 @@ import logging import re -from typing import Any, Dict, List +from typing import Any from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from prometheus_client.core import ( @@ -11,7 +11,7 @@ from prometheus_client.core import ( ) -class CustomCollector(object): +class CustomCollector: def __init__(self, _url): self.complete_stats = {} # Store complete stats data self.process_stats = {} # Keep for CPU processing @@ -355,16 +355,37 @@ class CustomCollector(object): gpu_mem_usages = GaugeMetricFamily( "frigate_gpu_mem_usage_percent", "GPU memory usage %", labels=["gpu_name"] ) + gpu_enc_usages = GaugeMetricFamily( + "frigate_gpu_encoder_usage_percent", + "GPU encoder utilisation %", + labels=["gpu_name"], + ) + gpu_compute_usages = GaugeMetricFamily( + "frigate_gpu_compute_usage_percent", + "GPU compute / encode utilisation %", + labels=["gpu_name"], + ) + gpu_dec_usages = GaugeMetricFamily( + "frigate_gpu_decoder_usage_percent", + "GPU decoder utilisation %", + labels=["gpu_name"], + ) try: for gpu_name, gpu_stats in stats["gpu_usages"].items(): self.add_metric(gpu_usages, [gpu_name], gpu_stats, "gpu") self.add_metric(gpu_mem_usages, [gpu_name], gpu_stats, "mem") + self.add_metric(gpu_enc_usages, [gpu_name], gpu_stats, "enc") + self.add_metric(gpu_compute_usages, [gpu_name], gpu_stats, "compute") + self.add_metric(gpu_dec_usages, [gpu_name], gpu_stats, "dec") except KeyError: pass yield gpu_usages yield gpu_mem_usages + yield gpu_enc_usages + yield gpu_compute_usages + yield gpu_dec_usages # service stats uptime_seconds = GaugeMetricFamily( @@ -470,7 +491,7 @@ collector = CustomCollector(None) REGISTRY.register(collector) -def update_metrics(stats: Dict[str, Any], event_counts: List[Dict[str, Any]]): +def update_metrics(stats: dict[str, Any], event_counts: list[dict[str, Any]]): """Updates the Prometheus metrics with the given stats data.""" try: # Store the complete stats for later use by collect() diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 410350d968..6e20197391 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -1,12 +1,13 @@ """Utilities for stats.""" import asyncio +import logging import os import shutil import time from json import JSONDecodeError from multiprocessing.managers import DictProxy -from typing import Any, Optional +from typing import Any import requests from requests.exceptions import RequestException @@ -19,9 +20,11 @@ from frigate.types import StatsTrackingTypes from frigate.util.services import ( calculate_shm_requirements, get_amd_gpu_stats, + get_axcl_npu_stats, get_bandwidth_stats, get_cpu_stats, get_fs_type, + get_hailo_temps, get_intel_gpu_stats, get_jetson_stats, get_nvidia_gpu_stats, @@ -32,6 +35,10 @@ from frigate.util.services import ( ) from frigate.version import VERSION +logger = logging.getLogger(__name__) + +HWACCEL_ERROR_COOLDOWN_SECONDS = 3600 + def get_latest_version(config: FrigateConfig) -> str: if not config.telemetry.version_check: @@ -55,7 +62,7 @@ def get_latest_version(config: FrigateConfig) -> str: def stats_init( config: FrigateConfig, camera_metrics: DictProxy, - embeddings_metrics: DataProcessorMetrics | None, + embeddings_metrics: DataProcessorMetrics, detectors: dict[str, ObjectDetectProcess], processes: dict[str, int], ) -> StatsTrackingTypes: @@ -71,7 +78,7 @@ def stats_init( return stats_tracking -def read_temperature(path: str) -> Optional[float]: +def read_temperature(path: str) -> float | None: if os.path.isfile(path): with open(path) as f: line = f.readline().strip() @@ -90,11 +97,84 @@ def get_temperatures() -> dict[str, float]: if temp is not None: temps[apex] = temp + # Get temperatures for Hailo devices + temps.update(get_hailo_temps()) + return temps +def get_detector_temperature( + detector_type: str, + detector_index_by_type: dict[str, int], +) -> float | None: + """Get temperature for a specific detector based on its type.""" + if detector_type == "edgetpu": + # Get temperatures for all attached Corals + base = "/sys/class/apex/" + if os.path.isdir(base): + apex_devices = sorted(os.listdir(base)) + index = detector_index_by_type.get("edgetpu", 0) + if index < len(apex_devices): + apex_name = apex_devices[index] + temp = read_temperature(os.path.join(base, apex_name, "temp")) + if temp is not None: + return temp + elif detector_type == "hailo8l": + # Get temperatures for Hailo devices + hailo_temps = get_hailo_temps() + if hailo_temps: + hailo_device_names = sorted(hailo_temps.keys()) + index = detector_index_by_type.get("hailo8l", 0) + if index < len(hailo_device_names): + device_name = hailo_device_names[index] + return hailo_temps[device_name] + elif detector_type == "rknn": + # Rockchip temperatures are handled by the GPU / NPU stats + # as there are not detector specific temperatures + pass + + return None + + +def get_detector_stats( + stats_tracking: StatsTrackingTypes, +) -> dict[str, dict[str, Any]]: + """Get stats for all detectors, including temperatures based on detector type.""" + detector_stats: dict[str, dict[str, Any]] = {} + detector_type_indices: dict[str, int] = {} + + for name, detector in stats_tracking["detectors"].items(): + pid = detector.detect_process.pid if detector.detect_process else None + detector_type = detector.detector_config.type + + # Keep track of the index for each detector type to match temperatures correctly + current_index = detector_type_indices.get(detector_type, 0) + detector_type_indices[detector_type] = current_index + 1 + + detector_stat = { + "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] + # issue https://github.com/python/typeshed/issues/8799 + # from mypy 0.981 onwards + "detection_start": detector.detection_start.value, # type: ignore[attr-defined] + # issue https://github.com/python/typeshed/issues/8799 + # from mypy 0.981 onwards + "pid": pid, + } + + temp = get_detector_temperature(detector_type, {detector_type: current_index}) + + if temp is not None: + detector_stat["temperature"] = round(temp, 1) + + detector_stats[name] = detector_stat + + return detector_stats + + def get_processing_stats( - config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str] + config: FrigateConfig, + stats: dict[str, str], + hwaccel_errors: dict[str, float], ) -> None: """Get stats for cpu / gpu.""" @@ -133,7 +213,9 @@ async def set_bandwidth_stats(config: FrigateConfig, all_stats: dict[str, Any]) async def set_gpu_stats( - config: FrigateConfig, all_stats: dict[str, Any], hwaccel_errors: list[str] + config: FrigateConfig, + all_stats: dict[str, Any], + hwaccel_errors: dict[str, float], ) -> None: """Parse GPUs from hwaccel args and use for stats.""" hwaccel_args = [] @@ -157,83 +239,81 @@ async def set_gpu_stats( hwaccel_args.append(args) stats: dict[str, dict] = {} + intel_gpu_collected = False + now = time.monotonic() for args in hwaccel_args: - if args in hwaccel_errors: - # known erroring args should automatically return as error - stats["error-gpu"] = {"gpu": "", "mem": ""} - elif "cuvid" in args or "nvidia" in args: + last_error = hwaccel_errors.get(args) + if last_error is not None: + if now - last_error < HWACCEL_ERROR_COOLDOWN_SECONDS: + continue + hwaccel_errors.pop(args, None) + + if "cuvid" in args or "nvidia" in args: # nvidia GPU nvidia_usage = get_nvidia_gpu_stats() if nvidia_usage: for i in range(len(nvidia_usage)): stats[nvidia_usage[i]["name"]] = { + "vendor": "nvidia", "gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%", "mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%", "enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%", "dec": str(round(float(nvidia_usage[i]["dec"]), 2)) + "%", + "temp": str(nvidia_usage[i]["temp"]), } else: - stats["nvidia-gpu"] = {"gpu": "", "mem": ""} - hwaccel_errors.append(args) + stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""} + hwaccel_errors[args] = time.monotonic() elif "nvmpi" in args or "jetson" in args: # nvidia Jetson jetson_usage = get_jetson_stats() if jetson_usage: - stats["jetson-gpu"] = jetson_usage + stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage} else: - stats["jetson-gpu"] = {"gpu": "", "mem": ""} - hwaccel_errors.append(args) - elif "qsv" in args: + stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""} + hwaccel_errors[args] = time.monotonic() + elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()): if not config.telemetry.stats.intel_gpu_stats: continue - # intel QSV GPU - intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device) - - if intel_usage is not None: - stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""} - else: - stats["intel-qsv"] = {"gpu": "", "mem": ""} - hwaccel_errors.append(args) - elif "vaapi" in args: - if is_vaapi_amd_driver(): - if not config.telemetry.stats.amd_gpu_stats: - continue - - # AMD VAAPI GPU - amd_usage = get_amd_gpu_stats() - - if amd_usage: - stats["amd-vaapi"] = amd_usage - else: - stats["amd-vaapi"] = {"gpu": "", "mem": ""} - hwaccel_errors.append(args) - else: - if not config.telemetry.stats.intel_gpu_stats: - continue - - # intel VAAPI GPU + if not intel_gpu_collected: + # intel GPU (QSV or VAAPI both use the same physical GPU) + intel_gpu_collected = True intel_usage = get_intel_gpu_stats( config.telemetry.stats.intel_gpu_device ) - if intel_usage is not None: - stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""} + if intel_usage: + for entry in intel_usage.values(): + name = entry.pop("name") + stats[name] = entry else: - stats["intel-vaapi"] = {"gpu": "", "mem": ""} - hwaccel_errors.append(args) + stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""} + hwaccel_errors[args] = time.monotonic() + elif "vaapi" in args: + if not config.telemetry.stats.amd_gpu_stats: + continue + + # AMD VAAPI GPU + amd_usage = get_amd_gpu_stats() + + if amd_usage: + stats["amd-vaapi"] = {"vendor": "amd", **amd_usage} + else: + stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""} + hwaccel_errors[args] = time.monotonic() elif "preset-rk" in args: rga_usage = get_rockchip_gpu_stats() if rga_usage: - stats["rockchip"] = rga_usage + stats["rockchip"] = {"vendor": "rockchip", **rga_usage} elif "v4l2m2m" in args or "rpi" in args: # RPi v4l2m2m is currently not able to get usage stats - stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""} + stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""} if stats: all_stats["gpu_usages"] = stats @@ -251,13 +331,19 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No # OpenVINO NPU usage ov_usage = get_openvino_npu_stats() stats["openvino"] = ov_usage + elif detector.type == "axengine": + # AXERA NPU usage + axcl_usage = get_axcl_npu_stats() + stats["axengine"] = axcl_usage if stats: all_stats["npu_usages"] = stats def stats_snapshot( - config: FrigateConfig, stats_tracking: StatsTrackingTypes, hwaccel_errors: list[str] + config: FrigateConfig, + stats_tracking: StatsTrackingTypes, + hwaccel_errors: dict[str, float], ) -> dict[str, Any]: """Get a snapshot of the current stats that are being tracked.""" camera_metrics = stats_tracking["camera_metrics"] @@ -267,6 +353,9 @@ def stats_snapshot( stats["cameras"] = {} for name, camera_stats in camera_metrics.items(): + if name not in config.cameras: + continue + total_camera_fps += camera_stats.camera_fps.value total_process_fps += camera_stats.process_fps.value total_skipped_fps += camera_stats.skipped_fps.value @@ -278,6 +367,32 @@ def stats_snapshot( if camera_stats.capture_process_pid.value else None ) + # Calculate connection quality based on current state + # This is computed at stats-collection time so offline cameras + # correctly show as unusable rather than excellent + expected_fps = config.cameras[name].detect.fps + current_fps = camera_stats.camera_fps.value + reconnects = camera_stats.reconnects_last_hour.value + stalls = camera_stats.stalls_last_hour.value + + if current_fps < 0.1: + quality_str = "unusable" + elif reconnects == 0 and current_fps >= 0.9 * expected_fps and stalls < 5: + quality_str = "excellent" + elif reconnects <= 2 and current_fps >= 0.6 * expected_fps: + quality_str = "fair" + elif reconnects > 10 or current_fps < 1.0 or stalls > 100: + quality_str = "unusable" + else: + quality_str = "poor" + + connection_quality = { + "connection_quality": quality_str, + "expected_fps": expected_fps, + "reconnects_last_hour": reconnects, + "stalls_last_hour": stalls, + } + stats["cameras"][name] = { "camera_fps": round(camera_stats.camera_fps.value, 2), "process_fps": round(camera_stats.process_fps.value, 2), @@ -289,20 +404,10 @@ def stats_snapshot( "ffmpeg_pid": ffmpeg_pid, "audio_rms": round(camera_stats.audio_rms.value, 4), "audio_dBFS": round(camera_stats.audio_dBFS.value, 4), + **connection_quality, } - stats["detectors"] = {} - for name, detector in stats_tracking["detectors"].items(): - pid = detector.detect_process.pid if detector.detect_process else None - stats["detectors"][name] = { - "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] - # issue https://github.com/python/typeshed/issues/8799 - # from mypy 0.981 onwards - "detection_start": detector.detection_start.value, # type: ignore[attr-defined] - # issue https://github.com/python/typeshed/issues/8799 - # from mypy 0.981 onwards - "pid": pid, - } + stats["detectors"] = get_detector_stats(stats_tracking) stats["camera_fps"] = round(total_camera_fps, 2) stats["process_fps"] = round(total_process_fps, 2) stats["skipped_fps"] = round(total_skipped_fps, 2) @@ -388,7 +493,6 @@ def stats_snapshot( "version": VERSION, "latest_version": stats_tracking["latest_frigate_version"], "storage": {}, - "temperatures": get_temperatures(), "last_updated": int(time.time()), } @@ -414,4 +518,30 @@ def stats_snapshot( "pid": pid, } + # Embed cpu/mem stats into detectors, cameras, and processes + # so history consumers don't need the full cpu_usages dict + cpu_usages = stats.get("cpu_usages", {}) + + for det_stats in stats["detectors"].values(): + pid_str = str(det_stats.get("pid", "")) + usage = cpu_usages.get(pid_str, {}) + det_stats["cpu"] = usage.get("cpu") + det_stats["mem"] = usage.get("mem") + + for cam_stats in stats["cameras"].values(): + for pid_key, field in [ + ("ffmpeg_pid", "ffmpeg_cpu"), + ("capture_pid", "capture_cpu"), + ("pid", "detect_cpu"), + ]: + pid_str = str(cam_stats.get(pid_key, "")) + usage = cpu_usages.get(pid_str, {}) + cam_stats[field] = usage.get("cpu") + + for proc_stats in stats["processes"].values(): + pid_str = str(proc_stats.get("pid", "")) + usage = cpu_usages.get(pid_str, {}) + proc_stats["cpu"] = usage.get("cpu") + proc_stats["mem"] = usage.get("mem") + return stats diff --git a/frigate/storage.py b/frigate/storage.py index 1234ef088a..585a5d87f1 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -3,12 +3,13 @@ import logging import shutil import threading +from multiprocessing.synchronize import Event as MpEvent from pathlib import Path from peewee import SQL, fn from frigate.config import FrigateConfig -from frigate.const import RECORD_DIR +from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX from frigate.models import Event, Recordings from frigate.util.builtin import clear_and_unlink @@ -23,7 +24,7 @@ MAX_CALCULATED_BANDWIDTH = 10000 # 10Gb/hr class StorageMaintainer(threading.Thread): """Maintain frigates recording storage.""" - def __init__(self, config: FrigateConfig, stop_event) -> None: + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: super().__init__(name="storage_maintainer") self.config = config self.stop_event = stop_event @@ -32,6 +33,10 @@ class StorageMaintainer(threading.Thread): def calculate_camera_bandwidth(self) -> None: """Calculate an average MB/hr for each camera.""" for camera in self.config.cameras.keys(): + # Skip replay cameras + if camera.startswith(REPLAY_CAMERA_PREFIX): + continue + # cameras with < 50 segments should be refreshed to keep size accurate # when few segments are available if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True): @@ -77,6 +82,10 @@ class StorageMaintainer(threading.Thread): usages: dict[str, dict] = {} for camera in self.config.cameras.keys(): + # Skip replay cameras + if camera.startswith(REPLAY_CAMERA_PREFIX): + continue + camera_storage = ( Recordings.select(fn.SUM(Recordings.segment_size)) .where(Recordings.camera == camera, Recordings.segment_size != 0) @@ -106,7 +115,7 @@ class StorageMaintainer(threading.Thread): logger.debug( f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}." ) - return remaining_storage < hourly_bandwidth + return remaining_storage < float(hourly_bandwidth) def reduce_storage_consumption(self) -> None: """Remove oldest hour of recordings.""" @@ -116,7 +125,7 @@ class StorageMaintainer(threading.Thread): [b["bandwidth"] for b in self.camera_storage_stats.values()] ) - recordings: Recordings = ( + recordings = ( Recordings.select( Recordings.id, Recordings.camera, @@ -130,7 +139,7 @@ class StorageMaintainer(threading.Thread): .iterator() ) - retained_events: Event = ( + retained_events = ( Event.select( Event.start_time, Event.end_time, @@ -188,7 +197,7 @@ class StorageMaintainer(threading.Thread): # check if need to delete retained segments if deleted_segments_size < hourly_bandwidth: logger.error( - f"Could not clear {hourly_bandwidth} MB, currently {deleted_segments_size} MB have been cleared. Retained recordings must be deleted." + f"Could not clear {hourly_bandwidth} MB, currently {deleted_segments_size:.2f} MB have been cleared. Retained recordings must be deleted." ) recordings = ( Recordings.select( @@ -216,7 +225,7 @@ class StorageMaintainer(threading.Thread): # this file was not found so we must assume no space was cleaned up pass else: - logger.info(f"Cleaned up {deleted_segments_size} MB of recordings") + logger.info(f"Cleaned up {deleted_segments_size:.2f} MB of recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings") # delete up to 100,000 at a time @@ -270,7 +279,7 @@ class StorageMaintainer(threading.Thread): Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() - def run(self): + def run(self) -> None: """Check every 5 minutes if storage needs to be cleaned up.""" if self.config.safe_mode: logger.info("Safe mode enabled, skipping storage maintenance") diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 16ded63f8f..32d110962e 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -2,6 +2,7 @@ import datetime import logging import os import unittest +from unittest.mock import patch from fastapi import Request from fastapi.testclient import TestClient @@ -13,6 +14,8 @@ from pydantic import Json from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig from frigate.const import BASE_DIR, CACHE_DIR +from frigate.debug_replay import DebugReplayManager +from frigate.jobs.export import JobStatePublisher from frigate.models import Event, Recordings, ReviewSegment from frigate.review.types import SeverityEnum from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -43,6 +46,19 @@ class BaseTestHttp(unittest.TestCase): self.db = SqliteQueueDatabase(TEST_DB) self.db.bind(models) + # The export job manager broadcasts via JobStatePublisher on + # enqueue/start/finish. There is no dispatcher process bound to + # the IPC socket in tests, so a real publish() would block on + # recv_json forever. Replace publish with a no-op for the + # lifetime of this test; the lookup goes through the class so any + # already-instantiated publisher (the singleton manager's) picks + # up the no-op too. + publisher_patch = patch.object( + JobStatePublisher, "publish", lambda self, payload: None + ) + publisher_patch.start() + self.addCleanup(publisher_patch.stop) + self.minimal_config = { "mqtt": {"host": "mqtt"}, "cameras": { @@ -141,6 +157,7 @@ class BaseTestHttp(unittest.TestCase): stats, event_metadata_publisher, None, + DebugReplayManager(), enforce_default_admin=False, ) diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py new file mode 100644 index 0000000000..ca2edaf36d --- /dev/null +++ b/frigate/test/http_api/test_debug_replay_api.py @@ -0,0 +1,151 @@ +"""Tests for /debug_replay API endpoints.""" + +from unittest.mock import patch + +from frigate.jobs.debug_replay import NoRecordingsError +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestDebugReplayAPI(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = self.create_app() + + def test_start_returns_202_with_job_id(self): + # Stub the factory to skip validation/threading and just record the + # name on the manager the way the real factory's mark_starting would. + def fake_start(**kwargs): + source = kwargs["source"] + kwargs["replay_manager"].mark_starting( + source_camera=source.source_camera, + replay_camera_name="_replay_front", + start_ts=source.start_ts, + end_ts=source.end_ts, + ) + return "job-1234" + + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=fake_start, + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 202) + body = resp.json() + self.assertTrue(body["success"]) + self.assertEqual(body["job_id"], "job-1234") + self.assertEqual(body["replay_camera"], "_replay_front") + + def test_start_returns_400_on_validation_error(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=ValueError("Camera 'missing' not found"), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "missing", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 400) + body = resp.json() + self.assertFalse(body["success"]) + # Message is hard-coded so we don't echo exception text back to clients + # (CodeQL: information exposure through an exception). + self.assertEqual(body["message"], "Invalid debug replay parameters") + + def test_start_returns_404_when_no_recordings(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=NoRecordingsError( + "No recordings found for camera 'front' in the specified time range" + ), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 404) + body = resp.json() + self.assertFalse(body["success"]) + # Message is hard-coded so we don't echo exception text back to clients + # (CodeQL: information exposure through an exception). + self.assertEqual( + body["message"], "No recordings found in the selected time range" + ) + + def test_start_returns_409_when_session_already_active(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=RuntimeError("A replay session is already active"), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 409) + body = resp.json() + self.assertFalse(body["success"]) + + def test_status_inactive_when_no_session(self): + with AuthTestClient(self.app) as client: + resp = client.get("/debug_replay/status") + + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertFalse(body["active"]) + self.assertIsNone(body["replay_camera"]) + self.assertIsNone(body["source_camera"]) + self.assertIsNone(body["start_time"]) + self.assertIsNone(body["end_time"]) + self.assertFalse(body["live_ready"]) + # Make sure deprecated fields are gone + self.assertNotIn("state", body) + self.assertNotIn("progress_percent", body) + self.assertNotIn("error_message", body) + + def test_status_active_after_mark_starting(self): + manager = self.app.replay_manager + manager.mark_starting( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + + with AuthTestClient(self.app) as client: + resp = client.get("/debug_replay/status") + + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertTrue(body["active"]) + self.assertEqual(body["replay_camera"], "_replay_front") + self.assertEqual(body["source_camera"], "front") + self.assertEqual(body["start_time"], 100.0) + self.assertEqual(body["end_time"], 200.0) + self.assertFalse(body["live_ready"]) diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index b04b1cf55b..ef5b99ad08 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -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 @@ -22,3 +26,228 @@ class TestHttpApp(BaseTestHttp): response = client.get("/stats") response_json = response.json() assert response_json == self.test_stats + + def test_recordings_storage_requires_admin(self): + stats = Mock(spec=StatsEmitter) + stats.get_latest_stats.return_value = self.test_stats + app = super().create_app(stats) + app.storage_maintainer = Mock() + app.storage_maintainer.calculate_camera_usages.return_value = { + "front_door": {"usage": 2.0}, + } + + with AuthTestClient(app) as client: + response = client.get( + "/recordings/storage", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + ) + assert response.status_code == 403 + + response = client.get("/recordings/storage") + assert response.status_code == 200 + assert response.json()["front_door"]["usage_percent"] == 25.0 + + def test_config_set_in_memory_replaces_objects_track_list(self): + self.minimal_config["cameras"]["front_door"]["objects"] = { + "track": ["person", "car"], + } + app = super().create_app() + app.config_publisher = Mock() + + with AuthTestClient(app) as client: + response = client.put( + "/config/set", + json={ + "requires_restart": 0, + "skip_save": True, + "update_topic": "config/cameras/front_door/objects", + "config_data": { + "cameras": { + "front_door": { + "objects": { + "track": ["person"], + } + } + } + }, + }, + ) + + 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_resolves_sentinel_to_saved_api_key(self): + # After a save the UI's api_key field holds the redaction sentinel; + # the probe must substitute the saved key for the named entry instead + # of sending the literal sentinel to the provider (GH discussion 23754). + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + self.minimal_config["genai"] = { + "llm": { + "provider": "openai", + "api_key": "sk-saved", + "base_url": "https://example.invalid", + "model": "fake-model", + } + } + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + "base_url": "https://example.invalid", + }, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + assert probed_keys == ["sk-saved"] + + def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self): + # If the sentinel arrives for an entry that has no saved config, the + # probe must drop the key entirely rather than leak the sentinel. + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + }, + ) + assert response.status_code == 200 + assert probed_keys == [None] + + 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 diff --git a/frigate/test/http_api/test_http_auth_internal_port.py b/frigate/test/http_api/test_http_auth_internal_port.py new file mode 100644 index 0000000000..75c36bd976 --- /dev/null +++ b/frigate/test/http_api/test_http_auth_internal_port.py @@ -0,0 +1,174 @@ +"""Tests that the internal port trusted by /auth cannot be moved at runtime.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + +import ruamel.yaml +from fastapi import Request + +from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user +from frigate.api.fastapi_app import create_fastapi_app +from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.const import JWT_SECRET_ENV_VAR +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"}) +class TestAuthInternalPort(BaseTestHttp): + """/auth grants anonymous admin by port, so that port must stay put. + + nginx binds its listeners once at container start and never reloads them, + but /api/config/set can swap the live config object mid-process. If /auth + read the port off the live config, saving networking.listen.internal would + hand unauthenticated admin to whoever can reach the external port. + """ + + def setUp(self): + super().setUp(models=[Event, Recordings, ReviewSegment]) + self.minimal_config = { + "mqtt": {"host": "mqtt"}, + "auth": {"enabled": True}, + "networking": {"listen": {"internal": 5000, "external": 8971}}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + }, + } + + def _create_app(self): + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + return { + "username": request.headers.get("remote-user"), + "role": request.headers.get("remote-role"), + } + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app + + def _write_config_file(self): + """Write the minimal config to a temp YAML file and return the path.""" + yaml = ruamel.yaml.YAML() + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) + yaml.dump(self.minimal_config, f) + f.close() + return f.name + + def test_internal_port_is_anonymous_admin(self): + app = self._create_app() + + with AuthTestClient(app) as client: + resp = client.get("/auth", headers={"x-server-port": "5000"}) + + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-user"], "anonymous") + self.assertEqual(resp.headers["remote-role"], "admin") + + def test_external_port_requires_auth(self): + app = self._create_app() + + with AuthTestClient(app) as client: + resp = client.get("/auth", headers={"x-server-port": "8971"}) + + self.assertEqual(resp.status_code, 401) + + def test_swapped_config_does_not_move_the_trusted_port(self): + """The live config is not what /auth trusts. + + Stands in for every path that can rebind app.frigate_config while the + process runs, whatever restart flag the caller claimed. + """ + app = self._create_app() + + swapped = FrigateConfig( + **{ + **self.minimal_config, + "networking": {"listen": {"internal": 8971, "external": 5000}}, + } + ) + app.frigate_config = swapped + + with AuthTestClient(app) as client: + resp = client.get("/auth", headers={"x-server-port": "8971"}) + self.assertEqual(resp.status_code, 401) + + # nginx is still listening where it was told to at boot + resp = client.get("/auth", headers={"x-server-port": "5000"}) + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-role"], "admin") + + @patch("frigate.api.app.find_config_file") + def test_config_set_rejects_internal_matching_external(self, mock_find_config): + """Saving the internal port onto the external one is refused outright.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app = self._create_app() + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"networking": {"listen": {"internal": 8971}}}, + "update_topic": "config/networking", + "requires_restart": 1, + }, + ) + + self.assertEqual(resp.status_code, 400) + self.assertFalse(resp.json()["success"]) + + # the rejected save must not have reached the live config + self.assertEqual( + app.frigate_config.networking.listen.internal_port, 5000 + ) + + resp = client.get("/auth", headers={"x-server-port": "8971"}) + self.assertEqual(resp.status_code, 401) + + with open(config_path) as f: + self.assertNotIn("8971", f.read().split("external")[0]) + finally: + os.unlink(config_path) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/frigate/test/http_api/test_http_camera.py b/frigate/test/http_api/test_http_camera.py new file mode 100644 index 0000000000..cab2d16675 --- /dev/null +++ b/frigate/test/http_api/test_http_camera.py @@ -0,0 +1,132 @@ +"""Tests for the camera delete endpoint's runtime config handling.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + +import ruamel.yaml + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestDeleteCameraRuntimeConfig(BaseTestHttp): + """Deleting a camera must keep the API and dispatcher on the same config.""" + + def setUp(self): + super().setUp(models=[Event, Recordings, ReviewSegment]) + self.minimal_config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_yard": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 720, "width": 1280, "fps": 10}, + }, + }, + } + + def _write_config_file(self): + yaml = ruamel.yaml.YAML() + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) + yaml.dump(self.minimal_config, f) + f.close() + return f.name + + def _create_app_with_dispatcher(self, dispatcher): + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + dispatcher=dispatcher, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + return { + "username": request.headers.get("remote-user"), + "role": request.headers.get("remote-role"), + } + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app, mock_publisher + + @patch("frigate.api.camera.requests.delete") + @patch("frigate.api.camera.cleanup_camera_files") + @patch("frigate.api.camera.cleanup_camera_db") + @patch("frigate.api.camera.find_config_file") + def test_delete_syncs_dispatcher_and_prunes_runtime_state( + self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete + ): + """Deleting a camera swaps every config reference and prunes its state.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + mock_cleanup_db.return_value = ({}, []) + + dispatcher = MagicMock() + dispatcher.comms = [] + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.delete("/cameras/front_door") + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.json()["success"]) + + # the dispatcher must be moved onto the same new object the API + # now serves, and that object must no longer contain the camera + self.assertIs(dispatcher.config, app.frigate_config) + self.assertNotIn("front_door", dispatcher.config.cameras) + self.assertIn("back_yard", dispatcher.config.cameras) + + # surviving cameras' overrides are re-layered onto the new object + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() + + # the deleted camera's persisted overrides are pruned + dispatcher.clear_runtime_state_for_camera.assert_called_once_with( + "front_door" + ) + finally: + os.unlink(config_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 44520d79f5..4c74792c14 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -440,3 +440,68 @@ class TestGo2rtcStreamAccess(BaseTestHttp): f"limited_user should be denied on alias back_door_main; " f"got {resp.status_code}" ) + + +class TestReviewSummaryAccess(BaseTestHttp): + """Tests for POST /review/summarize/start/{start_ts}/end/{end_ts}. + + The summary correlates each flagged event with overlapping activity on + other cameras, so it is gated on full camera access rather than scoped to + the caller's cameras. These tests pin that decision so the dependency is + not loosened without first scoping the query. + + GenAI is not configured in unit tests, so an authorized request returns 400 + while an unauthorized one is rejected with 403 before the handler runs. + """ + + def setUp(self): + super().setUp([Event, ReviewSegment, Recordings]) + self.minimal_config = _MULTI_CAMERA_CONFIG + self.app = super().create_app() + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def _summarize(self, allowed_cameras: list[str]): + async def mock_cameras(request: Request): + return allowed_cameras + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras + with AuthTestClient(self.app) as client: + return client.post("/review/summarize/start/0/end/9999999999") + + def _assert_allowed(self, resp): + assert resp.status_code not in (401, 403), ( + f"Caller should not be blocked; got {resp.status_code}" + ) + + def test_partial_camera_access_blocked(self): + assert self._summarize(["front_door"]).status_code == 403 + + def test_no_camera_access_blocked(self): + assert self._summarize([]).status_code == 403 + + def test_full_camera_access_allowed(self): + # Covers admin and viewer, which always resolve to every camera, and a + # custom role whose list happens to name them all. + self._assert_allowed(self._summarize(["front_door", "back_door"])) + + def _summarize_as_role(self, role: str): + """Summarize using the real role to allowed-cameras resolution.""" + self.app.dependency_overrides.pop(get_allowed_cameras_for_filter, None) + with AuthTestClient(self.app) as client: + return client.post( + "/review/summarize/start/0/end/9999999999", + headers={"remote-user": "test", "remote-role": role}, + ) + + def test_viewer_role_allowed(self): + # viewer is never camera restricted, so it resolves to every camera. + self._assert_allowed(self._summarize_as_role("viewer")) + + def test_admin_role_allowed(self): + self._assert_allowed(self._summarize_as_role("admin")) + + def test_restricted_role_blocked(self): + assert self._summarize_as_role("limited_user").status_code == 403 diff --git a/frigate/test/http_api/test_http_classification_traversal.py b/frigate/test/http_api/test_http_classification_traversal.py new file mode 100644 index 0000000000..ec74b1e061 --- /dev/null +++ b/frigate/test/http_api/test_http_classification_traversal.py @@ -0,0 +1,73 @@ +"""End to end checks that classification endpoints cannot escape their base dir.""" + +import os +import shutil +import tempfile +from unittest.mock import patch + +from frigate.models import Event +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + +# Percent encodings that survive nginx normalization. nginx collapses a bare +# ".." segment, but "..:" and friends are not relative segments to nginx while +# pathvalidate still reduces them to exactly "..". +TRAVERSAL_NAMES = ["..%3A", "..%2A", "..%3C", "..%7C", "..%20", ".."] + + +class TestHttpClassificationTraversal(BaseTestHttp): + def setUp(self): + super().setUp([Event]) + self.app = super().create_app() + + self.root = tempfile.mkdtemp() + self.clips = os.path.join(self.root, "clips") + self.model_cache = os.path.join(self.root, "model_cache") + os.makedirs(os.path.join(self.clips, "model1")) + os.makedirs(os.path.join(self.model_cache, "model1")) + os.makedirs(os.path.join(self.root, "recordings")) + + # Sibling data that a "/.." escape from clips would reach. + self.canary = os.path.join(self.root, "recordings", "seg.mp4") + + with open(self.canary, "w") as f: + f.write("recording") + + clips_patch = patch("frigate.api.classification.CLIPS_DIR", self.clips) + cache_patch = patch( + "frigate.api.classification.MODEL_CACHE_DIR", self.model_cache + ) + clips_patch.start() + cache_patch.start() + self.addCleanup(clips_patch.stop) + self.addCleanup(cache_patch.stop) + + def tearDown(self): + shutil.rmtree(self.root, ignore_errors=True) + self.app.dependency_overrides.clear() + super().tearDown() + + def test_delete_model_rejects_traversal_names(self): + client = AuthTestClient(self.app) + + for name in TRAVERSAL_NAMES: + with self.subTest(name=name): + response = client.delete(f"/classification/{name}") + + # Either the router never matches it or the handler rejects it, + # but the sibling directory must survive either way. + self.assertNotEqual(response.status_code, 200) + self.assertTrue( + os.path.exists(self.canary), + f"{name} deleted data outside the clips directory", + ) + self.assertTrue(os.path.exists(os.path.join(self.root, "recordings"))) + + def test_delete_model_still_removes_its_own_directories(self): + client = AuthTestClient(self.app) + + response = client.delete("/classification/model1") + + self.assertEqual(response.status_code, 200) + self.assertFalse(os.path.exists(os.path.join(self.clips, "model1"))) + self.assertFalse(os.path.exists(os.path.join(self.model_cache, "model1"))) + self.assertTrue(os.path.exists(self.canary)) diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py new file mode 100644 index 0000000000..4e2e851f48 --- /dev/null +++ b/frigate/test/http_api/test_http_config_set.py @@ -0,0 +1,501 @@ +"""Tests for the config_set endpoint's wildcard camera propagation.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + +import ruamel.yaml + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) +from frigate.config.holder import ConfigHolder +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestConfigSetWildcardPropagation(BaseTestHttp): + """Test that wildcard camera updates fan out to all cameras.""" + + def setUp(self): + super().setUp(models=[Event, Recordings, ReviewSegment]) + self.minimal_config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + }, + "back_yard": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 720, + "width": 1280, + "fps": 10, + }, + }, + }, + } + + def _create_app_with_publisher(self): + """Create app with a mocked config publisher.""" + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") + return {"username": username, "role": role} + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app, mock_publisher + + def _create_app_with_dispatcher(self, dispatcher): + """Create app with a mocked config publisher and a real-ish dispatcher.""" + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + dispatcher=dispatcher, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") + return {"username": username, "role": role} + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app, mock_publisher + + @patch("frigate.api.app.find_config_file") + def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config): + """A camera turned off at runtime stays off when another camera is saved.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + dispatcher = MagicMock() + dispatcher.comms = [] + + # front_door was turned off via the UI: the override is on disk, and + # yaml still says enabled: true. Stand in for the real replay, which + # reads dispatcher.config - the object the endpoint just swapped in. + def fake_reapply(): + dispatcher.config.cameras["front_door"].enabled = False + + dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": { + "cameras": {"back_yard": {"detect": {"fps": 7}}} + }, + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.json()["success"]) + + # the swap must be repaired: the new config object the API and + # dispatcher now share has to still show front_door as off + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() + self.assertFalse(app.frigate_config.cameras["front_door"].enabled) + self.assertIs(dispatcher.config, app.frigate_config) + + # yaml-wins ordering: the surgical clear for rewritten keys + # must run before the replay, or a save that rewrote a toggle + # would have its old override resurrected + call_names = [name for name, _, _ in dispatcher.mock_calls] + self.assertLess( + call_names.index("clear_runtime_state_for_yaml_keys"), + call_names.index("reapply_runtime_state_to_config"), + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_no_reapply_when_config_is_not_swapped(self, mock_find_config): + """A restart-required save with no update topic never swaps, so no replay.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + dispatcher = MagicMock() + dispatcher.comms = [] + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"mqtt": {"host": "other"}}, + "requires_restart": 1, + }, + ) + + self.assertEqual(resp.status_code, 200) + dispatcher.reapply_runtime_state_to_config.assert_not_called() + finally: + os.unlink(config_path) + + def _write_config_file(self): + """Write the minimal config to a temp YAML file and return the path.""" + yaml = ruamel.yaml.YAML() + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) + yaml.dump(self.minimal_config, f) + f.close() + return f.name + + @patch("frigate.api.app.find_config_file") + def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config): + """config/cameras/*/detect fans out to all cameras.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"detect": {"fps": 15}}, + "update_topic": "config/cameras/*/detect", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertTrue(data["success"]) + + # Verify publish_update called for each camera + self.assertEqual(mock_publisher.publish_update.call_count, 2) + + published_cameras = set() + for c in mock_publisher.publish_update.call_args_list: + topic = c[0][0] + self.assertIsInstance(topic, CameraConfigUpdateTopic) + self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect) + published_cameras.add(topic.camera) + + self.assertEqual(published_cameras, {"front_door", "back_yard"}) + + # Global publisher should NOT be called for wildcard + mock_publisher.publisher.publish.assert_not_called() + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_wildcard_motion_update_fans_out(self, mock_find_config): + """config/cameras/*/motion fans out to all cameras.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"motion": {"threshold": 30}}, + "update_topic": "config/cameras/*/motion", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + published_cameras = set() + for c in mock_publisher.publish_update.call_args_list: + topic = c[0][0] + self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion) + published_cameras.add(topic.camera) + + self.assertEqual(published_cameras, {"front_door", "back_yard"}) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config): + """config/cameras/front_door/detect only updates front_door.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": { + "cameras": {"front_door": {"detect": {"fps": 20}}} + }, + "update_topic": "config/cameras/front_door/detect", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + # Only one camera updated + self.assertEqual(mock_publisher.publish_update.call_count, 1) + topic = mock_publisher.publish_update.call_args[0][0] + self.assertEqual(topic.camera, "front_door") + self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect) + + # Global publisher should NOT be called + mock_publisher.publisher.publish.assert_not_called() + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_wildcard_sends_merged_per_camera_config(self, mock_find_config): + """Wildcard fan-out sends each camera's own merged config.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"detect": {"fps": 15}}, + "update_topic": "config/cameras/*/detect", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + for c in mock_publisher.publish_update.call_args_list: + camera_detect_config = c[0][1] + self.assertIsNotNone(camera_detect_config) + self.assertTrue(hasattr(camera_detect_config, "fps")) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config): + """Non-camera topics (e.g. config/live) use the generic publisher.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"live": {"height": 720}}, + "update_topic": "config/live", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + # Global topic publisher called + mock_publisher.publisher.publish.assert_called_once() + + # Camera-level publish_update NOT called + mock_publisher.publish_update.assert_not_called() + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_global_birdseye_save_fans_out_resolved_camera_configs( + self, mock_find_config + ): + """A global birdseye save must also publish the per-camera values. + + Global birdseye only seeds enabled and mode; the camera copies are what + the output process actually reads. Sending just the global object makes + a worker guess which cameras were inheriting, and the only available + guess (mode still equals the previous global) wrongly claims a camera + whose explicit yaml mode happens to match. + """ + self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"} + # explicit override that matches the global value being replaced + self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"} + + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, mock_publisher = self._create_app_with_publisher() + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"birdseye": {"mode": "continuous"}}, + "update_topic": "config/birdseye", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + # the global object still goes out on its own topic + mock_publisher.publisher.publish.assert_called_once() + topic, settings = mock_publisher.publisher.publish.call_args[0] + self.assertEqual(topic, "config/birdseye") + self.assertEqual(settings.mode.value, "continuous") + + published = { + call[0][0].camera: call[0][1] + for call in mock_publisher.publish_update.call_args_list + } + self.assertEqual(set(published), {"front_door", "back_yard"}) + + for call in mock_publisher.publish_update.call_args_list: + self.assertEqual( + call[0][0].update_type, CameraConfigUpdateEnum.birdseye + ) + + # the override survives, the inheriting camera follows global + self.assertEqual(published["front_door"].mode.value, "motion") + self.assertEqual(published["back_yard"].mode.value, "continuous") + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_save_updates_the_config_holder(self, mock_find_config): + """A save must move the holder onto the freshly parsed config. + + FrigateApp reads the holder when the watchdog rebuilds a crashed + process; if the save leaves it on the boot config, that process comes + back having lost every change made since Frigate started. + """ + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + boot_config = FrigateConfig(**self.minimal_config) + holder = ConfigHolder(boot_config) + + try: + app = create_fastapi_app( + boot_config, + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + enforce_default_admin=False, + config_holder=holder, + ) + + async def mock_get_current_user(request: Request): + return {"username": "admin", "role": "admin"} + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"birdseye": {"inactivity_threshold": 5}}, + "update_topic": "config/birdseye", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + self.assertIsNot(holder.config, boot_config) + self.assertIs(holder.config, app.frigate_config) + self.assertEqual(holder.config.birdseye.inactivity_threshold, 5) + finally: + os.unlink(config_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index bc7f388e15..8aca6577d9 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -219,6 +219,25 @@ class TestHttpApp(BaseTestHttp): assert len(events) == 1 assert events[0]["id"] == event_id + def test_similarity_search_hides_unauthorized_anchor_event(self): + mock_embeddings = Mock() + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + super().insert_mock_event("hidden.anchor", camera="back_door") + response = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": "hidden.anchor", + }, + ) + + assert response.status_code == 404 + assert response.json()["message"] == "Event not found" + mock_embeddings.search_thumbnail.assert_not_called() + def test_get_good_event(self): id = "123456.random" diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py new file mode 100644 index 0000000000..44eb0c2c4a --- /dev/null +++ b/frigate/test/http_api/test_http_export.py @@ -0,0 +1,1511 @@ +import io +import os +import tempfile +import zipfile +from unittest.mock import patch + +from frigate.jobs.export import ( + ExportJob, + get_export_job_manager, + reap_stale_exports, + start_export_job, +) +from frigate.models import Export, ExportCase, Previews, Recordings +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpExport(BaseTestHttp): + def setUp(self): + super().setUp([Export, ExportCase, Previews, Recordings]) + self.minimal_config["cameras"]["backyard"] = { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + self.app = super().create_app() + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def _insert_recording( + self, + recording_id: str, + camera: str, + start_time: float, + end_time: float, + ) -> None: + Recordings.create( + id=recording_id, + camera=camera, + path=f"/tmp/{recording_id}.mp4", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + motion=0, + objects=0, + dBFS=0, + segment_size=1, + regions=0, + motion_heatmap=[], + ) + + def test_create_export_case_uses_wall_clock_time(self): + with patch("frigate.api.export.time.time", return_value=1234.5): + with AuthTestClient(self.app) as client: + response = client.post( + "/cases", + json={ + "name": "Investigation", + "description": "A test case", + }, + ) + + assert response.status_code == 200 + response_json = response.json() + assert response_json["created_at"] == 1234.5 + assert response_json["updated_at"] == 1234.5 + + case = ExportCase.get(ExportCase.id == response_json["id"]) + assert case.created_at.timestamp() == 1234.5 + assert case.updated_at.timestamp() == 1234.5 + + def test_update_export_case_refreshes_updated_at(self): + case = ExportCase.create( + id="case123", + name="Old name", + description="Old description", + created_at=10, + updated_at=10, + ) + + with patch("frigate.api.export.time.time", return_value=2222.0): + with AuthTestClient(self.app) as client: + response = client.patch( + f"/cases/{case.id}", + json={"name": "New name", "description": "Updated"}, + ) + + assert response.status_code == 200 + + refreshed = ExportCase.get(ExportCase.id == case.id) + assert refreshed.name == "New name" + assert refreshed.description == "Updated" + assert refreshed.updated_at.timestamp() == 2222.0 + + def test_delete_export_case_delete_exports_cancels_queued_jobs(self): + case = ExportCase.create( + id="case_delete_me", + name="Delete me", + description="", + created_at=10, + updated_at=10, + ) + other_case = ExportCase.create( + id="case_keep_me", + name="Keep me", + description="", + created_at=20, + updated_at=20, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "case_export.mp4") + thumb_path = os.path.join(tmpdir, "case_export.webp") + other_video_path = os.path.join(tmpdir, "other_export.mp4") + other_thumb_path = os.path.join(tmpdir, "other_export.webp") + + with open(video_path, "wb") as handle: + handle.write(b"case") + with open(thumb_path, "wb") as handle: + handle.write(b"thumb") + with open(other_video_path, "wb") as handle: + handle.write(b"other") + with open(other_thumb_path, "wb") as handle: + handle.write(b"thumb") + + Export.create( + id="export_in_case", + camera="front_door", + name="Case export", + date=100, + video_path=video_path, + thumb_path=thumb_path, + in_progress=False, + export_case=case, + ) + Export.create( + id="export_other_case", + camera="front_door", + name="Other export", + date=110, + video_path=other_video_path, + thumb_path=other_thumb_path, + in_progress=False, + export_case=other_case, + ) + + with ( + patch("frigate.jobs.export._job_manager", None), + patch( + "frigate.jobs.export.ExportJobManager.ensure_started", + autospec=True, + return_value=None, + ), + ): + start_export_job( + self.app.frigate_config, + ExportJob( + id="queued_case_job", + camera="front_door", + export_case_id=case.id, + request_start_time=100, + request_end_time=120, + ), + ) + start_export_job( + self.app.frigate_config, + ExportJob( + id="queued_other_job", + camera="front_door", + export_case_id=other_case.id, + request_start_time=130, + request_end_time=150, + ), + ) + + manager = get_export_job_manager(self.app.frigate_config) + assert {job.id for job in manager.list_active_jobs()} == { + "queued_case_job", + "queued_other_job", + } + + with AuthTestClient(self.app) as client: + response = client.delete(f"/cases/{case.id}?delete_exports=true") + + assert response.status_code == 200 + assert ExportCase.get_or_none(ExportCase.id == case.id) is None + assert ExportCase.get_or_none(ExportCase.id == other_case.id) is not None + assert Export.get_or_none(Export.id == "export_in_case") is None + assert Export.get_or_none(Export.id == "export_other_case") is not None + assert not os.path.exists(video_path) + assert not os.path.exists(thumb_path) + + cancelled_job = manager.get_job("queued_case_job") + assert cancelled_job is not None + assert cancelled_job.status == "cancelled" + + remaining_job = manager.get_job("queued_other_job") + assert remaining_job is not None + assert remaining_job.status == "queued" + assert [job.id for job in manager.list_active_jobs()] == [ + "queued_other_job" + ] + + def test_batch_export_creates_case_and_reports_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Front Door", + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Backyard", + }, + ], + "new_case_name": "Case Alpha", + "new_case_description": "Batch export", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + assert response_json["results"] == [ + { + "camera": "front_door", + "export_id": response_json["export_ids"][0], + "success": True, + "status": "queued", + "error": None, + "item_index": 0, + "client_item_id": None, + }, + { + "camera": "backyard", + "export_id": None, + "success": False, + "status": None, + "error": "No recordings found for time range", + "item_index": 1, + "client_item_id": None, + }, + ] + start_export_job.assert_called_once() + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Case Alpha" + assert case.description == "Batch export" + + def test_single_export_is_queued_immediately(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + json={ + "name": "Queued export", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["success"] is True + assert response_json["status"] == "queued" + assert response_json["export_id"].startswith("front_door_") + start_export_job.assert_called_once() + + def test_single_export_returns_503_when_queue_full(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + from frigate.jobs.export import ExportQueueFullError + + with patch( + "frigate.api.export.start_export_job", + side_effect=ExportQueueFullError("Export queue is full"), + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + json={ + "name": "Rejected export", + }, + ) + + assert response.status_code == 503 + response_json = response.json() + assert response_json["success"] is False + assert "queue is full" in response_json["message"].lower() + + def test_batch_export_returns_503_when_queue_cannot_fit_batch(self): + self._insert_recording("rec-front", "front_door", 100, 200) + self._insert_recording("rec-back", "backyard", 100, 200) + + with patch( + "frigate.api.export.available_export_queue_slots", + return_value=1, + ): + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Overflow Case", + }, + ) + + assert response.status_code == 503 + assert response.json()["success"] is False + start_export_job.assert_not_called() + + # Empty case should NOT have been created + assert ExportCase.select().count() == 0 + + def test_get_active_export_jobs_returns_queue_state(self): + queued_job = ExportJob( + id="front_door_queued", + camera="front_door", + status="queued", + request_start_time=100, + request_end_time=150, + ) + + with patch( + "frigate.api.export.list_active_export_jobs", + return_value=[queued_job], + ): + with AuthTestClient(self.app) as client: + response = client.get("/jobs/export") + + assert response.status_code == 200 + assert response.json() == [queued_job.to_dict()] + + def test_reap_stale_exports_deletes_rows_with_no_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + stale_video = os.path.join(tmpdir, "stale.mp4") + stale_thumb = os.path.join(tmpdir, "stale.webp") + # stale_video is intentionally NOT created + with open(stale_thumb, "w") as handle: + handle.write("thumb") + + Export.create( + id="stale_no_file", + camera="front_door", + name="Stuck export", + date=100, + video_path=stale_video, + thumb_path=stale_thumb, + in_progress=True, + ) + + reap_stale_exports() + + assert Export.get_or_none(Export.id == "stale_no_file") is None + assert not os.path.exists(stale_thumb) + + def test_reap_stale_exports_recovers_rows_with_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + intact_video = os.path.join(tmpdir, "intact.mp4") + intact_thumb = os.path.join(tmpdir, "intact.webp") + with open(intact_video, "wb") as handle: + handle.write(b"not actually an mp4 but non-empty") + with open(intact_thumb, "wb") as handle: + handle.write(b"thumb") + + case = ExportCase.create( + id="case_for_stale", + name="Curated case", + description="", + created_at=10, + updated_at=10, + ) + + Export.create( + id="stale_with_file", + camera="front_door", + name="Recoverable export", + date=200, + video_path=intact_video, + thumb_path=intact_thumb, + in_progress=True, + export_case=case, + ) + + reap_stale_exports() + + recovered = Export.get(Export.id == "stale_with_file") + assert recovered.in_progress is False + # Case link must be cleared so the user re-triages the recovered row + assert recovered.export_case is None + # The case itself is untouched + assert ExportCase.get_or_none(ExportCase.id == "case_for_stale") is not None + # Recovered files must NOT be unlinked + assert os.path.exists(intact_video) + assert os.path.exists(intact_thumb) + + def test_reap_stale_exports_delete_path_severs_case_link(self): + with tempfile.TemporaryDirectory() as tmpdir: + missing_video = os.path.join(tmpdir, "missing.mp4") + # file intentionally not created + + case = ExportCase.create( + id="case_losing_member", + name="Case losing a member", + description="", + created_at=20, + updated_at=20, + ) + + Export.create( + id="stale_in_case_no_file", + camera="front_door", + name="Stuck and in a case", + date=250, + video_path=missing_video, + thumb_path="", + in_progress=True, + export_case=case, + ) + + reap_stale_exports() + + # The export row is gone entirely + assert Export.get_or_none(Export.id == "stale_in_case_no_file") is None + # The case stays but has no exports pointing at it + remaining_case = ExportCase.get(ExportCase.id == "case_losing_member") + assert list(remaining_case.exports) == [] + + def test_reap_stale_exports_deletes_rows_with_empty_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + empty_video = os.path.join(tmpdir, "empty.mp4") + # Create a zero-byte file — partial ffmpeg output + open(empty_video, "w").close() + + Export.create( + id="stale_empty_file", + camera="front_door", + name="Zero byte export", + date=300, + video_path=empty_video, + thumb_path="", + in_progress=True, + ) + + reap_stale_exports() + + assert Export.get_or_none(Export.id == "stale_empty_file") is None + assert not os.path.exists(empty_video) + + def test_reap_stale_exports_skips_completed_rows(self): + with tempfile.TemporaryDirectory() as tmpdir: + done_video = os.path.join(tmpdir, "done.mp4") + with open(done_video, "wb") as handle: + handle.write(b"done") + + Export.create( + id="already_done", + camera="front_door", + name="Completed export", + date=400, + video_path=done_video, + thumb_path="", + in_progress=False, + ) + + reap_stale_exports() + + row = Export.get(Export.id == "already_done") + assert row.in_progress is False + assert os.path.exists(done_video) + + def test_batch_export_without_case_goes_to_uncategorized(self): + """Exports without a case target go to uncategorized.""" + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + # --- /exports/batch (item-shaped multi-export) --------------------------- + + def test_batch_export_happy_path_creates_case_and_queues_all(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 200, + "end_time": 240, + }, + { + "camera": "backyard", + "start_time": 300, + "end_time": 340, + }, + ], + "new_case_name": "Incident Apr 11", + "new_case_description": "Review items", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 3 + assert all(r["success"] for r in response_json["results"]) + assert [r["item_index"] for r in response_json["results"]] == [0, 1, 2] + assert start_export_job.call_count == 3 + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Incident Apr 11" + assert case.description == "Review items" + + def test_batch_export_existing_case_does_not_create_new_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="existing_case", + name="Existing", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "existing_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "existing_case" + # No additional case was created + assert ExportCase.select().count() == 1 + + def test_batch_export_empty_items_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": [], "new_case_name": "Empty"}, + ) + + assert response.status_code == 422 + + def test_batch_export_over_limit_rejected(self): + items = [ + {"camera": "front_door", "start_time": 100 + i, "end_time": 100 + i + 5} + for i in range(51) + ] + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": items, "new_case_name": "Too many"}, + ) + + assert response.status_code == 422 + + def test_batch_export_end_before_start_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 200, + "end_time": 100, + } + ], + "new_case_name": "Bad range", + }, + ) + + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] + == "Value error, end_time must be after start_time" + ) + + def test_batch_export_non_admin_without_case_goes_to_uncategorized(self): + """Non-admin batch exports go to uncategorized.""" + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 100, + "end_time": 150, + } + ], + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + def test_batch_export_camera_access_denied_fails_closed(self): + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter + + self._insert_recording("rec-front", "front_door", 100, 400) + + async def restricted(request: Request): + return ["front_door"] + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = restricted + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", # not in allowed list + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Nope", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No case created + assert ExportCase.select().count() == 0 + + def test_batch_export_case_not_found(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "does_not_exist", + }, + ) + + assert response.status_code == 404 + + def test_batch_export_per_item_missing_recordings_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + # backyard has no recordings at all + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Partial", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results_by_camera = {r["camera"]: r for r in response_json["results"]} + assert results_by_camera["front_door"]["success"] is True + assert results_by_camera["backyard"]["success"] is False + assert ( + results_by_camera["backyard"]["error"] + == "No recordings found for time range" + ) + start_export_job.assert_called_once() + + # Case is still created because at least one item succeeded + assert ( + ExportCase.get(ExportCase.id == response_json["export_case_id"]) is not None + ) + + def test_batch_export_same_camera_different_ranges_one_missing(self): + # Recording covers 100-200 only. First item fits, second does not. + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 500, + "end_time": 540, + }, + ], + "new_case_name": "Split recordings", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results = response_json["results"] + assert results[0]["success"] is True + assert results[0]["item_index"] == 0 + assert results[1]["success"] is False + assert results[1]["item_index"] == 1 + assert results[1]["error"] == "No recordings found for time range" + # Both results carry the same camera — item_index is the only way + # the client can tell them apart. + assert results[0]["camera"] == results[1]["camera"] == "front_door" + start_export_job.assert_called_once() + + def test_batch_export_all_missing_recordings_rolls_back_case(self): + # No recordings inserted at all + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Should rollback", + }, + ) + + assert response.status_code == 400 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_preflight_queue_full(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.available_export_queue_slots", + return_value=1, + ): + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Queue full", + }, + ) + + assert response.status_code == 503 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_all_enqueue_calls_fail_rolls_back_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + def boom(_config, _job): + raise RuntimeError("simulated enqueue failure") + + with patch( + "frigate.api.export.start_export_job", + side_effect=boom, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Will fail", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_ids"] == [] + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + def test_batch_export_rejects_invalid_image_path(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "image_path": "/etc/passwd", + } + ], + "new_case_name": "Bad image", + }, + ) + + assert response.status_code == 400 + assert ExportCase.select().count() == 0 + + def test_batch_export_non_admin_can_queue(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Viewer export", + }, + ) + + assert response.status_code == 202 + assert len(response.json()["export_ids"]) == 1 + + def test_batch_export_non_admin_cannot_attach_to_existing_case(self): + """Non-admins can create cases via new_case_name but cannot attach + to existing cases they did not create. Closes a write-path hole that + would otherwise be reachable through the unfiltered GET /cases list. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "admins_only_case", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No exports should have been created in the target case + assert Export.select().count() == 0 + + def test_batch_export_admin_can_attach_to_existing_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="shared_case", + name="Shared", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "shared_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "shared_case" + # No additional case created + assert ExportCase.select().count() == 1 + + def test_batch_export_roundtrips_client_item_id(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "client_item_id": "review-123", + } + ], + "new_case_name": "Client id test", + }, + ) + + assert response.status_code == 202 + assert response.json()["results"][0]["client_item_id"] == "review-123" + + def test_single_export_non_admin_cannot_attach_to_existing_case(self): + """The single-export route has the same hole: non-admins should not + be able to smuggle exports into an existing case via export_case_id. + Admin-gating this matches /exports/batch. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"export_case_id": "admins_only_case"}, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + assert Export.select().count() == 0 + + def test_single_export_non_admin_can_still_export_without_case(self): + """Regression guard: the admin gate only applies to export_case_id, + not to single exports in general. Non-admins should still be able + to start a single export for a camera they have access to. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={}, + ) + + assert response.status_code == 202 + assert response.json()["success"] is True + + # ── Bulk delete exports ──────────────────────────────────────── + + def test_bulk_delete_exports_success(self): + """All IDs exist, none in-progress → 200, all deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + Export.create( + id="exp2", + camera="front_door", + name="export_2", + date=200, + video_path="/tmp/exp2.mp4", + thumb_path="/tmp/exp2.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1", "exp2"]}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + assert Export.select().count() == 0 + + def test_bulk_delete_exports_single_item(self): + """Regression: single-item delete via batch endpoint.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 200 + assert Export.select().count() == 0 + + def test_bulk_delete_exports_some_missing(self): + """Some IDs don't exist → 404, nothing deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1", "nonexistent"]}, + ) + + assert response.status_code == 404 + # Nothing deleted + assert Export.select().count() == 1 + + def test_bulk_delete_exports_all_missing(self): + """All IDs don't exist → 404.""" + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["nope1", "nope2"]}, + ) + + assert response.status_code == 404 + + def test_bulk_delete_exports_in_progress(self): + """Some exports in-progress → 400, nothing deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path=f"{os.environ.get('EXPORT_DIR', '/media/frigate/exports')}/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=True, + ) + + with patch( + "frigate.api.export._get_files_in_use", + return_value={"exp1.mp4"}, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 400 + assert Export.select().count() == 1 + + def test_bulk_delete_exports_non_admin_rejected(self): + """Non-admin users cannot bulk delete.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 403 + assert Export.select().count() == 1 + + # ── Bulk reassign exports ────────────────────────────────────── + + def test_bulk_reassign_exports_to_case(self): + """All IDs exist, case exists → 200, all reassigned.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + Export.create( + id="exp2", + camera="front_door", + name="export_2", + date=200, + video_path="/tmp/exp2.mp4", + thumb_path="/tmp/exp2.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1", "exp2"], "export_case_id": "case1"}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + for exp_id in ["exp1", "exp2"]: + exp = Export.get(Export.id == exp_id) + assert exp.export_case_id == "case1" + + def test_bulk_reassign_exports_to_null(self): + """Reassign to null (uncategorize) → 200.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + export_case="case1", + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": None}, + ) + + assert response.status_code == 200 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_single_item(self): + """Regression: single-item reassign via batch endpoint.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": "case1"}, + ) + + assert response.status_code == 200 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id == "case1" + + def test_bulk_reassign_exports_some_missing(self): + """Some IDs don't exist → 404, nothing reassigned.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={ + "ids": ["exp1", "nonexistent"], + "export_case_id": "case1", + }, + ) + + assert response.status_code == 404 + # Nothing reassigned + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_case_not_found(self): + """Target case doesn't exist → 404.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": "nonexistent"}, + ) + + assert response.status_code == 404 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_non_admin_rejected(self): + """Non-admin users cannot bulk reassign.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"ids": ["exp1"], "export_case_id": None}, + ) + + assert response.status_code == 403 + + def test_download_export_case_with_multibyte_name(self): + """A case name outside latin-1 must not break the response headers.""" + case = ExportCase.create( + id="case_multibyte", + name="テスト事案", + description="", + created_at=10, + updated_at=10, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "multibyte_export.mp4") + with open(video_path, "wb") as handle: + handle.write(b"video") + + Export.create( + id="export_multibyte", + camera="front_door", + name="現場カメラ", + date=100, + video_path=video_path, + thumb_path=os.path.join(tmpdir, "multibyte_export.webp"), + in_progress=False, + export_case=case, + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/cases/{case.id}/download") + + assert response.status_code == 200 + # RFC 5987/6266: the UTF-8 name rides in filename*, and a latin-1 safe + # fallback stays in filename for old clients. + assert response.headers["content-disposition"] == ( + 'attachment; filename="case_multibyte.zip"; ' + "filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E4%BA%8B%E6%A1%88.zip" + ) + + archive = zipfile.ZipFile(io.BytesIO(response.content)) + assert archive.namelist() == ["現場カメラ.mp4"] + + def test_download_export_case_with_ascii_name(self): + """An ASCII case name still gets a plain, readable filename.""" + case = ExportCase.create( + id="case_ascii", + name="Burglary 2026-08", + description="", + created_at=10, + updated_at=10, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "ascii_export.mp4") + with open(video_path, "wb") as handle: + handle.write(b"video") + + Export.create( + id="export_ascii", + camera="front_door", + name="Front door", + date=100, + video_path=video_path, + thumb_path=os.path.join(tmpdir, "ascii_export.webp"), + in_progress=False, + export_case=case, + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/cases/{case.id}/download") + + assert response.status_code == 200 + assert ( + response.headers["content-disposition"] + == 'attachment; filename="Burglary 2026-08.zip"; ' + "filename*=UTF-8''Burglary%202026-08.zip" + ) diff --git a/frigate/test/http_api/test_http_keyframe_analysis.py b/frigate/test/http_api/test_http_keyframe_analysis.py new file mode 100644 index 0000000000..6a49e105ec --- /dev/null +++ b/frigate/test/http_api/test_http_keyframe_analysis.py @@ -0,0 +1,58 @@ +from unittest.mock import AsyncMock, patch + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpKeyframeAnalysis(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + + def test_invalid_camera_returns_404(self): + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=does_not_exist") + assert response.status_code == 404 + + def test_record_disabled_returns_neutral(self): + # default minimal_config has recording disabled + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + assert response.status_code == 200 + assert response.json()["severity"] == "record_disabled" + + def test_probes_record_input_and_returns_severity(self): + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [ + { + "path": "rtsp://10.0.0.1:554/record", + "roles": ["detect", "record"], + } + ] + self.minimal_config["cameras"]["front_door"]["record"] = {"enabled": True} + app = super().create_app() + + canned = { + "severity": "ok", + "keyframe_count": 5, + "max_gap": 1.0, + "mean_gap": 1.0, + "min_gap": 1.0, + "segment_time": 10, + "duration_observed": 4.0, + "thresholds": {"warning": 4.0, "error": 10}, + } + + with patch( + "frigate.api.camera.analyze_record_keyframes", + AsyncMock(return_value=canned), + ) as mock_probe: + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + + assert response.status_code == 200 + assert response.json()["severity"] == "ok" + # index matches the input carrying the record role ("Stream 1") + assert response.json()["stream_index"] == 0 + # the record-role input path was probed + assert mock_probe.await_args.args[1] == "rtsp://10.0.0.1:554/record" diff --git a/frigate/test/http_api/test_http_latest_frame.py b/frigate/test/http_api/test_http_latest_frame.py new file mode 100644 index 0000000000..755ee6eb1f --- /dev/null +++ b/frigate/test/http_api/test_http_latest_frame.py @@ -0,0 +1,107 @@ +import os +import shutil +from unittest.mock import MagicMock + +import cv2 +import numpy as np + +from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpLatestFrame(BaseTestHttp): + def setUp(self): + super().setUp([]) + self.app = super().create_app() + self.app.detected_frames_processor = MagicMock() + + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + os.makedirs(PREVIEW_CACHE_DIR) + + def tearDown(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + super().tearDown() + + def test_latest_frame_fallback_to_preview(self): + camera = "front_door" + # 1. Mock frame processor to return None (simulating offline/missing frame) + self.app.detected_frames_processor.get_current_frame.return_value = None + # Return a timestamp that is after our dummy preview frame + self.app.detected_frames_processor.get_current_frame_time.return_value = ( + 1234567891.0 + ) + + # 2. Create a dummy preview file + dummy_frame = np.zeros((180, 320, 3), np.uint8) + cv2.putText( + dummy_frame, + "PREVIEW", + (50, 50), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (255, 255, 255), + 2, + ) + preview_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" + ) + cv2.imwrite(preview_path, dummy_frame) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert response.headers.get("X-Frigate-Offline") == "true" + # Verify we got an image (webp) + assert response.headers.get("content-type") == "image/webp" + + def test_latest_frame_no_fallback_when_live(self): + camera = "front_door" + # 1. Mock frame processor to return a live frame + dummy_frame = np.zeros((180, 320, 3), np.uint8) + self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame + self.app.detected_frames_processor.get_current_frame_time.return_value = ( + 2000000000.0 # Way in the future + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert "X-Frigate-Offline" not in response.headers + + def test_latest_frame_stale_falls_back_to_preview(self): + camera = "front_door" + # 1. Mock frame processor to return a stale frame + dummy_frame = np.zeros((180, 320, 3), np.uint8) + self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame + # Return a timestamp that is after our dummy preview frame, but way in the past + self.app.detected_frames_processor.get_current_frame_time.return_value = 1000.0 + + # 2. Create a dummy preview file + preview_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" + ) + cv2.imwrite(preview_path, dummy_frame) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert response.headers.get("X-Frigate-Offline") == "true" + + def test_latest_frame_no_preview_found(self): + camera = "front_door" + # 1. Mock frame processor to return None + self.app.detected_frames_processor.get_current_frame.return_value = None + + # 2. No preview file created + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + # Should fall back to camera-error.jpg (which might not exist in test env, but let's see) + # If camera-error.jpg is not found, it returns 500 "Unable to get valid frame" in latest_frame + # OR it uses request.app.camera_error_image if already loaded. + + # Since we didn't provide camera-error.jpg, it might 500 if glob fails or return 500 if frame is None. + assert response.status_code in [200, 500] + assert "X-Frigate-Offline" not in response.headers diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 6af3dd9724..b2d83cbc8a 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -1,6 +1,6 @@ """Unit tests for recordings/media API endpoints.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import pytz from fastapi import Request @@ -306,8 +306,8 @@ class TestHttpMedia(BaseTestHttp): Test recordings summary with UTC timezone (no DST). """ # Use UTC timestamps directly - march_9_utc = datetime(2024, 3, 9, 17, 0, 0, tzinfo=timezone.utc).timestamp() - march_10_utc = datetime(2024, 3, 10, 17, 0, 0, tzinfo=timezone.utc).timestamp() + march_9_utc = datetime(2024, 3, 9, 17, 0, 0, tzinfo=UTC).timestamp() + march_10_utc = datetime(2024, 3, 10, 17, 0, 0, tzinfo=UTC).timestamp() with AuthTestClient(self.app) as client: Recordings.insert( @@ -403,3 +403,127 @@ class TestHttpMedia(BaseTestHttp): assert len(summary) == 1 assert "2024-03-10" in summary assert summary["2024-03-10"] is True + + def test_recordings_unavailable_reports_gap_between_recordings(self): + """A gap between two recordings is reported as an unavailable segment.""" + with AuthTestClient(self.app) as client: + # Two recordings with a 20s gap (1010-1030) between them. + Recordings.insert( + id="rec_a", + path="/media/recordings/a.mp4", + camera="front_door", + start_time=1000, + end_time=1010, + duration=10, + motion=0, + ).execute() + Recordings.insert( + id="rec_b", + path="/media/recordings/b.mp4", + camera="front_door", + start_time=1030, + end_time=1040, + duration=10, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1040, + "scale": 5, + "cameras": "front_door", + }, + ) + + assert response.status_code == 200 + assert response.json() == [{"start_time": 1010, "end_time": 1030}] + + def test_recordings_unavailable_merges_overlapping_recordings(self): + """Overlapping recordings are merged so no false gap is reported.""" + with AuthTestClient(self.app) as client: + # Overlapping recordings spanning the whole requested range. + Recordings.insert( + id="rec_a", + path="/media/recordings/a.mp4", + camera="front_door", + start_time=1000, + end_time=1020, + duration=20, + motion=0, + ).execute() + Recordings.insert( + id="rec_b", + path="/media/recordings/b.mp4", + camera="front_door", + start_time=1010, + end_time=1030, + duration=20, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1030, + "scale": 5, + "cameras": "front_door", + }, + ) + + assert response.status_code == 200 + assert response.json() == [] + + def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self): + """cameras=all must not error and must only consider allowed cameras. + + allowed_cameras is mocked to ["front_door"]. A back_door recording that + would otherwise fill the gap must be ignored, and the request must not + 500 the way it did when cameras was reassigned to a list. + """ + with AuthTestClient(self.app) as client: + # front_door has a 20s gap (1010-1030). + Recordings.insert( + id="front_a", + path="/media/recordings/front_a.mp4", + camera="front_door", + start_time=1000, + end_time=1010, + duration=10, + motion=0, + ).execute() + Recordings.insert( + id="front_b", + path="/media/recordings/front_b.mp4", + camera="front_door", + start_time=1030, + end_time=1040, + duration=10, + motion=0, + ).execute() + # back_door is not in allowed_cameras; its full-window coverage must + # not mask the front_door gap. + Recordings.insert( + id="back_a", + path="/media/recordings/back_a.mp4", + camera="back_door", + start_time=1000, + end_time=1040, + duration=40, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1040, + "scale": 5, + "cameras": "all", + }, + ) + + assert response.status_code == 200 + assert response.json() == [{"start_time": 1010, "end_time": 1030}] diff --git a/frigate/test/http_api/test_http_password.py b/frigate/test/http_api/test_http_password.py new file mode 100644 index 0000000000..9305aa2c05 --- /dev/null +++ b/frigate/test/http_api/test_http_password.py @@ -0,0 +1,113 @@ +"""Tests for password change authorization.""" + +from fastapi import Request + +from frigate.api.auth import get_current_user, hash_password, verify_password +from frigate.models import Event, Recordings, ReviewSegment, User +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + +# Config carrying a custom role, which is the class of user the literal +# "viewer" check used to let through. +_CUSTOM_ROLE_CONFIG = { + "mqtt": {"host": "mqtt"}, + "auth": {"roles": {"neighbor": ["front_door"]}, "hash_iterations": 10}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + +ADMIN_PASSWORD = "admin-real-password" +NEW_PASSWORD = "AttackerChosenPassword123!" + + +class TestUpdatePasswordAccess(BaseTestHttp): + def setUp(self): + super().setUp([Event, ReviewSegment, Recordings, User]) + self.minimal_config = _CUSTOM_ROLE_CONFIG + self.app = super().create_app() + User.insert( + username="admin", + password_hash=hash_password(ADMIN_PASSWORD, iterations=10), + role="admin", + notification_tokens=[], + ).execute() + + async def mock_get_current_user(request: Request): + return { + "username": request.headers.get("remote-user"), + "role": request.headers.get("remote-role"), + } + + self.app.dependency_overrides[get_current_user] = mock_get_current_user + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def _change_password(self, actor: str, role: str, target: str, old_password: str): + with AuthTestClient(self.app) as client: + return client.put( + f"/users/{target}/password", + json={"password": NEW_PASSWORD, "old_password": old_password}, + headers={"remote-user": actor, "remote-role": role}, + ) + + def _admin_password_unchanged(self) -> bool: + return verify_password(ADMIN_PASSWORD, User.get_by_id("admin").password_hash) + + def test_custom_role_cannot_target_another_account(self): + resp = self._change_password("neighbor", "neighbor", "admin", "wrong-guess") + assert resp.status_code == 403 + assert self._admin_password_unchanged() + + def test_custom_role_cannot_target_another_account_with_correct_password(self): + # The 403 must land before old_password is checked, so knowing the + # target's password is not a way through + resp = self._change_password("neighbor", "neighbor", "admin", ADMIN_PASSWORD) + assert resp.status_code == 403 + assert self._admin_password_unchanged() + + def test_viewer_cannot_target_another_account(self): + resp = self._change_password("viewer_user", "viewer", "admin", ADMIN_PASSWORD) + assert resp.status_code == 403 + assert self._admin_password_unchanged() + + def test_admin_can_target_another_account(self): + User.insert( + username="neighbor", + password_hash=hash_password("neighbor-password", iterations=10), + role="neighbor", + notification_tokens=[], + ).execute() + + resp = self._change_password("admin", "admin", "neighbor", "") + assert resp.status_code == 200 + + def test_non_admin_can_change_own_password(self): + User.insert( + username="neighbor", + password_hash=hash_password("neighbor-password", iterations=10), + role="neighbor", + notification_tokens=[], + ).execute() + + resp = self._change_password( + "neighbor", "neighbor", "neighbor", "neighbor-password" + ) + assert resp.status_code == 200 + + def test_non_admin_own_password_still_requires_old_password(self): + User.insert( + username="neighbor", + password_hash=hash_password("neighbor-password", iterations=10), + role="neighbor", + notification_tokens=[], + ).execute() + + resp = self._change_password("neighbor", "neighbor", "neighbor", "wrong-guess") + assert resp.status_code == 401 diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index ca73c87064..d13a7bd273 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp): response = client.get("/review/activity/motion", params=params) assert response.status_code == 200 response_json = response.json() - assert len(response_json) == 61 + # Only buckets with an actual recording are returned. Empty + # gap-fill buckets between the two recordings are dropped. + assert len(response_json) == 2 self.assertDictEqual( {"motion": 50.5, "camera": "front_door", "start_time": now + 1}, response_json[0], ) - for item in response_json[1:-1]: - self.assertDictEqual( - {"motion": 0.0, "camera": "", "start_time": item["start_time"]}, - item, - ) self.assertDictEqual( {"motion": 100.0, "camera": "front_door", "start_time": one_m + 1}, - response_json[len(response_json) - 1], + response_json[1], ) #################################################################################################################### diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 33683f5c4b..bd70e37efe 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -1,8 +1,10 @@ """Test camera user and password cleanup.""" +import multiprocessing as mp import unittest -from frigate.output.birdseye import get_canvas_shape +from frigate.config import FrigateConfig +from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape class TestBirdseye(unittest.TestCase): @@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase): canvas_width, canvas_height = get_canvas_shape(width, height) assert canvas_width == width # width will be the same assert canvas_height != height + + +class TestBirdseyeCameraOrder(unittest.TestCase): + """Test that birdseye reacts to camera order changes without a restart.""" + + def setUp(self): + config = { + "mqtt": {"enabled": False}, + "birdseye": {"enabled": True, "mode": "continuous"}, + "cameras": { + camera: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + } + for camera in ("back", "front", "side") + }, + } + self.config = FrigateConfig(**config) + self.manager = BirdsEyeFrameManager(self.config, mp.Event()) + + # mark every camera as continuously active with no frame to draw, which + # exercises the layout without needing real yuv frames + for camera_data in self.manager.cameras.values(): + camera_data["current_frame"] = None + camera_data["current_frame_time"] = 1.0 + camera_data["last_active_frame"] = 1.0 + + def layout_order(self) -> list[str]: + """Return the cameras in the order the current layout renders them.""" + return [position[0] for row in self.manager.camera_layout for position in row] + + def test_layout_uses_configured_order(self): + """Test the layout is sorted by order, then by name when tied.""" + self.config.cameras["side"].birdseye.order = 0 + self.config.cameras["back"].birdseye.order = 10 + self.config.cameras["front"].birdseye.order = 20 + + self.manager.update_frame() + + assert self.layout_order() == ["side", "back", "front"] + + def test_order_change_rebuilds_layout(self): + """Test a reorder relayouts even though the active cameras are unchanged.""" + self.manager.update_frame() + assert self.layout_order() == ["back", "front", "side"] + + # a stable active set means only an order change can reset the layout, + # which is what a settings reorder publishes to this process + self.config.cameras["side"].birdseye.order = -10 + + _, layout_changed = self.manager.update_frame() + + assert layout_changed + assert self.layout_order() == ["side", "back", "front"] + + def test_unchanged_order_keeps_layout(self): + """Test a repeat update with no order change doesn't reset the layout.""" + self.manager.update_frame() + + _, layout_changed = self.manager.update_frame() + + assert not layout_changed + assert self.layout_order() == ["back", "front", "side"] diff --git a/frigate/test/test_builtin.py b/frigate/test/test_builtin.py new file mode 100644 index 0000000000..7cf47de5c6 --- /dev/null +++ b/frigate/test/test_builtin.py @@ -0,0 +1,41 @@ +"""Tests for frigate.util.builtin helpers.""" + +import unittest +from unittest.mock import patch + +from frigate.util.builtin import EventsPerSecond + + +class TestEventsPerSecond(unittest.TestCase): + def test_eps_is_zero_before_any_events(self) -> None: + eps = EventsPerSecond() + with patch("frigate.util.builtin.time.monotonic", return_value=100.0): + self.assertEqual(eps.eps(), 0.0) + + def test_eps_counts_events_in_window(self) -> None: + eps = EventsPerSecond(last_n_seconds=10) + clock = [1000.0] + with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]): + eps.start() + # one event per second for five seconds + for _ in range(5): + clock[0] += 1.0 + eps.update() + # five events over the five seconds since start + self.assertAlmostEqual(eps.eps(), 1.0) + + def test_old_timestamps_expire_from_window(self) -> None: + eps = EventsPerSecond(last_n_seconds=10) + clock = [0.0] + with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]): + eps.start() + for _ in range(10): + clock[0] += 1.0 + eps.update() + # jump well past the window so every timestamp ages out + clock[0] += 100.0 + self.assertEqual(eps.eps(), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_camera_maintainer.py b/frigate/test/test_camera_maintainer.py new file mode 100644 index 0000000000..c03d965784 --- /dev/null +++ b/frigate/test/test_camera_maintainer.py @@ -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() diff --git a/frigate/test/test_camera_pw.py b/frigate/test/test_camera_pw.py index 0964f38bea..af4cecbd22 100644 --- a/frigate/test/test_camera_pw.py +++ b/frigate/test/test_camera_pw.py @@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte class TestUserPassCleanup(unittest.TestCase): def setUp(self) -> None: self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live" - self.rtsp_with_special_pass = ( - "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live" - ) + self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live" self.rtsp_no_pass = "rtsp://192.168.0.3:554/live" def test_cleanup(self): diff --git a/frigate/test/test_chat_find_similar_objects.py b/frigate/test/test_chat_find_similar_objects.py new file mode 100644 index 0000000000..73fd3b27db --- /dev/null +++ b/frigate/test/test_chat_find_similar_objects.py @@ -0,0 +1,306 @@ +"""Tests for the find_similar_objects chat tool.""" + +import asyncio +import os +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from playhouse.sqlite_ext import SqliteExtDatabase + +from frigate.api.chat import ( + _execute_find_similar_objects, + get_tool_definitions, +) +from frigate.api.chat_util import ( + DESCRIPTION_WEIGHT, + VISUAL_WEIGHT, + distance_to_score, + fuse_scores, +) +from frigate.embeddings.util import ZScoreNormalization +from frigate.models import Event + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class TestDistanceToScore(unittest.TestCase): + def test_lower_distance_gives_higher_score(self): + stats = ZScoreNormalization() + # Seed the stats with a small distribution so stddev > 0. + stats._update([0.1, 0.2, 0.3, 0.4, 0.5]) + + close_score = distance_to_score(0.1, stats) + far_score = distance_to_score(0.5, stats) + + self.assertGreater(close_score, far_score) + self.assertGreaterEqual(close_score, 0.0) + self.assertLessEqual(close_score, 1.0) + self.assertGreaterEqual(far_score, 0.0) + self.assertLessEqual(far_score, 1.0) + + def test_uninitialized_stats_returns_neutral_score(self): + stats = ZScoreNormalization() # n == 0, stddev == 0 + self.assertEqual(distance_to_score(0.3, stats), 0.5) + + +class TestFuseScores(unittest.TestCase): + def test_weights_sum_to_one(self): + self.assertAlmostEqual(VISUAL_WEIGHT + DESCRIPTION_WEIGHT, 1.0) + + def test_fuses_both_sides(self): + fused = fuse_scores(visual_score=0.8, description_score=0.4) + expected = VISUAL_WEIGHT * 0.8 + DESCRIPTION_WEIGHT * 0.4 + self.assertAlmostEqual(fused, expected) + + def test_missing_description_uses_visual_only(self): + fused = fuse_scores(visual_score=0.7, description_score=None) + self.assertAlmostEqual(fused, 0.7) + + def test_missing_visual_uses_description_only(self): + fused = fuse_scores(visual_score=None, description_score=0.6) + self.assertAlmostEqual(fused, 0.6) + + def test_both_missing_returns_none(self): + self.assertIsNone(fuse_scores(visual_score=None, description_score=None)) + + +class TestToolDefinition(unittest.TestCase): + def test_find_similar_objects_is_registered(self): + tools = get_tool_definitions() + names = [t["function"]["name"] for t in tools] + self.assertIn("find_similar_objects", names) + + def test_find_similar_objects_schema(self): + tools = get_tool_definitions() + tool = next(t for t in tools if t["function"]["name"] == "find_similar_objects") + params = tool["function"]["parameters"]["properties"] + self.assertIn("event_id", params) + self.assertIn("after", params) + self.assertIn("before", params) + self.assertIn("cameras", params) + self.assertIn("labels", params) + self.assertIn("sub_labels", params) + self.assertIn("zones", params) + self.assertIn("similarity_mode", params) + self.assertIn("min_score", params) + self.assertIn("limit", params) + self.assertEqual(tool["function"]["parameters"]["required"], ["event_id"]) + self.assertEqual( + params["similarity_mode"]["enum"], ["visual", "semantic", "fused"] + ) + + +class TestExecuteFindSimilarObjects(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmp.close() + self.db = SqliteExtDatabase(self.tmp.name) + Event.bind(self.db, bind_refs=False, bind_backrefs=False) + self.db.connect() + self.db.create_tables([Event]) + + # Insert an anchor plus two candidates. + def make(event_id, label="car", camera="driveway", start=1_700_000_100): + Event.create( + id=event_id, + label=label, + sub_label=None, + camera=camera, + start_time=start, + end_time=start + 10, + top_score=0.9, + score=0.9, + false_positive=False, + zones=[], + thumbnail="", + has_clip=True, + has_snapshot=True, + region=[0, 0, 1, 1], + box=[0, 0, 1, 1], + area=1, + retain_indefinitely=False, + ratio=1.0, + plus_id="", + model_hash="", + detector_type="", + model_type="", + data={"description": "a green sedan"}, + ) + + make("anchor", start=1_700_000_200) + make("cand_a", start=1_700_000_100) + make("cand_b", start=1_700_000_150) + self.make = make + + def tearDown(self): + self.db.close() + os.unlink(self.tmp.name) + + def _make_request(self, semantic_enabled=True, embeddings=None): + app = SimpleNamespace( + embeddings=embeddings, + frigate_config=SimpleNamespace( + semantic_search=SimpleNamespace(enabled=semantic_enabled), + cameras={"driveway": object()}, + auth=SimpleNamespace(roles={"admin": [], "viewer": ["driveway"]}), + proxy=SimpleNamespace(separator=","), + ), + ) + return SimpleNamespace(app=app, headers={}) + + def test_semantic_search_disabled_returns_error(self): + req = self._make_request(semantic_enabled=False) + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor"}, + allowed_cameras=["driveway"], + ) + ) + self.assertEqual(result["error"], "semantic_search_disabled") + + def test_anchor_not_found_returns_error(self): + embeddings = MagicMock() + req = self._make_request(embeddings=embeddings) + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "nope"}, + allowed_cameras=["driveway"], + ) + ) + self.assertEqual(result["error"], "anchor_not_found") + + def test_empty_candidates_returns_empty_results(self): + embeddings = MagicMock() + req = self._make_request(embeddings=embeddings) + # Filter to a camera with no other events. + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor", "cameras": ["nonexistent_cam"]}, + allowed_cameras=["driveway"], + ) + ) + self.assertEqual(result["results"], []) + self.assertFalse(result["candidate_truncated"]) + self.assertEqual(result["anchor"]["id"], "anchor") + + def test_fused_calls_both_searches_and_ranks(self): + embeddings = MagicMock() + # cand_a visually closer, cand_b semantically closer. + embeddings.search_thumbnail.return_value = [ + ("cand_a", 0.10), + ("cand_b", 0.40), + ] + embeddings.search_description.return_value = [ + ("cand_a", 0.50), + ("cand_b", 0.20), + ] + embeddings.thumb_stats = ZScoreNormalization() + embeddings.thumb_stats._update([0.1, 0.2, 0.3, 0.4, 0.5]) + embeddings.desc_stats = ZScoreNormalization() + embeddings.desc_stats._update([0.1, 0.2, 0.3, 0.4, 0.5]) + + req = self._make_request(embeddings=embeddings) + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor"}, + allowed_cameras=["driveway"], + ) + ) + embeddings.search_thumbnail.assert_called_once() + embeddings.search_description.assert_called_once() + # cand_a should rank first because visual is weighted higher. + self.assertEqual(result["results"][0]["id"], "cand_a") + self.assertIn("score", result["results"][0]) + self.assertEqual(result["similarity_mode"], "fused") + + def test_visual_mode_only_calls_thumbnail(self): + embeddings = MagicMock() + embeddings.search_thumbnail.return_value = [("cand_a", 0.1)] + embeddings.thumb_stats = ZScoreNormalization() + embeddings.thumb_stats._update([0.1, 0.2, 0.3]) + + req = self._make_request(embeddings=embeddings) + _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor", "similarity_mode": "visual"}, + allowed_cameras=["driveway"], + ) + ) + embeddings.search_thumbnail.assert_called_once() + embeddings.search_description.assert_not_called() + + def test_semantic_mode_only_calls_description(self): + embeddings = MagicMock() + embeddings.search_description.return_value = [("cand_a", 0.1)] + embeddings.desc_stats = ZScoreNormalization() + embeddings.desc_stats._update([0.1, 0.2, 0.3]) + + req = self._make_request(embeddings=embeddings) + _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor", "similarity_mode": "semantic"}, + allowed_cameras=["driveway"], + ) + ) + embeddings.search_description.assert_called_once() + embeddings.search_thumbnail.assert_not_called() + + def test_min_score_drops_low_scoring_results(self): + embeddings = MagicMock() + embeddings.search_thumbnail.return_value = [ + ("cand_a", 0.10), + ("cand_b", 0.90), + ] + embeddings.search_description.return_value = [] + embeddings.thumb_stats = ZScoreNormalization() + embeddings.thumb_stats._update([0.1, 0.2, 0.3, 0.4, 0.5]) + embeddings.desc_stats = ZScoreNormalization() + + req = self._make_request(embeddings=embeddings) + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor", "similarity_mode": "visual", "min_score": 0.6}, + allowed_cameras=["driveway"], + ) + ) + ids = [r["id"] for r in result["results"]] + self.assertIn("cand_a", ids) + self.assertNotIn("cand_b", ids) + + def test_labels_defaults_to_anchor_label(self): + self.make("person_a", label="person") + embeddings = MagicMock() + embeddings.search_thumbnail.return_value = [ + ("cand_a", 0.1), + ("cand_b", 0.2), + ] + embeddings.search_description.return_value = [] + embeddings.thumb_stats = ZScoreNormalization() + embeddings.thumb_stats._update([0.1, 0.2, 0.3]) + embeddings.desc_stats = ZScoreNormalization() + + req = self._make_request(embeddings=embeddings) + result = _run( + _execute_find_similar_objects( + req, + {"event_id": "anchor", "similarity_mode": "visual"}, + allowed_cameras=["driveway"], + ) + ) + ids = [r["id"] for r in result["results"]] + self.assertNotIn("person_a", ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_classification_enabled.py b/frigate/test/test_classification_enabled.py new file mode 100644 index 0000000000..abb139cd08 --- /dev/null +++ b/frigate/test/test_classification_enabled.py @@ -0,0 +1,106 @@ +"""Tests that disabled custom classification models are not registered or run.""" + +import sys +import unittest +from unittest.mock import MagicMock + +# Mock TFLite before importing the maintainer / classification modules +_MOCK_MODULES = [ + "tflite_runtime", + "tflite_runtime.interpreter", + "ai_edge_litert", + "ai_edge_litert.interpreter", +] +for mod in _MOCK_MODULES: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +from frigate.data_processing.real_time.custom_classification import ( # noqa: E402 + CustomObjectClassificationProcessor, +) +from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402 + + +class TestCustomClassificationEnabledGating(unittest.TestCase): + """A model with enabled: false must not keep a processor registered.""" + + def _make_maintainer(self) -> EmbeddingMaintainer: + # Bypass the heavy __init__; only the attributes touched by the + # config update path are needed for these tests. + maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer) + maintainer.realtime_processors = [] + maintainer.config = MagicMock() + maintainer.config.classification.custom = {} + maintainer.requestor = MagicMock() + maintainer.metrics = MagicMock() + maintainer.event_metadata_publisher = MagicMock() + return maintainer + + def _make_model_config(self, name: str, enabled: bool) -> MagicMock: + model_config = MagicMock() + model_config.name = name + model_config.enabled = enabled + model_config.state_config = None + return model_config + + def _make_processor(self, name: str) -> MagicMock: + processor = MagicMock(spec=CustomObjectClassificationProcessor) + processor.model_config = MagicMock() + processor.model_config.name = name + return processor + + def test_disabled_update_tears_down_existing_processor(self): + """Toggling a running model to disabled shuts down and drops its processor.""" + maintainer = self._make_maintainer() + processor = self._make_processor("atli") + maintainer.realtime_processors = [processor] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + processor.shutdown.assert_called_once() + self.assertEqual(maintainer.realtime_processors, []) + + def test_disabled_update_does_not_register_processor(self): + """A disabled model that has no processor is never registered.""" + maintainer = self._make_maintainer() + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + self.assertEqual(maintainer.realtime_processors, []) + + def test_disabled_update_leaves_other_processors_untouched(self): + """Disabling one model must not affect other running processors.""" + maintainer = self._make_maintainer() + other = self._make_processor("simbi") + maintainer.realtime_processors = [other] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + other.shutdown.assert_not_called() + self.assertEqual(maintainer.realtime_processors, [other]) + + def test_removed_model_tears_down_processor(self): + """A None payload (model deleted) still shuts down its processor.""" + maintainer = self._make_maintainer() + processor = self._make_processor("atli") + maintainer.realtime_processors = [processor] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", None + ) + + processor.shutdown.assert_called_once() + self.assertEqual(maintainer.realtime_processors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index afe577f2f2..95884342be 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -151,6 +151,22 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert "dog" in frigate_config.cameras["back"].objects.track + def test_deep_merge_override_replaces_list_values(self): + base = {"objects": {"track": ["person", "face"]}} + update = {"objects": {"track": ["person"]}} + + merged = deep_merge(base, update, override=True) + + assert merged["objects"]["track"] == ["person"] + + def test_deep_merge_merge_lists_still_appends(self): + base = {"track": ["person"]} + update = {"track": ["face"]} + + merged = deep_merge(base, update, override=True, merge_lists=True) + + assert merged["track"] == ["person", "face"] + def test_override_birdseye(self): config = { "mqtt": {"host": "mqtt"}, @@ -272,6 +288,61 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert "dog" in frigate_config.cameras["back"].objects.filters + def test_default_audio_filters(self): + config = { + "mqtt": {"host": "mqtt"}, + "audio": {"listen": ["speech", "yell"]}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + }, + } + + frigate_config = FrigateConfig(**config) + assert set(frigate_config.cameras["back"].audio.filters.keys()) == { + "speech", + "yell", + } + + def test_override_audio_filters(self): + config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + "audio": { + "listen": ["speech", "yell"], + "filters": {"speech": {"threshold": 0.9}}, + }, + } + }, + } + + frigate_config = FrigateConfig(**config) + assert "speech" in frigate_config.cameras["back"].audio.filters + assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9 + assert "yell" in frigate_config.cameras["back"].audio.filters + assert "babbling" not in frigate_config.cameras["back"].audio.filters + def test_inherit_object_filters(self): config = { "mqtt": {"host": "mqtt"}, @@ -326,6 +397,43 @@ class TestConfig(unittest.TestCase): assert "dog" in frigate_config.cameras["back"].objects.filters assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7 + def test_unsupported_tracked_object_pruned_from_track_and_filters(self): + # "unicorn" is not in the model labelmap, so it must be removed from the + # tracked objects AND from the object filters, otherwise a stale filter + # entry lingers in the parsed config. + config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + "objects": { + "track": ["person", "unicorn"], + "filters": { + "person": {"threshold": 0.7}, + "unicorn": {"threshold": 0.7}, + }, + }, + } + }, + } + + frigate_config = FrigateConfig(**config) + objects = frigate_config.cameras["back"].objects + assert "unicorn" not in objects.track + assert "unicorn" not in objects.filters + # supported entries are left untouched + assert "person" in objects.track + assert "person" in objects.filters + def test_global_object_mask(self): config = { "mqtt": {"host": "mqtt"}, @@ -343,8 +451,24 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "objects": { - "mask": "0,0,1,1,0,1", - "filters": {"dog": {"mask": "1,1,1,1,1,1"}}, + "mask": { + "global_mask_1": { + "friendly_name": "Global Mask 1", + "enabled": True, + "coordinates": "0,0,1,1,0,1", + } + }, + "filters": { + "dog": { + "mask": { + "dog_mask_1": { + "friendly_name": "Dog Mask 1", + "enabled": True, + "coordinates": "1,1,1,1,1,1", + } + } + } + }, }, } }, @@ -353,8 +477,10 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) back_camera = frigate_config.cameras["back"] assert "dog" in back_camera.objects.filters - assert len(back_camera.objects.filters["dog"].raw_mask) == 2 - assert len(back_camera.objects.filters["person"].raw_mask) == 1 + # dog filter has its own mask + global mask merged + assert len(back_camera.objects.filters["dog"].mask) == 2 + # person filter only has the global mask + assert len(back_camera.objects.filters["person"].mask) == 1 def test_motion_mask_relative_matches_explicit(self): config = { @@ -373,9 +499,13 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "motion": { - "mask": [ - "0,0,200,100,600,300,800,400", - ] + "mask": { + "explicit_mask": { + "friendly_name": "Explicit Mask", + "enabled": True, + "coordinates": "0,0,200,100,600,300,800,400", + } + } }, }, "relative": { @@ -390,9 +520,13 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "motion": { - "mask": [ - "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", - ] + "mask": { + "relative_mask": { + "friendly_name": "Relative Mask", + "enabled": True, + "coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", + } + } }, }, }, @@ -400,8 +534,8 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert np.array_equal( - frigate_config.cameras["explicit"].motion.mask, - frigate_config.cameras["relative"].motion.mask, + frigate_config.cameras["explicit"].motion.rasterized_mask, + frigate_config.cameras["relative"].motion.rasterized_mask, ) def test_default_input_args(self): @@ -904,6 +1038,7 @@ class TestConfig(unittest.TestCase): config = { "mqtt": {"host": "mqtt"}, + "detectors": {"cpu": {"type": "cpu"}}, "model": {"path": "plus://test"}, "cameras": { "back": { @@ -1087,7 +1222,7 @@ class TestConfig(unittest.TestCase): def test_global_detect_merge(self): config = { "mqtt": {"host": "mqtt"}, - "detect": {"max_disappeared": 1, "height": 720}, + "detect": {"max_disappeared": 1, "height": 720, "width": 1280}, "cameras": { "back": { "ffmpeg": { @@ -1166,7 +1301,7 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert frigate_config.cameras["back"].snapshots.bounding_box - assert frigate_config.cameras["back"].snapshots.quality == 70 + assert frigate_config.cameras["back"].snapshots.quality == 60 def test_global_snapshots_merge(self): config = { @@ -1575,5 +1710,60 @@ class TestConfig(unittest.TestCase): self.assertRaises(ValueError, lambda: FrigateConfig(**config)) +class TestAttributeFilterDefaults(unittest.TestCase): + """Verify attribute filter min_score handling at config load.""" + + def setUp(self): + self.minimal = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + }, + } + + def _build_config(self, object_filters: dict | None = None) -> FrigateConfig: + config = deep_merge({}, self.minimal) + if object_filters is not None: + config.setdefault("objects", {})["filters"] = object_filters + return FrigateConfig(**config) + + def test_attribute_with_no_filter_gets_default_min_score(self): + """Attribute with no user-provided filter gets created with min_score=0.7.""" + config = self._build_config() + face_filter = config.objects.filters.get("face") + self.assertIsNotNone(face_filter) + self.assertEqual(face_filter.min_score, 0.7) + + def test_attribute_filter_without_min_score_gets_bumped(self): + """If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7.""" + config = self._build_config({"face": {"min_area": 500}}) + face_filter = config.objects.filters["face"] + self.assertEqual(face_filter.min_area, 500) + self.assertEqual(face_filter.min_score, 0.7) + + def test_attribute_filter_explicit_min_score_half_is_preserved(self): + """User-provided min_score=0.5 must NOT be silently rewritten to 0.7.""" + config = self._build_config({"face": {"min_score": 0.5}}) + face_filter = config.objects.filters["face"] + self.assertEqual(face_filter.min_score, 0.5) + + def test_attribute_filter_explicit_min_score_other_value_is_preserved(self): + """Sanity: explicit non-0.5 values pass through unchanged.""" + config = self._build_config({"face": {"min_score": 0.3}}) + face_filter = config.objects.filters["face"] + self.assertEqual(face_filter.min_score, 0.3) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py new file mode 100644 index 0000000000..5888f26188 --- /dev/null +++ b/frigate/test/test_config_util.py @@ -0,0 +1,86 @@ +"""Tests for the shared runtime config swap helper.""" + +import unittest +from unittest.mock import MagicMock + +from frigate.api.config_util import swap_runtime_config +from frigate.config.holder import ConfigHolder + + +class TestSwapRuntimeConfig(unittest.TestCase): + """swap_runtime_config rebinds every collaborator to the new config.""" + + def _make_app(self) -> MagicMock: + app = MagicMock() + app.dispatcher.comms = [MagicMock(), MagicMock()] + app.config_holder = ConfigHolder(MagicMock(name="boot_config")) + return app + + def test_rebinds_all_references(self) -> None: + app = self._make_app() + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + self.assertIs(app.frigate_config, config) + app.genai_manager.update_config.assert_called_once_with(config) + app.profile_manager.update_config.assert_called_once_with(config) + self.assertIs(app.stats_emitter.config, config) + self.assertIs(app.dispatcher.config, config) + for comm in app.dispatcher.comms: + self.assertIs(comm.config, config) + + def test_reapplies_runtime_state_after_swap(self) -> None: + app = self._make_app() + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + # the swap rebuilds cameras from yaml, so overrides must be re-layered + app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with() + + def test_updates_the_config_holder(self) -> None: + app = self._make_app() + holder = app.config_holder + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + self.assertIs(holder.config, config) + + def test_deferred_factory_builds_from_the_swapped_config(self) -> None: + """A watchdog-style factory must not rebuild from the boot config. + + The factories in FrigateApp are lambdas evaluated when a process is + restarted, long after a user may have saved. Reading through the + holder is what keeps a rebuilt process from reverting every change + made since Frigate started. + """ + app = self._make_app() + holder = app.config_holder + boot_config = holder.config + factory = lambda: holder.config # noqa: E731 + self.assertIs(factory(), boot_config) + + config = MagicMock(name="new_config") + swap_runtime_config(app, config) + + self.assertIs(factory(), config) + + def test_tolerates_missing_optional_collaborators(self) -> None: + app = MagicMock() + app.profile_manager = None + app.stats_emitter = None + app.dispatcher = None + app.config_holder = None + config = MagicMock(name="new_config") + + # must not raise when the optional collaborators are absent + swap_runtime_config(app, config) + + self.assertIs(app.frigate_config, config) + app.genai_manager.update_config.assert_called_once_with(config) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_debug_replay.py b/frigate/test/test_debug_replay.py new file mode 100644 index 0000000000..a91f759c44 --- /dev/null +++ b/frigate/test/test_debug_replay.py @@ -0,0 +1,250 @@ +"""Tests for the simplified DebugReplayManager. + +Startup orchestration lives in ``frigate.jobs.debug_replay`` (covered by +``test_debug_replay_job``). The manager owns only session presence and +cleanup. +""" + +import unittest +import unittest.mock +from unittest.mock import MagicMock, patch + + +class TestDebugReplayManagerSession(unittest.TestCase): + def test_inactive_by_default(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + self.assertFalse(manager.active) + self.assertIsNone(manager.replay_camera_name) + self.assertIsNone(manager.source_camera) + self.assertIsNone(manager.clip_path) + self.assertIsNone(manager.start_ts) + self.assertIsNone(manager.end_ts) + + def test_mark_starting_sets_session_pointers_and_active(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + manager.mark_starting( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + + self.assertTrue(manager.active) + self.assertEqual(manager.replay_camera_name, "_replay_front") + self.assertEqual(manager.source_camera, "front") + self.assertEqual(manager.start_ts, 100.0) + self.assertEqual(manager.end_ts, 200.0) + self.assertIsNone(manager.clip_path) + + def test_mark_session_ready_sets_clip_path(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + + manager.mark_session_ready(clip_path="/tmp/replay/_replay_front.mp4") + + self.assertEqual(manager.clip_path, "/tmp/replay/_replay_front.mp4") + self.assertTrue(manager.active) + + def test_clear_session_resets_all_pointers(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + manager.mark_session_ready("/tmp/replay/clip.mp4") + + manager.clear_session() + + self.assertFalse(manager.active) + self.assertIsNone(manager.replay_camera_name) + self.assertIsNone(manager.source_camera) + self.assertIsNone(manager.clip_path) + self.assertIsNone(manager.start_ts) + self.assertIsNone(manager.end_ts) + + +class TestDebugReplayManagerStop(unittest.TestCase): + def setUp(self) -> None: + # stop() publishes a terminal job_state via a real JobStatePublisher, + # which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs + # in unit tests, so substitute a no-op publisher. + patcher = patch("frigate.debug_replay.JobStatePublisher") + patcher.start() + self.addCleanup(patcher.stop) + + def test_stop_when_inactive_is_a_noop(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + frigate_config = MagicMock() + frigate_config.cameras = {} + publisher = MagicMock() + + # Should not raise; should not publish any events. + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + publisher.publish_update.assert_not_called() + + def test_stop_publishes_remove_when_camera_was_published(self) -> None: + from frigate.config.camera.updater import CameraConfigUpdateEnum + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + manager.mark_session_ready("/tmp/replay/_replay_front.mp4") + + camera_config = MagicMock() + frigate_config = MagicMock() + frigate_config.cameras = {"_replay_front": camera_config} + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch("frigate.debug_replay.cancel_debug_replay_job", return_value=False), + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + # One publish_update call with a remove topic. + self.assertEqual(publisher.publish_update.call_count, 1) + topic_arg = publisher.publish_update.call_args.args[0] + self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.remove) + self.assertFalse(manager.active) + + def test_stop_skips_remove_publish_when_camera_not_in_config(self) -> None: + """Cancellation during preparing_clip: no camera was published yet.""" + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + # clip_path stays None because we cancelled before camera publish. + + frigate_config = MagicMock() + frigate_config.cameras = {} # _replay_front not present + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch("frigate.debug_replay.cancel_debug_replay_job", return_value=True), + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + publisher.publish_update.assert_not_called() + self.assertFalse(manager.active) + + def test_stop_calls_cancel_debug_replay_job(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + + frigate_config = MagicMock() + frigate_config.cameras = {} + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch( + "frigate.debug_replay.cancel_debug_replay_job", + return_value=True, + ) as mock_cancel, + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + mock_cancel.assert_called_once() + + +class TestDebugReplayManagerPublishCamera(unittest.TestCase): + def test_publish_camera_invokes_publisher_with_add_topic(self) -> None: + from frigate.config.camera.updater import CameraConfigUpdateEnum + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + source_config = MagicMock() + new_camera_config = MagicMock() + frigate_config = MagicMock() + frigate_config.cameras = {"front": source_config} + publisher = MagicMock() + + with ( + patch.object( + manager, + "_build_camera_config_dict", + return_value={"enabled": True}, + ), + patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"), + patch("frigate.debug_replay.YAML") as yaml_cls, + patch("frigate.debug_replay.FrigateConfig.parse_object") as parse_object, + patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")), + ): + yaml_instance = yaml_cls.return_value + yaml_instance.load.return_value = {"cameras": {}} + parsed = MagicMock() + parsed.cameras = {"_replay_front": new_camera_config} + parse_object.return_value = parsed + + manager.publish_camera( + source_camera="front", + replay_name="_replay_front", + clip_path="/tmp/clip.mp4", + frigate_config=frigate_config, + config_publisher=publisher, + ) + + # Camera registered into the live config dict + self.assertIn("_replay_front", frigate_config.cameras) + # Publisher invoked with an add topic + self.assertEqual(publisher.publish_update.call_count, 1) + topic_arg = publisher.publish_update.call_args.args[0] + self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.add) + + def test_publish_camera_wraps_parse_failure_in_runtime_error(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + frigate_config = MagicMock() + frigate_config.cameras = {"front": MagicMock()} + publisher = MagicMock() + + with ( + patch.object( + manager, + "_build_camera_config_dict", + return_value={"enabled": True}, + ), + patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"), + patch("frigate.debug_replay.YAML") as yaml_cls, + patch( + "frigate.debug_replay.FrigateConfig.parse_object", + side_effect=ValueError("zone foo has invalid coordinates"), + ), + patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")), + ): + yaml_cls.return_value.load.return_value = {"cameras": {}} + + with self.assertRaises(RuntimeError) as ctx: + manager.publish_camera( + source_camera="front", + replay_name="_replay_front", + clip_path="/tmp/clip.mp4", + frigate_config=frigate_config, + config_publisher=publisher, + ) + + self.assertIn("replay camera config", str(ctx.exception)) + self.assertIn("invalid coordinates", str(ctx.exception)) + publisher.publish_update.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py new file mode 100644 index 0000000000..e84a67e1d8 --- /dev/null +++ b/frigate/test/test_debug_replay_job.py @@ -0,0 +1,489 @@ +"""Tests for the debug replay job runner and factory.""" + +import threading +import time +import unittest +import unittest.mock +from unittest.mock import MagicMock, patch + +from frigate.debug_replay import DebugReplayManager +from frigate.jobs.debug_replay import ( + DebugReplayJob, + NoRecordingsError, + RecordingDebugReplaySource, + cancel_debug_replay_job, + get_active_runner, + start_debug_replay_job, +) +from frigate.jobs.export import JobStatePublisher +from frigate.jobs.manager import _completed_jobs, _current_jobs +from frigate.types import JobStatusTypesEnum + + +def _reset_job_manager() -> None: + """Clear the global job manager state between tests.""" + _current_jobs.clear() + _completed_jobs.clear() + + +def _patch_publisher(test_case: unittest.TestCase) -> None: + """Replace JobStatePublisher.publish with a no-op to avoid hanging on IPC.""" + publisher_patch = patch.object( + JobStatePublisher, "publish", lambda self, payload: None + ) + publisher_patch.start() + test_case.addCleanup(publisher_patch.stop) + + +class TestDebugReplayJob(unittest.TestCase): + def test_default_fields(self) -> None: + job = DebugReplayJob() + + self.assertEqual(job.job_type, "debug_replay") + self.assertEqual(job.status, JobStatusTypesEnum.queued) + self.assertIsNone(job.current_step) + self.assertEqual(job.progress_percent, 0.0) + + def test_to_dict_whitelist(self) -> None: + job = DebugReplayJob( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + job.current_step = "preparing_clip" + job.progress_percent = 42.5 + + payload = job.to_dict() + + # Top-level matches the standard Job shape. + for key in ( + "id", + "job_type", + "status", + "start_time", + "end_time", + "error_message", + "results", + ): + self.assertIn(key, payload, f"missing top-level field: {key}") + + results = payload["results"] + self.assertEqual(results["source_camera"], "front") + self.assertEqual(results["replay_camera_name"], "_replay_front") + self.assertEqual(results["current_step"], "preparing_clip") + self.assertEqual(results["progress_percent"], 42.5) + self.assertEqual(results["start_ts"], 100.0) + self.assertEqual(results["end_ts"], 200.0) + + +class TestStartDebugReplayJob(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def test_rejects_unknown_camera(self) -> None: + with self.assertRaises(ValueError): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="missing", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_rejects_invalid_time_range(self) -> None: + with self.assertRaises(ValueError): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=200.0, + end_ts=100.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_rejects_when_no_recordings(self) -> None: + empty_qs = MagicMock() + empty_qs.count.return_value = 0 + with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs): + with self.assertRaises(NoRecordingsError): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_returns_job_id_and_marks_session_starting(self) -> None: + block = threading.Event() + + def slow_helper(cmd, **kwargs): + block.wait(timeout=5) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=slow_helper, + ), + patch.object(self.manager, "publish_camera"), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + job_id = start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertIsInstance(job_id, str) + self.assertTrue(self.manager.active) + self.assertEqual(self.manager.replay_camera_name, "_replay_front") + self.assertEqual(self.manager.source_camera, "front") + + block.set() + + def test_rejects_concurrent_calls(self) -> None: + block = threading.Event() + + def slow_helper(cmd, **kwargs): + block.wait(timeout=5) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=slow_helper, + ), + patch.object(self.manager, "publish_camera"), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + with self.assertRaises(RuntimeError): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + block.set() + + +class TestRunnerHappyPath(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_progress_callback_updates_job_percent(self) -> None: + captured: list[float] = [] + + def fake_helper(cmd, *, on_progress=None, **kwargs): + on_progress(0.0) + on_progress(50.0) + on_progress(100.0) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=fake_helper, + ), + patch.object( + self.manager, + "publish_camera", + side_effect=lambda *a, **kw: captured.append("published"), + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertIsNotNone(job) + self.assertEqual(job.status, JobStatusTypesEnum.success) + self.assertEqual(job.progress_percent, 100.0) + self.assertEqual(captured, ["published"]) + # Manager should have been told the session is ready with the clip path. + self.assertIsNotNone(self.manager.clip_path) + + +class TestRunnerFailurePath(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_ffmpeg_failure_marks_job_failed_and_clears_session(self) -> None: + def failing_helper(cmd, **kwargs): + return 1, "ffmpeg exploded" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=failing_helper, + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("os.remove"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertIsNotNone(job) + self.assertEqual(job.status, JobStatusTypesEnum.failed) + self.assertIsNotNone(job.error_message) + self.assertIn("ffmpeg", job.error_message.lower()) + # Session cleared so a new /start is allowed + self.assertFalse(self.manager.active) + + +class TestRunnerCancellation(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_cancel_terminates_ffmpeg_and_marks_cancelled(self) -> None: + terminated = threading.Event() + fake_proc = MagicMock() + fake_proc.terminate = MagicMock(side_effect=lambda: terminated.set()) + + def fake_helper(cmd, *, process_started=None, **kwargs): + if process_started is not None: + process_started(fake_proc) + terminated.wait(timeout=5) + return -15, "killed" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=fake_helper, + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("os.remove"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source=RecordingDebugReplaySource( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + internal_port=5000, + ), + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + # Wait for the runner to register the active process. + self.assertTrue( + self._wait_for( + lambda: ( + get_active_runner() is not None + and get_active_runner()._active_process is fake_proc + ) + ) + ) + + cancelled = cancel_debug_replay_job() + self.assertTrue(cancelled) + self.assertTrue(fake_proc.terminate.called) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertEqual(job.status, JobStatusTypesEnum.cancelled) + # Runner must not clear the manager session on cancellation — + # that belongs to the caller of cancel_debug_replay_job (stop()). + # If the runner cleared it, stop() would log "no active session" + # and skip its cleanup_db / cleanup_files calls. + self.assertTrue(self.manager.active) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_deferred_processor.py b/frigate/test/test_deferred_processor.py new file mode 100644 index 0000000000..c76b445fa7 --- /dev/null +++ b/frigate/test/test_deferred_processor.py @@ -0,0 +1,211 @@ +"""Tests for DeferredRealtimeProcessorApi.""" + +import sys +import time +import unittest +from typing import Any +from unittest.mock import MagicMock, patch + +import numpy as np + +from frigate.data_processing.real_time.api import DeferredRealtimeProcessorApi + +# Mock TFLite before importing classification module +_MOCK_MODULES = [ + "tflite_runtime", + "tflite_runtime.interpreter", + "ai_edge_litert", + "ai_edge_litert.interpreter", +] +for mod in _MOCK_MODULES: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +from frigate.data_processing.real_time.custom_classification import ( # noqa: E402 + CustomObjectClassificationProcessor, +) + + +class StubDeferredProcessor(DeferredRealtimeProcessorApi): + """Minimal concrete subclass for testing the deferred base.""" + + def __init__(self, max_queue: int = 8): + config = MagicMock() + metrics = MagicMock() + super().__init__(config, metrics, max_queue=max_queue) + self.processed_items: list[tuple] = [] + + def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None: + """Enqueue every call — no gating logic in the stub.""" + self._enqueue_task(("frame", obj_data, frame.copy())) + + def _process_task(self, task: tuple) -> None: + kind = task[0] + if kind == "frame": + _, obj_data, frame = task + self.processed_items.append((obj_data["id"], frame.shape)) + self._emit_result( + { + "type": "test_result", + "id": obj_data["id"], + "label": "cat", + "score": 0.95, + } + ) + elif kind == "expire": + _, object_id = task + self.processed_items.append(("expired", object_id)) + + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: + if topic == "reload": + + def _do_reload(data): + return {"success": True, "model": data.get("name")} + + return self._enqueue_request(_do_reload, request_data) + return None + + def expire_object(self, object_id: str, camera: str) -> None: + self._enqueue_task(("expire", object_id)) + + +class TestDeferredProcessorBase(unittest.TestCase): + def test_enqueue_and_drain(self): + """Tasks enqueued on main thread are processed by worker, results are drainable.""" + proc = StubDeferredProcessor() + frame = np.zeros((100, 100, 3), dtype=np.uint8) + proc.process_frame({"id": "obj1"}, frame) + proc.process_frame({"id": "obj2"}, frame) + + # Give the worker time to process + time.sleep(0.1) + + results = proc.drain_results() + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["id"], "obj1") + self.assertEqual(results[1]["id"], "obj2") + + # Second drain should be empty + self.assertEqual(len(proc.drain_results()), 0) + + def test_backpressure_drops_tasks(self): + """When queue is full, new tasks are silently dropped.""" + proc = StubDeferredProcessor(max_queue=2) + + frame = np.zeros((10, 10, 3), dtype=np.uint8) + for i in range(10): + proc.process_frame({"id": f"obj{i}"}, frame) + + time.sleep(0.2) + results = proc.drain_results() + # The key property: no crash, no unbounded growth + self.assertLessEqual(len(results), 10) + self.assertGreater(len(results), 0) + + def test_handle_request_through_worker(self): + """handle_request blocks until the worker processes it and returns a response.""" + proc = StubDeferredProcessor() + result = proc.handle_request("reload", {"name": "my_model"}) + self.assertEqual(result, {"success": True, "model": "my_model"}) + + def test_expire_object_serialized_with_work(self): + """expire_object goes through the queue, serialized with inference work.""" + proc = StubDeferredProcessor() + frame = np.zeros((10, 10, 3), dtype=np.uint8) + proc.process_frame({"id": "obj1"}, frame) + proc.expire_object("obj1", "front_door") + + time.sleep(0.1) + # Both should have been processed in order + self.assertEqual(len(proc.processed_items), 2) + self.assertEqual(proc.processed_items[0][0], "obj1") + self.assertEqual(proc.processed_items[1], ("expired", "obj1")) + + def test_shutdown_joins_worker(self): + """shutdown() signals the worker to stop and joins the thread.""" + proc = StubDeferredProcessor() + proc.shutdown() + self.assertFalse(proc._worker.is_alive()) + + def test_drain_results_returns_list(self): + """drain_results returns a plain list, not a deque.""" + proc = StubDeferredProcessor() + results = proc.drain_results() + self.assertIsInstance(results, list) + + +class TestCustomObjectClassificationDeferred(unittest.TestCase): + """Test that CustomObjectClassificationProcessor uses the deferred pattern correctly.""" + + def _make_processor(self): + config = MagicMock() + model_config = MagicMock() + model_config.name = "test_breed" + model_config.object_config = MagicMock() + model_config.object_config.objects = ["dog"] + model_config.threshold = 0.5 + model_config.save_attempts = 10 + model_config.object_config.classification_type = "sub_label" + publisher = MagicMock() + requestor = MagicMock() + metrics = MagicMock() + metrics.classification_speeds = {} + metrics.classification_cps = {} + + with patch.object( + CustomObjectClassificationProcessor, + "_CustomObjectClassificationProcessor__build_detector", + ): + proc = CustomObjectClassificationProcessor( + config, model_config, publisher, requestor, metrics + ) + proc.interpreter = None + proc.tensor_input_details = [{"index": 0}] + proc.tensor_output_details = [{"index": 0}] + proc.labelmap = {0: "labrador", 1: "poodle", 2: "none"} + return proc + + def test_is_deferred_processor(self): + """CustomObjectClassificationProcessor should be a DeferredRealtimeProcessorApi.""" + proc = self._make_processor() + self.assertIsInstance(proc, DeferredRealtimeProcessorApi) + + def test_expire_clears_history(self): + """expire_object should clear classification history for the object.""" + proc = self._make_processor() + proc.classification_history["obj1"] = [("labrador", 0.9, 1.0)] + + proc.expire_object("obj1", "front") + time.sleep(0.1) + + self.assertNotIn("obj1", proc.classification_history) + + def test_drain_results_empty_when_no_model(self): + """With no interpreter, process_frame saves training images but emits no results.""" + proc = self._make_processor() + proc.interpreter = None + + frame = np.zeros((150, 100), dtype=np.uint8) + obj_data = { + "id": "obj1", + "label": "dog", + "false_positive": False, + "end_time": None, + "box": [10, 10, 50, 50], + "camera": "front", + } + + with patch( + "frigate.data_processing.real_time.custom_classification.write_classification_attempt" + ): + proc.process_frame(obj_data, frame) + + time.sleep(0.1) + results = proc.drain_results() + self.assertEqual(len(results), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_detection_runners.py b/frigate/test/test_detection_runners.py new file mode 100644 index 0000000000..38ea457290 --- /dev/null +++ b/frigate/test/test_detection_runners.py @@ -0,0 +1,88 @@ +"""Tests for ONNX Runtime session option selection.""" + +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np +import onnxruntime as ort + +from frigate.detectors.detection_runners import ( + CudaGraphRunner, + get_ort_session_options, +) +from frigate.detectors.detector_config import ModelTypeEnum +from frigate.embeddings.types import EnrichmentModelTypeEnum + + +class TestGetOrtSessionOptions(unittest.TestCase): + def test_jina_v2_uses_extended(self): + """jina-clip-v2 returns an identical vector for every image on the CUDA + execution provider at anything below EXTENDED.""" + options = get_ort_session_options(EnrichmentModelTypeEnum.jina_v2.value) + + self.assertIsNotNone(options) + self.assertEqual( + options.graph_optimization_level, + ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED, + ) + + def test_jina_v1_uses_basic(self): + options = get_ort_session_options(EnrichmentModelTypeEnum.jina_v1.value) + + self.assertIsNotNone(options) + self.assertEqual( + options.graph_optimization_level, + ort.GraphOptimizationLevel.ORT_ENABLE_BASIC, + ) + + def test_other_models_use_defaults(self): + for model_type in [ + None, + EnrichmentModelTypeEnum.paddleocr.value, + EnrichmentModelTypeEnum.arcface.value, + ModelTypeEnum.rfdetr.value, + ]: + with self.subTest(model_type=model_type): + self.assertIsNone(get_ort_session_options(model_type)) + + +class TestCudaGraphRunner(unittest.TestCase): + """CUDA graph capture fails if the arena has to allocate during capture, so + the session is warmed up with capture disabled before the first real run.""" + + def setUp(self): + self.session = MagicMock() + self.session.get_outputs.return_value = [MagicMock(name="output")] + self.io_binding = self.session.io_binding.return_value + self.input = {"images": np.zeros((1, 3, 320, 320), np.float32)} + + def _annotations(self) -> list[str | None]: + """Graph annotation id passed with each run, None when unset.""" + annotations = [] + + for call in self.session.run_with_iobinding.call_args_list: + try: + annotations.append(call.args[1].get_run_config_entry("gpu_graph_id")) + except RuntimeError: + annotations.append(None) + + return annotations + + def test_first_run_warms_up_with_capture_disabled(self): + with patch.object(ort.OrtValue, "ortvalue_from_numpy"): + CudaGraphRunner(self.session, 0).run(self.input) + + self.assertEqual( + self._annotations(), + ["-1"] * CudaGraphRunner.GRAPH_FREE_WARMUP_RUNS + [None], + ) + + def test_later_runs_allow_capture(self): + with patch.object(ort.OrtValue, "ortvalue_from_numpy"): + runner = CudaGraphRunner(self.session, 0) + runner.run(self.input) + self.session.run_with_iobinding.reset_mock() + runner.run(self.input) + + self.assertEqual(self._annotations(), [None]) + runner._input_ortvalue.update_inplace.assert_called_once() diff --git a/frigate/test/test_dispatcher_runtime_state.py b/frigate/test/test_dispatcher_runtime_state.py new file mode 100644 index 0000000000..0eee255729 --- /dev/null +++ b/frigate/test/test_dispatcher_runtime_state.py @@ -0,0 +1,457 @@ +"""Tests for Dispatcher runtime state persistence wiring.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from frigate.app import FrigateApp +from frigate.comms.dispatcher import Dispatcher +from frigate.comms.runtime_state import RuntimeStatePersistence + + +def _make_camera_mock( + *, + enabled: bool = True, + enabled_in_config: bool = True, + detect_enabled: bool = True, + record_enabled: bool = True, + record_enabled_in_config: bool = True, + snapshots_enabled: bool = True, + audio_enabled: bool = True, + audio_enabled_in_config: bool = True, +) -> MagicMock: + """Build a camera config mock with the fields the in-scope handlers read.""" + camera = MagicMock() + camera.enabled = enabled + camera.enabled_in_config = enabled_in_config + camera.detect.enabled = detect_enabled + camera.motion.enabled = True # avoid the detect→motion side-effect path + camera.record.enabled = record_enabled + camera.record.enabled_in_config = record_enabled_in_config + camera.snapshots.enabled = snapshots_enabled + camera.audio.enabled = audio_enabled + camera.audio.enabled_in_config = audio_enabled_in_config + return camera + + +def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher: + """Construct a Dispatcher with the bare-minimum mocks the tests need.""" + config = MagicMock() + config.cameras = cameras + config_updater = MagicMock() + onvif = MagicMock() + ptz_metrics: dict = {} + communicators: list = [] + + with ( + patch("frigate.comms.dispatcher.CameraActivityManager"), + patch("frigate.comms.dispatcher.AudioActivityManager"), + ): + return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators) + + +class TestRestoreRuntimeState(unittest.TestCase): + """Verify replay routes through handlers and tolerates missing entries.""" + + def setUp(self) -> None: + self.dispatcher = _build_dispatcher( + { + "front_door": _make_camera_mock(), + "back_yard": _make_camera_mock(), + } + ) + # Swap each in-scope handler for a MagicMock so we can assert calls + # without exercising the handler's own logic. + self.handler_mocks: dict[str, MagicMock] = {} + for topic in ("enabled", "detect", "snapshots", "recordings", "audio"): + mock = MagicMock() + self.dispatcher._camera_settings_handlers[topic] = mock + self.handler_mocks[topic] = mock + + def test_replays_each_stored_entry_through_its_handler(self) -> None: + self.dispatcher._runtime_state = MagicMock( + spec=RuntimeStatePersistence, + load=MagicMock( + return_value={ + "front_door": {"detect": False, "recordings": False}, + "back_yard": {"audio": False}, + } + ), + ) + self.dispatcher.restore_runtime_state() + + self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF") + self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF") + self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF") + self.handler_mocks["enabled"].assert_not_called() + self.handler_mocks["snapshots"].assert_not_called() + + def test_skips_unknown_cameras(self) -> None: + self.dispatcher._runtime_state = MagicMock( + spec=RuntimeStatePersistence, + load=MagicMock(return_value={"removed_cam": {"detect": False}}), + ) + self.dispatcher.restore_runtime_state() + for mock in self.handler_mocks.values(): + mock.assert_not_called() + + def test_skips_unknown_topics(self) -> None: + self.dispatcher._runtime_state = MagicMock( + spec=RuntimeStatePersistence, + load=MagicMock(return_value={"front_door": {"some_old_topic": True}}), + ) + self.dispatcher.restore_runtime_state() + for mock in self.handler_mocks.values(): + mock.assert_not_called() + + def test_continues_after_handler_exception(self) -> None: + self.handler_mocks["detect"].side_effect = RuntimeError("boom") + self.dispatcher._runtime_state = MagicMock( + spec=RuntimeStatePersistence, + load=MagicMock( + return_value={ + "front_door": {"detect": False, "recordings": False}, + } + ), + ) + # Must not raise; the recordings handler must still run. + self.dispatcher.restore_runtime_state() + self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF") + + def test_true_value_routes_as_on_payload(self) -> None: + self.dispatcher._runtime_state = MagicMock( + spec=RuntimeStatePersistence, + load=MagicMock(return_value={"front_door": {"detect": True}}), + ) + self.dispatcher.restore_runtime_state() + self.handler_mocks["detect"].assert_called_once_with("front_door", "ON") + + def test_apply_runtime_state_replays_through_handlers(self) -> None: + """The extracted method replays every stored entry.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"front_door": {"enabled": False, "detect": True}}, + ): + self.dispatcher.apply_runtime_state() + + self.handler_mocks["enabled"].assert_called_once_with("front_door", "OFF") + self.handler_mocks["detect"].assert_called_once_with("front_door", "ON") + + def test_apply_runtime_state_returns_applied_entries(self) -> None: + """Callers get back what was replayed, for logging and assertions.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"front_door": {"enabled": False}, "nope": {"enabled": True}}, + ): + applied = self.dispatcher.apply_runtime_state() + + self.assertEqual(applied, {"front_door": {"enabled": False}}) + + def test_restore_runtime_state_still_replays(self) -> None: + """The startup entry point keeps working after the extraction.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"back_yard": {"snapshots": False}}, + ): + self.dispatcher.restore_runtime_state() + + self.handler_mocks["snapshots"].assert_called_once_with("back_yard", "OFF") + + +class TestHandlersPersistViaSet(unittest.TestCase): + """Verify each in-scope handler writes to the runtime state on success.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + self.config_path = os.path.join(self.tmp_dir, "config.yml") + with open(self.config_path, "w") as f: + f.write("") + self._patcher = patch( + "frigate.comms.runtime_state.find_config_file", + return_value=self.config_path, + ) + self._patcher.start() + + # Start with everything OFF so each ON payload triggers a real change + self.cameras = { + "front_door": _make_camera_mock( + enabled=False, + detect_enabled=False, + record_enabled=False, + snapshots_enabled=False, + audio_enabled=False, + ) + } + self.dispatcher = _build_dispatcher(self.cameras) + + def tearDown(self) -> None: + self._patcher.stop() + for name in os.listdir(self.tmp_dir): + os.remove(os.path.join(self.tmp_dir, name)) + os.rmdir(self.tmp_dir) + + def _stored_state(self) -> dict: + return RuntimeStatePersistence().load() + + def test_enabled_handler_persists(self) -> None: + self.dispatcher._on_enabled_command("front_door", "ON") + self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}}) + + def test_detect_handler_persists(self) -> None: + self.dispatcher._on_detect_command("front_door", "ON") + self.assertEqual(self._stored_state(), {"front_door": {"detect": True}}) + + def test_recordings_handler_persists(self) -> None: + self.dispatcher._on_recordings_command("front_door", "ON") + self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}}) + + def test_snapshots_handler_persists(self) -> None: + self.dispatcher._on_snapshots_command("front_door", "ON") + self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}}) + + def test_audio_handler_persists(self) -> None: + self.dispatcher._on_audio_command("front_door", "ON") + self.assertEqual(self._stored_state(), {"front_door": {"audio": True}}) + + def test_enabled_in_config_gate_blocks_persistence(self) -> None: + """An ON payload rejected by the gate must not be persisted.""" + cam = self.cameras["front_door"] + cam.enabled_in_config = False + cam.record.enabled_in_config = False + cam.audio.enabled_in_config = False + + self.dispatcher._on_enabled_command("front_door", "ON") + self.dispatcher._on_recordings_command("front_door", "ON") + self.dispatcher._on_audio_command("front_door", "ON") + + self.assertEqual(self._stored_state(), {}) + + +class TestClearPassthrough(unittest.TestCase): + """The dispatcher's public clear methods delegate to the store.""" + + def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None: + dispatcher = _build_dispatcher({}) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + keys = ["cameras.front_door.detect.enabled"] + dispatcher.clear_runtime_state_for_yaml_keys(keys) + dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys) + + def test_clear_runtime_state_passthrough(self) -> None: + dispatcher = _build_dispatcher({}) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher.clear_runtime_state() + dispatcher._runtime_state.clear_all.assert_called_once_with() + + def test_clear_runtime_state_for_camera_passthrough(self) -> None: + dispatcher = _build_dispatcher({}) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher.clear_runtime_state_for_camera("front_door") + dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door") + + +class TestReapplyRuntimeStateToConfig(unittest.TestCase): + """The silent re-apply corrects the config object with no side effects.""" + + def _dispatcher_with( + self, cameras: dict[str, MagicMock], state: dict + ) -> Dispatcher: + dispatcher = _build_dispatcher(cameras) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher._runtime_state.load.return_value = state + dispatcher.publish = MagicMock() + return dispatcher + + def test_mutates_every_tracked_field(self) -> None: + cameras = {"front_door": _make_camera_mock()} + dispatcher = self._dispatcher_with( + cameras, + { + "front_door": { + "enabled": False, + "detect": False, + "snapshots": False, + "recordings": False, + "audio": False, + } + }, + ) + + dispatcher.reapply_runtime_state_to_config() + + cam = cameras["front_door"] + self.assertFalse(cam.enabled) + self.assertFalse(cam.detect.enabled) + self.assertFalse(cam.snapshots.enabled) + self.assertFalse(cam.record.enabled) + self.assertFalse(cam.audio.enabled) + + def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, + {"front_door": {"enabled": False}}, + ) + + dispatcher.reapply_runtime_state_to_config() + + dispatcher.config_updater.publish_update.assert_not_called() + dispatcher._runtime_state.set.assert_not_called() + dispatcher.publish.assert_not_called() + + def test_respects_enabled_in_config_gate(self) -> None: + # an ON override for a camera disabled in yaml must not enable it + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=False) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].enabled) + + def test_respects_recordings_and_audio_gates(self) -> None: + # ON overrides for recordings/audio not enabled in yaml must be ignored + cameras = { + "front_door": _make_camera_mock( + record_enabled=False, + record_enabled_in_config=False, + audio_enabled=False, + audio_enabled_in_config=False, + ) + } + dispatcher = self._dispatcher_with( + cameras, {"front_door": {"recordings": True, "audio": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].record.enabled) + self.assertFalse(cameras["front_door"].audio.enabled) + + def test_applies_on_override_when_gate_passes(self) -> None: + # a camera off in yaml but enabled_in_config keeps its runtime-on state + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=True) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cameras["front_door"].enabled) + + def test_detect_on_couples_motion(self) -> None: + cam = _make_camera_mock(detect_enabled=False) + cam.motion.enabled = False + dispatcher = self._dispatcher_with( + {"front_door": cam}, {"front_door": {"detect": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cam.detect.enabled) + self.assertTrue(cam.motion.enabled) + + def test_skips_camera_not_in_config(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}} + ) + + # a stale entry for a deleted camera must be ignored, not raise + dispatcher.reapply_runtime_state_to_config() + + +class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase): + """Both layers must reach the config before config-carrying workers start. + + A worker started before a layer is applied keeps the yaml value for the + rest of the session: the config_updater broadcast sent later is dropped + for subscribers that have not connected yet, and nothing re-sends it. + """ + + CONFIG_LAYERS = ( + "profile_manager.restore_persisted_profile_to_config", + "dispatcher.reapply_runtime_state_to_config", + ) + + # started with a copy of the camera config + CONFIG_CARRYING_WORKERS = ( + "start_video_output_processor", + "start_ptz_autotracker", + "start_detected_frames_processor", + "start_camera_processor", + "start_audio_processor", + ) + + def _start_call_order(self) -> list[str]: + """Return the names FrigateApp.start() calls, in order.""" + app = MagicMock() + + with ( + patch("frigate.app.set_file_limit"), + patch("frigate.app.cleanup_replay_cameras"), + patch("frigate.app.reap_stale_exports"), + patch("frigate.app.create_fastapi_app"), + patch("frigate.app.uvicorn"), + ): + FrigateApp.start(app) + + return [name for name, _, _ in app.mock_calls] + + def test_applied_before_any_config_carrying_worker(self) -> None: + order = self._start_call_order() + + for layer in self.CONFIG_LAYERS: + for worker in self.CONFIG_CARRYING_WORKERS: + self.assertLess(order.index(layer), order.index(worker)) + + def test_applied_after_the_dispatcher_exists(self) -> None: + order = self._start_call_order() + + for layer in self.CONFIG_LAYERS: + self.assertLess(order.index("init_dispatcher"), order.index(layer)) + + def test_applied_after_the_profile_base_is_snapshotted(self) -> None: + # ProfileManager snapshots the config as the "no profile" base that + # deactivation resets to, so neither layer may be in the config yet + order = self._start_call_order() + + for layer in self.CONFIG_LAYERS: + self.assertLess(order.index("init_profile_manager"), order.index(layer)) + + def test_layers_applied_in_order(self) -> None: + # a runtime toggle is the layer the user set last, so it goes on top + order = self._start_call_order() + + self.assertLess( + order.index("profile_manager.restore_persisted_profile_to_config"), + order.index("dispatcher.reapply_runtime_state_to_config"), + ) + + def test_overrides_still_re_applied_after_the_profile_is_restored(self) -> None: + # activation resets the sections it owns to the base first, so the + # overrides have to land on top again + order = self._start_call_order() + + self.assertLess( + order.index("profile_manager.restore_persisted_profile"), + order.index("dispatcher.restore_runtime_state"), + ) + + def test_broadcast_replay_still_runs_at_the_end(self) -> None: + # the broadcast is the only channel for the recording, review, and + # embeddings processes, which start before the config can be corrected + order = self._start_call_order() + + for replay in ( + "profile_manager.restore_persisted_profile", + "dispatcher.restore_runtime_state", + ): + self.assertLess(order.index("start_audio_processor"), order.index(replay)) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_env.py b/frigate/test/test_env.py index fe2ce8d3eb..37b81a6564 100644 --- a/frigate/test/test_env.py +++ b/frigate/test/test_env.py @@ -2,6 +2,7 @@ import os import unittest +from unittest.mock import MagicMock, patch from frigate.config.env import ( FRIGATE_ENV_VARS, @@ -10,6 +11,71 @@ from frigate.config.env import ( ) +class TestGo2RtcAddStreamSubstitution(unittest.TestCase): + """Covers the API path: PUT /go2rtc/streams/{stream_name}. + + The route shells out to go2rtc via `requests.put`; we mock the HTTP call + and assert that the substituted `src` parameter handles the same mixed + {FRIGATE_*} + literal-brace strings as the config-loading path. + """ + + def setUp(self): + self._original_env_vars = dict(FRIGATE_ENV_VARS) + + def tearDown(self): + FRIGATE_ENV_VARS.clear() + FRIGATE_ENV_VARS.update(self._original_env_vars) + + def _call_route(self, src: str) -> str: + """Invoke go2rtc_add_stream and return the substituted src param.""" + from frigate.api import camera as camera_api + + captured = {} + + def fake_put(url, params=None, timeout=None): + captured["params"] = params + resp = MagicMock() + resp.ok = True + resp.text = "" + resp.status_code = 200 + return resp + + with patch.object(camera_api.requests, "put", side_effect=fake_put): + camera_api.go2rtc_add_stream( + request=MagicMock(), stream_name="cam1", src=src + ) + return captured["params"]["src"] + + def test_mixed_localtime_and_frigate_var(self): + """%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var.""" + FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin" + src = ( + "ffmpeg:rtsp://host/s#raw=-vf " + "drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}" + ) + self.assertEqual( + self._call_route(src), + "ffmpeg:rtsp://host/s#raw=-vf " + "drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin", + ) + + def test_unknown_var_falls_back_to_raw_src(self): + """Existing route behavior: unknown {FRIGATE_*} keeps raw src.""" + src = "rtsp://host/{FRIGATE_NONEXISTENT}/stream" + self.assertEqual(self._call_route(src), src) + + def test_malformed_placeholder_rejected_via_api(self): + """Malformed FRIGATE placeholders raise (not silently passed through). + + Regression: previously camera.py caught any KeyError and fell back + to the raw src, so `{FRIGATE_FOO:>5}` was silently accepted via the + API while config loading rejected it. The helper now raises + ValueError for malformed syntax to keep the two paths consistent. + """ + with self.assertRaises(ValueError): + self._call_route("rtsp://host/{FRIGATE_FOO:>5}/stream") + + class TestEnvString(unittest.TestCase): def setUp(self): self._original_env_vars = dict(FRIGATE_ENV_VARS) @@ -43,6 +109,72 @@ class TestEnvString(unittest.TestCase): with self.assertRaises(KeyError): validate_env_string("{FRIGATE_NONEXISTENT_VAR}") + def test_non_frigate_braces_passthrough(self): + """Braces that are not {FRIGATE_*} placeholders pass through untouched. + + Regression test for ffmpeg drawtext expressions like + "%{localtime\\:%Y-%m-%d}" being mangled by str.format(). + """ + expr = ( + "ffmpeg:rtsp://127.0.0.1/src#raw=-vf " + "drawtext=text=%{localtime\\:%Y-%m-%d_%H\\:%M\\:%S}" + ":x=5:fontcolor=white" + ) + self.assertEqual(validate_env_string(expr), expr) + + def test_double_brace_escape_preserved(self): + """`{{output}}` collapses to `{output}` (documented go2rtc escape).""" + result = validate_env_string( + "exec:ffmpeg -i /media/file.mp4 -f rtsp {{output}}" + ) + self.assertEqual(result, "exec:ffmpeg -i /media/file.mp4 -f rtsp {output}") + + def test_double_brace_around_frigate_var(self): + """`{{FRIGATE_FOO}}` stays literal — escape takes precedence.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + self.assertEqual(validate_env_string("{{FRIGATE_FOO}}"), "{FRIGATE_FOO}") + + def test_mixed_frigate_var_and_braces(self): + """A FRIGATE_ var alongside literal single braces substitutes only the var.""" + FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin" + result = validate_env_string( + "drawtext=text=%{localtime}:user={FRIGATE_USER}:x=5" + ) + self.assertEqual(result, "drawtext=text=%{localtime}:user=admin:x=5") + + def test_triple_braces_around_frigate_var(self): + """`{{{FRIGATE_FOO}}}` collapses like str.format(): `{bar}`.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + self.assertEqual(validate_env_string("{{{FRIGATE_FOO}}}"), "{bar}") + + def test_trailing_double_brace_after_var(self): + """`{FRIGATE_FOO}}}` collapses like str.format(): `bar}`.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + self.assertEqual(validate_env_string("{FRIGATE_FOO}}}"), "bar}") + + def test_leading_double_brace_then_var(self): + """`{{{FRIGATE_FOO}` collapses like str.format(): `{bar`.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + self.assertEqual(validate_env_string("{{{FRIGATE_FOO}"), "{bar") + + def test_malformed_unterminated_placeholder_raises(self): + """`{FRIGATE_FOO` (no closing brace) raises like str.format() did.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + with self.assertRaises(ValueError): + validate_env_string("prefix-{FRIGATE_FOO") + + def test_malformed_format_spec_raises(self): + """`{FRIGATE_FOO:>5}` (format spec) raises like str.format() did.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + with self.assertRaises(ValueError): + validate_env_string("{FRIGATE_FOO:>5}") + + def test_malformed_conversion_raises(self): + """`{FRIGATE_FOO!r}` (conversion) raises like str.format() did.""" + FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar" + with self.assertRaises(ValueError): + validate_env_string("{FRIGATE_FOO!r}") + class TestEnvVars(unittest.TestCase): def setUp(self): diff --git a/frigate/test/test_export.py b/frigate/test/test_export.py new file mode 100644 index 0000000000..7612a4144f --- /dev/null +++ b/frigate/test/test_export.py @@ -0,0 +1,132 @@ +import unittest + +from frigate.record.export import validate_ffmpeg_args + + +class TestValidateFfmpegArgs(unittest.TestCase): + """Tests for the non-admin custom export ffmpeg arg validator. + + The validator uses a structural allowlist: every token must be an + allowlisted flag or the value of one, filter values are restricted to a + safe set of filters, and no token may become a bare input/output URL. + """ + + def assertRejected(self, args: str) -> None: + valid, message = validate_ffmpeg_args(args) + self.assertFalse(valid, f"expected {args!r} to be rejected") + self.assertNotEqual(message, "") + + def assertAllowed(self, args: str) -> None: + valid, message = validate_ffmpeg_args(args) + self.assertTrue(valid, f"expected {args!r} to be allowed, got: {message}") + self.assertEqual(message, "") + + # --- legitimate use cases must keep working --------------------------- + + def test_timelapse_setpts_allowed(self): + # The whole reason -vf cannot simply be blocked: timelapse exports. + self.assertAllowed("-vf setpts=PTS/60 -r 25") + self.assertAllowed("-vf setpts=0.04*PTS -r 30") # server default + self.assertAllowed("-filter:v setpts=PTS/60 -r 25") + + def test_default_input_args_allowed(self): + self.assertAllowed("") + self.assertAllowed("-an -skip_frame nokey") + + def test_encoding_args_allowed(self): + self.assertAllowed("-c:v libx264 -crf 23 -preset fast") + self.assertAllowed("-c:v copy -c:a copy") + self.assertAllowed("-c:v libx264 -b:v 2M -maxrate 2M -bufsize 4M") + self.assertAllowed("-movflags +faststart") + self.assertAllowed("-pix_fmt yuv420p -r 30 -g 30") + + def test_safe_filters_allowed(self): + self.assertAllowed("-vf scale=640:480") + self.assertAllowed("-vf scale=640:480,setpts=0.5*PTS") + self.assertAllowed("-vf format=yuv420p") + self.assertAllowed("-vf transpose=1") + self.assertAllowed("-vf hflip") + self.assertAllowed("-vf fps=15") + self.assertAllowed("-vf setsar=1 -an") + self.assertAllowed("-vf setdar=16/9") + + # --- the reported advisory and file-read class ------------------------ + + def test_reported_advisory_rejected(self): + self.assertRejected( + "-filter:v drawtext=textfile=/etc/passwd:fontcolor=white:fontsize=20" + ) + + def test_file_reading_filters_rejected(self): + self.assertRejected("-vf movie=/etc/passwd") + self.assertRejected("-vf drawtext=textfile=/etc/passwd") + self.assertRejected("-vf subtitles=/etc/passwd") + # marker embedded as an option of an otherwise-allowed filter name + self.assertRejected("-vf scale=movie=/etc/passwd") + + def test_filtergraph_brackets_rejected(self): + # link labels aren't needed for safe filters; rejecting "[" / "]" keeps + # filtergraph validation linear (no ReDoS on attacker input) + self.assertRejected("-vf [in]scale=640:480[out]") + self.assertRejected("-vf " + "[" * 5000) + + def test_preset_file_read_rejected(self): + # cwd-anchored traversal slipped past the old startswith() path check + self.assertRejected("-fpre frigate/../../../etc/passwd") + self.assertRejected("-fpre evil.preset") + self.assertRejected("-vpre x") + self.assertRejected("-apre x") + self.assertRejected("-pre x") + + def test_slash_option_file_read_rejected(self): + # ffmpeg "-/option file" reads the option value from a file + self.assertRejected("-/filter:v graph.txt") + self.assertRejected("-/filter_complex graph.txt") + + # --- network / SSRF class --------------------------------------------- + + def test_schemeless_protocol_rejected(self): + self.assertRejected("-f mpegts tcp:10.0.0.5:4444") + self.assertRejected("tcp:10.0.0.5:4444") + self.assertRejected("udp:10.0.0.5:4444") + self.assertRejected("-progress http:attacker.example.com:80/p") + + # --- file-write class -------------------------------------------------- + + def test_tee_write_rejected(self): + self.assertRejected("-c:v libx264 -map 0 -f tee [f=mpegts]/tmp/owned.ts") + self.assertRejected("-f tee [f=mpegts]/etc/frigate/x.ts") + self.assertRejected("tee:/tmp/x") + + def test_bare_output_token_rejected(self): + self.assertRejected("evil.mp4") + self.assertRejected("-c copy evil.mp4") + self.assertRejected("x/../escaped.mkv") + + def test_file_producing_muxers_rejected(self): + self.assertRejected("-f hls -hls_segment_filename pwn%03d.ts out.m3u8") + self.assertRejected("-f md5 victim.txt") + self.assertRejected("-f segment seg%03d.ts") + + def test_write_flags_rejected(self): + self.assertRejected("-progress evil.log") + self.assertRejected("-stats_enc_pre evil.csv") + self.assertRejected("-report") + + # --- resource exhaustion / misc --------------------------------------- + + def test_dos_input_flags_rejected(self): + self.assertRejected("-stream_loop -1") + self.assertRejected("-readrate 0.001") + + def test_disallowed_flags_rejected(self): + self.assertRejected("-map 0") + self.assertRejected("-i /etc/passwd") + self.assertRejected("-attach evil.bin") + self.assertRejected("-dump_attachment evil.bin") + self.assertRejected("/etc/passwd") + self.assertRejected("-metadata comment=x") + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_export_progress.py b/frigate/test/test_export_progress.py new file mode 100644 index 0000000000..81dcdb96e2 --- /dev/null +++ b/frigate/test/test_export_progress.py @@ -0,0 +1,553 @@ +"""Tests for export progress tracking, broadcast, and FFmpeg parsing.""" + +import io +import os +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from frigate.jobs.export import ( + PROGRESS_BROADCAST_MIN_INTERVAL, + ExportJob, + ExportJobManager, +) +from frigate.record.export import PlaybackSourceEnum, RecordingExporter +from frigate.types import JobStatusTypesEnum +from frigate.util.ffmpeg import inject_progress_flags + + +def _make_exporter( + end_minus_start: int = 100, + ffmpeg_input_args=None, + ffmpeg_output_args=None, + on_progress=None, +) -> RecordingExporter: + """Build a RecordingExporter without invoking its real __init__ side + effects (which create directories and require a full FrigateConfig).""" + exporter = RecordingExporter.__new__(RecordingExporter) + exporter.config = MagicMock() + exporter.export_id = "test_export" + exporter.camera = "front" + exporter.user_provided_name = None + exporter.user_provided_image = None + exporter.start_time = 1_000 + exporter.end_time = 1_000 + end_minus_start + exporter.playback_source = PlaybackSourceEnum.recordings + exporter.export_case_id = None + exporter.ffmpeg_input_args = ffmpeg_input_args + exporter.ffmpeg_output_args = ffmpeg_output_args + exporter.cpu_fallback = False + exporter.on_progress = on_progress + return exporter + + +class TestExportJobToDict(unittest.TestCase): + def test_to_dict_includes_progress_fields(self) -> None: + job = ExportJob(camera="front", request_start_time=0, request_end_time=10) + result = job.to_dict() + + assert "current_step" in result + assert "progress_percent" in result + assert result["current_step"] == "queued" + assert result["progress_percent"] == 0.0 + + def test_to_dict_reflects_updated_progress(self) -> None: + job = ExportJob(camera="front", request_start_time=0, request_end_time=10) + job.current_step = "encoding" + job.progress_percent = 42.5 + + result = job.to_dict() + + assert result["current_step"] == "encoding" + assert result["progress_percent"] == 42.5 + + +class TestExpectedOutputDuration(unittest.TestCase): + def test_normal_export_uses_input_duration(self) -> None: + exporter = _make_exporter(end_minus_start=600) + assert exporter._expected_output_duration_seconds() == 600.0 + + def test_timelapse_uses_setpts_factor(self) -> None: + exporter = _make_exporter( + end_minus_start=1000, + ffmpeg_input_args="-y", + ffmpeg_output_args="-vf setpts=0.04*PTS -r 30", + ) + # 1000s input * 0.04 = 40s of output + assert exporter._expected_output_duration_seconds() == 40.0 + + def test_unknown_factor_falls_back_to_input_duration(self) -> None: + exporter = _make_exporter( + end_minus_start=300, + ffmpeg_input_args="-y", + ffmpeg_output_args="-c:v libx264 -preset veryfast", + ) + assert exporter._expected_output_duration_seconds() == 300.0 + + def test_zero_factor_falls_back_to_input_duration(self) -> None: + exporter = _make_exporter( + end_minus_start=300, + ffmpeg_input_args="-y", + ffmpeg_output_args="-vf setpts=0*PTS", + ) + assert exporter._expected_output_duration_seconds() == 300.0 + + def test_uses_actual_recorded_seconds_when_available(self) -> None: + """If the DB shows only 120s of saved recordings inside a 1h + requested range, progress should be computed against 120s.""" + exporter = _make_exporter(end_minus_start=3600) + exporter._sum_source_duration_seconds = lambda: 120.0 # type: ignore[method-assign] + assert exporter._expected_output_duration_seconds() == 120.0 + + def test_actual_recorded_seconds_scaled_by_setpts(self) -> None: + """Recorded duration must still be scaled by the timelapse factor.""" + exporter = _make_exporter( + end_minus_start=3600, + ffmpeg_input_args="-y", + ffmpeg_output_args="-vf setpts=0.04*PTS -r 30", + ) + exporter._sum_source_duration_seconds = lambda: 600.0 # type: ignore[method-assign] + # 600s * 0.04 = 24s of output + assert exporter._expected_output_duration_seconds() == 24.0 + + def test_db_failure_falls_back_to_requested_range(self) -> None: + exporter = _make_exporter(end_minus_start=300) + exporter._sum_source_duration_seconds = lambda: None # type: ignore[method-assign] + assert exporter._expected_output_duration_seconds() == 300.0 + + +class TestProgressFlagInjection(unittest.TestCase): + def test_inserts_before_output_path(self) -> None: + cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"] + + result = inject_progress_flags(cmd) + + assert result == [ + "ffmpeg", + "-i", + "input.m3u8", + "-c", + "copy", + "-progress", + "pipe:2", + "-nostats", + "/tmp/output.mp4", + ] + + def test_handles_empty_cmd(self) -> None: + assert inject_progress_flags([]) == [] + + +class TestFfmpegProgressParsing(unittest.TestCase): + """Verify percentage calculation from FFmpeg ``-progress`` output.""" + + def _run_with_stderr( + self, + stderr_text: str, + expected_duration_seconds: int = 90, + ) -> list[tuple[str, float]]: + """Helper: run _run_ffmpeg_with_progress against a mocked Popen + whose stderr emits the supplied text. Returns the list of + (step, percent) tuples that the on_progress callback received.""" + captured: list[tuple[str, float]] = [] + + def on_progress(step: str, percent: float) -> None: + captured.append((step, percent)) + + exporter = _make_exporter( + end_minus_start=expected_duration_seconds, + on_progress=on_progress, + ) + + fake_proc = MagicMock() + fake_proc.stdin = io.StringIO() + fake_proc.stderr = io.StringIO(stderr_text) + fake_proc.returncode = 0 + fake_proc.wait = MagicMock(return_value=0) + + with patch("frigate.util.ffmpeg.sp.Popen", return_value=fake_proc): + returncode, _stderr = exporter._run_ffmpeg_with_progress( + ["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding" + ) + + assert returncode == 0 + return captured + + def test_parses_out_time_us_into_percent(self) -> None: + # 90s duration; 45s out_time => 50% + stderr = "out_time_us=45000000\nprogress=continue\n" + captured = self._run_with_stderr(stderr, expected_duration_seconds=90) + + # The first call is the synchronous 0.0 emit before Popen runs. + assert captured[0] == ("encoding", 0.0) + assert any(percent == 50.0 for step, percent in captured if step == "encoding") + + def test_progress_end_emits_100_percent(self) -> None: + stderr = "out_time_us=10000000\nprogress=end\n" + captured = self._run_with_stderr(stderr, expected_duration_seconds=90) + + assert captured[-1] == ("encoding", 100.0) + + def test_clamps_overshoot_at_100(self) -> None: + # 150s of output reported against 90s expected duration. + stderr = "out_time_us=150000000\nprogress=continue\n" + captured = self._run_with_stderr(stderr, expected_duration_seconds=90) + + encoding_values = [p for s, p in captured if s == "encoding" and p > 0] + assert all(p <= 100.0 for p in encoding_values) + assert encoding_values[-1] == 100.0 + + def test_ignores_garbage_lines(self) -> None: + stderr = ( + "frame= 120 fps= 30 q=23.0 size= 512kB\n" + "out_time_us=not-a-number\n" + "out_time_us=30000000\n" + "progress=continue\n" + ) + captured = self._run_with_stderr(stderr, expected_duration_seconds=90) + + # We expect 0.0 (from initial emit) plus the 30s/90s = 33.33...% step + encoding_percents = sorted({round(p, 2) for s, p in captured}) + assert 0.0 in encoding_percents + assert any(abs(p - (30 / 90 * 100)) < 0.01 for p in encoding_percents) + + +class TestBroadcastAggregation(unittest.TestCase): + """Verify ExportJobManager broadcast payload shape and throttling.""" + + def _make_manager(self) -> tuple[ExportJobManager, MagicMock]: + """Build a manager with an injected mock publisher. Returns + ``(manager, publisher)`` so tests can assert on broadcast payloads + without touching ZMQ at all.""" + config = MagicMock() + publisher = MagicMock() + manager = ExportJobManager( + config, max_concurrent=2, max_queued=10, publisher=publisher + ) + return manager, publisher + + @staticmethod + def _last_payload(publisher: MagicMock) -> dict: + return publisher.publish.call_args.args[0] + + def test_empty_jobs_broadcasts_empty_list(self) -> None: + manager, publisher = self._make_manager() + manager._broadcast_all_jobs(force=True) + + publisher.publish.assert_called_once() + payload = self._last_payload(publisher) + assert payload["job_type"] == "export" + assert payload["status"] == "queued" + assert payload["results"]["jobs"] == [] + + def test_single_running_job_payload(self) -> None: + manager, publisher = self._make_manager() + job = ExportJob(camera="front", request_start_time=0, request_end_time=10) + job.status = JobStatusTypesEnum.running + job.current_step = "encoding" + job.progress_percent = 75.0 + manager.jobs[job.id] = job + + manager._broadcast_all_jobs(force=True) + + payload = self._last_payload(publisher) + assert payload["status"] == "running" + assert len(payload["results"]["jobs"]) == 1 + broadcast_job = payload["results"]["jobs"][0] + assert broadcast_job["current_step"] == "encoding" + assert broadcast_job["progress_percent"] == 75.0 + + def test_multiple_jobs_broadcast(self) -> None: + manager, publisher = self._make_manager() + for i, status in enumerate( + (JobStatusTypesEnum.queued, JobStatusTypesEnum.running) + ): + job = ExportJob( + id=f"job_{i}", + camera="front", + request_start_time=0, + request_end_time=10, + ) + job.status = status + manager.jobs[job.id] = job + + manager._broadcast_all_jobs(force=True) + + payload = self._last_payload(publisher) + assert payload["status"] == "running" + assert len(payload["results"]["jobs"]) == 2 + + def test_completed_jobs_are_excluded(self) -> None: + manager, publisher = self._make_manager() + active = ExportJob(id="active", camera="front") + active.status = JobStatusTypesEnum.running + finished = ExportJob(id="done", camera="front") + finished.status = JobStatusTypesEnum.success + manager.jobs[active.id] = active + manager.jobs[finished.id] = finished + + manager._broadcast_all_jobs(force=True) + + payload = self._last_payload(publisher) + ids = [j["id"] for j in payload["results"]["jobs"]] + assert ids == ["active"] + + def test_throttle_skips_rapid_unforced_broadcasts(self) -> None: + manager, publisher = self._make_manager() + job = ExportJob(camera="front") + job.status = JobStatusTypesEnum.running + manager.jobs[job.id] = job + + manager._broadcast_all_jobs(force=True) + # Immediately following non-forced broadcasts should be skipped. + for _ in range(5): + manager._broadcast_all_jobs(force=False) + + assert publisher.publish.call_count == 1 + + def test_throttle_allows_broadcast_after_interval(self) -> None: + manager, publisher = self._make_manager() + job = ExportJob(camera="front") + job.status = JobStatusTypesEnum.running + manager.jobs[job.id] = job + + with patch("frigate.jobs.export.time.monotonic") as mock_mono: + mock_mono.return_value = 100.0 + manager._broadcast_all_jobs(force=True) + + mock_mono.return_value = 100.0 + PROGRESS_BROADCAST_MIN_INTERVAL + 0.01 + manager._broadcast_all_jobs(force=False) + + assert publisher.publish.call_count == 2 + + def test_force_bypasses_throttle(self) -> None: + manager, publisher = self._make_manager() + job = ExportJob(camera="front") + job.status = JobStatusTypesEnum.running + manager.jobs[job.id] = job + + manager._broadcast_all_jobs(force=True) + manager._broadcast_all_jobs(force=True) + + assert publisher.publish.call_count == 2 + + def test_publisher_exceptions_do_not_propagate(self) -> None: + """A failing publisher must not break the manager: broadcasts are + best-effort since the dispatcher may not be available (tests, + startup races).""" + manager, publisher = self._make_manager() + publisher.publish.side_effect = RuntimeError("comms down") + + job = ExportJob(camera="front") + job.status = JobStatusTypesEnum.running + manager.jobs[job.id] = job + + # Swallow our own RuntimeError if the manager doesn't; the real + # JobStatePublisher handles its own exceptions internally, so the + # manager can stay naive. But if something bubbles up it should + # not escape _broadcast_all_jobs — enforce that contract here. + try: + manager._broadcast_all_jobs(force=True) + except RuntimeError: + self.fail("_broadcast_all_jobs must tolerate publisher failures") + + def test_progress_callback_updates_job_and_broadcasts(self) -> None: + manager, _publisher = self._make_manager() + job = ExportJob(camera="front") + job.status = JobStatusTypesEnum.running + manager.jobs[job.id] = job + + callback = manager._make_progress_callback(job) + callback("encoding", 33.0) + + assert job.current_step == "encoding" + assert job.progress_percent == 33.0 + + +class TestGetDatetimeFromTimestamp(unittest.TestCase): + """Auto-generated export name should honor config.ui.timezone, not + fall back to the container's UTC clock when a timezone is configured. + """ + + def test_uses_configured_ui_timezone(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "America/New_York" + # 2025-01-15 12:00:00 UTC is 07:00:00 EST + assert exporter.get_datetime_from_timestamp(1736942400) == "2025-01-15 07:00:00" + + def test_falls_back_to_local_when_timezone_unset(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = None + # No assertion on the exact wall-clock value — just confirm no + # exception and that pytz isn't required when the field is unset. + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + def test_invalid_timezone_falls_back_to_local(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "Not/A_Real_Zone" + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + +class TestSaveThumbnailFromPreviewFrames(unittest.TestCase): + """Short exports in the current hour can fall between preview frame + writes (1-2 fps during activity, every 30s otherwise). When no frame + falls inside the export window, save_thumbnail should fall back to + the most recent prior frame instead of returning no thumbnail.""" + + def setUp(self) -> None: + self.tmp_root = tempfile.mkdtemp(prefix="frigate_thumb_test_") + self.preview_dir = os.path.join(self.tmp_root, "cache", "preview_frames") + self.export_clips = os.path.join(self.tmp_root, "clips", "export") + os.makedirs(self.preview_dir, exist_ok=True) + os.makedirs(self.export_clips, exist_ok=True) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def _write_frame(self, camera: str, frame_time: float) -> str: + path = os.path.join(self.preview_dir, f"preview_{camera}-{frame_time}.webp") + with open(path, "wb") as f: + f.write(b"fake-webp-bytes") + return path + + def _make_short_current_hour_exporter(self) -> RecordingExporter: + # Use a "now-ish" timestamp so save_thumbnail's start-of-hour + # comparison takes the current-hour branch (preview frames). + import datetime + + now = datetime.datetime.now(datetime.UTC).timestamp() + exporter = _make_exporter() + exporter.export_id = "thumb_short" + exporter.start_time = now + exporter.end_time = now + 3 + return exporter + + def test_short_export_falls_back_to_prior_preview_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + # Most recent preview frame is 10s before the export window + prior = self._write_frame(exporter.camera, exporter.start_time - 10.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + assert os.path.isfile(thumb_target) + with open(thumb_target, "rb") as f, open(prior, "rb") as src: + assert f.read() == src.read() + + def test_returns_empty_when_no_preview_frames_exist(self) -> None: + exporter = self._make_short_current_hour_exporter() + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == "" + + def test_prefers_in_window_frame_over_prior_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + self._write_frame(exporter.camera, exporter.start_time - 10.0) + in_window = self._write_frame(exporter.camera, exporter.start_time + 1.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + with open(thumb_target, "rb") as f, open(in_window, "rb") as src: + assert f.read() == src.read() + + +class TestSchedulesCleanup(unittest.TestCase): + def test_schedule_job_cleanup_removes_after_delay(self) -> None: + config = MagicMock() + manager = ExportJobManager(config, max_concurrent=1, max_queued=1) + job = ExportJob(id="cleanup_me", camera="front") + manager.jobs[job.id] = job + + with patch("frigate.jobs.export.threading.Timer") as mock_timer: + manager._schedule_job_cleanup(job.id) + mock_timer.assert_called_once() + delay, fn = mock_timer.call_args.args + assert delay > 0 + + # Invoke the callback directly to confirm it removes the job. + fn() + assert job.id not in manager.jobs + + +class TestChapterMetadataInProgressReview(unittest.TestCase): + """Regression: in-progress review segments have end_time=NULL until the + activity closes. The chapter builder must clamp the chapter end to the + last recorded second instead of crashing on float(None).""" + + def _fake_select_returning(self, rows: list) -> MagicMock: + mock_query = MagicMock() + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.iterator.return_value = iter(rows) + return mock_query + + def test_in_progress_review_does_not_crash_and_clamps_to_last_recording( + self, + ) -> None: + exporter = _make_exporter(end_minus_start=200) + # Recordings cover [1000, 1150]; export window is [1000, 1200] so + # the last recorded second is 1150 (a 50s gap at the tail). + recordings = [ + MagicMock(start_time=1000.0, end_time=1150.0), + ] + in_progress = MagicMock( + start_time=1100.0, + end_time=None, + severity="alert", + data={"objects": ["person"]}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + chapter_path = os.path.join(tmpdir, "chapters.txt") + exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign] + + with patch( + "frigate.record.export.ReviewSegment.select", + return_value=self._fake_select_returning([in_progress]), + ): + result = exporter._build_chapter_metadata_file(recordings) + + assert result == chapter_path + with open(chapter_path) as f: + content = f.read() + + # Output time is windows[-1][1] - windows[-1][0] = 150s. + # Review starts at wall=1100, output offset = 100s -> 100000ms. + # Clamped end = last_recorded_end (1150) -> output offset = 150s -> 150000ms. + assert "[CHAPTER]" in content + assert "START=100000" in content + assert "END=150000" in content + assert "title=Alert: person" in content + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_ffmpeg_presets.py b/frigate/test/test_ffmpeg_presets.py index 92df0571bb..86fdd5f3a6 100644 --- a/frigate/test/test_ffmpeg_presets.py +++ b/frigate/test/test_ffmpeg_presets.py @@ -73,9 +73,8 @@ class TestFfmpegPresets(unittest.TestCase): assert "preset-nvidia-h264" not in ( " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]) ) - assert ( - "fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5" - in (" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])) + assert "fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12" in ( + " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]) ) def test_default_ffmpeg_input_arg_preset(self): diff --git a/frigate/test/test_ffmpeg_progress.py b/frigate/test/test_ffmpeg_progress.py new file mode 100644 index 0000000000..5210511168 --- /dev/null +++ b/frigate/test/test_ffmpeg_progress.py @@ -0,0 +1,111 @@ +"""Tests for the shared ffmpeg progress helper.""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.util.ffmpeg import inject_progress_flags, run_ffmpeg_with_progress + + +class TestInjectProgressFlags(unittest.TestCase): + def test_inserts_flags_before_output_path(self): + cmd = ["ffmpeg", "-i", "in.mp4", "-c", "copy", "out.mp4"] + result = inject_progress_flags(cmd) + self.assertEqual( + result, + [ + "ffmpeg", + "-i", + "in.mp4", + "-c", + "copy", + "-progress", + "pipe:2", + "-nostats", + "out.mp4", + ], + ) + + def test_empty_cmd_returns_empty(self): + self.assertEqual(inject_progress_flags([]), []) + + +class TestRunFfmpegWithProgress(unittest.TestCase): + def _make_fake_proc(self, stderr_lines, returncode=0): + proc = MagicMock() + proc.stderr = iter(stderr_lines) + proc.stdin = MagicMock() + proc.returncode = returncode + proc.wait = MagicMock() + return proc + + def test_emits_percent_from_out_time_us_lines(self): + captured: list[float] = [] + + def on_progress(percent: float) -> None: + captured.append(percent) + + stderr_lines = [ + "out_time_us=1000000\n", + "out_time_us=5000000\n", + "progress=end\n", + ] + proc = self._make_fake_proc(stderr_lines) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter(stderr_lines) + proc.stderr.read = MagicMock(return_value="") + + with patch("subprocess.Popen", return_value=proc): + returncode, _stderr = run_ffmpeg_with_progress( + ["ffmpeg", "-i", "in", "out"], + expected_duration_seconds=10.0, + on_progress=on_progress, + use_low_priority=False, + ) + + self.assertEqual(returncode, 0) + self.assertEqual(len(captured), 4) # initial 0.0 + two parsed + final 100.0 + self.assertAlmostEqual(captured[0], 0.0) + self.assertAlmostEqual(captured[1], 10.0) + self.assertAlmostEqual(captured[2], 50.0) + self.assertAlmostEqual(captured[3], 100.0) + + def test_passes_started_process_to_callback(self): + proc = self._make_fake_proc([]) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter([]) + proc.stderr.read = MagicMock(return_value="") + + seen: list = [] + + with patch("subprocess.Popen", return_value=proc): + run_ffmpeg_with_progress( + ["ffmpeg", "out"], + expected_duration_seconds=1.0, + process_started=lambda p: seen.append(p), + use_low_priority=False, + ) + + self.assertEqual(seen, [proc]) + + def test_clamps_percent_to_0_100(self): + captured: list[float] = [] + + def on_progress(percent: float) -> None: + captured.append(percent) + + stderr_lines = ["out_time_us=999999999999\n"] + proc = self._make_fake_proc(stderr_lines) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter(stderr_lines) + proc.stderr.read = MagicMock(return_value="") + + with patch("subprocess.Popen", return_value=proc): + run_ffmpeg_with_progress( + ["ffmpeg", "out"], + expected_duration_seconds=10.0, + on_progress=on_progress, + use_low_priority=False, + ) + + # initial 0.0 then a clamped reading + self.assertEqual(captured[-1], 100.0) diff --git a/frigate/test/test_file.py b/frigate/test/test_file.py new file mode 100644 index 0000000000..6bbe2b6a87 --- /dev/null +++ b/frigate/test/test_file.py @@ -0,0 +1,72 @@ +import os +import tempfile +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch + +import cv2 +import numpy as np + +from frigate.util import file as file_util + + +class TestFileUtils(TestCase): + def _write_clean_snapshot( + self, clips_dir: str, event_id: str, image: np.ndarray + ) -> None: + assert cv2.imwrite( + os.path.join(clips_dir, f"front_door-{event_id}-clean.webp"), + image, + ) + + def test_get_event_snapshot_bytes_reads_clean_webp(self): + event_id = "clean-webp" + image = np.zeros((100, 200, 3), np.uint8) + event = SimpleNamespace( + id=event_id, + camera="front_door", + label="Mock", + top_score=100, + score=0, + start_time=0, + data={ + "box": [0.25, 0.25, 0.25, 0.5], + "score": 0.85, + "attributes": [], + }, + ) + + with ( + tempfile.TemporaryDirectory() as clips_dir, + patch.object(file_util, "CLIPS_DIR", clips_dir), + ): + self._write_clean_snapshot(clips_dir, event_id, image) + + snapshot_image, is_clean = file_util.load_event_snapshot_image( + event, clean_only=True + ) + + assert is_clean + assert snapshot_image is not None + assert snapshot_image.shape[:2] == image.shape[:2] + + rendered_bytes, _ = file_util.get_event_snapshot_bytes( + event, + ext="jpg", + timestamp=False, + bounding_box=True, + crop=False, + height=40, + quality=None, + timestamp_style=None, + colormap={}, + ) + assert rendered_bytes is not None + + rendered_image = cv2.imdecode( + np.frombuffer(rendered_bytes, dtype=np.uint8), + cv2.IMREAD_COLOR, + ) + assert rendered_image is not None + assert rendered_image.shape[0] == 40 + assert rendered_image.max() > 0 diff --git a/frigate/test/test_genai_processor_sync.py b/frigate/test/test_genai_processor_sync.py new file mode 100644 index 0000000000..41e2bf3355 --- /dev/null +++ b/frigate/test/test_genai_processor_sync.py @@ -0,0 +1,213 @@ +"""Tests for GenAI enablement gating in the embeddings maintainer. + +Covers creating post processors when GenAI is enabled at runtime, and the +per-camera gating those processors apply once they exist. +""" + +import sys +import unittest +from unittest.mock import MagicMock, patch + +# Mock TFLite before importing the maintainer +_MOCK_MODULES = [ + "tflite_runtime", + "tflite_runtime.interpreter", + "ai_edge_litert", + "ai_edge_litert.interpreter", +] +for mod in _MOCK_MODULES: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +# imported from the maintainer to avoid tripping the circular import between +# the maintainer and the processor modules +from frigate.embeddings.maintainer import ( # noqa: E402 + EmbeddingMaintainer, + ObjectDescriptionProcessor, + PostProcessDataEnum, + ReviewDescriptionProcessor, +) + + +class TestGenAIProcessorSync(unittest.TestCase): + """Enabling GenAI on the first camera must not require a restart.""" + + def _make_maintainer( + self, + review: bool = False, + objects: bool = False, + review_in_config: bool | None = None, + objects_in_config: bool | None = None, + ) -> EmbeddingMaintainer: + # Bypass the heavy __init__; only the attributes touched by + # _sync_genai_processors are needed for these tests. + maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer) + maintainer.post_processors = [] + maintainer.config = MagicMock() + maintainer.config.cameras = { + "front": self._make_camera( + review, + objects, + review if review_in_config is None else review_in_config, + objects if objects_in_config is None else objects_in_config, + ) + } + maintainer.config_updater = MagicMock() + maintainer.embeddings = None + maintainer.requestor = MagicMock() + maintainer.metrics = MagicMock() + maintainer.genai_manager = MagicMock() + maintainer.semantic_trigger_processor = None + return maintainer + + def _make_camera( + self, + review: bool, + objects: bool, + review_in_config: bool, + objects_in_config: bool, + ) -> MagicMock: + camera = MagicMock() + camera.review.genai.enabled = review + camera.review.genai.enabled_in_config = review_in_config + camera.objects.genai.enabled = objects + camera.objects.genai.enabled_in_config = objects_in_config + return camera + + def _processor_types(self, maintainer: EmbeddingMaintainer) -> list[type]: + return [type(p) for p in maintainer.post_processors] + + def test_no_processors_when_genai_disabled(self): + """A config with no GenAI cameras registers neither processor.""" + maintainer = self._make_maintainer() + + maintainer._sync_genai_processors() + + self.assertEqual(maintainer.post_processors, []) + + def test_review_processor_added_when_enabled_after_startup(self): + """Enabling review GenAI on the first camera registers the processor.""" + maintainer = self._make_maintainer() + maintainer._sync_genai_processors() + + camera = maintainer.config.cameras["front"] + camera.review.genai.enabled = True + camera.review.genai.enabled_in_config = True + maintainer._sync_genai_processors() + + self.assertEqual( + self._processor_types(maintainer), [ReviewDescriptionProcessor] + ) + + def test_object_processor_added_when_enabled_after_startup(self): + """Enabling object GenAI on the first camera registers the processor.""" + maintainer = self._make_maintainer() + maintainer._sync_genai_processors() + + camera = maintainer.config.cameras["front"] + camera.objects.genai.enabled = True + camera.objects.genai.enabled_in_config = True + maintainer._sync_genai_processors() + + self.assertEqual( + self._processor_types(maintainer), [ObjectDescriptionProcessor] + ) + + def test_processor_added_when_only_enabled_by_profile(self): + """A profile enables GenAI without setting enabled_in_config.""" + maintainer = self._make_maintainer( + review=True, objects=True, review_in_config=False, objects_in_config=False + ) + + maintainer._sync_genai_processors() + + self.assertEqual( + self._processor_types(maintainer), + [ReviewDescriptionProcessor, ObjectDescriptionProcessor], + ) + + def test_processors_are_not_duplicated(self): + """Repeated config updates must not register a second processor.""" + maintainer = self._make_maintainer(review=True, objects=True) + + maintainer._sync_genai_processors() + maintainer._sync_genai_processors() + + self.assertEqual( + self._processor_types(maintainer), + [ReviewDescriptionProcessor, ObjectDescriptionProcessor], + ) + + def test_genai_topic_triggers_sync(self): + """A camera config update on a GenAI topic registers the processor.""" + maintainer = self._make_maintainer(review=True) + maintainer.config_updater.check_for_updates.return_value = {"review": ["front"]} + + maintainer._check_camera_config_updates() + + self.assertEqual( + self._processor_types(maintainer), [ReviewDescriptionProcessor] + ) + + def test_unrelated_topic_does_not_sync(self): + """An unrelated camera config update must not register processors.""" + maintainer = self._make_maintainer(review=True) + maintainer.config_updater.check_for_updates.return_value = {"motion": ["front"]} + + maintainer._check_camera_config_updates() + + self.assertEqual(maintainer.post_processors, []) + + +class TestObjectDescriptionCameraGating(unittest.TestCase): + """One camera enabling object descriptions must not enlist the others.""" + + def _make_processor(self, enabled: bool) -> ObjectDescriptionProcessor: + config = MagicMock() + camera = MagicMock() + camera.objects.genai.enabled = enabled + camera.objects.genai.send_triggers.after_significant_updates = None + config.cameras = {"front": camera} + + genai_manager = MagicMock() + genai_manager.description_client = MagicMock() + + return ObjectDescriptionProcessor( + config, None, MagicMock(), MagicMock(), genai_manager, None + ) + + def _update(self, processor: ObjectDescriptionProcessor) -> None: + processor.process_data( + { + "camera": "front", + "data": { + "id": "1234.5-abcdef", + "box": (0, 0, 10, 10), + "stationary": False, + }, + "state": "update", + "yuv_frame": MagicMock(), + }, + PostProcessDataEnum.tracked_object, + ) + + @patch("frigate.data_processing.post.object_descriptions.create_thumbnail") + def test_disabled_camera_collects_no_thumbnails(self, mock_create_thumbnail): + """A camera with object descriptions off does no thumbnail work.""" + processor = self._make_processor(enabled=False) + + self._update(processor) + + mock_create_thumbnail.assert_not_called() + self.assertEqual(processor.tracked_events, {}) + + @patch("frigate.data_processing.post.object_descriptions.create_thumbnail") + def test_enabled_camera_collects_thumbnails(self, mock_create_thumbnail): + """A camera with object descriptions on still collects thumbnails.""" + mock_create_thumbnail.return_value = b"jpg" + processor = self._make_processor(enabled=True) + + self._update(processor) + + mock_create_thumbnail.assert_called_once() + self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1) diff --git a/frigate/test/test_genai_providers.py b/frigate/test/test_genai_providers.py new file mode 100644 index 0000000000..5352a81e25 --- /dev/null +++ b/frigate/test/test_genai_providers.py @@ -0,0 +1,524 @@ +"""Smoke tests for GenAI chat providers. + +Each provider's ``chat_with_tools_stream`` is driven with a canned "test +response" so the two conversion layers are exercised without any network: + + 1. Frigate (OpenAI-style) messages -> provider-native request format + 2. provider-native response -> Frigate ``("kind", value)`` stream events + +These guard against regressions such as tool-call arguments arriving as raw +strings instead of dicts (which crash the ``ToolCall`` model), and multimodal +user content (a list of text/image parts, as injected by ``get_live_context``) +crashing message conversion. +""" + +import asyncio +import base64 +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from frigate.config import GenAIConfig, GenAIProviderEnum +from frigate.genai import PROVIDERS, load_providers + +load_providers() + +# A minimal but valid JPEG data URI, mirroring what get_live_context injects. +_TINY_JPEG = base64.b64encode(b"\xff\xd8\xff\xd9").decode("ascii") +_IMAGE_DATA_URI = f"data:image/jpeg;base64,{_TINY_JPEG}" + +# Conversation ending in a multimodal user message (text + live image), the +# exact shape the chat endpoint builds after a get_live_context tool result. +MULTIMODAL_MESSAGES = [ + {"role": "system", "content": "You are a test assistant."}, + {"role": "user", "content": "what do you see on the front camera?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_live_context", + "arguments": json.dumps({"camera": "front"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "name": "get_live_context", + "content": json.dumps({"camera": "front"}), + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Here is the current live image from camera 'front'.", + }, + {"type": "image_url", "image_url": {"url": _IMAGE_DATA_URI}}, + ], + }, +] + +SIMPLE_MESSAGES = [ + {"role": "system", "content": "You are a test assistant."}, + {"role": "user", "content": "hello"}, +] + +TOOLS = [ + { + "type": "function", + "function": { + "name": "search_objects", + "description": "Search tracked objects", + "parameters": { + "type": "object", + "properties": {"label": {"type": "string"}}, + }, + }, + } +] + + +def _make_client(provider: str, **cfg_overrides): + """Build a provider client offline (no model validation, no network).""" + cfg = GenAIConfig(provider=provider, **cfg_overrides) + cls = PROVIDERS[GenAIProviderEnum(provider)] + return cls(cfg, timeout=5, validate_model=False) + + +def _collect(client, messages, tools=TOOLS): + """Drain chat_with_tools_stream into a list of (kind, value) events.""" + + async def _run(): + events = [] + async for event in client.chat_with_tools_stream( + messages=messages, tools=tools, tool_choice="auto" + ): + events.append(event) + return events + + return asyncio.run(_run()) + + +def _final_message(events) -> dict: + messages = [value for (kind, value) in events if kind == "message"] + assert messages, f"stream produced no final message: {events}" + return messages[-1] + + +def _assert_tool_args_are_dicts(final: dict) -> None: + """Every returned tool call must expose arguments as a dict, never a string.""" + for tool_call in final.get("tool_calls") or []: + assert isinstance(tool_call["arguments"], dict), ( + f"tool call arguments must be a dict, got " + f"{type(tool_call['arguments']).__name__}: {tool_call['arguments']!r}" + ) + + +# --------------------------------------------------------------------------- +# OpenAI +# --------------------------------------------------------------------------- +def _openai_tc(index, id=None, name=None, arguments=None): + return SimpleNamespace( + index=index, + id=id, + function=SimpleNamespace(name=name, arguments=arguments), + ) + + +def _openai_chunk(content=None, tool_calls=None, finish_reason=None, usage=None): + delta = SimpleNamespace( + content=content, + tool_calls=tool_calls, + reasoning_content=None, + reasoning=None, + ) + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=usage) + + +class TestOpenAIProvider(unittest.TestCase): + def _client(self): + return _make_client( + "openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1" + ) + + def test_stream_tool_call_arguments_are_dict(self): + # Arguments arrive split across chunks, as the real API streams them. + chunks = [ + _openai_chunk( + tool_calls=[ + _openai_tc(0, id="c1", name="search_objects", arguments='{"label":') + ] + ), + _openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]), + _openai_chunk(finish_reason="tool_calls"), + ] + client = self._client() + client.provider.chat.completions.create = MagicMock(return_value=iter(chunks)) + + final = _final_message(_collect(client, SIMPLE_MESSAGES)) + self.assertEqual(final["finish_reason"], "tool_calls") + self.assertEqual(len(final["tool_calls"]), 1) + _assert_tool_args_are_dicts(final) + self.assertEqual(final["tool_calls"][0]["arguments"], {"label": "person"}) + + def test_stream_content_response(self): + chunks = [ + _openai_chunk(content="hel"), + _openai_chunk(content="lo"), + _openai_chunk(finish_reason="stop"), + ] + client = self._client() + client.provider.chat.completions.create = MagicMock(return_value=iter(chunks)) + + events = _collect(client, SIMPLE_MESSAGES) + deltas = [v for (k, v) in events if k == "content_delta"] + self.assertEqual("".join(deltas), "hello") + self.assertEqual(_final_message(events)["content"], "hello") + + def test_multimodal_message_does_not_crash(self): + client = self._client() + client.provider.chat.completions.create = MagicMock( + return_value=iter([_openai_chunk(content="ok", finish_reason="stop")]) + ) + # Passing the OpenAI-native multimodal list through must not raise. + final = _final_message(_collect(client, MULTIMODAL_MESSAGES)) + self.assertEqual(final["content"], "ok") + + +# --------------------------------------------------------------------------- +# Gemini +# --------------------------------------------------------------------------- +def _gemini_part(text=None, thought=False, function_call=None, thought_signature=None): + return SimpleNamespace( + text=text, + thought=thought, + function_call=function_call, + thought_signature=thought_signature, + ) + + +def _gemini_chunk(parts, finish_reason=None, usage_metadata=None): + candidate = SimpleNamespace( + content=SimpleNamespace(parts=parts), finish_reason=finish_reason + ) + return SimpleNamespace(candidates=[candidate], usage_metadata=usage_metadata) + + +def _gemini_stream(chunks): + async def _agen(*args, **kwargs): + for chunk in chunks: + yield chunk + + return _agen + + +class TestGeminiProvider(unittest.TestCase): + def _client(self): + return _make_client("gemini", model="gemini-2.5-flash", api_key="k") + + def _patch_stream(self, client, chunks): + client.provider = MagicMock() + client.provider.aio.models.generate_content_stream = AsyncMock( + side_effect=_gemini_stream(chunks) + ) + + def test_stream_parallel_tool_calls_stay_separate_dicts(self): + # Regression: Gemini streams complete function calls. Two calls to the + # same tool must NOT be merged into one concatenated arguments string. + from google.genai.types import FinishReason + + chunks = [ + _gemini_chunk( + parts=[ + _gemini_part( + function_call=SimpleNamespace( + name="search_objects", args={"label": "person"} + ) + ), + _gemini_part( + function_call=SimpleNamespace( + name="search_objects", args={"limit": 1} + ) + ), + ], + finish_reason=FinishReason.STOP, + ), + ] + client = self._client() + self._patch_stream(client, chunks) + + final = _final_message(_collect(client, SIMPLE_MESSAGES)) + self.assertEqual(final["finish_reason"], "tool_calls") + self.assertEqual(len(final["tool_calls"]), 2) + _assert_tool_args_are_dicts(final) + self.assertEqual(final["tool_calls"][0]["arguments"], {"label": "person"}) + self.assertEqual(final["tool_calls"][1]["arguments"], {"limit": 1}) + + def test_stream_content_response(self): + from google.genai.types import FinishReason + + chunks = [ + _gemini_chunk(parts=[_gemini_part(text="hel")]), + _gemini_chunk( + parts=[_gemini_part(text="lo")], finish_reason=FinishReason.STOP + ), + ] + client = self._client() + self._patch_stream(client, chunks) + + events = _collect(client, SIMPLE_MESSAGES) + deltas = [v for (k, v) in events if k == "content_delta"] + self.assertEqual("".join(deltas), "hello") + self.assertEqual(_final_message(events)["content"], "hello") + + def test_multimodal_message_converts_without_crash(self): + # Regression: a user message with list content (text + image_url) used + # to be handed to Part.from_text(text=) and raise ValidationError. + from google.genai.types import FinishReason + + client = self._client() + self._patch_stream( + client, + [ + _gemini_chunk( + parts=[_gemini_part(text="ok")], finish_reason=FinishReason.STOP + ) + ], + ) + final = _final_message(_collect(client, MULTIMODAL_MESSAGES)) + self.assertEqual(final["content"], "ok") + + +# --------------------------------------------------------------------------- +# Ollama +# --------------------------------------------------------------------------- +class TestOllamaProvider(unittest.TestCase): + def _client(self): + return _make_client("ollama", model="llama3", base_url="http://localhost:9999") + + def _run_with_response(self, client, response, messages): + # Ollama uses a non-streaming call when tools are present, via an + # internally-constructed async client. + fake_async = MagicMock() + fake_async.chat = AsyncMock(return_value=response) + with patch( + "frigate.genai.plugins.ollama.OllamaAsyncClient", + return_value=fake_async, + ): + return _collect(client, messages) + + def test_tool_call_arguments_are_dict(self): + response = { + "message": { + "content": "", + "tool_calls": [ + { + "function": { + "name": "search_objects", + "arguments": {"label": "person"}, + } + } + ], + }, + "done": True, + "done_reason": "stop", + "eval_count": 5, + "prompt_eval_count": 3, + "eval_duration": 1_000_000, + } + client = self._client() + final = _final_message( + self._run_with_response(client, response, SIMPLE_MESSAGES) + ) + self.assertEqual(final["finish_reason"], "tool_calls") + _assert_tool_args_are_dicts(final) + self.assertEqual(final["tool_calls"][0]["arguments"], {"label": "person"}) + + def test_multimodal_message_normalizes_image(self): + # Ollama needs content as a string with images pulled into a separate + # field; the normalizer must extract both without crashing. + response = { + "message": {"content": "ok"}, + "done": True, + "done_reason": "stop", + } + client = self._client() + final = _final_message( + self._run_with_response(client, response, MULTIMODAL_MESSAGES) + ) + self.assertEqual(final["content"], "ok") + + def test_normalize_multimodal_content(self): + from frigate.genai.plugins.ollama import _normalize_multimodal_content + + text, images = _normalize_multimodal_content(MULTIMODAL_MESSAGES[-1]["content"]) + self.assertIn("live image", text) + self.assertEqual(len(images), 1) + self.assertEqual(images[0], b"\xff\xd8\xff\xd9") + + +# --------------------------------------------------------------------------- +# llama.cpp +# --------------------------------------------------------------------------- +class _FakeStreamResponse: + def __init__(self, lines): + self._lines = lines + + def raise_for_status(self): + return None + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeStreamCtx: + def __init__(self, lines): + self._resp = _FakeStreamResponse(lines) + + async def __aenter__(self): + return self._resp + + async def __aexit__(self, *exc): + return False + + +class _FakeAsyncClient: + def __init__(self, lines): + self._lines = lines + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, method, url, json=None, headers=None): + return _FakeStreamCtx(self._lines) + + +class TestLlamaCppProvider(unittest.TestCase): + def _client(self): + return _make_client("llamacpp", model="m", base_url="http://localhost:9999") + + def _run_with_lines(self, client, lines, messages): + with patch( + "frigate.genai.plugins.llama_cpp.httpx.AsyncClient", + return_value=_FakeAsyncClient(lines), + ): + return _collect(client, messages) + + def test_stream_tool_call_arguments_are_dict(self): + lines = [ + "data: " + + json.dumps( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "c1", + "function": { + "name": "search_objects", + "arguments": '{"label":', + }, + } + ] + } + } + ] + } + ), + "data: " + + json.dumps( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": ' "person"}'}, + } + ] + } + } + ] + } + ), + "data: " + + json.dumps({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}), + "data: [DONE]", + ] + client = self._client() + final = _final_message(self._run_with_lines(client, lines, SIMPLE_MESSAGES)) + self.assertEqual(final["finish_reason"], "tool_calls") + _assert_tool_args_are_dicts(final) + self.assertEqual(final["tool_calls"][0]["arguments"], {"label": "person"}) + + def test_stream_content_response(self): + lines = [ + "data: " + json.dumps({"choices": [{"delta": {"content": "hel"}}]}), + "data: " + json.dumps({"choices": [{"delta": {"content": "lo"}}]}), + "data: " + + json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}), + "data: [DONE]", + ] + client = self._client() + events = self._run_with_lines(client, lines, SIMPLE_MESSAGES) + deltas = [v for (k, v) in events if k == "content_delta"] + self.assertEqual("".join(deltas), "hello") + self.assertEqual(_final_message(events)["content"], "hello") + + def test_multimodal_message_does_not_crash(self): + lines = [ + "data: " + json.dumps({"choices": [{"delta": {"content": "ok"}}]}), + "data: " + + json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}), + "data: [DONE]", + ] + client = self._client() + final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES)) + self.assertEqual(final["content"], "ok") + + def _validated_client(self, server_context_size, provider_options=None): + """Build a client as if the server reported the given context size.""" + cfg = GenAIConfig( + provider="llamacpp", + model="m", + base_url="http://localhost:9999", + provider_options=provider_options or {}, + ) + info = { + "context_size": server_context_size, + "supports_vision": False, + "supports_audio": False, + "supports_tools": False, + "supports_reasoning": False, + "media_marker": "<__media__>", + } + cls = PROVIDERS[GenAIProviderEnum.llamacpp] + with patch.object(cls, "_get_model_info", return_value=info): + return cls(cfg, timeout=5) + + def test_server_context_size_used_without_override(self): + client = self._validated_client(4096) + self.assertEqual(client.get_context_size(), 4096) + + def test_provider_options_context_size_overrides_server(self): + client = self._validated_client(4096, {"context_size": 32768}) + self.assertEqual(client.get_context_size(), 32768) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_go2rtc_stream_auth.py b/frigate/test/test_go2rtc_stream_auth.py new file mode 100644 index 0000000000..b525c94ee1 --- /dev/null +++ b/frigate/test/test_go2rtc_stream_auth.py @@ -0,0 +1,175 @@ +"""Unit tests for `deny_response_for_go2rtc_stream`. + +Covers the camera-level authorization enforced in the `/auth` subrequest for +the nginx-proxied go2rtc live-stream paths (MSE/WebRTC WebSockets and the +WebRTC signaling endpoint). These paths name the stream via the `src` query +param, which the static-media auth in `media_auth` does not inspect. +""" + +import types +import unittest + +from frigate.api.auth import deny_response_for_go2rtc_stream +from frigate.config import FrigateConfig + +_CONFIG = { + "mqtt": {"host": "mqtt"}, + "auth": { + "roles": { + "limited_user": ["front_door"], + "dual_user": ["front_door", "back_door"], + } + }, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + # go2rtc stream name differs from the camera name (substream) + "live": {"streams": {"Main Stream": "front_door_sub"}}, + }, + "back_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "garage": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + + +def _request(config: FrigateConfig) -> types.SimpleNamespace: + return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config)) + + +class TestDenyResponseForGo2rtcStream(unittest.TestCase): + def setUp(self) -> None: + self.config = FrigateConfig(**_CONFIG) + self.request = _request(self.config) + + def _deny(self, url: str, role: str): + return deny_response_for_go2rtc_stream(url, role, self.request) + + # --- non-stream paths pass through --- + + def test_non_stream_path_passes_through(self): + self.assertIsNone( + self._deny("http://host/clips/back_door-1.jpg", "limited_user") + ) + + def test_empty_url_passes_through(self): + self.assertIsNone(self._deny("", "limited_user")) + + def test_jsmpeg_path_not_handled_here(self): + # jsmpeg is authorized per-frame in the output pipeline, not here + self.assertIsNone( + self._deny("http://host/live/jsmpeg/back_door", "limited_user") + ) + + # --- restricted role: allowed vs forbidden cameras --- + + def test_mse_allowed_camera(self): + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=front_door", "limited_user") + ) + + def test_mse_forbidden_camera_denied(self): + self.assertEqual( + self._deny("http://host/live/mse/api/ws?src=back_door", "limited_user"), + 403, + ) + + def test_webrtc_ws_forbidden_camera_denied(self): + self.assertEqual( + self._deny("http://host/live/webrtc/api/ws?src=back_door", "limited_user"), + 403, + ) + + def test_webrtc_signaling_forbidden_camera_denied(self): + self.assertEqual( + self._deny("http://host/api/go2rtc/webrtc?src=back_door", "limited_user"), + 403, + ) + + def test_unknown_camera_denied(self): + self.assertEqual( + self._deny("http://host/live/mse/api/ws?src=nonexistent", "limited_user"), + 403, + ) + + def test_missing_src_denied(self): + self.assertEqual(self._deny("http://host/live/mse/api/ws", "limited_user"), 403) + + # --- multi-camera role: each assigned camera allowed, others denied --- + + def test_multi_camera_role_allows_first_assigned(self): + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=front_door", "dual_user") + ) + + def test_multi_camera_role_allows_second_assigned(self): + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=back_door", "dual_user") + ) + + def test_multi_camera_role_denies_unassigned(self): + # garage is configured but not in dual_user's allow-list + self.assertEqual( + self._deny("http://host/live/mse/api/ws?src=garage", "dual_user"), + 403, + ) + + # --- substream names resolve to their owning camera --- + + def test_allowed_substream_resolves_to_owning_camera(self): + # front_door_sub is owned by front_door, which limited_user may access + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=front_door_sub", "limited_user") + ) + + # --- multiple src values: deny if any is forbidden --- + + def test_multiple_src_one_forbidden_denied(self): + self.assertEqual( + self._deny( + "http://host/live/mse/api/ws?src=front_door&src=back_door", + "limited_user", + ), + 403, + ) + + def test_multiple_src_all_allowed(self): + self.assertIsNone( + self._deny( + "http://host/live/mse/api/ws?src=front_door&src=front_door_sub", + "limited_user", + ) + ) + + # --- privileged roles bypass the check --- + + def test_admin_bypasses(self): + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=back_door", "admin") + ) + + def test_builtin_viewer_role_bypasses(self): + # the built-in viewer role is not in the config allow-list map, so it + # is treated as full access + self.assertIsNone( + self._deny("http://host/live/mse/api/ws?src=back_door", "viewer") + ) + + def test_missing_role_bypasses(self): + self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index fd0df94c4c..c99414a419 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -7,8 +7,6 @@ from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats class TestGpuStats(unittest.TestCase): def setUp(self): self.amd_results = "Unknown Radeon card. <= R500 won't work, new cards might.\nDumping to -, line limit 1.\n1664070990.607556: bus 10, gpu 4.17%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.83%, smx 0.00%, cr 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 60.37% 294.04mb, gtt 0.33% 52.21mb, mclk 100.00% 1.800ghz, sclk 26.65% 0.533ghz\n" - self.intel_results = """{"period":{"duration":1.194033,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":3349.991164,"unit":"irq/s"},"rc6":{"value":47.844741,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":4.533124,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":6.194385,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}},{"period":{"duration":1.189291,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":0.000000,"unit":"irq/s"},"rc6":{"value":100.000000,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}}""" - self.nvidia_results = "name, utilization.gpu [%], memory.used [MiB], memory.total [MiB]\nNVIDIA GeForce RTX 3050, 42 %, 5036 MiB, 8192 MiB\n" @patch("subprocess.run") def test_amd_gpu_stats(self, sp): @@ -19,28 +17,284 @@ class TestGpuStats(unittest.TestCase): amd_stats = get_amd_gpu_stats() assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"} - # @patch("subprocess.run") - # def test_nvidia_gpu_stats(self, sp): - # process = MagicMock() - # process.returncode = 0 - # process.stdout = self.nvidia_results - # sp.return_value = process - # nvidia_stats = get_nvidia_gpu_stats() - # assert nvidia_stats == { - # "name": "NVIDIA GeForce RTX 3050", - # "gpu": "42 %", - # "mem": "61.5 %", - # } + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_fdinfo( + self, drm_devices, read_fdinfo, monotonic, sleep, get_names + ): + # 1 second of wall clock between snapshots + drm_devices.return_value = {"0000:00:02.0": "i915"} + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} - @patch("subprocess.run") - def test_intel_gpu_stats(self, sp): - process = MagicMock() - process.returncode = 124 - process.stdout = self.intel_results - sp.return_value = process - intel_stats = get_intel_gpu_stats(False) - print(f"the intel stats are {intel_stats}") + # Two i915 clients on the same iGPU. Engine values are cumulative ns. + # Deltas over the 1s window: + # client A (pid 100): render +200_000_000 (20%), video +500_000_000 (50%), + # video-enhance +100_000_000 (10%) + # client B (pid 200): compute +100_000_000 (10%) + # Engine totals → render 20, video 50, video-enhance 10, compute 10 + # → compute = render + compute = 30 + # → dec = video + video-enhance = 60 + # → gpu = compute + dec = 90 + snapshot_a = { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": { + "render": (1_000_000_000, 0, 1), + "video": (5_000_000_000, 0, 1), + "video-enhance": (200_000_000, 0, 1), + "compute": (0, 0, 1), + }, + }, + ("0000:00:02.0", "2", "200"): { + "driver": "i915", + "pid": "200", + "engines": { + "render": (0, 0, 1), + "compute": (2_000_000_000, 0, 1), + }, + }, + } + snapshot_b = { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": { + "render": (1_200_000_000, 0, 1), + "video": (5_500_000_000, 0, 1), + "video-enhance": (300_000_000, 0, 1), + "compute": (0, 0, 1), + }, + }, + ("0000:00:02.0", "2", "200"): { + "driver": "i915", + "pid": "200", + "engines": { + "render": (0, 0, 1), + "compute": (2_100_000_000, 0, 1), + }, + }, + } + read_fdinfo.side_effect = [snapshot_a, snapshot_b] + + intel_stats = get_intel_gpu_stats(None) + + sleep.assert_called_once() assert intel_stats == { - "gpu": "1.13%", - "mem": "-%", + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "90.0%", + "mem": "-%", + "compute": "30.0%", + "dec": "60.0%", + "clients": {"100": "80.0%", "200": "10.0%"}, + }, + } + + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_xe_capacity( + self, drm_devices, read_fdinfo, monotonic, sleep, get_names + ): + # Xe engines report cumulative cycles paired with total cycles, plus a + # per-class capacity. drm-cycles-* is summed across every instance of a + # class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be + # divided by capacity to land in 0-100%. + drm_devices.return_value = {"0000:03:00.0": "xe"} + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:03:00.0": "Intel Arc"} + + # Deltas over the window (busy, total): render 200/1000 cap 1 = 20%, + # video 800/1000 cap 2 = 40%, video-enhance 400/1000 cap 2 = 20%, + # compute 100/1000 cap 1 = 10%. Without the capacity divisor video/ + # video-enhance would read 80%/40% and dec would clamp at 100%. + snapshot_a = { + ("0000:03:00.0", "1", "300"): { + "driver": "xe", + "pid": "300", + "engines": { + "render": (0, 0, 1), + "video": (0, 0, 2), + "video-enhance": (0, 0, 2), + "compute": (0, 0, 1), + }, + }, + } + snapshot_b = { + ("0000:03:00.0", "1", "300"): { + "driver": "xe", + "pid": "300", + "engines": { + "render": (200, 1000, 1), + "video": (800, 1000, 2), + "video-enhance": (400, 1000, 2), + "compute": (100, 1000, 1), + }, + }, + } + read_fdinfo.side_effect = [snapshot_a, snapshot_b] + + intel_stats = get_intel_gpu_stats(None) + + assert intel_stats == { + "0000:03:00.0": { + "name": "Intel Arc", + "vendor": "intel", + "gpu": "90.0%", + "mem": "-%", + "compute": "30.0%", + "dec": "60.0%", + "clients": {"300": "90.0%"}, + }, + } + + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_no_clients_reports_idle( + self, drm_devices, read_fdinfo, sleep, get_names + ): + # The device exists but nothing holds it open, e.g. while camera + # processes are restarting. This is an idle state, not an error: + # returning None here would latch the hwaccel error cooldown and + # blank GPU stats for an hour over a momentary gap. + drm_devices.return_value = {"0000:00:02.0": "i915"} + read_fdinfo.return_value = {} + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} + + assert get_intel_gpu_stats(None) == { + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + }, + } + # Idle short-circuits before spending the sample window + sleep.assert_not_called() + read_fdinfo.assert_called_once() + + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_clients_without_engine_counters( + self, drm_devices, read_fdinfo, sleep + ): + # i915 publishes drm-driver/drm-pdev/drm-client-id but no drm-engine-* + # lines while GuC submission is active on kernels older than 6.5, so + # clients are found with nothing to sample. Reporting idle here would + # be a lie, and sampling a second time cannot help. + drm_devices.return_value = {"0000:00:02.0": "i915"} + read_fdinfo.return_value = { + ("0000:00:02.0", "48", "1109"): { + "driver": "i915", + "pid": "1109", + "engines": {}, + }, + ("0000:00:02.0", "51", "1258"): { + "driver": "i915", + "pid": "1258", + "engines": {}, + }, + } + + assert get_intel_gpu_stats(None) is None + sleep.assert_not_called() + read_fdinfo.assert_called_once() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo): + # Only a non-Intel GPU is visible in sysfs; /proc is never scanned + drm_devices.return_value = {"0000:01:00.0": "nvidia"} + + assert get_intel_gpu_stats(None) is None + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + @patch("frigate.util.services._resolve_intel_gpu_pdev") + def test_intel_gpu_stats_unresolvable_device_hint( + self, resolve_pdev, drm_devices, read_fdinfo + ): + # A configured intel_gpu_device that cannot be resolved is a config + # error, not a reason to silently fall back to reporting all GPUs + resolve_pdev.return_value = None + + assert get_intel_gpu_stats("/dev/dri/renderD999") is None + drm_devices.assert_not_called() + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + @patch("frigate.util.services._resolve_intel_gpu_pdev") + def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu( + self, resolve_pdev, drm_devices, read_fdinfo + ): + # card numbering can reorder across reboots on multi-GPU hosts, so a + # configured hint may point at another vendor's card; call it out + # instead of reporting nothing + resolve_pdev.return_value = "0000:01:00.0" + drm_devices.return_value = { + "0000:00:02.0": "i915", + "0000:01:00.0": "nvidia", + } + + assert get_intel_gpu_stats("/dev/dri/card0") is None + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo): + # A scan failure (None) is a different condition than a scan that + # finds no clients ({}) and must not report idle + drm_devices.return_value = {"0000:00:02.0": "i915"} + read_fdinfo.return_value = None + + assert get_intel_gpu_stats(None) is None + + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_clients_lost_between_samples( + self, drm_devices, read_fdinfo, monotonic, sleep, get_names + ): + # Clients disappearing during the sample window is transient process + # churn, so report idle rather than latching an error + drm_devices.return_value = {"0000:00:02.0": "i915"} + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} + read_fdinfo.side_effect = [ + { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": {"video": (5_000_000_000, 0, 1)}, + }, + }, + {}, + ] + + assert get_intel_gpu_stats(None) == { + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + }, } diff --git a/frigate/test/test_keyframe_analysis.py b/frigate/test/test_keyframe_analysis.py new file mode 100644 index 0000000000..14dad5849d --- /dev/null +++ b/frigate/test/test_keyframe_analysis.py @@ -0,0 +1,110 @@ +"""Tests for keyframe-spacing analysis used to detect smart/+ codecs.""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from frigate.util.services import ( + analyze_record_keyframes, + classify_keyframe_gaps, + parse_keyframe_packets, +) + + +class TestClassifyKeyframeGaps(unittest.TestCase): + def test_ok_when_gaps_small(self): + # keyframes every ~1s + pts = [0.0, 1.0, 2.0, 3.0, 4.0] + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "ok") + self.assertEqual(result["max_gap"], 1.0) + self.assertEqual(result["keyframe_count"], 5) + self.assertEqual(result["thresholds"], {"warning": 4.0, "error": 10}) + + def test_warning_when_gap_exceeds_four_seconds(self): + pts = [0.0, 1.0, 6.5] # 5.5s gap + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "warning") + self.assertEqual(result["max_gap"], 5.5) + + def test_error_when_gap_exceeds_segment_time(self): + pts = [0.0, 12.0] # 12s gap > 10s segment + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "error") + + def test_error_threshold_tracks_segment_time(self): + pts = [0.0, 6.0] # 6s gap, segment_time=5 -> error + result = classify_keyframe_gaps(pts, segment_time=5) + self.assertEqual(result["severity"], "error") + + def test_unknown_with_single_keyframe(self): + result = classify_keyframe_gaps([1.0], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertIsNone(result["max_gap"]) + self.assertEqual(result["keyframe_count"], 1) + + def test_unknown_with_no_keyframes(self): + result = classify_keyframe_gaps([], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertEqual(result["keyframe_count"], 0) + + +class TestParseKeyframePackets(unittest.TestCase): + def test_extracts_keyframe_pts_and_max(self): + output = "0.000000,K__\n0.033333,___\n1.000000,K__\n1.500000,___\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [0.0, 1.0]) + self.assertEqual(max_pts, 1.5) + + def test_skips_unparseable_and_empty_lines(self): + output = "N/A,K__\n\n2.0,K__\nbad line\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [2.0]) + self.assertEqual(max_pts, 2.0) + + def test_empty_output(self): + keyframe_pts, max_pts = parse_keyframe_packets("") + self.assertEqual(keyframe_pts, []) + self.assertIsNone(max_pts) + + +class TestAnalyzeRecordKeyframes(unittest.IsolatedAsyncioTestCase): + async def test_merges_duration_and_classification(self): + csv = b"0.0,K__\n1.0,___\n6.0,K__\n7.0,___\n" + proc = MagicMock() + proc.communicate = AsyncMock(return_value=(csv, b"")) + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "warning") # 6s gap > 4s + self.assertEqual(result["max_gap"], 6.0) + self.assertEqual(result["duration_observed"], 7.0) + + async def test_timeout_returns_unknown(self): + proc = MagicMock() + proc.communicate = AsyncMock(side_effect=TimeoutError()) + proc.kill = MagicMock() + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "unknown") + proc.kill.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_maintainer.py b/frigate/test/test_maintainer.py index d978cfd9fe..715cd5a1a1 100644 --- a/frigate/test/test_maintainer.py +++ b/frigate/test/test_maintainer.py @@ -1,17 +1,31 @@ +import datetime import sys import unittest from unittest.mock import MagicMock, patch -# Mock complex imports before importing maintainer -sys.modules["frigate.comms.inter_process"] = MagicMock() -sys.modules["frigate.comms.detections_updater"] = MagicMock() -sys.modules["frigate.comms.recordings_updater"] = MagicMock() -sys.modules["frigate.config.camera.updater"] = MagicMock() +# Mock complex imports before importing maintainer, saving originals so we can +# restore them after import and avoid polluting sys.modules for other tests. +_MOCKED_MODULES = [ + "frigate.comms.inter_process", + "frigate.comms.detections_updater", + "frigate.comms.recordings_updater", + "frigate.config.camera.updater", +] +_originals = {name: sys.modules.get(name) for name in _MOCKED_MODULES} +for name in _MOCKED_MODULES: + sys.modules[name] = MagicMock() # Now import the class under test from frigate.config import FrigateConfig # noqa: E402 from frigate.record.maintainer import RecordingMaintainer # noqa: E402 +# Restore original modules (or remove mock if there was no original) +for name, orig in _originals.items(): + if orig is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = orig + class TestMaintainer(unittest.IsolatedAsyncioTestCase): async def test_move_files_survives_bad_filename(self): @@ -61,6 +75,86 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): f"Expected a single warning for unexpected files, got {len(matching)}", ) + async def test_drops_quiet_segment_when_only_motion_retention(self): + # Regression: when motion retention is enabled but a segment has no + # motion and no review overlaps it, the segment must still be dropped. + # Otherwise it sits in cache forever, accumulates, and triggers the + # "Unable to keep up with recording segments in cache" warning every + # ~10s as the overflow trim in move_files discards the oldest one. + config = MagicMock(spec=FrigateConfig) + + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.continuous.days = 0 + camera_config.record.motion.days = 1 + camera_config.record.event_pre_capture = 5 + config.cameras = {"test_cam": camera_config} + + stop_event = MagicMock() + maintainer = RecordingMaintainer(config, stop_event) + + now = datetime.datetime.now(datetime.UTC) + start_time = now - datetime.timedelta(seconds=20) + end_time = now - datetime.timedelta(seconds=10) + cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4" + + maintainer.end_time_cache = {cache_path: (end_time, 10.0)} + # Single processed frame well past end_time with no motion/objects. + maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])] + maintainer.audio_recordings_info["test_cam"] = [] + + maintainer.drop_segment = MagicMock() + maintainer.recordings_publisher = MagicMock() + + result = await maintainer.validate_and_move_segment( + "test_cam", + reviews=[], + recording={"start_time": start_time, "cache_path": cache_path}, + ) + + self.assertIsNone(result) + maintainer.drop_segment.assert_called_once_with(cache_path) + + async def test_expire_stale_recordings_info_drops_only_absent_cameras(self): + config = MagicMock(spec=FrigateConfig) + config.cameras = {} + stop_event = MagicMock() + maintainer = RecordingMaintainer(config, stop_event) + + now = datetime.datetime.now().timestamp() + ancient = now - 86400 + recent = now - 1 + + maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])] + maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])] + + maintainer.object_recordings_info["absent_cam"] = [ + (ancient, [], [], []), + (recent, [], [], []), + ] + maintainer.audio_recordings_info["absent_cam"] = [ + (ancient, 0, []), + (recent, 0, []), + ] + + grouped_recordings = {"present_cam": [{"start_time": ancient}]} + + maintainer._expire_stale_recordings_info(grouped_recordings) + + self.assertEqual( + maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])] + ) + self.assertEqual( + maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])] + ) + + self.assertEqual( + maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])] + ) + self.assertEqual( + maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])] + ) + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_media_auth.py b/frigate/test/test_media_auth.py new file mode 100644 index 0000000000..d025fea614 --- /dev/null +++ b/frigate/test/test_media_auth.py @@ -0,0 +1,381 @@ +"""Unit tests for `frigate.api.media_auth`. + +Covers URI classification, the role-vs-camera decision matrix, and the export +DB-lookup path. These are pure functions/DB lookups — no HTTP stack involved. +""" + +import datetime +import logging +import os +import unittest + +from peewee_migrate import Router +from playhouse.sqlite_ext import SqliteExtDatabase +from playhouse.sqliteq import SqliteQueueDatabase + +from frigate.api.media_auth import ( + MediaAuthResolution, + deny_response_for_media_uri, + extract_path, + resolve_media_uri, +) +from frigate.config import FrigateConfig +from frigate.models import Event, Export, Recordings, ReviewSegment +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS + +_CONFIG = { + "mqtt": {"host": "mqtt"}, + "auth": {"roles": {"limited_user": ["front_door"]}}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + # Camera name with a hyphen — exercises longest-prefix match. + "back-yard": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + + +class TestExtractPath(unittest.TestCase): + def test_full_url(self): + self.assertEqual( + extract_path("http://host:8971/clips/front_door-1.jpg"), + "/clips/front_door-1.jpg", + ) + + def test_strips_query_string(self): + self.assertEqual( + extract_path("http://h/recordings/2026-05-11/14/front_door/00.00.mp4?t=1"), + "/recordings/2026-05-11/14/front_door/00.00.mp4", + ) + + def test_path_only(self): + self.assertEqual(extract_path("/exports/x.mp4"), "/exports/x.mp4") + + def test_percent_decoded(self): + self.assertEqual( + extract_path("http://h/clips/front%20door-1.jpg"), + "/clips/front door-1.jpg", + ) + + def test_empty(self): + self.assertIsNone(extract_path(None)) + self.assertIsNone(extract_path("")) + + +class TestResolveMediaUri(unittest.TestCase): + def setUp(self): + self.config = FrigateConfig(**_CONFIG) + + def _assert(self, uri, resolution, camera=None): + got_resolution, got_camera = resolve_media_uri(uri, self.config) + self.assertEqual(got_resolution, resolution, uri) + self.assertEqual(got_camera, camera, uri) + + def test_unknown_paths(self): + self._assert("/api/events", MediaAuthResolution.UNKNOWN) + self._assert("/", MediaAuthResolution.UNKNOWN) + self._assert("", MediaAuthResolution.UNKNOWN) + + def test_recordings(self): + self._assert("/recordings/", MediaAuthResolution.LISTING_NEUTRAL) + self._assert("/recordings/2026-05-11/", MediaAuthResolution.LISTING_NEUTRAL) + self._assert( + "/recordings/2026-05-11/14/", MediaAuthResolution.LISTING_MULTI_CAMERA + ) + self._assert( + "/recordings/2026-05-11/14/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/recordings/2026-05-11/14/back_door/00.00.mp4", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_flat_filename_resolves_camera(self): + self._assert( + "/clips/front_door-1234.jpg", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/back_door-1234-clean.webp", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_filename_with_hyphenated_camera_name(self): + # Camera name "back-yard" itself contains a hyphen; longest-prefix + # match must pick `back-yard`, not the bogus `back` prefix. + self._assert( + "/clips/back-yard-1234.jpg", + MediaAuthResolution.CAMERA, + camera="back-yard", + ) + + def test_clip_filename_no_matching_camera(self): + # Looks like a media path but couldn't classify — fail closed for + # restricted users (UNRESOLVED_MEDIA), not pass-through. + self._assert( + "/clips/nonexistent-1234.jpg", MediaAuthResolution.UNRESOLVED_MEDIA + ) + + def test_clip_thumbs(self): + self._assert("/clips/thumbs/", MediaAuthResolution.LISTING_MULTI_CAMERA) + self._assert( + "/clips/thumbs/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/thumbs/back_door/abc.webp", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_previews(self): + self._assert("/clips/previews/", MediaAuthResolution.LISTING_MULTI_CAMERA) + self._assert( + "/clips/previews/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/previews/back_door/segment.mp4", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_review_thumbs(self): + # Format: /clips/review/thumb-{camera}-{review_id}.webp (frigate/review/maintainer.py). + self._assert( + "/clips/review/thumb-front_door-abc123.webp", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + # Hyphenated camera name — longest-prefix match. + self._assert( + "/clips/review/thumb-back-yard-abc123.webp", + MediaAuthResolution.CAMERA, + camera="back-yard", + ) + # Unknown camera prefix → unresolved, not allowed for restricted users. + self._assert( + "/clips/review/thumb-unknown-cam-abc123.webp", + MediaAuthResolution.UNRESOLVED_MEDIA, + ) + + def test_clip_admin_only_subtrees(self): + self._assert("/clips/faces/train/foo.webp", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/faces/", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/genai-requests/x/0.webp", MediaAuthResolution.ADMIN_ONLY) + self._assert( + "/clips/preview_restart_cache/x.mp4", MediaAuthResolution.ADMIN_ONLY + ) + self._assert("/clips/some_model/train/x.jpg", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/some_model/dataset/x.jpg", MediaAuthResolution.ADMIN_ONLY) + + def test_clip_unknown_subtree_is_unresolved(self): + # Unknown /clips/{x}/{y}/... subtree falls through as unresolved (not + # admin-only) so restricted users get 403 without admins being denied + # access to legitimate but unrecognized resources. + self._assert("/clips/random_dir/foo.jpg", MediaAuthResolution.UNRESOLVED_MEDIA) + + def test_clip_top_level_listing(self): + self._assert("/clips/", MediaAuthResolution.LISTING_MULTI_CAMERA) + + def test_exports_listing(self): + self._assert("/exports/", MediaAuthResolution.LISTING_MULTI_CAMERA) + + +class TestExportResolution(unittest.TestCase): + """Export resolution requires a DB lookup.""" + + def setUp(self): + migrate_db = SqliteExtDatabase("test.db") + del logging.getLogger("peewee_migrate").handlers[:] + Router(migrate_db).run() + migrate_db.close() + self.db = SqliteQueueDatabase(TEST_DB) + self.db.bind([Event, ReviewSegment, Recordings, Export]) + self.config = FrigateConfig(**_CONFIG) + + def tearDown(self): + if not self.db.is_closed(): + self.db.close() + for f in TEST_DB_CLEANUPS: + try: + os.remove(f) + except OSError: + pass + + def _insert_export(self, export_id, camera, filename): + Export.insert( + id=export_id, + camera=camera, + name=f"export-{export_id}", + date=int(datetime.datetime.now().timestamp()), + video_path=f"/media/frigate/exports/{filename}", + thumb_path=f"/media/frigate/exports/{filename}.jpg", + in_progress=False, + ).execute() + + def test_export_resolves_camera(self): + self._insert_export( + "exp1", "back_door", "back_door_20260511_140000-20260511_150000_abc123.mp4" + ) + resolution, camera = resolve_media_uri( + "/exports/back_door_20260511_140000-20260511_150000_abc123.mp4", + self.config, + ) + self.assertEqual(resolution, MediaAuthResolution.CAMERA) + self.assertEqual(camera, "back_door") + + def test_unknown_export_is_unresolved(self): + # No matching row → UNRESOLVED_MEDIA (fail closed for restricted users), + # not UNKNOWN (which would pass-through). + resolution, camera = resolve_media_uri( + "/exports/does_not_exist.mp4", self.config + ) + self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA) + self.assertIsNone(camera) + + def test_export_anchored_match_not_endswith(self): + # Anchored exact-path equality must NOT match by filename suffix. + # A request like /exports/clip.mp4 must not authorize against a row at + # /media/frigate/exports/back_door_clip.mp4 just because the suffix matches. + self._insert_export("exp_bd", "back_door", "back_door_clip.mp4") + self._insert_export("exp_fd", "front_door", "front_door_clip.mp4") + resolution, _ = resolve_media_uri("/exports/clip.mp4", self.config) + self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA) + + +class TestDenyResponseForMediaUri(unittest.TestCase): + """End-to-end decision check used by /auth.""" + + def setUp(self): + self.config = FrigateConfig(**_CONFIG) + + def _deny(self, url, role): + return deny_response_for_media_uri(url, role, self.config) + + def test_admin_always_allowed(self): + self.assertIsNone(self._deny("/clips/back_door-1.jpg", "admin")) + self.assertIsNone(self._deny("/clips/", "admin")) + self.assertIsNone(self._deny("/clips/faces/x.webp", "admin")) + self.assertIsNone( + self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "admin") + ) + + def test_unrestricted_role_allowed(self): + # "viewer" role has no entry in roles_dict → full access (matches the + # behavior of require_camera_access). + self.assertIsNone(self._deny("/clips/back_door-1.jpg", "viewer")) + self.assertIsNone(self._deny("/clips/", "viewer")) + + def test_restricted_role_allowed_camera(self): + self.assertIsNone(self._deny("/clips/front_door-1.jpg", "limited_user")) + self.assertIsNone( + self._deny("/recordings/2026-05-11/14/front_door/00.00.mp4", "limited_user") + ) + self.assertIsNone( + self._deny("/clips/thumbs/front_door/abc.webp", "limited_user") + ) + + def test_restricted_role_blocked_other_camera(self): + self.assertEqual(self._deny("/clips/back_door-1.jpg", "limited_user"), 403) + self.assertEqual( + self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "limited_user"), + 403, + ) + self.assertEqual( + self._deny("/clips/thumbs/back_door/abc.webp", "limited_user"), 403 + ) + + def test_restricted_role_blocked_admin_only(self): + self.assertEqual(self._deny("/clips/faces/train/foo.webp", "limited_user"), 403) + + def test_restricted_role_blocked_multi_camera_listing(self): + self.assertEqual(self._deny("/clips/", "limited_user"), 403) + self.assertEqual(self._deny("/exports/", "limited_user"), 403) + self.assertEqual(self._deny("/recordings/2026-05-11/14/", "limited_user"), 403) + + def test_restricted_role_allowed_neutral_listing(self): + self.assertIsNone(self._deny("/recordings/", "limited_user")) + self.assertIsNone(self._deny("/recordings/2026-05-11/", "limited_user")) + + def test_non_media_uri_passes_through(self): + self.assertIsNone(self._deny("/api/events", "limited_user")) + self.assertIsNone(self._deny("http://h/login", "limited_user")) + + def test_missing_header(self): + self.assertIsNone(self._deny(None, "limited_user")) + self.assertIsNone(self._deny("", "limited_user")) + + def test_traversal_in_media_uri_denied_for_all_roles(self): + # Bypass attempt: parts[3] looks like an allowed camera, but the + # normalized path nginx would serve points at a forbidden camera. + # Both restricted and admin should be denied — the URI is malformed + # and we refuse to make an auth decision against it. + traversal_uris = [ + "/recordings/2026-05-11/14/front_door/../back_door/00.00.mp4", + "/clips/front_door-1.jpg/../back_door-1.jpg", + "/exports/../recordings/2026-05-11/14/back_door/00.00.mp4", + "/clips/./back_door-1.jpg", + ] + for uri in traversal_uris: + self.assertEqual(self._deny(uri, "limited_user"), 403, uri) + self.assertEqual(self._deny(uri, "admin"), 403, uri) + self.assertEqual(self._deny(uri, "viewer"), 403, uri) + + def test_traversal_outside_media_passes_through(self): + # `..` in non-media URIs is not our problem; the backend handles it. + self.assertIsNone(self._deny("/api/foo/../bar", "limited_user")) + + def test_percent_encoded_traversal_denied(self): + # nginx may decode percent-encoded `%2E%2E` to `..` before serving; + # we must apply the same denial after percent-decoding. + self.assertEqual( + self._deny( + "/recordings/2026-05-11/14/front_door/%2E%2E/back_door/00.mp4", + "limited_user", + ), + 403, + ) + + def test_unresolved_media_fails_closed_for_restricted(self): + # Restricted user requesting a media URI we can't classify (no DB row, + # unknown clip prefix, unknown clip subtree) must be denied. + self.assertEqual(self._deny("/clips/nonexistent-1.jpg", "limited_user"), 403) + self.assertEqual(self._deny("/clips/random_dir/foo.jpg", "limited_user"), 403) + self.assertEqual( + self._deny("/clips/review/thumb-unknown_cam-1.webp", "limited_user"), + 403, + ) + + def test_unresolved_media_allowed_for_admin(self): + # Admin and full-access roles are *not* denied on UNRESOLVED_MEDIA — + # nginx returns 404 if the file doesn't exist on disk anyway, and we + # don't want a stale DB to lock out admins. + self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "admin")) + self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "viewer")) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_motion_detector.py b/frigate/test/test_motion_detector.py new file mode 100644 index 0000000000..cdf4210a51 --- /dev/null +++ b/frigate/test/test_motion_detector.py @@ -0,0 +1,91 @@ +import unittest + +import numpy as np + +from frigate.config.camera.motion import MotionConfig +from frigate.motion.improved_motion import ImprovedMotionDetector + + +class TestImprovedMotionDetector(unittest.TestCase): + def setUp(self): + # small frame for testing; actual frames are grayscale + self.frame_shape = (100, 100) # height, width + self.config = MotionConfig() + # motion detector assumes a rasterized_mask attribute exists on config + # when update_mask() is called; add one manually by bypassing pydantic. + object.__setattr__( + self.config, + "rasterized_mask", + np.ones((self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8), + ) + + # create minimal PTZ metrics stub to satisfy detector checks + class _Stub: + def __init__(self, value=False): + self.value = value + + def is_set(self): + return bool(self.value) + + class DummyPTZ: + def __init__(self): + self.autotracker_enabled = _Stub(False) + self.motor_stopped = _Stub(False) + self.stop_time = _Stub(0) + + self.detector = ImprovedMotionDetector( + self.frame_shape, self.config, fps=30, ptz_metrics=DummyPTZ() + ) + + # establish a baseline frame (all zeros) + base_frame = np.zeros( + (self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8 + ) + self.detector.detect(base_frame) + + def _half_change_frame(self) -> np.ndarray: + """Produce a frame where roughly half of the pixels are different.""" + frame = np.zeros((self.frame_shape[0], self.frame_shape[1]), dtype=np.uint8) + # flip the top half to white + frame[: self.frame_shape[0] // 2, :] = 255 + return frame + + def test_skip_motion_threshold_default(self): + """With the default (None) setting, motion should always be reported.""" + frame = self._half_change_frame() + boxes = self.detector.detect(frame) + self.assertTrue( + boxes, "Expected motion boxes when skip threshold is unset (disabled)" + ) + + def test_skip_motion_threshold_applied(self): + """Setting a low skip threshold should prevent any boxes from being returned.""" + # change the config and update the detector reference + self.config.skip_motion_threshold = 0.4 + self.detector.config = self.config + self.detector.update_mask() + + frame = self._half_change_frame() + boxes = self.detector.detect(frame) + self.assertEqual( + boxes, + [], + "Motion boxes should be empty when scene change exceeds skip threshold", + ) + + def test_skip_motion_threshold_does_not_affect_calibration(self): + """Even when skipping, the detector should go into calibrating state.""" + self.config.skip_motion_threshold = 0.4 + self.detector.config = self.config + self.detector.update_mask() + + frame = self._half_change_frame() + _ = self.detector.detect(frame) + self.assertTrue( + self.detector.calibrating, + "Detector should be in calibrating state after skip event", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_motion_search_batch.py b/frigate/test/test_motion_search_batch.py new file mode 100644 index 0000000000..0e7fcdfd8a --- /dev/null +++ b/frigate/test/test_motion_search_batch.py @@ -0,0 +1,58 @@ +"""Tests for motion search batch helpers (runs + timestamp mapping).""" + +import unittest +from dataclasses import dataclass + +from frigate.jobs.motion_search_batch import ( + build_segment_time_map, + coalesce_runs, + stream_time_to_absolute, +) + + +@dataclass +class _Seg: + path: str + start_time: float + end_time: float + + +def _run_seconds(run): + return float(run[-1].end_time) - float(run[0].start_time) + + +class TestCoalesceRuns(unittest.TestCase): + def test_contiguous_segments_form_one_run(self): + segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 20.0, 30.0)] + runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5) + self.assertEqual(len(runs), 1) + self.assertEqual(len(runs[0]), 3) + + def test_time_gap_splits_runs(self): + # b ends 20, c starts 25 -> 5s gap > epsilon -> two runs. + segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 25.0, 35.0)] + runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5) + self.assertEqual([len(r) for r in runs], [2, 1]) + + def test_max_duration_caps_a_run(self): + # Five contiguous 10s segments, cap 25s. + segs = [_Seg(str(i), i * 10.0, i * 10.0 + 10.0) for i in range(5)] + runs = coalesce_runs(segs, max_seconds=25.0, epsilon=0.5) + self.assertTrue(all(_run_seconds(r) <= 30.0 for r in runs)) + self.assertEqual(sum(len(r) for r in runs), 5) + + def test_empty(self): + self.assertEqual(coalesce_runs([], max_seconds=600.0, epsilon=0.5), []) + + +class TestTimestampMapping(unittest.TestCase): + def test_gapfree_run_maps_to_start_plus_pts(self): + run = [_Seg("a", 1000.0, 1010.0), _Seg("b", 1010.0, 1020.0)] + time_map = build_segment_time_map(run) + self.assertAlmostEqual(stream_time_to_absolute(time_map, 3.0), 1003.0) + self.assertAlmostEqual(stream_time_to_absolute(time_map, 12.0), 1012.0) + + def test_past_end_clamps(self): + run = [_Seg("a", 1000.0, 1010.0)] + time_map = build_segment_time_map(run) + self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9) diff --git a/frigate/test/test_motion_search_decode.py b/frigate/test/test_motion_search_decode.py new file mode 100644 index 0000000000..fda0837662 --- /dev/null +++ b/frigate/test/test_motion_search_decode.py @@ -0,0 +1,190 @@ +"""Tests for the motion search hardware-accelerated decode helpers.""" + +import unittest +from types import SimpleNamespace +from unittest import mock + +from frigate.jobs.motion_search_decode import ( + KEYFRAME_MAX_GAP_SECONDS, + build_vod_decode_command, + keyframe_sampling_eligible, + probe_video_dimensions, + probe_vod_keyframe_pts, + resolve_motion_decode_args, +) + + +def _fake_camera_config( + hwaccel_args, gpu=0, fps=5, width=1280, height=720, ffmpeg_path="ffmpeg" +): + return SimpleNamespace( + ffmpeg=SimpleNamespace( + hwaccel_args=hwaccel_args, gpu=gpu, ffmpeg_path=ffmpeg_path + ), + detect=SimpleNamespace(fps=fps, width=width, height=height), + ) + + +class TestResolveMotionDecodeArgs(unittest.TestCase): + def test_vaapi_preset_is_accelerated(self): + args = resolve_motion_decode_args(_fake_camera_config("preset-vaapi")) + self.assertIn("-hwaccel", args) + self.assertIn("vaapi", args) + + def test_non_nv12_preset_falls_back_to_software(self): + # rkmpp produces drm_prime surfaces that do not download to nv12, so it + # must resolve to software decode (empty args) rather than risk corrupt + # frames. + self.assertEqual( + resolve_motion_decode_args(_fake_camera_config("preset-rkmpp")), [] + ) + + def test_custom_args_fall_back_to_software(self): + # Arbitrary custom hwaccel args (a list, not a preset) decode in software + # to preserve byte-identical results. + self.assertEqual( + resolve_motion_decode_args(_fake_camera_config(["-hwaccel", "vulkan"])), + [], + ) + + def test_nvidia_codec_preset_is_accelerated(self): + # Codec-specific nvidia presets resolve to the same cuda decode args as + # the bare preset, so eligibility is derived from -hwaccel_output_format + # rather than a hardcoded list that omitted these aliases. + args = resolve_motion_decode_args(_fake_camera_config("preset-nvidia-h264")) + self.assertIn("-hwaccel_output_format", args) + self.assertIn("cuda", args) + + def test_software_only_preset_falls_back_to_software(self): + # A preset with no -hwaccel_output_format (decoder-based, no GPU surface) + # cannot use the nv12 download step, so it decodes in software. + self.assertEqual( + resolve_motion_decode_args(_fake_camera_config("preset-rpi-64-h264")), [] + ) + + +class TestKeyframeEligibility(unittest.TestCase): + def test_regular_short_gop_is_eligible(self): + pts = [0.0, 0.5, 1.0, 1.5, 2.0] # 0.5s gaps + self.assertTrue(keyframe_sampling_eligible(pts)) + + def test_long_gop_is_ineligible(self): + pts = [0.0, 5.0, 10.0] # 5s gaps + self.assertFalse(keyframe_sampling_eligible(pts)) + + def test_irregular_gop_ineligible_when_a_gap_is_long(self): + pts = [0.0, 0.5, 1.0, 8.0] # one 7s gap + self.assertFalse(keyframe_sampling_eligible(pts)) + + def test_too_few_keyframes_ineligible(self): + self.assertFalse(keyframe_sampling_eligible([1.0])) + self.assertFalse(keyframe_sampling_eligible([])) + + def test_default_max_gap_constant(self): + self.assertEqual(KEYFRAME_MAX_GAP_SECONDS, 2.0) + + +class TestVodDecodeCommand(unittest.TestCase): + URL = "http://127.0.0.1:5000/vod/cam/start/1/end/2/index.m3u8" + + def test_keyframe_command_shape(self): + cmd = build_vod_decode_command( + "ffmpeg", + self.URL, + decode_args=[], + crop=(100, 80, 10, 20), + scale=(50, 40), + gray=True, + skip_nonkey=True, + fps_rate=None, + ) + joined = " ".join(cmd) + self.assertIn("-skip_frame nokey", joined) + self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined) + self.assertIn(f"-i {self.URL}", joined) + self.assertIn("crop=100:80:10:20", joined) + self.assertIn("scale=50:40", joined) + self.assertIn("-pix_fmt gray", joined) + self.assertNotIn("fps=", joined) + + def test_fps_command_uses_fps_filter_not_skip_frame(self): + cmd = build_vod_decode_command( + "ffmpeg", + self.URL, + decode_args=[], + crop=None, + scale=None, + gray=False, + skip_nonkey=False, + fps_rate=2.0, + ) + joined = " ".join(cmd) + self.assertNotIn("skip_frame", joined) + self.assertIn("fps=2.0", joined) + self.assertIn("-pix_fmt bgr24", joined) + + def test_hwaccel_inserts_hwdownload(self): + cmd = build_vod_decode_command( + "ffmpeg", + self.URL, + decode_args=["-hwaccel", "vaapi"], + crop=None, + scale=None, + gray=True, + skip_nonkey=True, + fps_rate=None, + ) + joined = " ".join(cmd) + self.assertIn("hwdownload", joined) + self.assertIn("format=nv12", joined) + + +class TestProbeVodKeyframePts(unittest.TestCase): + def test_parses_keyframe_packets(self): + sample = ( + '{"packets":[' + '{"pts_time":"0.000000","flags":"K__"},' + '{"pts_time":"1.000000","flags":"___"},' + '{"pts_time":"2.000000","flags":"K__"}]}' + ) + completed = mock.Mock(stdout=sample, returncode=0) + with mock.patch( + "frigate.jobs.motion_search_decode.sp.run", return_value=completed + ): + pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8") + self.assertEqual(pts, [0.0, 2.0]) + + def test_returns_empty_on_failure(self): + with mock.patch( + "frigate.jobs.motion_search_decode.sp.run", + side_effect=OSError("boom"), + ): + self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), []) + + +class TestProbeVideoDimensions(unittest.TestCase): + def test_parses_dimensions_and_fps(self): + sample = ( + '{"streams":[{"width":1920,"height":1080,"avg_frame_rate":"30000/1001"}]}' + ) + completed = mock.Mock(stdout=sample, returncode=0) + with mock.patch( + "frigate.jobs.motion_search_decode.sp.run", return_value=completed + ): + dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4") + assert dims is not None + width, height, fps = dims + self.assertEqual((width, height), (1920, 1080)) + self.assertAlmostEqual(fps, 29.97, places=2) + + def test_returns_none_on_zero_dimensions(self): + sample = '{"streams":[{"width":0,"height":0,"avg_frame_rate":"0/0"}]}' + completed = mock.Mock(stdout=sample, returncode=0) + with mock.patch( + "frigate.jobs.motion_search_decode.sp.run", return_value=completed + ): + self.assertIsNone(probe_video_dimensions("ffprobe", "/tmp/a.mp4")) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_motion_search_spatial.py b/frigate/test/test_motion_search_spatial.py new file mode 100644 index 0000000000..b8044df4b0 --- /dev/null +++ b/frigate/test/test_motion_search_spatial.py @@ -0,0 +1,87 @@ +"""Tests for motion search spatial (crop/scale/mask) helpers.""" + +import unittest + +import numpy as np + +from frigate.jobs.motion_search import ( + build_scaled_roi_mask, + compute_roi_crop_and_scale, + detect_motion_scaled, +) + + +class TestComputeRoiCropAndScale(unittest.TestCase): + def test_crop_box_in_record_pixels(self): + # ROI covering x [0.25, 0.75], y [0.5, 1.0] of a 1000x600 frame. + polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]] + crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125) + cw, ch, cx, cy = crop + self.assertEqual((cx, cy), (250, 300)) + self.assertEqual((cw, ch), (500, 300)) + # longest side 500 -> factor 0.25 -> (125, 75), rounded down to even. + self.assertEqual(scaled, (124, 74)) + + def test_never_upscales(self): + polygon = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1]] + crop, scaled = compute_roi_crop_and_scale(polygon, 200, 200, scale_target=400) + cw, ch, _, _ = crop + # crop is 20x20; target 400 would upscale, so scaled == crop size. + self.assertEqual(scaled, (cw, ch)) + + def test_scaled_dims_are_at_least_one(self): + polygon = [[0.0, 0.0], [0.02, 0.0], [0.02, 0.02], [0.0, 0.02]] + crop, scaled = compute_roi_crop_and_scale(polygon, 50, 50, scale_target=1) + self.assertGreaterEqual(scaled[0], 1) + self.assertGreaterEqual(scaled[1], 1) + + def test_all_dims_are_even_for_nv12(self): + # Odd-aligned ROI on an odd-ish frame must still yield even crop/scale so + # the nv12 hwdownload byte stream matches the expected frame size. + polygon = [[0.123, 0.321], [0.777, 0.321], [0.777, 0.901], [0.123, 0.901]] + crop, scaled = compute_roi_crop_and_scale(polygon, 1377, 911, scale_target=257) + for value in (*crop, *scaled): + self.assertEqual(value % 2, 0, f"{value} is not even") + + +class TestBuildScaledRoiMask(unittest.TestCase): + def test_mask_matches_scaled_dims_and_has_coverage(self): + polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]] + crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125) + mask = build_scaled_roi_mask(polygon, 1000, 600, crop, scaled) + self.assertEqual(mask.shape, (scaled[1], scaled[0])) + self.assertEqual(mask.dtype, np.uint8) + # A full rectangle ROI fills its whole crop -> mask is all 255. + self.assertGreater(np.count_nonzero(mask), 0) + self.assertEqual(np.count_nonzero(mask), mask.size) + + +class TestDetectMotionScaled(unittest.TestCase): + def _ts(self, idx): + return float(idx) + + def test_finds_change_between_frames(self): + mask = np.full((60, 80), 255, dtype=np.uint8) + f0 = np.zeros((60, 80), dtype=np.uint8) + f1 = np.zeros((60, 80), dtype=np.uint8) + f1[10:50, 20:60] = 255 # big bright block appears + frames = [(0, f0), (30, f1)] + results = detect_motion_scaled( + frames, mask, threshold=30, min_area=1.0, timestamp_fn=self._ts + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].timestamp, 30.0) + self.assertGreater(results[0].change_percentage, 0.0) + + def test_no_change_yields_nothing(self): + mask = np.full((60, 80), 255, dtype=np.uint8) + f0 = np.zeros((60, 80), dtype=np.uint8) + f1 = np.zeros((60, 80), dtype=np.uint8) + results = detect_motion_scaled( + [(0, f0), (30, f1)], mask, threshold=30, min_area=1.0, timestamp_fn=self._ts + ) + self.assertEqual(results, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_mqtt_topic_registration.py b/frigate/test/test_mqtt_topic_registration.py new file mode 100644 index 0000000000..309fafb983 --- /dev/null +++ b/frigate/test/test_mqtt_topic_registration.py @@ -0,0 +1,100 @@ +"""Tests for MQTT command topic callback registration.""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.comms.mqtt import MqttClient + + +def _make_camera_mock( + *, + enabled: bool = True, + notifications_enabled_in_config: bool = False, +) -> MagicMock: + """Build a camera config mock with the fields _start() reads.""" + camera = MagicMock() + camera.enabled = enabled + camera.notifications.enabled_in_config = notifications_enabled_in_config + camera.onvif.host = None + camera.motion.mask = {} + camera.objects.mask = {} + camera.zones = {} + return camera + + +def _registered_topics( + cameras: dict[str, MagicMock], + *, + global_notifications_enabled_in_config: bool = False, +) -> set[str]: + """Start an MqttClient against a mocked paho client and collect the + topics registered via message_callback_add.""" + config = MagicMock() + config.cameras = cameras + config.notifications.enabled_in_config = global_notifications_enabled_in_config + config.mqtt.topic_prefix = "frigate" + config.mqtt.client_id = "frigate" + config.mqtt.user = None + config.mqtt.tls_ca_certs = None + config.mqtt.tls_insecure = None + + with patch("frigate.comms.mqtt.mqtt.Client") as client_cls: + mqtt_client = MqttClient(config) + mqtt_client.subscribe(MagicMock()) + + paho_client = client_cls.return_value + return {call.args[0] for call in paho_client.message_callback_add.call_args_list} + + +class TestMqttTopicRegistration(unittest.TestCase): + def test_camera_notification_topics_registered(self): + """Per-camera notification set/suspend must be registered so paho + routes them to the dispatcher (unregistered topics drop silently).""" + topics = _registered_topics( + {"front_door": _make_camera_mock(notifications_enabled_in_config=True)} + ) + + self.assertIn("frigate/front_door/notifications/set", topics) + self.assertIn("frigate/front_door/notifications/suspend", topics) + + def test_global_set_registered_with_camera_only_notifications(self): + """The global topic must work when notifications are enabled only at + the camera level, matching the WebPushClient gating in app.py.""" + topics = _registered_topics( + {"front_door": _make_camera_mock(notifications_enabled_in_config=True)}, + global_notifications_enabled_in_config=False, + ) + + self.assertIn("frigate/notifications/set", topics) + + def test_global_set_registered_with_global_notifications(self): + topics = _registered_topics( + {"front_door": _make_camera_mock()}, + global_notifications_enabled_in_config=True, + ) + + self.assertIn("frigate/notifications/set", topics) + + def test_global_set_not_registered_when_notifications_unconfigured(self): + topics = _registered_topics( + {"front_door": _make_camera_mock()}, + global_notifications_enabled_in_config=False, + ) + + self.assertNotIn("frigate/notifications/set", topics) + + def test_disabled_camera_does_not_enable_global_set(self): + topics = _registered_topics( + { + "front_door": _make_camera_mock( + enabled=False, notifications_enabled_in_config=True + ) + }, + global_notifications_enabled_in_config=False, + ) + + self.assertNotIn("frigate/notifications/set", topics) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_network_config.py b/frigate/test/test_network_config.py new file mode 100644 index 0000000000..7c78bf7bc4 --- /dev/null +++ b/frigate/test/test_network_config.py @@ -0,0 +1,41 @@ +"""Tests for networking config validation.""" + +import unittest + +from pydantic import ValidationError + +from frigate.config.network import ListenConfig + + +class TestListenConfig(unittest.TestCase): + def test_defaults_are_distinct(self): + listen = ListenConfig() + + self.assertEqual(listen.internal_port, 5000) + self.assertEqual(listen.external_port, 8971) + + def test_address_and_port_string_is_parsed(self): + listen = ListenConfig(internal="127.0.0.1:5000", external="0.0.0.0:8971") + + self.assertEqual(listen.internal_port, 5000) + self.assertEqual(listen.external_port, 8971) + + def test_identical_ports_rejected(self): + with self.assertRaises(ValidationError): + ListenConfig(internal=8971, external=8971) + + def test_same_port_on_different_addresses_rejected(self): + # nginx would accept these as distinct listeners, but /auth decides on + # the port alone, so the external one would inherit anonymous admin + with self.assertRaises(ValidationError): + ListenConfig(internal="127.0.0.1:8971", external="0.0.0.0:8971") + + def test_distinct_ports_accepted(self): + listen = ListenConfig(internal=5001, external="0.0.0.0:8971") + + self.assertEqual(listen.internal_port, 5001) + self.assertEqual(listen.external_port, 8971) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/frigate/test/test_norfair_distance.py b/frigate/test/test_norfair_distance.py new file mode 100644 index 0000000000..a79b91c3cf --- /dev/null +++ b/frigate/test/test_norfair_distance.py @@ -0,0 +1,91 @@ +import math +import unittest + +import numpy as np +from norfair.camera_motion import ( + HomographyTransformation, + TranslationTransformation, +) + +from frigate.ptz.autotrack import transform_is_finite +from frigate.track.norfair_tracker import distance + + +class TestNorfairDistance(unittest.TestCase): + """Regression tests for the tracker distance guard. + + norfair raises a hard ValueError on any nan distance, which kills the camera + process. During autotracking, an ill-conditioned homography can hand the + tracker a non-finite or degenerate estimate box, so distance() must never + return nan for any input. + """ + + def setUp(self) -> None: + # boxes are [[x1, y1], [x2, y2]] + self.detection = np.array([[805.0, 402.0], [864.0, 521.0]]) + self.estimate = np.array([[800.0, 400.0], [860.0, 520.0]]) + + def test_finite_boxes_give_finite_distance(self) -> None: + d = distance(self.detection, self.estimate) + self.assertTrue(math.isfinite(d)) + + def test_inf_estimate_corner_does_not_return_nan(self) -> None: + estimate = np.array([[np.inf, 400.0], [860.0, 520.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_nan_estimate_corner_does_not_return_nan(self) -> None: + # the actual autotracking crash: a positive-only guard would miss this + # because nan <= 0 is False + estimate = np.array([[np.nan, 400.0], [860.0, 520.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_zero_area_estimate_does_not_return_nan(self) -> None: + estimate = np.array([[900.0, 500.0], [900.0, 500.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_zero_area_detection_does_not_return_nan(self) -> None: + detection = np.array([[805.0, 402.0], [805.0, 521.0]]) + d = distance(detection, self.estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_inverted_estimate_corners_do_not_return_nan(self) -> None: + # Kalman estimates can occasionally cross corners (x2 < x1) + estimate = np.array([[860.0, 520.0], [800.0, 400.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + +class TestTransformIsFinite(unittest.TestCase): + def test_finite_homography_is_finite(self) -> None: + matrix = np.array([[1.0, 0.0, 5.0], [0.0, 1.0, 3.0], [0.0, 0.0, 1.0]]) + self.assertTrue(transform_is_finite(HomographyTransformation(matrix))) + + def test_finite_translation_is_finite(self) -> None: + self.assertTrue( + transform_is_finite(TranslationTransformation(np.array([12.0, -4.0]))) + ) + + def test_non_finite_homography_is_not_finite(self) -> None: + transform = HomographyTransformation(np.eye(3)) + # simulate accumulation overflowing to a non-finite matrix + transform.homography_matrix = np.array( + [[1.0, 0.0, np.inf], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + self.assertFalse(transform_is_finite(transform)) + + def test_nan_translation_is_not_finite(self) -> None: + self.assertFalse( + transform_is_finite(TranslationTransformation(np.array([np.nan, 0.0]))) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_obects.py b/frigate/test/test_obects.py index 8fe831980e..ee0162ec01 100644 --- a/frigate/test/test_obects.py +++ b/frigate/test/test_obects.py @@ -1,6 +1,22 @@ +import random import unittest +import numpy as np + from frigate.track.tracked_object import TrackedObjectAttribute +from frigate.util.object import average_boxes + + +class TestBoxStatistics(unittest.TestCase): + def test_average_boxes_matches_numpy(self) -> None: + rng = random.Random(0) + for _ in range(5000): + boxes = [ + [rng.randint(0, 4000) for _ in range(4)] + for _ in range(rng.randint(1, 10)) + ] + expected = [float(np.mean([b[i] for b in boxes])) for i in range(4)] + self.assertEqual(average_boxes(boxes), expected) class TestAttribute(unittest.TestCase): diff --git a/frigate/test/test_onvif_probe.py b/frigate/test/test_onvif_probe.py new file mode 100644 index 0000000000..aee7cc17a5 --- /dev/null +++ b/frigate/test/test_onvif_probe.py @@ -0,0 +1,124 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from zeep.exceptions import Fault, TransportError +from zeep.transports import AsyncTransport + +from frigate.api.camera import _build_digest_transport, _connect_onvif_camera + + +def _make_camera(update_side_effect=None): + """Build a mock ONVIFCamera whose update_xaddrs can raise or succeed.""" + camera = MagicMock() + camera.update_xaddrs = AsyncMock(side_effect=update_side_effect) + return camera + + +class TestConnectOnvifCamera(unittest.IsolatedAsyncioTestCase): + async def test_password_digest_succeeds_first(self): + # Cameras that accept PasswordDigest authenticate on the first attempt + # and should never trigger the PasswordText fallback. + camera = _make_camera() + + with patch("frigate.api.camera.ONVIFCamera", return_value=camera) as mock_cls: + result = await _connect_onvif_camera( + "cam.local", 80, "user", "pass", None, "basic" + ) + + self.assertIs(result, camera) + mock_cls.assert_called_once() + self.assertTrue(mock_cls.call_args.kwargs["encrypt"]) + + async def test_falls_back_to_password_text(self): + # A PasswordDigest rejection should retry once with PasswordText. + camera_digest = _make_camera(update_side_effect=Fault("token rejected")) + camera_text = _make_camera() + + with patch( + "frigate.api.camera.ONVIFCamera", + side_effect=[camera_digest, camera_text], + ) as mock_cls: + result = await _connect_onvif_camera( + "cam.local", 80, "user", "pass", None, "basic" + ) + + self.assertIs(result, camera_text) + self.assertEqual(mock_cls.call_count, 2) + self.assertTrue(mock_cls.call_args_list[0].kwargs["encrypt"]) + self.assertFalse(mock_cls.call_args_list[1].kwargs["encrypt"]) + + async def test_both_encodings_fail_raises_first_fault(self): + # When both encodings fault, the original (PasswordDigest) fault is + # surfaced so the caller's existing Fault handler reports it. + first_fault = Fault("digest rejected") + camera_digest = _make_camera(update_side_effect=first_fault) + camera_text = _make_camera(update_side_effect=Fault("text rejected")) + + with patch( + "frigate.api.camera.ONVIFCamera", + side_effect=[camera_digest, camera_text], + ) as mock_cls: + with self.assertRaises(Fault) as ctx: + await _connect_onvif_camera( + "cam.local", 80, "user", "pass", None, "basic" + ) + + self.assertIs(ctx.exception, first_fault) + self.assertEqual(mock_cls.call_count, 2) + + async def test_transport_error_is_not_retried(self): + # Connection-level errors (timeout, refused, unreachable) should + # propagate immediately without doubling latency on a second encoding. + camera = _make_camera(update_side_effect=TransportError("unreachable")) + + with patch("frigate.api.camera.ONVIFCamera", side_effect=[camera]) as mock_cls: + with self.assertRaises(TransportError): + await _connect_onvif_camera( + "cam.local", 80, "user", "pass", None, "basic" + ) + + mock_cls.assert_called_once() + + async def test_digest_auth_replaces_service_transports(self): + # auth_type "digest" wires an HTTP digest transport onto each service, + # independently of the WS-Security encoding. + camera = _make_camera() + + with ( + patch("frigate.api.camera.ONVIFCamera", return_value=camera), + patch( + "frigate.api.camera._build_digest_transport", + return_value="TRANSPORT", + ) as mock_transport, + ): + result = await _connect_onvif_camera( + "cam.local", 80, "user", "pass", None, "digest" + ) + + self.assertIs(result, camera) + mock_transport.assert_called_once_with("user", "pass") + self.assertEqual(camera.devicemgmt.zeep_client.transport, "TRANSPORT") + self.assertEqual(camera.media.zeep_client.transport, "TRANSPORT") + self.assertEqual(camera.ptz.zeep_client.transport, "TRANSPORT") + + async def test_basic_auth_does_not_replace_transports(self): + # Without digest auth, no transport override is built. + camera = _make_camera() + + with ( + patch("frigate.api.camera.ONVIFCamera", return_value=camera), + patch("frigate.api.camera._build_digest_transport") as mock_transport, + ): + await _connect_onvif_camera("cam.local", 80, "user", "pass", None, "basic") + + mock_transport.assert_not_called() + + +class TestBuildDigestTransport(unittest.TestCase): + def test_returns_async_transport(self): + transport = _build_digest_transport("user", "pass") + self.assertIsInstance(transport, AsyncTransport) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_output_ws_auth.py b/frigate/test/test_output_ws_auth.py new file mode 100644 index 0000000000..ea4834ef13 --- /dev/null +++ b/frigate/test/test_output_ws_auth.py @@ -0,0 +1,57 @@ +"""Tests for JSMPEG websocket authorization.""" + +import unittest +from types import SimpleNamespace + +from frigate.config import FrigateConfig +from frigate.output.ws_auth import ws_has_camera_access + + +class TestWsHasCameraAccess(unittest.TestCase): + def setUp(self): + self.config = FrigateConfig( + mqtt={"host": "mqtt"}, + auth={"roles": {"limited_user": ["front_door"]}}, + cameras={ + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, + ) + + def _make_ws(self, role: str): + return SimpleNamespace(environ={"HTTP_REMOTE_ROLE": role}) + + def test_restricted_role_only_gets_allowed_camera(self): + ws = self._make_ws("limited_user") + self.assertTrue(ws_has_camera_access(ws, "front_door", self.config)) + self.assertFalse(ws_has_camera_access(ws, "back_door", self.config)) + + def test_unrestricted_role_can_access_any_camera(self): + ws = self._make_ws("viewer") + self.assertTrue(ws_has_camera_access(ws, "front_door", self.config)) + self.assertTrue(ws_has_camera_access(ws, "back_door", self.config)) + + def test_birdseye_requires_unrestricted_access(self): + self.assertTrue( + ws_has_camera_access(self._make_ws("admin"), "birdseye", self.config) + ) + self.assertTrue( + ws_has_camera_access(self._make_ws("viewer"), "birdseye", self.config) + ) + self.assertFalse( + ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config) + ) diff --git a/frigate/test/test_post_processing.py b/frigate/test/test_post_processing.py new file mode 100644 index 0000000000..9290c0c152 --- /dev/null +++ b/frigate/test/test_post_processing.py @@ -0,0 +1,226 @@ +"""Tests for detector post-processing NMS box format handling. + +cv2.dnn.NMSBoxes expects boxes as [x, y, width, height]. Passing corner +coordinates [x1, y1, x2, y2] makes OpenCV treat x2/y2 as width/height, +inflating every box toward the bottom-right by its distance from the origin. +Two well separated objects far from the origin then appear to overlap and the +lower scoring one is silently suppressed. + +The regression geometry used throughout: two boxes with zero true overlap, +A = (393, 499, 484, 620) and B = (527, 499, 618, 620) in a 640x640 input +(43 px gap). Misread as [x, y, w, h] their IoU is 0.465, above the 0.4 NMS +threshold, so the buggy format drops the lower scoring box while correct +conversion keeps both. +""" + +import math +import unittest +from queue import Queue + +import numpy as np + +from frigate.detectors.plugins.memryx import MemryXDetector +from frigate.util.model import ( + post_process_dfine, + post_process_rfdetr, + post_process_yolo, + post_process_yolox, +) + +WIDTH = 640 +HEIGHT = 640 + +# box A: xyxy (393, 499, 484, 620) as center format +A_CX, A_CY, A_W, A_H = 438.5, 559.5, 91.0, 121.0 +# box B: xyxy (527, 499, 618, 620) as center format +B_CX, B_CY, B_W, B_H = 572.5, 559.5, 91.0, 121.0 + +# expected normalized output rows: [class_id, conf, y1, x1, y2, x2] +A_ROW = [499 / 640, 393 / 640, 620 / 640, 484 / 640] +B_ROW = [499 / 640, 527 / 640, 620 / 640, 618 / 640] + + +def kept(detections: np.ndarray) -> np.ndarray: + """Rows of the padded (20, 6) output that hold real detections.""" + return detections[detections[:, 1] > 0] + + +class TestYoloNmsPostProcess(unittest.TestCase): + def _single_output(self, rows: list[list[float]]) -> list[np.ndarray]: + """Build a single-tensor YOLO output (1, attrs, anchors) from + [cx, cy, w, h, class scores...] rows, padded with empty anchors.""" + anchors = np.zeros((10, len(rows[0])), dtype=np.float32) + anchors[: len(rows)] = np.array(rows, dtype=np.float32) + return [anchors.T[np.newaxis, ...]] + + def test_keeps_separated_objects_far_from_origin(self): + output = self._single_output( + [ + [A_CX, A_CY, A_W, A_H, 0.90, 0.0], + [B_CX, B_CY, B_W, B_H, 0.0, 0.85], + ] + ) + + detections = kept(post_process_yolo(output, WIDTH, HEIGHT)) + + self.assertEqual(len(detections), 2) + np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3) + np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3) + + def test_still_suppresses_true_duplicates(self): + # same object twice, shifted 4 px: true IoU 0.92, must dedupe to one + output = self._single_output( + [ + [A_CX, A_CY, A_W, A_H, 0.90, 0.0], + [A_CX + 4, A_CY, A_W, A_H, 0.85, 0.0], + ] + ) + + detections = kept(post_process_yolo(output, WIDTH, HEIGHT)) + + self.assertEqual(len(detections), 1) + np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3) + + +class TestMultipartYoloPostProcess(unittest.TestCase): + def _multipart_output(self) -> list[np.ndarray]: + """Build a 3-scale anchor-based YOLO output containing boxes A and B, + both decoded through anchor 0 of the stride-32 scale.""" + outputs = [ + np.zeros((1, 255, 80, 80), dtype=np.float32), + np.zeros((1, 255, 40, 40), dtype=np.float32), + np.zeros((1, 255, 20, 20), dtype=np.float32), + ] + stride, (anchor_w, anchor_h) = 32, (142, 110) + + for cx, cy, w, h, conf, class_channel in [ + (A_CX, A_CY, A_W, A_H, 0.95, 5), # class 0 + (B_CX, B_CY, B_W, B_H, 0.90, 6), # class 1 + ]: + cell_x, cell_y = int(cx // stride), int(cy // stride) + dx = (cx / stride - cell_x + 0.5) / 2 + dy = (cy / stride - cell_y + 0.5) / 2 + dw = math.sqrt(w / anchor_w) / 2 + dh = math.sqrt(h / anchor_h) / 2 + # anchor 0 occupies channels 0-84 of the 255 channel tensor + outputs[2][0, 0:4, cell_y, cell_x] = [dx, dy, dw, dh] + outputs[2][0, 4, cell_y, cell_x] = conf + outputs[2][0, class_channel, cell_y, cell_x] = 1.0 + + return outputs + + def test_keeps_separated_objects_far_from_origin(self): + detections = kept(post_process_yolo(self._multipart_output(), WIDTH, HEIGHT)) + + self.assertEqual(len(detections), 2) + np.testing.assert_allclose(detections[0], [0, 0.95, *A_ROW], atol=2e-3) + np.testing.assert_allclose(detections[1], [1, 0.90, *B_ROW], atol=2e-3) + + def test_empty_output_returns_no_detections(self): + outputs = [ + np.zeros((1, 255, 80, 80), dtype=np.float32), + np.zeros((1, 255, 40, 40), dtype=np.float32), + np.zeros((1, 255, 20, 20), dtype=np.float32), + ] + + detections = kept(post_process_yolo(outputs, WIDTH, HEIGHT)) + + self.assertEqual(len(detections), 0) + + +class TestYoloxPostProcess(unittest.TestCase): + def test_keeps_separated_objects_far_from_origin(self): + # with zero grids and unit strides the decode reduces to + # cx = raw cx and w = exp(raw w) + rows = np.zeros((10, 7), dtype=np.float32) + rows[0] = [A_CX, A_CY, math.log(A_W), math.log(A_H), 1.0, 0.90, 0.0] + rows[1] = [B_CX, B_CY, math.log(B_W), math.log(B_H), 1.0, 0.0, 0.85] + predictions = rows[np.newaxis, ...] + grids = np.zeros((1, 10, 2), dtype=np.float32) + expanded_strides = np.ones((1, 10, 1), dtype=np.float32) + + detections = kept( + post_process_yolox(predictions, WIDTH, HEIGHT, grids, expanded_strides) + ) + + self.assertEqual(len(detections), 2) + np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3) + np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3) + + +class TestDfinePostProcess(unittest.TestCase): + def test_keeps_separated_objects_far_from_origin(self): + # D-FINE emits absolute pixel xyxy boxes alongside labels and scores + labels = np.zeros((1, 10), dtype=np.int64) + labels[0, 1] = 1 + boxes = np.zeros((1, 10, 4), dtype=np.float32) + boxes[0, 0] = [393, 499, 484, 620] + boxes[0, 1] = [527, 499, 618, 620] + scores = np.zeros((1, 10), dtype=np.float32) + scores[0, 0] = 0.90 + scores[0, 1] = 0.85 + + detections = kept(post_process_dfine([labels, boxes, scores], WIDTH, HEIGHT)) + + self.assertEqual(len(detections), 2) + np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3) + np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3) + + +class TestRfdetrPostProcess(unittest.TestCase): + def test_keeps_separated_objects_far_from_origin(self): + # RF-DETR emits normalized center format boxes and class logits where + # logit index 0 is the background class + boxes = np.zeros((1, 10, 4), dtype=np.float32) + boxes[0, 0] = [A_CX / WIDTH, A_CY / HEIGHT, A_W / WIDTH, A_H / HEIGHT] + boxes[0, 1] = [B_CX / WIDTH, B_CY / HEIGHT, B_W / WIDTH, B_H / HEIGHT] + # background heavy logits everywhere, then two confident objects + logits = np.tile(np.array([10.0, 0.0, 0.0], dtype=np.float32), (1, 10, 1)) + logits[0, 0] = [0.0, 4.0, 0.0] # class 0 after background offset + logits[0, 1] = [0.0, 0.0, 3.5] # class 1 after background offset + + detections = kept(post_process_rfdetr([boxes, logits])) + + conf_a = math.exp(4.0) / (math.exp(4.0) + 2) + conf_b = math.exp(3.5) / (math.exp(3.5) + 2) + self.assertEqual(len(detections), 2) + np.testing.assert_allclose(detections[0], [0, conf_a, *A_ROW], atol=2e-3) + np.testing.assert_allclose(detections[1], [1, conf_b, *B_ROW], atol=2e-3) + + +class TestMemryxSsdlitePostProcess(unittest.TestCase): + def test_keeps_separated_objects_far_from_origin(self): + # the NMS math runs on the host CPU, so the real method is testable + # without MemryX hardware; it only needs the model dimensions and + # the output queue + detector = object.__new__(MemryXDetector) + detector.memx_model_width = WIDTH + detector.memx_model_height = HEIGHT + detector.output_queue = Queue() + + # this path uses a 0.5 NMS threshold, so use a tighter pair: zero + # true overlap (10 px gap), IoU 0.69 when misread as [x, y, w, h] + dets = np.zeros((1, 10, 5), dtype=np.float32) + dets[0, 0] = [480, 500, 540, 620, 0.90] + dets[0, 1] = [550, 500, 610, 620, 0.85] + labels = np.zeros((1, 10), dtype=np.float32) + labels[0, 1] = 1 + + detector.post_process_ssdlite([dets, labels]) + detections = kept(detector.output_queue.get()) + + self.assertEqual(len(detections), 2) + np.testing.assert_allclose( + detections[0], + [0, 0.90, 500 / 640, 480 / 640, 620 / 640, 540 / 640], + atol=2e-3, + ) + np.testing.assert_allclose( + detections[1], + [1, 0.85, 500 / 640, 550 / 640, 620 / 640, 610 / 640], + atol=2e-3, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_preview_loader.py b/frigate/test/test_preview_loader.py new file mode 100644 index 0000000000..e2062fce19 --- /dev/null +++ b/frigate/test/test_preview_loader.py @@ -0,0 +1,80 @@ +import os +import shutil +import unittest + +from frigate.output.preview import ( + PREVIEW_CACHE_DIR, + PREVIEW_FRAME_TYPE, + get_most_recent_preview_frame, +) + + +class TestPreviewLoader(unittest.TestCase): + def setUp(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + os.makedirs(PREVIEW_CACHE_DIR) + + def tearDown(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + + def test_get_most_recent_preview_frame_missing(self): + self.assertIsNone(get_most_recent_preview_frame("test_camera")) + + def test_get_most_recent_preview_frame_exists(self): + camera = "test_camera" + # create dummy preview files + for ts in ["1000.0", "2000.0", "1500.0"]: + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write(f"test_{ts}") + + expected_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" + ) + self.assertEqual(get_most_recent_preview_frame(camera), expected_path) + + def test_get_most_recent_preview_frame_before(self): + camera = "test_camera" + # create dummy preview files + for ts in ["1000.0", "2000.0"]: + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write(f"test_{ts}") + + # Test finding frame before or at 1500 + expected_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" + ) + self.assertEqual( + get_most_recent_preview_frame(camera, before=1500.0), expected_path + ) + + # Test finding frame before or at 999 + self.assertIsNone(get_most_recent_preview_frame(camera, before=999.0)) + + def test_get_most_recent_preview_frame_other_camera(self): + camera = "test_camera" + other_camera = "other_camera" + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write("test") + + self.assertIsNone(get_most_recent_preview_frame(camera)) + + def test_get_most_recent_preview_frame_no_directory(self): + shutil.rmtree(PREVIEW_CACHE_DIR) + self.assertIsNone(get_most_recent_preview_frame("test_camera")) diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py new file mode 100644 index 0000000000..355865a8fe --- /dev/null +++ b/frigate/test/test_profiles.py @@ -0,0 +1,1064 @@ +"""Tests for the profiles system.""" + +import copy +import json +import os +import unittest +from unittest.mock import MagicMock, patch + +from frigate.config import FrigateConfig +from frigate.config.camera.profile import CameraProfileConfig +from frigate.config.profile import ProfileDefinitionConfig +from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager +from frigate.const import MODEL_CACHE_DIR + + +class TestCameraProfileConfig(unittest.TestCase): + """Test the CameraProfileConfig Pydantic model.""" + + def test_empty_profile(self): + """All sections default to None.""" + profile = CameraProfileConfig() + assert profile.detect is None + assert profile.motion is None + assert profile.objects is None + assert profile.review is None + assert profile.notifications is None + + def test_partial_detect(self): + """Profile with only detect.enabled set.""" + profile = CameraProfileConfig(detect={"enabled": False}) + assert profile.detect is not None + assert profile.detect.enabled is False + dumped = profile.detect.model_dump(exclude_unset=True) + assert dumped == {"enabled": False} + + def test_partial_notifications(self): + """Profile with only notifications.enabled set.""" + profile = CameraProfileConfig(notifications={"enabled": True}) + assert profile.notifications is not None + assert profile.notifications.enabled is True + dumped = profile.notifications.model_dump(exclude_unset=True) + assert dumped == {"enabled": True} + + def test_partial_objects(self): + """Profile with objects.track set.""" + profile = CameraProfileConfig(objects={"track": ["car", "package"]}) + assert profile.objects is not None + assert profile.objects.track == ["car", "package"] + + def test_partial_review(self): + """Profile with nested review.alerts.labels.""" + profile = CameraProfileConfig(review={"alerts": {"labels": ["person", "car"]}}) + assert profile.review is not None + assert profile.review.alerts.labels == ["person", "car"] + + def test_enabled_field(self): + """Profile with enabled set to False.""" + profile = CameraProfileConfig(enabled=False) + assert profile.enabled is False + dumped = profile.model_dump(exclude_unset=True) + assert dumped == {"enabled": False} + + def test_enabled_field_true(self): + """Profile with enabled set to True.""" + profile = CameraProfileConfig(enabled=True) + assert profile.enabled is True + + def test_enabled_default_none(self): + """Enabled defaults to None when not set.""" + profile = CameraProfileConfig() + assert profile.enabled is None + + def test_zones_field(self): + """Profile with zones override.""" + profile = CameraProfileConfig( + zones={ + "driveway": { + "coordinates": "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + "objects": ["car"], + } + } + ) + assert profile.zones is not None + assert "driveway" in profile.zones + + def test_zones_default_none(self): + """Zones defaults to None when not set.""" + profile = CameraProfileConfig() + assert profile.zones is None + + def test_none_sections_not_in_dump(self): + """Sections left as None should not appear in exclude_unset dump.""" + profile = CameraProfileConfig(detect={"enabled": False}) + dumped = profile.model_dump(exclude_unset=True) + assert "detect" in dumped + assert "motion" not in dumped + assert "objects" not in dumped + + def test_invalid_field_value_rejected(self): + """Invalid field values are caught by Pydantic.""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig(detect={"fps": "not_a_number"}) + + def test_invalid_section_key_rejected(self): + """Unknown section keys are rejected (extra=forbid from FrigateBaseModel).""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig(ffmpeg={"inputs": []}) + + def test_invalid_nested_field_rejected(self): + """Invalid nested field values are caught.""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig(review={"alerts": {"labels": "not_a_list"}}) + + def test_invalid_profile_in_camera_config(self): + """Invalid profile section in full config is caught at parse time.""" + 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}, + "profiles": { + "armed": { + "detect": {"fps": "invalid"}, + }, + }, + }, + }, + } + with self.assertRaises(ValidationError): + FrigateConfig(**config_data) + + def test_undefined_profile_reference_rejected(self): + """Camera referencing a profile not defined in top-level profiles 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}, + "profiles": { + "nonexistent": { + "detect": {"enabled": False}, + }, + }, + }, + }, + } + 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.""" + + def setUp(self): + self.base_config = { + "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + "objects": {"track": ["person", "car", "package"]}, + }, + "disarmed": { + "notifications": {"enabled": False}, + "objects": {"track": ["package"]}, + }, + }, + }, + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.2:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "detect": {"enabled": True}, + }, + }, + }, + }, + } + + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + + def test_profiles_parse(self): + """Profiles are parsed into Dict[str, CameraProfileConfig].""" + config = FrigateConfig(**self.base_config) + front = config.cameras["front"] + assert "armed" in front.profiles + assert "disarmed" in front.profiles + assert isinstance(front.profiles["armed"], CameraProfileConfig) + + def test_profile_sections_parsed(self): + """Profile sections are properly typed.""" + config = FrigateConfig(**self.base_config) + armed = config.cameras["front"].profiles["armed"] + assert armed.notifications is not None + assert armed.notifications.enabled is True + assert armed.objects is not None + assert armed.objects.track == ["person", "car", "package"] + assert armed.detect is None # not set in this profile + + def test_camera_without_profiles(self): + """Camera with no profiles has empty dict.""" + config_data = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, + } + config = FrigateConfig(**config_data) + assert config.cameras["front"].profiles == {} + + +class TestProfileManager(unittest.TestCase): + """Test ProfileManager activation, deactivation, and switching.""" + + def setUp(self): + self.config_data = { + "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "notifications": {"enabled": False}, + "objects": {"track": ["person"]}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + "objects": {"track": ["person", "car", "package"]}, + }, + "disarmed": { + "notifications": {"enabled": False}, + "objects": {"track": ["package"]}, + }, + }, + }, + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.2:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + }, + }, + }, + }, + } + + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + + self.config = FrigateConfig(**self.config_data) + self.mock_updater = MagicMock() + self.manager = ProfileManager(self.config, self.mock_updater) + + def test_get_available_profiles(self): + """Available profiles come from top-level profile definitions.""" + profiles = self.manager.get_available_profiles() + assert len(profiles) == 2 + names = [p["name"] for p in profiles] + assert "armed" in names + assert "disarmed" in names + # Verify friendly_name is included + armed = next(p for p in profiles if p["name"] == "armed") + assert armed["friendly_name"] == "Armed" + + def test_activate_invalid_profile(self): + """Activating non-existent profile returns error.""" + err = self.manager.activate_profile("nonexistent") + assert err is not None + assert "not defined" in err + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile(self, mock_persist): + """Activating a profile applies overrides.""" + err = self.manager.activate_profile("armed") + assert err is None + assert self.config.active_profile == "armed" + + # Front camera should have armed overrides + front = self.config.cameras["front"] + assert front.notifications.enabled is True + assert front.objects.track == ["person", "car", "package"] + + # Back camera should have armed overrides + back = self.config.cameras["back"] + assert back.notifications.enabled is True + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_profile(self, mock_persist): + """Deactivating a profile restores base config.""" + # Activate first + self.manager.activate_profile("armed") + assert self.config.cameras["front"].notifications.enabled is True + + # Deactivate + err = self.manager.activate_profile(None) + assert err is None + assert self.config.active_profile is None + + # Should be back to base + front = self.config.cameras["front"] + assert front.notifications.enabled is False + assert front.objects.track == ["person"] + + @patch.object(ProfileManager, "_persist_active_profile") + def test_switch_profiles(self, mock_persist): + """Switching from one profile to another works.""" + self.manager.activate_profile("armed") + assert self.config.cameras["front"].objects.track == [ + "person", + "car", + "package", + ] + + self.manager.activate_profile("disarmed") + assert self.config.active_profile == "disarmed" + assert self.config.cameras["front"].objects.track == ["package"] + assert self.config.cameras["front"].notifications.enabled is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_unaffected_camera(self, mock_persist): + """Camera without the activated profile is unaffected.""" + back_base_notifications = self.config.cameras["back"].notifications.enabled + + self.manager.activate_profile("disarmed") + + # Back camera has no "disarmed" profile, should be unchanged + assert ( + self.config.cameras["back"].notifications.enabled == back_base_notifications + ) + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile_disables_camera(self, mock_persist): + """Profile with enabled=false disables the camera.""" + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + assert self.config.cameras["front"].enabled is True + err = self.manager.activate_profile("away") + assert err is None + assert self.config.cameras["front"].enabled is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_restores_enabled(self, mock_persist): + """Deactivating a profile restores the camera's base enabled state.""" + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + self.manager.activate_profile("away") + assert self.config.cameras["front"].enabled is False + + self.manager.activate_profile(None) + assert self.config.cameras["front"].enabled is True + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile_adds_zone(self, mock_persist): + """Profile with zones adds/overrides zones on camera.""" + from frigate.config.camera.zone import ZoneConfig + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + assert "driveway" not in self.config.cameras["front"].zones + + err = self.manager.activate_profile("away") + assert err is None + assert "driveway" in self.config.cameras["front"].zones + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_restores_zones(self, mock_persist): + """Deactivating a profile restores base zones.""" + from frigate.config.camera.zone import ZoneConfig + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + self.manager.activate_profile("away") + assert "driveway" in self.config.cameras["front"].zones + + self.manager.activate_profile(None) + assert "driveway" not in self.config.cameras["front"].zones + + @patch.object(ProfileManager, "_persist_active_profile") + def test_zones_zmq_published(self, mock_persist): + """ZMQ update is published for zones change.""" + from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, + ) + from frigate.config.camera.zone import ZoneConfig + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + self.mock_updater.reset_mock() + + self.manager.activate_profile("away") + + zones_calls = [ + call + for call in self.mock_updater.publish_update.call_args_list + if call[0][0] + == CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, "front") + ] + assert len(zones_calls) == 1 + + @patch.object(ProfileManager, "_persist_active_profile") + def test_enabled_zmq_published(self, mock_persist): + """ZMQ update is published for enabled state change.""" + from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, + ) + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + self.manager = ProfileManager(self.config, self.mock_updater) + self.mock_updater.reset_mock() + + self.manager.activate_profile("away") + + # Find the enabled update call + enabled_calls = [ + call + for call in self.mock_updater.publish_update.call_args_list + if call[0][0] + == CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, "front") + ] + assert len(enabled_calls) == 1 + assert enabled_calls[0][0][1] is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_zmq_updates_published(self, mock_persist): + """ZMQ updates are published when a profile is activated.""" + self.manager.activate_profile("armed") + assert self.mock_updater.publish_update.called + + def test_get_profile_info(self): + """Profile info returns correct structure with friendly names.""" + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value={"active": None, "last_activated": {}}, + ): + info = self.manager.get_profile_info() + assert "profiles" in info + assert "active_profile" in info + assert "last_activated" in info + assert info["active_profile"] is None + assert info["last_activated"] == {} + names = [p["name"] for p in info["profiles"]] + assert "armed" in names + assert "disarmed" in names + + @patch.object(ProfileManager, "_persist_active_profile") + def test_base_configs_for_api_unchanged_after_activation(self, mock_persist): + """API base configs reflect pre-profile values after activation.""" + base_track = self.config.cameras["front"].objects.track[:] + assert base_track == ["person"] + + self.manager.activate_profile("armed") + + # In-memory config has the profile-merged values + assert self.config.cameras["front"].objects.track == [ + "person", + "car", + "package", + ] + + # But the API base configs still return the original base values + api_base = self.manager.get_base_configs_for_api("front") + assert "objects" in api_base + assert api_base["objects"]["track"] == ["person"] + + def test_base_configs_for_api_are_json_serializable(self): + """API base configs are JSON-serializable (mode='json').""" + import json + + api_base = self.manager.get_base_configs_for_api("front") + # Should not raise + json.dumps(api_base) + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist): + """User-initiated activation drops runtime overrides (steady-state rule).""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed") + dispatcher.clear_runtime_state.assert_called_once_with() + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist): + """Deactivating a profile also drops runtime overrides.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed") + dispatcher.clear_runtime_state.reset_mock() + + manager.activate_profile(None) + dispatcher.clear_runtime_state.assert_called_once_with() + + @patch.object(ProfileManager, "_persist_active_profile") + def test_profile_change_republishes_switch_states(self, mock_persist): + """Profile changes republish MQTT switch states so HA stays in sync. + + Regression: activating/deactivating a profile updated the in-memory + config (and Frigate's behavior) but left the retained MQTT state + topics stale, so external integrations like Home Assistant kept + showing the pre-profile toggle position. + """ + config_data = copy.deepcopy(self.config_data) + config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = { + "alerts": {"enabled": False}, + } + config = FrigateConfig(**config_data) + dispatcher = MagicMock() + manager = ProfileManager(config, self.mock_updater, dispatcher) + + # Activating disarmed turns alerts off -> MQTT state must follow + manager.activate_profile("disarmed") + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "OFF", retain=True + ) + + # Deactivating restores the base (alerts on) -> MQTT state must follow + dispatcher.publish.reset_mock() + manager.activate_profile(None) + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "ON", retain=True + ) + + @patch.object(ProfileManager, "_persist_active_profile") + def test_startup_replay_does_not_clear_runtime_state(self, mock_persist): + """Startup callers pass clear_runtime_overrides=False to preserve state.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed", clear_runtime_overrides=False) + dispatcher.clear_runtime_state.assert_not_called() + + def test_apply_profile_to_config_mutates_the_config(self): + """The config-only half applies the same overrides as activation.""" + err = self.manager.apply_profile_to_config("armed") + assert err is None + + front = self.config.cameras["front"] + assert front.notifications.enabled is True + assert front.objects.track == ["person", "car", "package"] + + def test_apply_profile_to_config_makes_no_zmq_mqtt_or_disk_writes(self): + """Workers are started with the values, so nothing is published yet.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + + with patch.object(ProfileManager, "_persist_active_profile") as mock_persist: + manager.apply_profile_to_config("armed") + + self.mock_updater.publish_update.assert_not_called() + dispatcher.publish.assert_not_called() + mock_persist.assert_not_called() + # bookkeeping stays with activate_profile + assert self.config.active_profile is None + + def test_apply_profile_to_config_rejects_an_unknown_profile(self): + err = self.manager.apply_profile_to_config("nonexistent") + assert err is not None + assert "not defined" in err + + def test_restore_persisted_profile_to_config_applies_it(self): + """The startup config pass restores what was persisted.""" + with patch.object( + ProfileManager, "load_persisted_profile", return_value="armed" + ): + self.manager.restore_persisted_profile_to_config() + + assert self.config.cameras["front"].notifications.enabled is True + # still the config-only half, so nothing is published or persisted + self.mock_updater.publish_update.assert_not_called() + assert self.config.active_profile is None + + def test_restore_persisted_profile_to_config_no_op_when_none_persisted(self): + with patch.object(ProfileManager, "load_persisted_profile", return_value=None): + self.manager.restore_persisted_profile_to_config() + + assert self.config.cameras["front"].notifications.enabled is False + + def test_restore_persisted_profile_to_config_ignores_a_stale_name(self): + """A profile no longer offered by any camera must not be applied.""" + with patch.object( + ProfileManager, "load_persisted_profile", return_value="ghost" + ): + self.manager.restore_persisted_profile_to_config() + + assert self.config.cameras["front"].notifications.enabled is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_restore_persisted_profile_activates_and_publishes(self, mock_persist): + """The startup publish pass runs a full activation.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + + with patch.object( + ProfileManager, "load_persisted_profile", return_value="armed" + ): + manager.restore_persisted_profile() + + assert self.config.active_profile == "armed" + self.mock_updater.publish_update.assert_called() + # a startup replay must not wipe the runtime overrides layered on top + dispatcher.clear_runtime_state.assert_not_called() + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activation_after_apply_still_publishes_every_section(self, mock_persist): + """Re-deriving the same state must not skip the broadcast. + + The processes that started before the config was corrected have no + other channel. + """ + self.manager.apply_profile_to_config("armed") + self.mock_updater.publish_update.reset_mock() + + err = self.manager.activate_profile("armed", clear_runtime_overrides=False) + assert err is None + + published = { + call.args[0].update_type.name + for call in self.mock_updater.publish_update.call_args_list + } + assert "notifications" in published + assert "objects" in published + assert self.config.active_profile == "armed" + + @patch.object(ProfileManager, "_persist_active_profile") + def test_update_config_preserves_runtime_state_with_active_profile( + self, mock_persist + ): + """A config/set save must not wipe overrides it never rewrote. + + The save path clears matching entries itself via + clear_runtime_state_for_yaml_keys; a broad wipe here would drop + overrides for unrelated cameras. + """ + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed") + dispatcher.clear_runtime_state.reset_mock() + + new_config = FrigateConfig(**self.config_data) + manager.update_config(new_config) + dispatcher.clear_runtime_state.assert_not_called() + + @patch.object(ProfileManager, "_persist_active_profile") + def test_update_config_still_reapplies_active_profile(self, mock_persist): + """Dropping the wipe must not disturb profile re-application.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed") + + new_config = FrigateConfig(**self.config_data) + manager.update_config(new_config) + + self.assertEqual(manager.config, new_config) + self.assertEqual(new_config.active_profile, "armed") + + @patch.object(ProfileManager, "_persist_active_profile") + def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist): + """Plain /api/config/set without a profile doesn't trigger the broad clear.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + # No activate_profile call — config.active_profile is None + new_config = FrigateConfig(**self.config_data) + manager.update_config(new_config) + dispatcher.clear_runtime_state.assert_not_called() + + +class TestProfilePersistence(unittest.TestCase): + """Test profile persistence to disk.""" + + def test_persist_and_load(self): + """Active profile name can be persisted and loaded via JSON.""" + data = {"active": "armed", "last_activated": {"armed": 1700000000.0}} + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value=data, + ): + result = ProfileManager.load_persisted_profile() + assert result == "armed" + + def test_load_empty_file(self): + """Empty persistence file returns None.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""): + result = ProfileManager.load_persisted_profile() + assert result is None + + def test_load_missing_file(self): + """Missing persistence file returns None.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False): + result = ProfileManager.load_persisted_profile() + assert result is None + + def test_load_persisted_data_valid_json(self): + """Valid JSON file is loaded correctly.""" + data = {"active": "home", "last_activated": {"home": 1700000000.0}} + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object( + type(PERSISTENCE_FILE), + "read_text", + return_value=json.dumps(data), + ): + result = ProfileManager._load_persisted_data() + assert result == data + + def test_load_persisted_data_invalid_json(self): + """Invalid JSON returns default structure.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object( + type(PERSISTENCE_FILE), "read_text", return_value="not json" + ): + result = ProfileManager._load_persisted_data() + assert result == {"active": None, "last_activated": {}} + + def test_load_persisted_data_missing_file(self): + """Missing file returns default structure.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False): + result = ProfileManager._load_persisted_data() + assert result == {"active": None, "last_activated": {}} + + def test_persist_records_timestamp(self): + """Persisting a profile records the activation timestamp.""" + 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}, + "profiles": {"armed": {"detect": {"enabled": True}}}, + }, + }, + } + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + config = FrigateConfig(**config_data) + manager = ProfileManager(config, MagicMock()) + + written_data = {} + + def mock_write(_self, content): + written_data.update(json.loads(content)) + + with patch.object( + ProfileManager, + "_load_persisted_data", + return_value={"active": None, "last_activated": {}}, + ): + with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write): + manager._persist_active_profile("armed") + + assert written_data["active"] == "armed" + assert "armed" in written_data["last_activated"] + assert isinstance(written_data["last_activated"]["armed"], float) + + def test_persist_deactivate_keeps_timestamps(self): + """Deactivating sets active to None but preserves last_activated.""" + existing = { + "active": "armed", + "last_activated": {"armed": 1700000000.0}, + } + written_data = {} + + def mock_write(_self, content): + written_data.update(json.loads(content)) + + 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}, + "profiles": {"armed": {"detect": {"enabled": True}}}, + }, + }, + } + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + config = FrigateConfig(**config_data) + manager = ProfileManager(config, MagicMock()) + + with patch.object( + ProfileManager, "_load_persisted_data", return_value=existing + ): + with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write): + manager._persist_active_profile(None) + + assert written_data["active"] is None + assert written_data["last_activated"]["armed"] == 1700000000.0 + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index 2ffad957c1..e4d2c9ce96 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -2,6 +2,7 @@ import unittest from frigate.api.auth import resolve_role from frigate.config import HeaderMappingConfig, ProxyConfig +from frigate.config.env import FRIGATE_ENV_VARS class TestProxyRoleResolution(unittest.TestCase): @@ -91,3 +92,39 @@ class TestProxyRoleResolution(unittest.TestCase): headers = {"x-remote-role": "group_unknown"} role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, self.proxy_config.default_role) + + +class TestProxyAuthSecretEnvString(unittest.TestCase): + def setUp(self): + self._original_env_vars = dict(FRIGATE_ENV_VARS) + + def tearDown(self): + FRIGATE_ENV_VARS.clear() + FRIGATE_ENV_VARS.update(self._original_env_vars) + + def test_auth_secret_env_substitution(self): + """auth_secret resolves FRIGATE_ env vars via EnvString.""" + FRIGATE_ENV_VARS["FRIGATE_PROXY_SECRET"] = "my_secret_value" + config = ProxyConfig(auth_secret="{FRIGATE_PROXY_SECRET}") + self.assertEqual(config.auth_secret, "my_secret_value") + + def test_auth_secret_env_embedded_in_string(self): + """auth_secret resolves env vars embedded in a larger string.""" + FRIGATE_ENV_VARS["FRIGATE_SECRET_PART"] = "abc123" + config = ProxyConfig(auth_secret="prefix-{FRIGATE_SECRET_PART}-suffix") + self.assertEqual(config.auth_secret, "prefix-abc123-suffix") + + def test_auth_secret_plain_string(self): + """auth_secret accepts a plain string without substitution.""" + config = ProxyConfig(auth_secret="literal_secret") + self.assertEqual(config.auth_secret, "literal_secret") + + def test_auth_secret_none(self): + """auth_secret defaults to None.""" + config = ProxyConfig() + self.assertIsNone(config.auth_secret) + + def test_auth_secret_unknown_var_raises(self): + """auth_secret raises KeyError for unknown env var references.""" + with self.assertRaises(Exception): + ProxyConfig(auth_secret="{FRIGATE_NONEXISTENT_VAR}") diff --git a/frigate/test/test_ptz_autotrack.py b/frigate/test/test_ptz_autotrack.py new file mode 100644 index 0000000000..1abcc780d0 --- /dev/null +++ b/frigate/test/test_ptz_autotrack.py @@ -0,0 +1,130 @@ +"""Tests for autotracker state that must survive runtime config changes. + +Regression coverage for a family of bugs where per-camera autotracker state was +built once at startup and never revisited. A camera that is added or enabled +after startup, or has autotracking enabled from the UI, would either raise a +KeyError on the autotracker thread or silently keep the wrong state: + +- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was + constructed, so runtime-enabled cameras raised KeyError on lookup. +- ptz_metrics autotracker_enabled is what the camera processes read, but nothing + updated it when autotracking was enabled through a config save, so it stayed + False and the tracker never built a motion estimator. +""" + +import unittest +from unittest.mock import MagicMock + +from frigate.camera import PTZMetrics +from frigate.config import FrigateConfig +from frigate.ptz.autotrack import PtzAutoTracker + +CAMERA = "ptz_cam" + + +def _config(autotracking_enabled: bool) -> FrigateConfig: + return FrigateConfig( + **{ + "mqtt": {"enabled": False}, + "cameras": { + CAMERA: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"width": 1920, "height": 1080}, + "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}}, + "onvif": { + "host": "10.0.0.1", + "autotracking": { + "enabled": autotracking_enabled, + "required_zones": ["zone"], + }, + }, + } + }, + } + ) + + +def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker: + """Build a PtzAutoTracker without invoking __init__, which would try to set up + onvif over the network. Only the config/metrics state is relevant here.""" + tracker = PtzAutoTracker.__new__(PtzAutoTracker) + tracker.config = _config(autotracking_enabled) + tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)} + tracker.onvif = MagicMock() + tracker.config_subscriber = MagicMock() + tracker.autotracker_init = {} + tracker.calibrating = {} + tracker.tracked_object = {} + return tracker + + +class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase): + async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None: + # a camera enabled at runtime has no autotracker_init entry, which used to + # raise KeyError and kill the autotracker thread for every camera + tracker = _make_tracker() + self.assertNotIn(CAMERA, tracker.autotracker_init) + + await tracker.camera_maintenance(CAMERA) + + tracker.onvif.get_camera_status.assert_not_called() + + async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None: + # autotracker_init is seeded False for enabled cameras before setup runs + tracker = _make_tracker() + tracker.autotracker_init[CAMERA] = False + + await tracker.camera_maintenance(CAMERA) + + tracker.onvif.get_camera_status.assert_not_called() + + +class TestAutotrackerMetricSync(unittest.TestCase): + def test_metric_follows_config_when_enabled_by_update(self) -> None: + # autotracking enabled via a config save: the metric was seeded False when + # the camera was added and nothing else updates it + tracker = _make_tracker(autotracking_enabled=True) + metrics = tracker.ptz_metrics[CAMERA] + self.assertFalse(metrics.autotracker_enabled.value) + + tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]} + tracker.check_for_updates() + + self.assertTrue(metrics.autotracker_enabled.value) + + def test_metric_follows_config_when_disabled_by_update(self) -> None: + tracker = _make_tracker(autotracking_enabled=False) + metrics = tracker.ptz_metrics[CAMERA] + metrics.autotracker_enabled.value = True + + tracker.config_subscriber.check_for_updates.return_value = { + "autotracking": [CAMERA] + } + tracker.check_for_updates() + + self.assertFalse(metrics.autotracker_enabled.value) + + def test_metric_sync_skips_camera_without_metrics(self) -> None: + # `add` reaches the maintainer and the autotracker on separate threads with + # no ordering guarantee, so the metrics may not exist yet + tracker = _make_tracker() + tracker.ptz_metrics = {} + tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]} + + tracker.check_for_updates() + + def test_metric_sync_skips_unknown_camera(self) -> None: + tracker = _make_tracker() + tracker.config_subscriber.check_for_updates.return_value = { + "add": ["not_in_config"] + } + + tracker.check_for_updates() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_ptz_onvif.py b/frigate/test/test_ptz_onvif.py new file mode 100644 index 0000000000..192985df7c --- /dev/null +++ b/frigate/test/test_ptz_onvif.py @@ -0,0 +1,238 @@ +"""Tests for ONVIF state that must not depend on the autotracking config. + +Regression coverage for a camera that is initialized while autotracking is off and +has it enabled later, which is the normal wizard flow: set the camera up first, +configure autotracking afterwards. The autotracking-only request objects used to +be created only when autotracking was enabled at init time, so the camera was left +with init=True but no status_request. get_camera_status skips its re-init branch +when init is True, so it went straight to the missing key and raised KeyError on +the tracking thread. + +The request objects are built from the locally parsed WSDL and cost no network, so +they are always created and init=True now implies they exist. + +Also covers the inverse direction: the ptz movement timestamps must not be written +for a camera that has autotracking off, because nothing clears them back out. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from frigate.camera import PTZMetrics +from frigate.config import FrigateConfig +from frigate.ptz.autotrack import ptz_moving_at_frame_time +from frigate.ptz.onvif import OnvifController + +CAMERA = "ptz_cam" + + +def _config(autotracking_enabled: bool) -> FrigateConfig: + return FrigateConfig( + **{ + "mqtt": {"enabled": False}, + "cameras": { + CAMERA: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"width": 1920, "height": 1080}, + "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}}, + "onvif": { + "host": "10.0.0.1", + "autotracking": { + "enabled": autotracking_enabled, + "required_zones": ["zone"], + }, + }, + } + }, + } + ) + + +def _make_profile() -> MagicMock: + profile = MagicMock() + profile.token = "profile_1" + profile.Name = "MainStream" + profile.VideoEncoderConfiguration = MagicMock() + ptz_config = MagicMock() + ptz_config.token = "ptz_config_1" + ptz_config.DefaultContinuousPanTiltVelocitySpace = "space" + ptz_config.DefaultContinuousZoomVelocitySpace = "space" + profile.PTZConfiguration = ptz_config + return profile + + +def _make_onvif_camera() -> MagicMock: + """A camera that supports PTZ but nothing optional, so init takes the simplest + path through the feature detection below.""" + onvif = MagicMock() + onvif.update_xaddrs = AsyncMock() + + video_source = MagicMock() + video_source.token = "video_source_1" + + media = MagicMock() + media.GetProfiles = AsyncMock(return_value=[_make_profile()]) + media.GetVideoSources = AsyncMock(return_value=[video_source]) + onvif.create_media_service = AsyncMock(return_value=media) + onvif.get_definition = MagicMock(return_value={"ptz": "definition"}) + + ptz = MagicMock() + # create_type is a local WSDL lookup, so tag the result to assert on it later + ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name)) + ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported")) + onvif.create_ptz_service = AsyncMock(return_value=ptz) + onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported")) + return onvif + + +def _make_controller(autotracking_enabled: bool) -> OnvifController: + """Build a controller without invoking __init__, which would start an event loop + thread and reach out to the camera.""" + config = _config(autotracking_enabled) + controller = OnvifController.__new__(OnvifController) + controller.config = config + controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}} + controller.failed_cams = {} + controller.camera_configs = {CAMERA: config.cameras[CAMERA]} + controller.ptz_metrics = {CAMERA: MagicMock()} + return controller + + +def _make_move_controller(autotracking_enabled: bool) -> OnvifController: + """Build an already initialized controller for a camera that supports relative + FOV movement, with real metrics so the timestamp writes can be asserted on.""" + config = _config(autotracking_enabled) + controller = OnvifController.__new__(OnvifController) + controller.config = config + controller.camera_configs = {CAMERA: config.cameras[CAMERA]} + controller.failed_cams = {} + + ptz = MagicMock() + ptz.RelativeMove = AsyncMock() + controller.cams = { + CAMERA: { + "init": True, + "active": False, + "ptz": ptz, + "features": ["pt", "pt-r-fov"], + "relative_move_request": MagicMock(), + "relative_fov_range": { + "XRange": {"Min": -1.0, "Max": 1.0}, + "YRange": {"Min": -1.0, "Max": 1.0}, + }, + } + } + controller.ptz_metrics = { + CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled) + } + return controller + + +class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase): + async def test_status_request_created_when_autotracking_disabled(self) -> None: + # the wizard flow: onvif configured first, autotracking enabled later + controller = _make_controller(autotracking_enabled=False) + + self.assertTrue(await controller._init_onvif(CAMERA)) + + cam = controller.cams[CAMERA] + self.assertTrue(cam["init"]) + self.assertIn("status_request", cam) + self.assertIn("service_capabilities_request", cam) + + async def test_status_request_created_when_autotracking_enabled(self) -> None: + controller = _make_controller(autotracking_enabled=True) + + self.assertTrue(await controller._init_onvif(CAMERA)) + + cam = controller.cams[CAMERA] + self.assertIn("status_request", cam) + self.assertIn("service_capabilities_request", cam) + + async def test_init_implies_status_request_exists(self) -> None: + # the invariant get_camera_status relies on: it skips re-init when init is + # True and then reads status_request without guarding + for autotracking_enabled in (True, False): + with self.subTest(autotracking_enabled=autotracking_enabled): + controller = _make_controller(autotracking_enabled) + + await controller._init_onvif(CAMERA) + + cam = controller.cams[CAMERA] + if cam["init"]: + self.assertEqual(cam["status_request"].request_type, "GetStatus") + + async def test_requests_built_without_contacting_camera(self) -> None: + # create_type is a local WSDL lookup; cameras that do not implement + # GetServiceCapabilities must not be asked about it during init + controller = _make_controller(autotracking_enabled=False) + + await controller._init_onvif(CAMERA) + + ptz = controller.cams[CAMERA]["ptz"] + ptz.GetServiceCapabilities.assert_not_called() + ptz.GetStatus.assert_not_called() + + +class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase): + """A manual move from the UI (click to move, drag to zoom) sends move_relative + for any camera that advertises pt-r-fov, autotracking or not.""" + + async def test_metrics_untouched_when_autotracking_disabled(self) -> None: + # only camera_maintenance polls get_camera_status, and only for autotracking + # cameras, so a manual move that starts the clock here is never stopped + controller = _make_move_controller(autotracking_enabled=False) + metrics = controller.ptz_metrics[CAMERA] + metrics.frame_time.value = 1000.0 + + await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1) + + controller.cams[CAMERA]["ptz"].RelativeMove.assert_awaited_once() + self.assertEqual(metrics.start_time.value, 0) + self.assertEqual(metrics.stop_time.value, 0) + self.assertTrue(metrics.motor_stopped.is_set()) + + async def test_detection_regions_not_suppressed_after_manual_move(self) -> None: + # the symptom of the bug: object detection stops entirely because motion + # boxes are never promoted to detection regions again + controller = _make_move_controller(autotracking_enabled=False) + metrics = controller.ptz_metrics[CAMERA] + metrics.frame_time.value = 1000.0 + + await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1) + + for later_frame_time in (1001.0, 1060.0, 4600.0): + with self.subTest(frame_time=later_frame_time): + self.assertFalse( + ptz_moving_at_frame_time( + later_frame_time, + metrics.start_time.value, + metrics.stop_time.value, + ) + ) + + async def test_metrics_written_when_autotracking_enabled(self) -> None: + # get_camera_status resets stop_time once the camera reports IDLE, so the + # autotracking path keeps its motion estimation timestamps + controller = _make_move_controller(autotracking_enabled=True) + metrics = controller.ptz_metrics[CAMERA] + metrics.frame_time.value = 1000.0 + + await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1) + + self.assertEqual(metrics.start_time.value, 1000.0) + self.assertEqual(metrics.stop_time.value, 0) + self.assertFalse(metrics.motor_stopped.is_set()) + self.assertTrue( + ptz_moving_at_frame_time( + 1001.0, metrics.start_time.value, metrics.stop_time.value + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_review_manual_event_severity.py b/frigate/test/test_review_manual_event_severity.py new file mode 100644 index 0000000000..9dd61e18c5 --- /dev/null +++ b/frigate/test/test_review_manual_event_severity.py @@ -0,0 +1,154 @@ +"""Tests for manual event severity categorization. + +Regression coverage for manual events created via the events API being +categorized as detections when their label appears in both the alerts and +detections label lists. Alert labels must win, matching how tracked objects +are categorized, and labels in neither list must default to alerts so the +historical behavior of the API is preserved. +""" + +import unittest + +from frigate.config import FrigateConfig +from frigate.review.maintainer import ReviewSegmentMaintainer +from frigate.review.types import SeverityEnum + +BASE_CONFIG = """ +mqtt: + enabled: False +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://10.0.0.1:554/video + roles: + - detect + detect: + width: 1920 + height: 1080 + fps: 5 +%s +""" + + +class TestManualEventSeverity(unittest.TestCase): + def _make_maintainer(self, review_config: str = "") -> ReviewSegmentMaintainer: + """Build a maintainer without invoking __init__ (avoids needing ZMQ + sockets, shared memory, and clip dirs). Only the config is read when + categorizing a manual event label.""" + maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer) + maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config) + return maintainer + + def test_defaults_to_alert(self) -> None: + maintainer = self._make_maintainer() + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.alert, + ) + + def test_unlisted_label_defaults_to_alert(self) -> None: + maintainer = self._make_maintainer( + """ + review: + detections: + labels: + - dog +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "pir_sensor"), + SeverityEnum.alert, + ) + + def test_detection_label_is_detection(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - pir_sensor +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "pir_sensor"), + SeverityEnum.detection, + ) + + def test_alert_label_wins_over_detection_label(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - person + - dog +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.alert, + ) + + def test_sub_label_is_stripped_before_categorizing(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - person +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person: Bob"), + SeverityEnum.alert, + ) + + def test_alert_label_is_detection_when_alerts_disabled(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + enabled: False + labels: + - person + detections: + labels: + - person +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.detection, + ) + + def test_no_severity_when_alerts_disabled_and_label_not_a_detection(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + enabled: False + detections: + labels: + - dog +""" + ) + + self.assertIsNone( + maintainer.get_manual_event_severity("front_door", "pir_sensor") + ) diff --git a/frigate/test/test_runtime_state.py b/frigate/test/test_runtime_state.py new file mode 100644 index 0000000000..5a373ade9e --- /dev/null +++ b/frigate/test/test_runtime_state.py @@ -0,0 +1,155 @@ +"""Tests for RuntimeStatePersistence.""" + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from frigate.comms.runtime_state import RuntimeStatePersistence + + +class TestRuntimeStatePersistence(unittest.TestCase): + """Unit tests for the JSON-backed runtime state store.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + self.config_path = os.path.join(self.tmp_dir, "config.yml") + # Touch a placeholder config.yml so find_config_file returns a real path + with open(self.config_path, "w") as f: + f.write("") + self._patcher = patch( + "frigate.comms.runtime_state.find_config_file", + return_value=self.config_path, + ) + self._patcher.start() + self.store = RuntimeStatePersistence() + + def tearDown(self) -> None: + self._patcher.stop() + for name in os.listdir(self.tmp_dir): + os.remove(os.path.join(self.tmp_dir, name)) + os.rmdir(self.tmp_dir) + + def test_load_returns_empty_when_file_missing(self) -> None: + self.assertEqual(self.store.load(), {}) + + def test_set_then_load_round_trip(self) -> None: + self.store.set("front_door", "detect", False) + self.store.set("front_door", "recordings", True) + self.store.set("back_yard", "audio", False) + + result = self.store.load() + self.assertEqual( + result, + { + "front_door": {"detect": False, "recordings": True}, + "back_yard": {"audio": False}, + }, + ) + + def test_set_with_untracked_topic_is_noop(self) -> None: + self.store.set("front_door", "ptz_autotracker", True) + self.assertEqual(self.store.load(), {}) + # File should not even be created if no tracked entries were written + runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json") + self.assertFalse(os.path.exists(runtime_path)) + + def test_set_overwrites_previous_value(self) -> None: + self.store.set("front_door", "detect", True) + self.store.set("front_door", "detect", False) + self.assertEqual(self.store.load(), {"front_door": {"detect": False}}) + + def test_load_returns_empty_when_file_corrupt(self) -> None: + runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json") + with open(runtime_path, "w") as f: + f.write("{not valid json") + self.assertEqual(self.store.load(), {}) + + def test_load_handles_unexpected_top_level_shape(self) -> None: + runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json") + with open(runtime_path, "w") as f: + json.dump(["unexpected", "list"], f) + self.assertEqual(self.store.load(), {}) + + def test_clear_for_yaml_keys_removes_matching_entries(self) -> None: + self.store.set("front_door", "detect", False) + self.store.set("front_door", "recordings", False) + self.store.set("back_yard", "audio", False) + + self.store.clear_for_yaml_keys( + [ + "cameras.front_door.detect.enabled", + "cameras.back_yard.audio.enabled", + ] + ) + + self.assertEqual( + self.store.load(), + {"front_door": {"recordings": False}}, + ) + + def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None: + self.store.set("front_door", "detect", False) + self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"]) + self.assertEqual(self.store.load(), {}) + + def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None: + self.store.set("front_door", "detect", False) + self.store.clear_for_yaml_keys( + [ + "ui.theme", + "go2rtc.streams.x", + "cameras.front_door.ffmpeg.inputs", + "not_cameras.front_door.detect.enabled", + ] + ) + self.assertEqual(self.store.load(), {"front_door": {"detect": False}}) + + def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None: + self.store.set("front_door", "detect", False) + self.store.clear_for_yaml_keys([]) + self.assertEqual(self.store.load(), {"front_door": {"detect": False}}) + + def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None: + """`enabled` topic maps to the camera-level `cameras..enabled` key.""" + self.store.set("front_door", "enabled", False) + self.store.clear_for_yaml_keys(["cameras.front_door.enabled"]) + self.assertEqual(self.store.load(), {}) + + def test_clear_all_wipes_every_entry(self) -> None: + self.store.set("front_door", "detect", False) + self.store.set("front_door", "recordings", True) + self.store.set("back_yard", "audio", False) + + self.store.clear_all() + + self.assertEqual(self.store.load(), {}) + + def test_clear_all_is_safe_when_file_missing(self) -> None: + # No prior set() calls — file does not exist + self.store.clear_all() + self.assertEqual(self.store.load(), {}) + + def test_clear_camera_removes_only_that_camera(self) -> None: + self.store.set("front_door", "enabled", False) + self.store.set("front_door", "detect", False) + self.store.set("back_yard", "audio", False) + + self.store.clear_camera("front_door") + + self.assertEqual(self.store.load(), {"back_yard": {"audio": False}}) + + def test_clear_camera_is_noop_for_unknown_camera(self) -> None: + self.store.set("front_door", "enabled", False) + self.store.clear_camera("side_gate") + self.assertEqual(self.store.load(), {"front_door": {"enabled": False}}) + + def test_clear_camera_is_safe_when_file_missing(self) -> None: + # No prior set() calls, so the file does not exist + self.store.clear_camera("front_door") + self.assertEqual(self.store.load(), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_shared_memory_frame_manager.py b/frigate/test/test_shared_memory_frame_manager.py new file mode 100644 index 0000000000..63c96f732d --- /dev/null +++ b/frigate/test/test_shared_memory_frame_manager.py @@ -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() diff --git a/frigate/test/test_sqlitevecq_embeddings.py b/frigate/test/test_sqlitevecq_embeddings.py new file mode 100644 index 0000000000..dc80cd96f8 --- /dev/null +++ b/frigate/test/test_sqlitevecq_embeddings.py @@ -0,0 +1,63 @@ +"""Tests for embedding cleanup on the main Frigate database. + +Embeddings are deleted whether or not semantic search is currently enabled, so +the delete path has to tolerate databases where the vec0 tables were never +created and installs where the sqlite-vec extension is unavailable. +""" + +import os +import tempfile +import unittest + +from frigate.db.sqlitevecq import SqliteVecQueueDatabase + + +class TestDeleteEmbeddings(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db")) + self.db.start() + # the extension is not available to tests, so stand in for a database + # that has it loaded and use a plain table for the deletes + self.db.load_vec_extension = True + + def tearDown(self) -> None: + self.db.stop() + self.db.close() + self.tmp_dir.cleanup() + + def _flush_writes(self) -> None: + # writes are queued and applied by a worker thread, and the queue is + # FIFO, so awaiting a later write means the earlier ones are done + self.db.execute_sql("PRAGMA user_version = 0").fetchall() + + def _create_thumbnails_table(self) -> None: + self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)") + self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')") + self._flush_writes() + + def _thumbnail_ids(self) -> list[str]: + return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")] + + def test_delete_without_tables_does_not_raise(self) -> None: + # semantic search was never enabled, so event cleanup has nothing to do + self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"]) + self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"]) + + def test_delete_removes_embeddings(self) -> None: + self._create_thumbnails_table() + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + self.assertEqual(self._thumbnail_ids(), ["b"]) + + def test_delete_skipped_without_extension(self) -> None: + self._create_thumbnails_table() + self.db.load_vec_extension = False + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + # the vec0 tables cannot be written without the extension + self.assertEqual(self._thumbnail_ids(), ["a", "b"]) diff --git a/frigate/test/test_sqlitevecq_regexp.py b/frigate/test/test_sqlitevecq_regexp.py new file mode 100644 index 0000000000..71f4fcb6b4 --- /dev/null +++ b/frigate/test/test_sqlitevecq_regexp.py @@ -0,0 +1,54 @@ +"""Tests for the REGEXP function registered on the main Frigate database. + +Regression coverage for GHSA-q8jx-q884-jcq9: an attacker-controlled +catastrophic (ReDoS) pattern reaching the REGEXP sink must not be able to +stall the serialized database worker thread. +""" + +import sqlite3 +import time +import unittest + +from frigate.db.sqlitevecq import REGEXP_TIMEOUT_SECONDS, SqliteVecQueueDatabase + + +class TestRegexpFunction(unittest.TestCase): + def setUp(self) -> None: + # autostart=False keeps the queue worker thread from spinning up; we + # only need the REGEXP registration, exercised on our own connection. + self.db = SqliteVecQueueDatabase(":memory:", autostart=False) + self.conn = sqlite3.connect(":memory:") + self.db._register_regexp(self.conn) + + def tearDown(self) -> None: + self.conn.close() + + def _regexp(self, value: str | None, pattern: str) -> int | None: + # SQLite maps "value REGEXP pattern" to regexp(pattern, value). + return self.conn.execute("SELECT ? REGEXP ?", (value, pattern)).fetchone()[0] + + def test_normal_patterns_still_match(self) -> None: + self.assertTrue(self._regexp("ABC123", "^ABC")) + self.assertTrue(self._regexp("ABC123", "ABC.*")) + self.assertTrue(self._regexp("ABC123", "[0-9]+$")) + self.assertFalse(self._regexp("ABC123", "^XYZ")) + + def test_null_value_does_not_match(self) -> None: + self.assertFalse(self._regexp(None, ".*")) + + def test_invalid_pattern_does_not_raise(self) -> None: + self.assertFalse(self._regexp("ABC123", "(unclosed")) + + def test_catastrophic_pattern_is_time_bounded(self) -> None: + # Without the timeout this evaluation backtracks for minutes to hours + # and wedges the whole database thread (GHSA-q8jx-q884-jcq9). + catastrophic = "(a{2,})+c" + subject = "a" * 4000 + + start = time.monotonic() + result = self._regexp(subject, catastrophic) + elapsed = time.monotonic() - start + + # The pattern does not match; the guarantee is that it returns quickly. + self.assertFalse(result) + self.assertLess(elapsed, REGEXP_TIMEOUT_SECONDS + 2.0) diff --git a/frigate/test/test_stationary_classifier.py b/frigate/test/test_stationary_classifier.py new file mode 100644 index 0000000000..bd5e2c41e2 --- /dev/null +++ b/frigate/test/test_stationary_classifier.py @@ -0,0 +1,39 @@ +"""Tests for stationary object classification thresholds.""" + +import unittest + +from frigate.track.stationary_classifier import ( + DEFAULT_OBJECT_THRESHOLDS, + DYNAMIC_OBJECT_THRESHOLDS, + NON_STATIONARY_OBJECT_THRESHOLDS, + STATIONARY_OBJECT_THRESHOLDS, + StationaryThresholds, + get_stationary_threshold, +) + + +class TestStationaryThresholds(unittest.TestCase): + def test_known_labels_return_expected_singletons(self) -> None: + self.assertIs(get_stationary_threshold("package"), STATIONARY_OBJECT_THRESHOLDS) + self.assertIs(get_stationary_threshold("car"), DYNAMIC_OBJECT_THRESHOLDS) + self.assertIs( + get_stationary_threshold("license_plate"), + NON_STATIONARY_OBJECT_THRESHOLDS, + ) + + def test_unknown_label_returns_shared_default(self) -> None: + # an unknown label must reuse the shared default instance, not allocate + # a fresh one on every call (this runs per object per frame) + first = get_stationary_threshold("person") + second = get_stationary_threshold("dog") + self.assertIs(first, DEFAULT_OBJECT_THRESHOLDS) + self.assertIs(second, DEFAULT_OBJECT_THRESHOLDS) + + def test_default_matches_a_fresh_instance(self) -> None: + # the shared default must be value-equivalent to the previous + # per-call StationaryThresholds() + self.assertEqual(DEFAULT_OBJECT_THRESHOLDS, StationaryThresholds()) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_update_yaml.py b/frigate/test/test_update_yaml.py new file mode 100644 index 0000000000..e9e160c8f4 --- /dev/null +++ b/frigate/test/test_update_yaml.py @@ -0,0 +1,183 @@ +"""Test in-place yaml config updates.""" + +import os +import tempfile +import unittest + +from ruamel.yaml import YAML + +from frigate.util.builtin import update_yaml_file_bulk + + +class TestUpdateYaml(unittest.TestCase): + def setUp(self) -> None: + self.yaml = YAML() + fd, self.config_path = tempfile.mkstemp(suffix=".yml") + os.close(fd) + + def tearDown(self) -> None: + os.unlink(self.config_path) + + def _write(self, text: str) -> None: + with open(self.config_path, "w") as f: + f.write(text) + + def _read(self) -> str: + with open(self.config_path) as f: + return f.read() + + def _load(self): + with open(self.config_path) as f: + return self.yaml.load(f) + + def test_delete_key(self): + """Deleting a key removes it and leaves valid yaml.""" + self._write( + "cameras:\n" + " cam1:\n" + " objects:\n" + " filters:\n" + " car:\n" + " mask: 0,0.45,0.245,0.45\n" + ) + update_yaml_file_bulk( + self.config_path, {"cameras.cam1.objects.filters.car.mask": ""} + ) + data = self._load() + assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"] + + def test_delete_commented_key_emptying_map(self): + """Deleting the only key of a map whose key carries comments must not + emit unparseable yaml (orphaned comment tokens above a flow-style {}).""" + self._write( + "cameras:\n" + " cam1:\n" + " objects:\n" + " filters:\n" + " car:\n" + " # cars parked across the street\n" + " # second comment line\n" + " mask: 0,0.45,0.245,0.45\n" + " motion:\n" + " mask: 0,0.449,0.686,0.395\n" + ) + update_yaml_file_bulk( + self.config_path, {"cameras.cam1.objects.filters.car.mask": ""} + ) + # must re-parse cleanly + data = self._load() + assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"] + assert data["cameras"]["cam1"]["motion"]["mask"] == "0,0.449,0.686,0.395" + # the orphaned comments must be gone from the file, not just parseable + content = self._read() + assert "cars parked across the street" not in content + assert "second comment line" not in content + + def test_delete_last_named_mask_emptying_map(self): + """The path the current UI actually sends: a named object mask deleted + down to an empty `mask` map, with a comment inside that map.""" + self._write( + "cameras:\n" + " cam1:\n" + " objects:\n" + " filters:\n" + " car:\n" + " mask:\n" + " # ignore the neighbor's driveway\n" + " driveway:\n" + " coordinates: 0,0.1,0.2,0.3\n" + ) + update_yaml_file_bulk( + self.config_path, + {"cameras.cam1.objects.filters.car.mask.driveway": ""}, + ) + data = self._load() + assert data["cameras"]["cam1"]["objects"]["filters"]["car"]["mask"] == {} + assert "ignore the neighbor's driveway" not in self._read() + + def test_delete_last_commented_list_item(self): + """Deleting the last element of a commented sequence must not emit + an orphaned comment above a flow-style [] at column 0.""" + self._write( + "cameras:\n" + " cam1:\n" + " motion:\n" + " mask:\n" + " # driveway motion mask\n" + " - 0,0.4,0.6,0.4\n" + ) + update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""}) + data = self._load() + assert data["cameras"]["cam1"]["motion"]["mask"] == [] + assert "driveway motion mask" not in self._read() + + def test_delete_list_item_preserves_remaining(self): + """Deleting one element of a sequence keeps the others and stays valid.""" + self._write( + "cameras:\n" + " cam1:\n" + " motion:\n" + " mask:\n" + " - 0,0.4,0.6,0.4\n" + " - 0,0.1,0.2,0.3\n" + ) + update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""}) + data = self._load() + assert data["cameras"]["cam1"]["motion"]["mask"] == ["0,0.1,0.2,0.3"] + + def test_delete_key_preserves_siblings(self): + """Deleting one key among several keeps the sibling entries and any + comments on keys preceding the deleted one.""" + self._write( + "cameras:\n" + " cam1:\n" + " objects:\n" + " filters:\n" + " car:\n" + " # mask drawn around the parked suv\n" + " mask: 0,0.45,0.245,0.45\n" + " threshold: 0.8\n" + ) + update_yaml_file_bulk( + self.config_path, {"cameras.cam1.objects.filters.car.threshold": ""} + ) + data = self._load() + car = data["cameras"]["cam1"]["objects"]["filters"]["car"] + assert "threshold" not in car + assert car["mask"] == "0,0.45,0.245,0.45" + assert "# mask drawn around the parked suv" in self._read() + + def test_delete_first_commented_key_keeps_map_valid(self): + """Deleting a commented key from a map that still has other keys + leaves the remaining entries intact and the file parseable.""" + self._write( + "cameras:\n" + " cam1:\n" + " objects:\n" + " filters:\n" + " car:\n" + " # comment on the deleted key\n" + " mask: 0,0.45,0.245,0.45\n" + " threshold: 0.8\n" + ) + update_yaml_file_bulk( + self.config_path, {"cameras.cam1.objects.filters.car.mask": ""} + ) + data = self._load() + car = data["cameras"]["cam1"]["objects"]["filters"]["car"] + assert "mask" not in car + assert car["threshold"] == 0.8 + + def test_update_value_preserves_comments(self): + """Updating a value keeps surrounding comments intact.""" + self._write( + "cameras:\n cam1:\n detect:\n # tuned for the pi\n fps: 4\n" + ) + update_yaml_file_bulk(self.config_path, {"cameras.cam1.detect.fps": 5}) + data = self._load() + assert data["cameras"]["cam1"]["detect"]["fps"] == 5 + assert "# tuned for the pi" in self._read() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/frigate/test/test_util_path.py b/frigate/test/test_util_path.py new file mode 100644 index 0000000000..56951a76cd --- /dev/null +++ b/frigate/test/test_util_path.py @@ -0,0 +1,197 @@ +"""Tests for safe filesystem path construction.""" + +import os +import shutil +import tempfile +import unittest + +from frigate.const import TRIGGER_DIR +from frigate.util.path import ( + get_trigger_thumbnail_path, + is_contained_in, + safe_join, + sanitize_contained_path, + sanitize_path_component, +) + +# Values that pathvalidate's sanitize_filename reduces to exactly "..", because +# it strips reserved characters but leaves relative markers intact. nginx only +# normalizes a bare ".." segment, so the decorated variants reach the app. +DOT_DOT_VARIANTS = ["..", "..:", "..*", "..?", '.."', "..<", "..>", "..|", ".. ", " .."] + + +class TestSanitizePathComponent(unittest.TestCase): + def test_rejects_dot_dot_variants(self): + for value in DOT_DOT_VARIANTS: + with self.subTest(value=value): + self.assertIsNone(sanitize_path_component(value)) + + def test_rejects_relative_markers_and_empty(self): + for value in [".", "", None, " ", "/", "//", "\\"]: + with self.subTest(value=value): + self.assertIsNone(sanitize_path_component(value)) + + def test_strips_separators(self): + component = sanitize_path_component("a/b/c") + self.assertIsNotNone(component) + self.assertNotIn("/", component) + + def test_allows_ordinary_names(self): + for value in ["model1", "front-door", "My Model", "café", "a.b_c-1"]: + with self.subTest(value=value): + self.assertEqual(sanitize_path_component(value), value) + + +class TestSafeJoin(unittest.TestCase): + base = "/media/frigate/clips" + + def test_rejects_dot_dot_variants(self): + for value in DOT_DOT_VARIANTS: + with self.subTest(value=value): + self.assertIsNone(safe_join(self.base, value)) + + def test_rejects_dot_dot_in_any_segment(self): + self.assertIsNone(safe_join(self.base, "model", "dataset", "..")) + self.assertIsNone(safe_join(self.base, "..", "dataset", "..")) + + def test_result_stays_inside_base(self): + for value in ["model1", "a/../..", "....//", "..\\..", "%2e%2e"]: + with self.subTest(value=value): + joined = safe_join(self.base, value) + + if joined is not None: + self.assertTrue(is_contained_in(joined, self.base)) + + def test_joins_multiple_segments(self): + self.assertEqual( + safe_join(self.base, "model1", "dataset", "none"), + "/media/frigate/clips/model1/dataset/none", + ) + + def test_rejects_empty_segment(self): + self.assertIsNone(safe_join(self.base, "model1", "", "none")) + + +class TestIsContainedIn(unittest.TestCase): + def test_rejects_sibling_sharing_a_name_prefix(self): + self.assertFalse( + is_contained_in("/media/frigate/clips_evil/x.webp", "/media/frigate/clips") + ) + + def test_accepts_base_itself_and_children(self): + self.assertTrue(is_contained_in("/media/frigate/clips", "/media/frigate/clips")) + self.assertTrue( + is_contained_in("/media/frigate/clips/a/b.webp", "/media/frigate/clips") + ) + + def test_rejects_parent(self): + self.assertFalse(is_contained_in("/media/frigate", "/media/frigate/clips")) + + def test_handles_a_root_base(self): + # A prefix test would compare against "//" here and wrongly report that + # the root directory contains nothing. + self.assertTrue(is_contained_in("/child", "/")) + self.assertEqual(safe_join("/", "child"), "/child") + + def test_rejects_uncomparable_paths(self): + self.assertFalse(is_contained_in("relative/x", "/media/frigate/clips")) + + +class TestSanitizeContainedPath(unittest.TestCase): + base = "/media/frigate/clips" + + def test_rejects_dot_dot_anywhere(self): + for value in [ + "/media/frigate/clips/../../etc/passwd", + "clips\\..\\..\\etc/passwd", + "/media/frigate/clips/a/../../../x", + ]: + with self.subTest(value=value): + self.assertIsNone(sanitize_contained_path(value, self.base)) + + def test_rejects_sibling_sharing_a_name_prefix(self): + self.assertIsNone( + sanitize_contained_path("/media/frigate/clips_evil/x.webp", self.base) + ) + + def test_rejects_outside_base(self): + self.assertIsNone(sanitize_contained_path("/etc/passwd", self.base)) + + def test_rejects_empty(self): + self.assertIsNone(sanitize_contained_path("", self.base)) + self.assertIsNone(sanitize_contained_path(None, self.base)) + + def test_keeps_a_valid_nested_path(self): + self.assertEqual( + sanitize_contained_path("/media/frigate/clips/a/b.webp", self.base), + "/media/frigate/clips/a/b.webp", + ) + + +class TestTriggerThumbnailPath(unittest.TestCase): + def test_stays_inside_the_trigger_dir(self): + for camera, data in [ + ("cam", "../../../../etc/passwd"), + ("cam", "../../../../config/config.yml"), + ("cam", "normal-event-id"), + ]: + with self.subTest(camera=camera, data=data): + path = get_trigger_thumbnail_path(camera, data) + + self.assertIsNotNone(path) + self.assertTrue(is_contained_in(path, TRIGGER_DIR)) + + def test_rejects_traversal_camera_names(self): + for camera in DOT_DOT_VARIANTS: + with self.subTest(camera=camera): + self.assertIsNone(get_trigger_thumbnail_path(camera, "data")) + + def test_builds_the_expected_path(self): + self.assertEqual( + get_trigger_thumbnail_path("front_door", "abc"), + os.path.join(TRIGGER_DIR, "front_door", "abc.webp"), + ) + + +class TestRmtreeContainment(unittest.TestCase): + """A recursive delete built through safe_join must not reach a parent. + + shutil.rmtree on a path ending in ".." deletes the parent's contents before + failing on the final rmdir, so the guard has to run before the call. + """ + + def setUp(self): + self.root = tempfile.mkdtemp() + self.clips = os.path.join(self.root, "clips") + os.makedirs(os.path.join(self.clips, "model1")) + os.makedirs(os.path.join(self.root, "recordings")) + + with open(os.path.join(self.root, "recordings", "seg.mp4"), "w") as f: + f.write("recording") + + def tearDown(self): + shutil.rmtree(self.root, ignore_errors=True) + + def test_traversal_name_never_yields_a_path_to_delete(self): + for value in DOT_DOT_VARIANTS: + with self.subTest(value=value): + self.assertIsNone(safe_join(self.clips, value)) + + self.assertTrue( + os.path.exists(os.path.join(self.root, "recordings", "seg.mp4")) + ) + + def test_ordinary_name_still_deletes_its_own_directory(self): + target = safe_join(self.clips, "model1") + self.assertIsNotNone(target) + + shutil.rmtree(target) + + self.assertFalse(os.path.exists(os.path.join(self.clips, "model1"))) + self.assertTrue( + os.path.exists(os.path.join(self.root, "recordings", "seg.mp4")) + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/frigate/test/test_video.py b/frigate/test/test_video.py index 8612990e2e..d09a60559f 100644 --- a/frigate/test/test_video.py +++ b/frigate/test/test_video.py @@ -82,6 +82,24 @@ class TestRegion(unittest.TestCase): assert len(cluster_candidates) == 2 + def test_cluster_candidates_partition_boxes(self): + # every box index must appear in exactly one cluster (no box used twice, + # none dropped) - the invariant the used-box tracking enforces + boxes = [ + (100, 100, 200, 200), + (202, 150, 252, 200), + (210, 160, 260, 210), + (900, 900, 950, 950), + (905, 905, 955, 955), + ] + + cluster_candidates = get_cluster_candidates( + self.frame_shape, self.min_region_size, boxes + ) + + assigned = [idx for cluster in cluster_candidates for idx in cluster] + self.assertEqual(sorted(assigned), list(range(len(boxes)))) + def test_transliterate_to_latin(self): self.assertEqual(transliterate_to_latin("frégate"), "fregate") self.assertEqual(transliterate_to_latin("utilité"), "utilite") diff --git a/frigate/test/test_webpush_camera_monitoring.py b/frigate/test/test_webpush_camera_monitoring.py new file mode 100644 index 0000000000..fa9172ad20 --- /dev/null +++ b/frigate/test/test_webpush_camera_monitoring.py @@ -0,0 +1,29 @@ +"""Tests for camera monitoring notification authorization.""" + +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from frigate.comms.webpush import WebPushClient + + +class TestCameraMonitoringNotifications(unittest.TestCase): + def test_send_camera_monitoring_filters_by_camera_access(self): + client = WebPushClient.__new__(WebPushClient) + client.config = SimpleNamespace( + cameras={"front_door": SimpleNamespace(friendly_name=None)} + ) + client.web_pushers = {"allowed": [], "denied": []} + client.user_cameras = {"allowed": {"front_door"}, "denied": set()} + client.check_registrations = MagicMock() + client.cleanup_registrations = MagicMock() + client.send_push_notification = MagicMock() + + client.send_camera_monitoring( + {"camera": "front_door", "message": "Monitoring condition met"} + ) + + self.assertEqual(client.send_push_notification.call_count, 1) + self.assertEqual( + client.send_push_notification.call_args.kwargs["user"], "allowed" + ) diff --git a/frigate/test/test_webpush_registration.py b/frigate/test/test_webpush_registration.py new file mode 100644 index 0000000000..16e0129152 --- /dev/null +++ b/frigate/test/test_webpush_registration.py @@ -0,0 +1,150 @@ +"""Tests for push notification subscription validation.""" + +import unittest + +from frigate.api.notification import _validate_push_endpoint, _validate_subscription + +VALID_ENDPOINTS = [ + "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu", + "https://updates.push.services.mozilla.com/wpush/v2/dGhpcy1pcy1hLXRva2Vu", + "https://web.push.apple.com/dGhpcy1pcy1hLXRva2Vu", + "https://wns2-by3p.notify.windows.com/w/?token=dGhpcy1pcy1hLXRva2Vu", + "https://fcm.googleapis.com:443/fcm/send/dGhpcy1pcy1hLXRva2Vu", +] + + +def _subscription(endpoint: str) -> dict: + return { + "endpoint": endpoint, + "keys": {"p256dh": "cHVibGljLWtleQ", "auth": "YXV0aC1zZWNyZXQ"}, + } + + +class TestValidatePushEndpoint(unittest.TestCase): + def test_accepts_real_push_service_endpoints(self): + for endpoint in VALID_ENDPOINTS: + with self.subTest(endpoint=endpoint): + self.assertIsNone(_validate_push_endpoint(endpoint)) + + def test_rejects_http(self): + self.assertIsNotNone( + _validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token") + ) + + def test_rejects_non_http_schemes(self): + for endpoint in ( + "file:///etc/passwd", + "ftp://example.com/token", + "//example.com/token", + ): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_localhost(self): + for endpoint in ( + "https://localhost/token", + "https://localhost:443/token", + "https://127.0.0.1/token", + "https://[::1]/token", + ): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_private_addresses(self): + for endpoint in ( + "https://192.168.1.10/token", + "https://10.0.0.5/token", + "https://172.16.0.1/token", + "https://169.254.169.254/token", + "https://0.0.0.0/token", + ): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_internal_hostnames(self): + for endpoint in ( + "https://frigate/token", + "https://nas.local/token", + "https://push.internal/token", + "https://host.home.arpa/token", + ): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_non_default_port(self): + self.assertIsNotNone( + _validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token") + ) + + def test_rejects_embedded_credentials(self): + self.assertIsNotNone( + _validate_push_endpoint( + "https://user:pass@fcm.googleapis.com/fcm/send/token" + ) + ) + + def test_rejects_endpoint_without_path(self): + for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_endpoint_that_breaks_audience_parsing(self): + # webpush.py locates the host by searching for a separator after index + # 10, which raises ValueError when the url has no path at all + endpoint = "https://fcm.googleapis.com" + + with self.assertRaises(ValueError): + endpoint.index("/", 10) + + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_missing_or_non_string_endpoint(self): + for endpoint in (None, "", 5, {"url": "https://example.com/token"}): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(_validate_push_endpoint(endpoint)) + + def test_rejects_overlong_endpoint(self): + self.assertIsNotNone( + _validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}") + ) + + +class TestValidateSubscription(unittest.TestCase): + def test_accepts_valid_subscription(self): + self.assertIsNone(_validate_subscription(_subscription(VALID_ENDPOINTS[0]))) + + def test_accepts_extra_fields_sent_by_the_browser(self): + sub = _subscription(VALID_ENDPOINTS[0]) + sub["expirationTime"] = None + self.assertIsNone(_validate_subscription(sub)) + + def test_rejects_non_object(self): + for sub in ("https://fcm.googleapis.com/fcm/send/token", ["endpoint"], 5): + with self.subTest(sub=sub): + self.assertIsNotNone(_validate_subscription(sub)) + + def test_rejects_bad_endpoint(self): + self.assertIsNotNone( + _validate_subscription(_subscription("https://localhost/t")) + ) + + def test_rejects_missing_keys(self): + sub = _subscription(VALID_ENDPOINTS[0]) + del sub["keys"] + self.assertIsNotNone(_validate_subscription(sub)) + + def test_rejects_incomplete_keys(self): + for keys in ( + {"p256dh": "cHVibGljLWtleQ"}, + {"auth": "YXV0aC1zZWNyZXQ"}, + {"p256dh": "cHVibGljLWtleQ", "auth": ""}, + {"p256dh": None, "auth": "YXV0aC1zZWNyZXQ"}, + ): + with self.subTest(keys=keys): + sub = _subscription(VALID_ENDPOINTS[0]) + sub["keys"] = keys + self.assertIsNotNone(_validate_subscription(sub)) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_webpush_suspend.py b/frigate/test/test_webpush_suspend.py new file mode 100644 index 0000000000..1442bad21d --- /dev/null +++ b/frigate/test/test_webpush_suspend.py @@ -0,0 +1,64 @@ +"""Tests for notification suspension expiry handling.""" + +import datetime +import unittest +from unittest.mock import MagicMock + +from frigate.comms.webpush import WebPushClient + + +class TestSuspensionExpiry(unittest.TestCase): + def _make_client(self, suspended_cameras: dict[str, int]) -> WebPushClient: + client = WebPushClient.__new__(WebPushClient) + client.suspended_cameras = suspended_cameras + client.suspension_broadcaster = MagicMock() + return client + + def test_clears_and_broadcasts_expired_suspension(self): + now = datetime.datetime.now().timestamp() + client = self._make_client({"front_door": int(now - 60)}) + + client._clear_expired_suspensions() + + self.assertEqual(client.suspended_cameras["front_door"], 0) + client.suspension_broadcaster.assert_called_once_with( + "front_door/notifications/suspended", "0", True + ) + + def test_leaves_active_suspension_untouched(self): + now = datetime.datetime.now().timestamp() + suspend_until = int(now + 3600) + client = self._make_client({"front_door": suspend_until}) + + client._clear_expired_suspensions() + + self.assertEqual(client.suspended_cameras["front_door"], suspend_until) + client.suspension_broadcaster.assert_not_called() + + def test_ignores_unsuspended_camera(self): + client = self._make_client({"front_door": 0}) + + client._clear_expired_suspensions() + + self.assertEqual(client.suspended_cameras["front_door"], 0) + client.suspension_broadcaster.assert_not_called() + + def test_only_expired_cameras_are_cleared(self): + now = datetime.datetime.now().timestamp() + active_until = int(now + 3600) + client = self._make_client( + { + "expired": int(now - 5), + "active": active_until, + "idle": 0, + } + ) + + client._clear_expired_suspensions() + + self.assertEqual(client.suspended_cameras["expired"], 0) + self.assertEqual(client.suspended_cameras["active"], active_until) + self.assertEqual(client.suspended_cameras["idle"], 0) + client.suspension_broadcaster.assert_called_once_with( + "expired/notifications/suspended", "0", True + ) diff --git a/frigate/test/test_ws_auth.py b/frigate/test/test_ws_auth.py index a9fc6e1320..4fc6701136 100644 --- a/frigate/test/test_ws_auth.py +++ b/frigate/test/test_ws_auth.py @@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase): ) ) + def test_viewer_blocked_from_notification_test(self): + self.assertFalse( + _check_ws_authorization( + "notification_test", "viewer", self.DEFAULT_SEPARATOR + ) + ) + # --- Admin access --- def test_admin_can_send_restart(self): @@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase): _check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR) ) + def test_admin_can_send_notification_test(self): + self.assertTrue( + _check_ws_authorization( + "notification_test", "admin", self.DEFAULT_SEPARATOR + ) + ) + # --- Comma-separated roles --- def test_comma_separated_admin_viewer_grants_admin(self): diff --git a/frigate/test/test_ws_outbound_filter.py b/frigate/test/test_ws_outbound_filter.py new file mode 100644 index 0000000000..ab1489da54 --- /dev/null +++ b/frigate/test/test_ws_outbound_filter.py @@ -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 + ``/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() diff --git a/frigate/timeline.py b/frigate/timeline.py index cf2f5e8c75..d82f17cb7d 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -8,7 +8,7 @@ from multiprocessing.synchronize import Event as MpEvent from typing import Any from frigate.config import FrigateConfig -from frigate.events.maintainer import EventStateEnum, EventTypeEnum +from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Timeline from frigate.util.builtin import to_relative_box @@ -28,7 +28,7 @@ class TimelineProcessor(threading.Thread): self.config = config self.queue = queue self.stop_event = stop_event - self.pre_event_cache: dict[str, list[dict[str, Any]]] = {} + self.pre_event_cache: dict[str, list[dict[Any, Any]]] = {} def run(self) -> None: while not self.stop_event.is_set(): @@ -56,7 +56,7 @@ class TimelineProcessor(threading.Thread): def insert_or_save( self, - entry: dict[str, Any], + entry: dict[Any, Any], prev_event_data: dict[Any, Any], event_data: dict[Any, Any], ) -> None: @@ -84,9 +84,15 @@ class TimelineProcessor(threading.Thread): event_type: str, prev_event_data: dict[Any, Any], event_data: dict[Any, Any], - ) -> bool: + ) -> None: """Handle object detection.""" - camera_config = self.config.cameras[camera] + camera_config = self.config.cameras.get(camera) + if ( + camera_config is None + or camera_config.detect.width is None + or camera_config.detect.height is None + ): + return event_id = event_data["id"] # Base timeline entry data that all entries will share @@ -110,6 +116,8 @@ class TimelineProcessor(threading.Thread): ), "attribute": "", "score": event_data["score"], + "computed_score": event_data.get("computed_score"), + "top_score": event_data.get("top_score"), }, } diff --git a/frigate/track/norfair_tracker.py b/frigate/track/norfair_tracker.py index 84a0f390a3..6b39885c0d 100644 --- a/frigate/track/norfair_tracker.py +++ b/frigate/track/norfair_tracker.py @@ -1,7 +1,8 @@ import logging import random import string -from typing import Any, Sequence, cast +from collections.abc import Sequence +from typing import Any, cast import cv2 import numpy as np @@ -45,6 +46,17 @@ def distance(detection: np.ndarray, estimate: np.ndarray) -> float: estimate_dim = np.diff(estimate, axis=0).flatten() detection_dim = np.diff(detection, axis=0).flatten() + # Guard against degenerate or non-finite boxes + if ( + not np.all(np.isfinite(estimate_dim)) + or not np.all(np.isfinite(detection_dim)) + or estimate_dim[0] <= 0 + or estimate_dim[1] <= 0 + or detection_dim[0] <= 0 + or detection_dim[1] <= 0 + ): + return float("inf") + # get bottom center positions detection_position = np.array( [np.average(detection[:, 0]), np.max(detection[:, 1])] @@ -630,9 +642,11 @@ class NorfairTracker(ObjectTracker): self.deregister(self.track_id_map[e_id], e_id) # update list of object boxes that don't have a tracked object yet - tracked_object_boxes = [obj["box"] for obj in self.tracked_objects.values()] + tracked_object_boxes = { + tuple(obj["box"]) for obj in self.tracked_objects.values() + } self.untracked_object_boxes = [ - o[2] for o in detections if o[2] not in tracked_object_boxes + o[2] for o in detections if tuple(o[2]) not in tracked_object_boxes ] def print_objects_as_table(self, tracked_objects: Sequence) -> None: diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index e0ee74228a..999ec5c04b 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -81,6 +81,7 @@ class TrackedObjectProcessor(threading.Thread): CameraConfigUpdateEnum.motion, CameraConfigUpdateEnum.objects, CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.timestamp_style, CameraConfigUpdateEnum.zones, ], ) @@ -185,7 +186,7 @@ class TrackedObjectProcessor(threading.Thread): def snapshot(camera: str, obj: TrackedObject) -> bool: mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj): - jpg_bytes = obj.get_img_bytes( + jpg_bytes, _ = obj.get_img_bytes( ext="jpg", timestamp=mqtt_config.timestamp, bounding_box=mqtt_config.bounding_box, @@ -356,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread): def get_current_frame_time(self, camera: str) -> float: """Returns the latest frame time for a given camera.""" + if camera not in self.camera_states: + return 0.0 + return self.camera_states[camera].current_frame_time def set_sub_label( @@ -515,6 +519,7 @@ class TrackedObjectProcessor(threading.Thread): duration, source_type, draw, + pre_capture, ) = payload # save the snapshot image @@ -522,6 +527,11 @@ class TrackedObjectProcessor(threading.Thread): None, event_id, label, draw ) end_time = frame_time + duration if duration is not None else None + start_time = ( + frame_time - self.config.cameras[camera_name].record.event_pre_capture + if pre_capture is None + else frame_time - pre_capture + ) # send event to event maintainer self.event_sender.publish( @@ -536,13 +546,15 @@ class TrackedObjectProcessor(threading.Thread): "sub_label": sub_label, "score": score, "camera": camera_name, - "start_time": frame_time - - self.config.cameras[camera_name].record.event_pre_capture, + "start_time": start_time, "end_time": end_time, "has_clip": self.config.cameras[camera_name].record.enabled and include_recording, "has_snapshot": True, + "snapshot_clean": True, + "snapshot_frame_time": frame_time, "type": source_type, + "draw": draw, }, ) ) @@ -598,6 +610,7 @@ class TrackedObjectProcessor(threading.Thread): "has_clip": self.config.cameras[camera_name].record.enabled and include_recording, "has_snapshot": True, + "snapshot_clean": True, "type": "api", "recognized_license_plate": plate, "recognized_license_plate_score": score, @@ -671,23 +684,26 @@ class TrackedObjectProcessor(threading.Thread): # check for config updates updated_topics = self.camera_config_subscriber.check_for_updates() - if "enabled" in updated_topics: - for camera in updated_topics["enabled"]: - if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = self.config.cameras[ - camera - ].enabled - elif "add" in updated_topics: - for camera in updated_topics["add"]: - self.config.cameras[camera] = ( - self.camera_config_subscriber.camera_configs[camera] - ) - self.create_camera_state(camera) - elif "remove" in updated_topics: + # a single drain can carry several topics at once, so add and + # remove are handled independently rather than as exclusive branches + for camera in updated_topics.get("add", []): + self.config.cameras[camera] = ( + self.camera_config_subscriber.camera_configs[camera] + ) + self.create_camera_state(camera) + + if "remove" in updated_topics: for camera in updated_topics["remove"]: - camera_state = self.camera_states[camera] + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue + camera_state.shutdown() self.camera_states.pop(camera) + self.camera_activity.pop(camera, None) + self.last_motion_detected.pop(camera, None) + + self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity) # manage camera disabled state for camera, config in self.config.cameras.items(): @@ -695,6 +711,10 @@ class TrackedObjectProcessor(threading.Thread): continue current_enabled = config.enabled + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue + camera_state = self.camera_states[camera] if camera_state.prev_enabled and not current_enabled: @@ -747,11 +767,17 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue - if not self.config.cameras[camera].enabled: + camera_config = self.config.cameras.get(camera) + if camera_config is None: + continue + + if not camera_config.enabled: logger.debug(f"Camera {camera} disabled, skipping update") continue - camera_state = self.camera_states[camera] + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue camera_state.update( frame_name, frame_time, current_tracked_objects, motion_boxes, regions diff --git a/frigate/track/stationary_classifier.py b/frigate/track/stationary_classifier.py index bea37f641b..1e22ec6f6d 100644 --- a/frigate/track/stationary_classifier.py +++ b/frigate/track/stationary_classifier.py @@ -63,6 +63,9 @@ NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds( max_stationary_history=4, ) +# Default thresholds for any other object label +DEFAULT_OBJECT_THRESHOLDS = StationaryThresholds() + def get_stationary_threshold(label: str) -> StationaryThresholds: """Get the stationary thresholds for a given object label.""" @@ -76,7 +79,7 @@ def get_stationary_threshold(label: str) -> StationaryThresholds: if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects: return NON_STATIONARY_OBJECT_THRESHOLDS - return StationaryThresholds() + return DEFAULT_OBJECT_THRESHOLDS class StationaryMotionClassifier: diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index a95221bbdf..75234c3a87 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -5,7 +5,7 @@ import math import os from collections import defaultdict from statistics import median -from typing import Any, Optional, cast +from typing import Any, cast import cv2 import numpy as np @@ -13,18 +13,15 @@ import numpy as np from frigate.config import ( CameraConfig, FilterConfig, - SnapshotsConfig, UIConfig, ) -from frigate.const import CLIPS_DIR, THUMB_DIR +from frigate.const import CLIPS_DIR, REPLAY_CAMERA_PREFIX, THUMB_DIR from frigate.detectors.detector_config import ModelConfig from frigate.review.types import SeverityEnum from frigate.util.builtin import sanitize_float from frigate.util.image import ( area, - calculate_region, - draw_box_with_label, - draw_timestamp, + get_snapshot_bytes, is_better_thumbnail, ) from frigate.util.object import box_inside @@ -57,6 +54,11 @@ class TrackedObject: self.obj_data = obj_data self.colormap = model_config.colormap self.logos = model_config.all_attribute_logos + self.thumbnail_attributes = [ + attr + for attr in model_config.attributes_map.get(obj_data["label"], []) + if attr in model_config.non_logo_attributes + ] self.camera_config = camera_config self.ui_config = ui_config self.frame_cache = frame_cache @@ -64,14 +66,15 @@ class TrackedObject: self.zone_loitering: dict[str, int] = {} self.current_zones: list[str] = [] self.entered_zones: list[str] = [] + self.new_zone_entered: bool = False self.attributes: dict[str, float] = defaultdict(float) self.false_positive = True self.has_clip = False self.has_snapshot = False self.top_score = self.computed_score = 0.0 self.thumbnail_data: dict[str, Any] | None = None - self.last_updated = 0 - self.last_published = 0 + self.last_updated: float = 0 + self.last_published: float = 0 self.frame = None self.active = True self.pending_loitering = False @@ -83,7 +86,7 @@ class TrackedObject: self.previous = self.to_dict() @property - def max_severity(self) -> Optional[str]: + def max_severity(self) -> str | None: review_config = self.camera_config.review if ( @@ -151,7 +154,7 @@ class TrackedObject: if not self.false_positive and has_valid_frame: # determine if this frame is a better thumbnail if self.thumbnail_data is None or is_better_thumbnail( - self.obj_data["label"], + self.thumbnail_attributes, self.thumbnail_data, obj_data, self.camera_config.frame_shape, @@ -188,6 +191,10 @@ class TrackedObject: # check each zone for name, zone in self.camera_config.zones.items(): + # skip disabled zones + if not zone.enabled: + continue + # if the zone is not for this object type, skip if len(zone.objects) > 0 and obj_data["label"] not in zone.objects: continue @@ -277,6 +284,7 @@ class TrackedObject: if name not in self.entered_zones: self.entered_zones.append(name) + self.new_zone_entered = True else: self.zone_loitering[name] = loitering_score @@ -327,7 +335,12 @@ class TrackedObject: if self.obj_data["position_changes"] != obj_data["position_changes"]: significant_change = True - if self.obj_data["attributes"] != obj_data["attributes"]: + # disappearance of a per-frame attribute can be caused by detection + # skipping the object on a frame (stationary objects on non-interval + # frames), so only flag when a new attribute label appears + prev_labels = {a["label"] for a in self.obj_data["attributes"]} + curr_labels = {a["label"] for a in obj_data["attributes"]} + if curr_labels - prev_labels: significant_change = True # if the state changed between stationary and active @@ -389,6 +402,7 @@ class TrackedObject: "camera": self.camera_config.name, "frame_time": self.obj_data["frame_time"], "snapshot": self.thumbnail_data, + "snapshot_clean": True, "label": self.obj_data["label"], "sub_label": self.obj_data.get("sub_label"), "top_score": self.top_score, @@ -396,6 +410,7 @@ class TrackedObject: "start_time": self.obj_data["start_time"], "end_time": self.obj_data.get("end_time", None), "score": self.obj_data["score"], + "computed_score": self.computed_score, "box": self.obj_data["box"], "area": self.obj_data["area"], "ratio": self.obj_data["ratio"], @@ -434,7 +449,7 @@ class TrackedObject: return count > (self.camera_config.detect.stationary.threshold or 50) def get_thumbnail(self, ext: str) -> bytes | None: - img_bytes = self.get_img_bytes( + img_bytes, _ = self.get_img_bytes( ext, timestamp=False, bounding_box=False, crop=True, height=175 ) @@ -445,27 +460,15 @@ class TrackedObject: return img.tobytes() def get_clean_webp(self) -> bytes | None: - if self.thumbnail_data is None: - return None - - try: - best_frame = cv2.cvtColor( - self.frame_cache[self.thumbnail_data["frame_time"]]["frame"], - cv2.COLOR_YUV2BGR_I420, - ) - except KeyError: - logger.warning( - f"Unable to create clean webp because frame {self.thumbnail_data['frame_time']} is not in the cache" - ) - return None - - ret, webp = cv2.imencode( - ".webp", best_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] + webp_bytes, _ = self.get_img_bytes( + ext="webp", + timestamp=False, + bounding_box=False, + crop=False, + height=None, + quality=self.camera_config.snapshots.quality, ) - if ret: - return webp.tobytes() - else: - return None + return webp_bytes def get_img_bytes( self, @@ -475,151 +478,65 @@ class TrackedObject: crop: bool = False, height: int | None = None, quality: int | None = None, - ) -> bytes | None: + ) -> tuple[bytes | None, float | None]: if self.thumbnail_data is None: - return None + return None, None try: + frame_time = self.thumbnail_data["frame_time"] best_frame = cv2.cvtColor( - self.frame_cache[self.thumbnail_data["frame_time"]]["frame"], + self.frame_cache[frame_time]["frame"], cv2.COLOR_YUV2BGR_I420, ) except KeyError: logger.warning( - f"Unable to create jpg because frame {self.thumbnail_data['frame_time']} is not in the cache" + f"Unable to create snapshot because frame {frame_time} is not in the cache" ) - return None + return None, None - if bounding_box: - thickness = 2 - color = self.colormap.get(self.obj_data["label"], (255, 255, 255)) - - # draw the bounding boxes on the frame - box = self.thumbnail_data["box"] - draw_box_with_label( - best_frame, - box[0], - box[1], - box[2], - box[3], - self.obj_data["label"], - f"{int(self.thumbnail_data['score'] * 100)}% {int(self.thumbnail_data['area'])}" - + ( - f" {self.thumbnail_data['current_estimated_speed']:.1f}" - if self.thumbnail_data["current_estimated_speed"] != 0 - else "" - ), - thickness=thickness, - color=color, - ) - - # draw any attributes - for attribute in self.thumbnail_data["attributes"]: - box = attribute["box"] - box_area = int((box[2] - box[0]) * (box[3] - box[1])) - draw_box_with_label( - best_frame, - box[0], - box[1], - box[2], - box[3], - attribute["label"], - f"{attribute['score']:.0%} {str(box_area)}", - thickness=thickness, - color=color, - ) - - if crop: - box = self.thumbnail_data["box"] - box_size = 300 - region = calculate_region( - best_frame.shape, - box[0], - box[1], - box[2], - box[3], - box_size, - multiplier=1.1, - ) - best_frame = best_frame[region[1] : region[3], region[0] : region[2]] - - if height: - width = int(height * best_frame.shape[1] / best_frame.shape[0]) - best_frame = cv2.resize( - best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA - ) - if timestamp: - colors = self.camera_config.timestamp_style.color - draw_timestamp( - best_frame, - self.thumbnail_data["frame_time"], - self.camera_config.timestamp_style.format, - font_effect=self.camera_config.timestamp_style.effect, - font_thickness=self.camera_config.timestamp_style.thickness, - font_color=(colors.blue, colors.green, colors.red), - position=self.camera_config.timestamp_style.position, - ) - - quality_params = [] - - if ext == "jpg": - quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality or 70] - elif ext == "webp": - quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality or 60] - - ret, jpg = cv2.imencode(f".{ext}", best_frame, quality_params) - - if ret: - return jpg.tobytes() - else: - return None + return get_snapshot_bytes( + best_frame, + frame_time, + ext=ext, + timestamp=timestamp, + bounding_box=bounding_box, + crop=crop, + height=height, + quality=quality, + label=self.obj_data["label"], + box=self.thumbnail_data["box"], + score=self.thumbnail_data["score"], + area=self.thumbnail_data["area"], + attributes=self.thumbnail_data["attributes"], + color=self.colormap.get(self.obj_data["label"], (255, 255, 255)), + timestamp_style=self.camera_config.timestamp_style, + estimated_speed=self.thumbnail_data["current_estimated_speed"], + ) def write_snapshot_to_disk(self) -> None: - snapshot_config: SnapshotsConfig = self.camera_config.snapshots - jpg_bytes = self.get_img_bytes( - ext="jpg", - timestamp=snapshot_config.timestamp, - bounding_box=snapshot_config.bounding_box, - crop=snapshot_config.crop, - height=snapshot_config.height, - quality=snapshot_config.quality, - ) - if jpg_bytes is None: + webp_bytes = self.get_clean_webp() + if webp_bytes is None: logger.warning(f"Unable to save snapshot for {self.obj_data['id']}.") else: with open( os.path.join( - CLIPS_DIR, f"{self.camera_config.name}-{self.obj_data['id']}.jpg" + CLIPS_DIR, + f"{self.camera_config.name}-{self.obj_data['id']}-clean.webp", ), "wb", - ) as j: - j.write(jpg_bytes) - - # write clean snapshot if enabled - if snapshot_config.clean_copy: - webp_bytes = self.get_clean_webp() - if webp_bytes is None: - logger.warning( - f"Unable to save clean snapshot for {self.obj_data['id']}." - ) - else: - with open( - os.path.join( - CLIPS_DIR, - f"{self.camera_config.name}-{self.obj_data['id']}-clean.webp", - ), - "wb", - ) as p: - p.write(webp_bytes) + ) as p: + p.write(webp_bytes) def write_thumbnail_to_disk(self) -> None: if not self.camera_config.name: return + if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX): + return + directory = os.path.join(THUMB_DIR, self.camera_config.name) - if not os.path.exists(directory): - os.makedirs(directory) + os.makedirs(directory, exist_ok=True) thumb_bytes = self.get_thumbnail("webp") @@ -678,7 +595,7 @@ class TrackedObjectAttribute: "box": self.box, } - def find_best_object(self, objects: list[dict[str, Any]]) -> Optional[str]: + def find_best_object(self, objects: list[dict[str, Any]]) -> str | None: """Find the best attribute for each object and return its ID.""" best_object_area: float | None = None best_object_id: str | None = None diff --git a/frigate/types.py b/frigate/types.py index 6c51356168..e5f913d4fb 100644 --- a/frigate/types.py +++ b/frigate/types.py @@ -8,7 +8,7 @@ from frigate.object_detection.base import ObjectDetectProcess class StatsTrackingTypes(TypedDict): camera_metrics: dict[str, CameraMetrics] - embeddings_metrics: DataProcessorMetrics | None + embeddings_metrics: DataProcessorMetrics detectors: dict[str, ObjectDetectProcess] started: int latest_frigate_version: str @@ -26,6 +26,15 @@ class ModelStatusTypesEnum(str, Enum): failed = "failed" +class JobStatusTypesEnum(str, Enum): + pending = "pending" + queued = "queued" + running = "running" + success = "success" + failed = "failed" + cancelled = "cancelled" + + class TrackedObjectUpdateTypesEnum(str, Enum): description = "description" face = "face" diff --git a/frigate/util/audio.py b/frigate/util/audio.py index eede9c0ea0..28fb0a6bad 100644 --- a/frigate/util/audio.py +++ b/frigate/util/audio.py @@ -3,7 +3,6 @@ import logging import os import subprocess as sp -from typing import Optional from pathvalidate import sanitize_filename @@ -19,7 +18,7 @@ def get_audio_from_recording( start_ts: float, end_ts: float, sample_rate: int = 16000, -) -> Optional[bytes]: +) -> bytes | None: """Extract audio from recording files between start_ts and end_ts in WAV format suitable for sherpa-onnx. Args: diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 867d2533df..c1a6e787cb 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -2,7 +2,6 @@ import ast import copy -import datetime import logging import math import multiprocessing.queues @@ -10,17 +9,22 @@ import queue import re import shlex import struct +import time import urllib.parse +from collections import deque from collections.abc import Mapping -from multiprocessing.sharedctypes import Synchronized +from multiprocessing.managers import ValueProxy from pathlib import Path -from typing import Any, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any import numpy as np from ruamel.yaml import YAML from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS +if TYPE_CHECKING: + from frigate.config import CameraConfig + logger = logging.getLogger(__name__) @@ -29,23 +33,20 @@ class EventsPerSecond: self._start = None self._max_events = max_events self._last_n_seconds = last_n_seconds - self._timestamps = [] + self._timestamps: deque[float] = deque(maxlen=max_events) def start(self) -> None: - self._start = datetime.datetime.now().timestamp() + self._start = time.monotonic() def update(self) -> None: - now = datetime.datetime.now().timestamp() + now = time.monotonic() if self._start is None: self._start = now self._timestamps.append(now) - # truncate the list when it goes 100 over the max_size - if len(self._timestamps) > self._max_events + 100: - self._timestamps = self._timestamps[(1 - self._max_events) :] self.expire_timestamps(now) def eps(self) -> float: - now = datetime.datetime.now().timestamp() + now = time.monotonic() if self._start is None: self._start = now # compute the (approximate) events in the last n seconds @@ -60,11 +61,11 @@ class EventsPerSecond: def expire_timestamps(self, now: float) -> None: threshold = now - self._last_n_seconds while self._timestamps and self._timestamps[0] < threshold: - del self._timestamps[0] + self._timestamps.popleft() class InferenceSpeed: - def __init__(self, metric: Synchronized) -> None: + def __init__(self, metric: ValueProxy[float]) -> None: self.__metric = metric self.__initialized = False @@ -84,7 +85,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic """ :param dct1: First dict to merge :param dct2: Second dict to merge - :param override: if same key exists in both dictionaries, should override? otherwise ignore. (default=True) + :param override: if same key exists in both dictionaries, should override? otherwise ignore. + :param merge_lists: if True, lists will be merged. :return: The merge dictionary """ merged = copy.deepcopy(dct1) @@ -96,6 +98,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic elif isinstance(v1, list) and isinstance(v2, list): if merge_lists: merged[k] = v1 + v2 + elif override: + merged[k] = copy.deepcopy(v2) else: if override: merged[k] = copy.deepcopy(v2) @@ -113,7 +117,7 @@ def clean_camera_user_pass(line: str) -> str: def escape_special_characters(path: str) -> str: """Cleans reserved characters to encodings for ffmpeg.""" if len(path) > 1000: - return ValueError("Input too long to check") + raise ValueError("Input too long to check") try: found = re.search(REGEX_RTSP_CAMERA_USER_PASS, path).group(0)[3:-1] @@ -129,8 +133,26 @@ def get_ffmpeg_arg_list(arg: Any) -> list: return arg if isinstance(arg, list) else shlex.split(arg) +# all built-in record presets use this segment_time +DEFAULT_RECORD_SEGMENT_TIME = 10 + + +def get_record_segment_time(config: "CameraConfig") -> int: + """Extract -segment_time from the camera's record output args.""" + record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) + + if record_args and record_args[0].startswith("preset"): + return DEFAULT_RECORD_SEGMENT_TIME + + try: + idx = record_args.index("-segment_time") + return int(record_args[idx + 1]) + except (ValueError, IndexError): + return DEFAULT_RECORD_SEGMENT_TIME + + def load_labels( - path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None + path: str | None, encoding="utf-8", prefill=91, indexed: bool | None = None ): """Loads labels from file (with or without index numbers). Args: @@ -142,7 +164,7 @@ def load_labels( if path is None: return {} - with open(path, "r", encoding=encoding) as f: + with open(path, encoding=encoding) as f: labels = {index: "unknown" for index in range(prefill)} lines = f.readlines() if not lines: @@ -158,8 +180,8 @@ def load_labels( def to_relative_box( - width: int, height: int, box: Tuple[int, int, int, int] -) -> Tuple[int | float, int | float, int | float, int | float]: + width: int, height: int, box: tuple[int, int, int, int] +) -> tuple[int | float, int | float, int | float, int | float]: return ( box[0] / width, # x box[1] / height, # y @@ -173,7 +195,7 @@ def create_mask(frame_shape, mask): mask_img[:] = 255 -def process_config_query_string(query_string: Dict[str, list]) -> Dict[str, Any]: +def process_config_query_string(query_string: dict[str, list]) -> dict[str, Any]: updates = {} for key_path_str, new_value_list in query_string.items(): # use the string key as-is for updates dictionary @@ -191,11 +213,12 @@ def process_config_query_string(query_string: Dict[str, list]) -> Dict[str, Any] def flatten_config_data( - config_data: Dict[str, Any], parent_key: str = "" -) -> Dict[str, Any]: + config_data: dict[str, Any], parent_key: str = "" +) -> dict[str, Any]: items = [] for key, value in config_data.items(): - new_key = f"{parent_key}.{key}" if parent_key else key + escaped_key = escape_config_key_segment(str(key)) + new_key = f"{parent_key}.{escaped_key}" if parent_key else escaped_key if isinstance(value, dict): items.extend(flatten_config_data(value, new_key).items()) else: @@ -203,12 +226,47 @@ def flatten_config_data( return dict(items) -def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): +def escape_config_key_segment(segment: str) -> str: + """Escape dots and backslashes so they can be treated as literal key chars.""" + return segment.replace("\\", "\\\\").replace(".", "\\.") + + +def split_config_key_path(key_path_str: str) -> list[str]: + """Split a dotted config path, honoring \\. as a literal dot in a key.""" + parts: list[str] = [] + current: list[str] = [] + escaped = False + + for char in key_path_str: + if escaped: + current.append(char) + escaped = False + continue + + if char == "\\": + escaped = True + continue + + if char == ".": + parts.append("".join(current)) + current = [] + continue + + current.append(char) + + if escaped: + current.append("\\") + + parts.append("".join(current)) + return parts + + +def update_yaml_file_bulk(file_path: str, updates: dict[str, Any]): yaml = YAML() yaml.indent(mapping=2, sequence=4, offset=2) try: - with open(file_path, "r") as f: + with open(file_path) as f: data = yaml.load(f) except FileNotFoundError: logger.error( @@ -218,7 +276,7 @@ def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): # Apply all updates for key_path_str, new_value in updates.items(): - key_path = key_path_str.split(".") + key_path = split_config_key_path(key_path_str) for i in range(len(key_path)): try: index = int(key_path[i]) @@ -235,26 +293,51 @@ def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): logger.error(f"Unable to write to Frigate config file {file_path}: {e}") +def clear_orphaned_comments(collection, parent, parent_key) -> None: + """Drop stale ruamel comment tokens after a deletion empties a collection. + + When the last entry of a mapping or sequence is removed, any comments that + lived inside that collection's block are orphaned. ruamel then emits them + above a flow-style `{}`/`[]` dedented to column 0, which is unparseable and + corrupts the config. Clearing the emptied collection's own comment metadata + (and the parent's entry pointing at it) keeps the dump valid. Non-empty + collections are left untouched so comments on remaining siblings survive. + """ + if not hasattr(collection, "ca") or len(collection) != 0: + return + + collection.ca.items.clear() + collection.ca.comment = None + if parent is not None and hasattr(parent, "ca"): + parent.ca.items.pop(parent_key, None) + + def update_yaml(data, key_path, new_value): temp = data + parent = None + parent_key = None for key in key_path[:-1]: if isinstance(key, tuple): if key[0] not in temp: temp[key[0]] = [{}] * max(1, key[1] + 1) elif len(temp[key[0]]) <= key[1]: temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1) + parent, parent_key = temp[key[0]], key[1] temp = temp[key[0]][key[1]] else: if key not in temp or temp[key] is None: temp[key] = {} + parent, parent_key = temp, key temp = temp[key] last_key = key_path[-1] if new_value == "": if isinstance(last_key, tuple): del temp[last_key[0]][last_key[1]] + clear_orphaned_comments(temp[last_key[0]], temp, last_key[0]) else: del temp[last_key] + clear_orphaned_comments(temp, parent, parent_key) else: if isinstance(last_key, tuple): if last_key[0] not in temp: @@ -355,9 +438,7 @@ def generate_color_palette(n): return colors -def serialize( - vector: Union[list[float], np.ndarray, float], pack: bool = True -) -> bytes: +def serialize(vector: list[float] | np.ndarray | float, pack: bool = True) -> bytes: """Serializes a list of floats, numpy array, or single float into a compact "raw bytes" format""" if isinstance(vector, np.ndarray): # Convert numpy array to list of floats @@ -376,7 +457,7 @@ def serialize( else: return vector except struct.error as e: - raise ValueError(f"Failed to pack vector: {e}. Vector: {vector}") + raise ValueError(f"Failed to pack vector: {e}. Vector: {vector}") from e def deserialize(bytes_data: bytes) -> list[float]: @@ -391,6 +472,18 @@ def sanitize_float(value): return value +def has_non_finite_number(value: Any) -> bool: + """Return True if any number in a parsed JSON value is NaN or infinite.""" + if isinstance(value, float): + return not math.isfinite(value) + if isinstance(value, dict): + return any(has_non_finite_number(v) for v in value.values()) + if isinstance(value, list): + return any(has_non_finite_number(v) for v in value) + + return False + + def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return 1 - cosine_distance(a, b) diff --git a/frigate/util/camera_cleanup.py b/frigate/util/camera_cleanup.py new file mode 100644 index 0000000000..76a6891f7d --- /dev/null +++ b/frigate/util/camera_cleanup.py @@ -0,0 +1,165 @@ +"""Utilities for cleaning up camera data from database and filesystem.""" + +import glob +import logging +import os +import shutil + +from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR +from frigate.models import ( + Event, + Export, + Previews, + Recordings, + Regions, + ReviewSegment, + Timeline, + Trigger, +) + +logger = logging.getLogger(__name__) + + +def cleanup_camera_db( + camera_name: str, delete_exports: bool = False +) -> tuple[dict[str, int], list[str]]: + """Remove all database rows for a camera. + + Args: + camera_name: The camera name to clean up + delete_exports: Whether to also delete export records + + Returns: + Tuple of (deletion counts dict, list of export file paths to remove) + """ + counts: dict[str, int] = {} + export_paths: list[str] = [] + + try: + counts["events"] = Event.delete().where(Event.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete events for camera %s: %s", camera_name, e) + + try: + counts["timeline"] = ( + Timeline.delete().where(Timeline.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete timeline for camera %s: %s", camera_name, e) + + try: + counts["recordings"] = ( + Recordings.delete().where(Recordings.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete recordings for camera %s: %s", camera_name, e) + + try: + counts["review_segments"] = ( + ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute() + ) + except Exception as e: + logger.error( + "Failed to delete review segments for camera %s: %s", camera_name, e + ) + + try: + counts["previews"] = ( + Previews.delete().where(Previews.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete previews for camera %s: %s", camera_name, e) + + try: + counts["regions"] = ( + Regions.delete().where(Regions.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete regions for camera %s: %s", camera_name, e) + + try: + counts["triggers"] = ( + Trigger.delete().where(Trigger.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete triggers for camera %s: %s", camera_name, e) + + if delete_exports: + try: + exports = Export.select(Export.video_path, Export.thumb_path).where( + Export.camera == camera_name + ) + for export in exports: + export_paths.append(export.video_path) + export_paths.append(export.thumb_path) + + counts["exports"] = ( + Export.delete().where(Export.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete exports for camera %s: %s", camera_name, e) + + return counts, export_paths + + +def cleanup_camera_files( + camera_name: str, export_paths: list[str] | None = None +) -> None: + """Remove filesystem artifacts for a camera. + + Args: + camera_name: The camera name to clean up + export_paths: Optional list of export file paths to remove + """ + dirs_to_clean = [ + os.path.join(RECORD_DIR, camera_name), + os.path.join(CLIPS_DIR, camera_name), + os.path.join(THUMB_DIR, camera_name), + os.path.join(CLIPS_DIR, "previews", camera_name), + ] + + for dir_path in dirs_to_clean: + if os.path.exists(dir_path): + try: + shutil.rmtree(dir_path) + logger.debug("Removed directory: %s", dir_path) + except Exception as e: + logger.error("Failed to remove %s: %s", dir_path, e) + + # Remove event snapshot files + for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")): + try: + os.remove(snapshot) + except Exception as e: + logger.error("Failed to remove snapshot %s: %s", snapshot, e) + + for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*-clean.webp")): + try: + os.remove(snapshot) + except Exception as e: + logger.error("Failed to remove snapshot %s: %s", snapshot, e) + + for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*-clean.png")): + try: + os.remove(snapshot) + except Exception as e: + logger.error("Failed to remove snapshot %s: %s", snapshot, e) + + # Remove review thumbnail files + for thumb in glob.glob( + os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp") + ): + try: + os.remove(thumb) + except Exception as e: + logger.error("Failed to remove review thumbnail %s: %s", thumb, e) + + # Remove export files if requested + if export_paths: + for path in export_paths: + if path and os.path.exists(path): + try: + os.remove(path) + logger.debug("Removed export file: %s", path) + except Exception as e: + logger.error("Failed to remove export file %s: %s", path, e) diff --git a/frigate/util/classification.py b/frigate/util/classification.py index 643f77d3be..a9345bbc56 100644 --- a/frigate/util/classification.py +++ b/frigate/util/classification.py @@ -5,6 +5,7 @@ import json import logging import os import random +import shutil from collections import defaultdict import cv2 @@ -23,8 +24,12 @@ from frigate.log import redirect_output_to_logger, suppress_stderr_during from frigate.models import Event, Recordings, ReviewSegment from frigate.types import ModelStatusTypesEnum from frigate.util.downloader import ModelDownloader -from frigate.util.file import get_event_thumbnail_bytes -from frigate.util.image import get_image_from_recording +from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image +from frigate.util.image import ( + calculate_region, + get_image_from_recording, + relative_box_to_absolute, +) from frigate.util.process import FrigateProcess BATCH_SIZE = 16 @@ -79,7 +84,7 @@ def read_training_metadata(model_name: str) -> dict[str, any] | None: return None try: - with open(metadata_path, "r") as f: + with open(metadata_path) as f: metadata = json.load(f) return metadata except Exception as e: @@ -289,7 +294,7 @@ class ClassificationTrainingProcess(FrigateProcess): return True except Exception as e: - logger.error(f"Training failed for {self.model_name}: {e}", exc_info=True) + logger.exception(f"Training failed for {self.model_name}: {e}") return False @@ -389,7 +394,7 @@ def collect_state_classification_examples( # Step 3: Extract keyframes from recordings with crops applied keyframes = _extract_keyframes( - "/usr/lib/ffmpeg/7.0/bin/ffmpeg", timestamps, temp_dir, cameras + "/usr/lib/ffmpeg/8.0/bin/ffmpeg", timestamps, temp_dir, cameras ) # Step 4: Select 24 most visually distinct images (they're already cropped) @@ -397,6 +402,8 @@ def collect_state_classification_examples( # Step 5: Save to train directory for later classification train_dir = os.path.join(CLIPS_DIR, model_name, "train") + if os.path.exists(train_dir): + shutil.rmtree(train_dir) os.makedirs(train_dir, exist_ok=True) saved_count = 0 @@ -411,8 +418,6 @@ def collect_state_classification_examples( except Exception as e: logger.error(f"Failed to save image {image_path}: {e}") - import shutil - try: shutil.rmtree(temp_dir) except Exception as e: @@ -561,7 +566,7 @@ def _extract_keyframes( relative_time = timestamp - recording.start_time try: - config = FfmpegConfig(path="/usr/lib/ffmpeg/7.0") + config = FfmpegConfig(path="/usr/lib/ffmpeg/8.0") image_data = get_image_from_recording( config, recording.path, @@ -712,7 +717,7 @@ def collect_object_classification_examples( This function: 1. Queries events for the specified label 2. Selects 100 balanced events across different cameras and times - 3. Retrieves thumbnails for selected events (with 33% center crop applied) + 3. Crops each event's clean snapshot around the object bounding box 4. Selects 24 most visually distinct thumbnails 5. Saves to dataset directory @@ -727,7 +732,7 @@ def collect_object_classification_examples( # Step 1: Query events for the specified label and cameras events = list( - Event.select().where((Event.label == label)).order_by(Event.start_time.asc()) + Event.select().where(Event.label == label).order_by(Event.start_time.asc()) ) if not events: @@ -750,6 +755,8 @@ def collect_object_classification_examples( # Step 5: Save to train directory for later classification train_dir = os.path.join(CLIPS_DIR, model_name, "train") + if os.path.exists(train_dir): + shutil.rmtree(train_dir) os.makedirs(train_dir, exist_ok=True) saved_count = 0 @@ -764,8 +771,6 @@ def collect_object_classification_examples( except Exception as e: logger.error(f"Failed to save image {image_path}: {e}") - import shutil - try: shutil.rmtree(temp_dir) except Exception as e: @@ -806,90 +811,131 @@ def _select_balanced_events( selected = [] for group_events in grouped.values(): + # Take top events by score, then randomly sample from them sorted_events = sorted( group_events, key=lambda e: e.data.get("score", 0) if e.data else 0, reverse=True, ) - sample_size = min(samples_per_group, len(sorted_events)) - selected.extend(sorted_events[:sample_size]) + # Consider top 3x candidates to allow randomness while preferring higher scores + candidate_pool = sorted_events[: samples_per_group * 3] + sample_size = min(samples_per_group, len(candidate_pool)) + selected.extend(random.sample(candidate_pool, sample_size)) if len(selected) < target_count: remaining = [e for e in events if e not in selected] - remaining_sorted = sorted( - remaining, - key=lambda e: e.data.get("score", 0) if e.data else 0, - reverse=True, - ) needed = target_count - len(selected) - selected.extend(remaining_sorted[:needed]) + if len(remaining) > needed: + selected.extend(random.sample(remaining, needed)) + else: + selected.extend(remaining) return selected[:target_count] def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: """ - Extract thumbnails from events and save to disk. + Extract a training image for each event. + + Preferred path: load the full-frame clean snapshot and crop around the + stored bounding box with the same calculate_region(..., max(w, h), 1.0) + call the live ObjectClassificationProcessor uses, so wizard examples + are framed like inference-time inputs. + + Fallback: if no clean snapshot exists (snapshots disabled, or only a + legacy annotated JPG is on disk), center-crop the stored thumbnail + using a step ladder sized from the box/region area ratio. Args: events: List of Event objects - output_dir: Directory to save thumbnails + output_dir: Directory to save crops Returns: - List of paths to successfully extracted thumbnail images + List of paths to successfully extracted images """ - thumbnail_paths = [] + image_paths = [] for idx, event in enumerate(events): try: - thumbnail_bytes = get_event_thumbnail_bytes(event) + img = _load_event_classification_crop(event) + if img is None: + continue - if thumbnail_bytes: - nparr = np.frombuffer(thumbnail_bytes, np.uint8) - img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - - if img is not None: - height, width = img.shape[:2] - - crop_size = 1.0 - if event.data and "box" in event.data and "region" in event.data: - box = event.data["box"] - region = event.data["region"] - - if len(box) == 4 and len(region) == 4: - box_w, box_h = box[2], box[3] - region_w, region_h = region[2], region[3] - - box_area = (box_w * box_h) / (region_w * region_h) - - if box_area < 0.05: - crop_size = 0.4 - elif box_area < 0.10: - crop_size = 0.5 - elif box_area < 0.20: - crop_size = 0.65 - elif box_area < 0.35: - crop_size = 0.80 - else: - crop_size = 0.95 - - crop_width = int(width * crop_size) - crop_height = int(height * crop_size) - - x1 = (width - crop_width) // 2 - y1 = (height - crop_height) // 2 - x2 = x1 + crop_width - y2 = y1 + crop_height - - cropped = img[y1:y2, x1:x2] - resized = cv2.resize(cropped, (224, 224)) - output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg") - cv2.imwrite(output_path, resized) - thumbnail_paths.append(output_path) + resized = cv2.resize(img, (224, 224)) + output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg") + cv2.imwrite(output_path, resized) + image_paths.append(output_path) except Exception as e: - logger.debug(f"Failed to extract thumbnail for event {event.id}: {e}") + logger.debug(f"Failed to extract image for event {event.id}: {e}") continue - return thumbnail_paths + return image_paths + + +def _load_event_classification_crop(event: Event) -> np.ndarray | None: + """Prefer a snapshot-based object crop; fall back to a center-cropped thumbnail.""" + if event.data and "box" in event.data: + snapshot, _ = load_event_snapshot_image(event, clean_only=True) + if snapshot is not None: + abs_box = relative_box_to_absolute(snapshot.shape, event.data["box"]) + if abs_box is not None: + xmin, ymin, xmax, ymax = abs_box + box_w = xmax - xmin + box_h = ymax - ymin + if box_w > 0 and box_h > 0: + x1, y1, x2, y2 = calculate_region( + snapshot.shape, + xmin, + ymin, + xmax, + ymax, + max(box_w, box_h), + 1.0, + ) + cropped = snapshot[y1:y2, x1:x2] + if cropped.size > 0: + return cropped + + thumbnail_bytes = get_event_thumbnail_bytes(event) + if not thumbnail_bytes: + return None + + nparr = np.frombuffer(thumbnail_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None or img.size == 0: + return None + + height, width = img.shape[:2] + crop_size = 1.0 + + if event.data and "box" in event.data and "region" in event.data: + box = event.data["box"] + region = event.data["region"] + + if len(box) == 4 and len(region) == 4: + box_w, box_h = box[2], box[3] + region_w, region_h = region[2], region[3] + box_area = (box_w * box_h) / (region_w * region_h) + + if box_area < 0.05: + crop_size = 0.4 + elif box_area < 0.10: + crop_size = 0.5 + elif box_area < 0.20: + crop_size = 0.65 + elif box_area < 0.35: + crop_size = 0.80 + else: + crop_size = 0.95 + + crop_width = int(width * crop_size) + crop_height = int(height * crop_size) + x1 = (width - crop_width) // 2 + y1 = (height - crop_height) // 2 + cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width] + if cropped.size == 0: + return None + + return cropped diff --git a/frigate/util/config.py b/frigate/util/config.py index c3d796397b..3c7f0ca8d9 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -4,19 +4,61 @@ import asyncio import logging import os import shutil -from typing import Any, Optional, Union +from typing import Any from ruamel.yaml import YAML -from frigate.const import CONFIG_DIR, EXPORT_DIR +from frigate.const import ( + CONFIG_DIR, + DEFAULT_FFMPEG_VERSION, + EXPORT_DIR, + INCLUDED_FFMPEG_VERSIONS, + REDACTED_CREDENTIAL_SENTINEL, +) +from frigate.util.builtin import deep_merge from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) -CURRENT_CONFIG_VERSION = "0.17-0" +CURRENT_CONFIG_VERSION = "0.18-0" DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml") +def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str: + """Resolve an ffmpeg version alias or custom path to a binary path. + + A bare version alias that is no longer bundled (for example one that was + dropped when the default version changed) falls back to the default + bundled version so existing configs keep working across an upgrade or a + revert. Custom install paths (anything absolute) are used as-is. + """ + if path == "default" or ( + not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS + ): + version = DEFAULT_FFMPEG_VERSION + elif path in INCLUDED_FFMPEG_VERSIONS: + version = path + else: + return f"{path}/bin/{binary}" + + return f"/usr/lib/ffmpeg/{version}/bin/{binary}" + + +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) @@ -36,7 +78,7 @@ def migrate_frigate_config(config_file: str): yaml = YAML() yaml.indent(mapping=2, sequence=4, offset=2) - with open(config_file, "r") as f: + with open(config_file) as f: config: dict[str, dict[str, Any]] = yaml.load(f) if config is None: @@ -98,6 +140,13 @@ def migrate_frigate_config(config_file: str): yaml.dump(new_config, f) previous_version = "0.17-0" + if previous_version < "0.18-0": + logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...") + new_config = migrate_018_0(config) + with open(config_file, "w") as f: + yaml.dump(new_config, f) + previous_version = "0.18-0" + logger.info("Finished frigate config migration...") @@ -427,12 +476,197 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] return new_config +def _convert_legacy_mask_to_dict( + mask: str | list | None, mask_type: str = "motion_mask", label: str = "" +) -> dict[str, dict[str, Any]]: + """Convert legacy mask format (str or list[str]) to new dict format. + + Args: + mask: Legacy mask format (string or list of strings) + mask_type: Type of mask for naming ("motion_mask" or "object_mask") + label: Optional label for object masks (e.g., "person") + + Returns: + Dictionary with mask_id as key and mask config as value + """ + if not mask: + return {} + + result = {} + + if isinstance(mask, str): + if mask: + mask_id = f"{mask_type}_1" + friendly_name = ( + f"Object Mask 1 ({label})" + if label + else f"{mask_type.replace('_', ' ').title()} 1" + ) + result[mask_id] = { + "friendly_name": friendly_name, + "enabled": True, + "coordinates": mask, + } + elif isinstance(mask, list): + for i, coords in enumerate(mask): + if coords: + mask_id = f"{mask_type}_{i + 1}" + friendly_name = ( + f"Object Mask {i + 1} ({label})" + if label + else f"{mask_type.replace('_', ' ').title()} {i + 1}" + ) + result[mask_id] = { + "friendly_name": friendly_name, + "enabled": True, + "coordinates": coords, + } + + return result + + +def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Handle migrating frigate config to 0.18-0""" + new_config = config.copy() + + # Migrate GenAI to new format + genai = new_config.get("genai") + + if genai and genai.get("provider"): + genai["roles"] = ["descriptions", "chat"] + new_config["genai"] = {"default": genai} + + # Remove deprecated sync_recordings from global record config + if new_config.get("record", {}).get("sync_recordings") is not None: + del new_config["record"]["sync_recordings"] + + # Remove deprecated timelapse_args from global record export config + if new_config.get("record", {}).get("export", {}).get("timelapse_args") is not None: + del new_config["record"]["export"]["timelapse_args"] + # Remove export section if empty + if not new_config.get("record", {}).get("export"): + del new_config["record"]["export"] + # Remove record section if empty + if not new_config.get("record"): + del new_config["record"] + + # Migrate global motion masks + global_motion = new_config.get("motion", {}) + if global_motion and "mask" in global_motion: + mask = global_motion.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["motion"]["mask"] = _convert_legacy_mask_to_dict( + mask, "motion_mask" + ) + + # Migrate global object masks + global_objects = new_config.get("objects", {}) + if global_objects and "mask" in global_objects: + mask = global_objects.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["objects"]["mask"] = _convert_legacy_mask_to_dict( + mask, "object_mask" + ) + + # Migrate global object filters masks + if global_objects and "filters" in global_objects: + for obj_name, filter_config in global_objects.get("filters", {}).items(): + if isinstance(filter_config, dict) and "mask" in filter_config: + mask = filter_config.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["objects"]["filters"][obj_name]["mask"] = ( + _convert_legacy_mask_to_dict(mask, "object_mask", obj_name) + ) + + # Remove deprecated sync_recordings and migrate masks for camera-specific configs + for name, camera in config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, Any]] = camera.copy() + + if camera_config.get("record", {}).get("sync_recordings") is not None: + del camera_config["record"]["sync_recordings"] + + if ( + camera_config.get("record", {}).get("export", {}).get("timelapse_args") + is not None + ): + del camera_config["record"]["export"]["timelapse_args"] + # Remove export section if empty + if not camera_config.get("record", {}).get("export"): + del camera_config["record"]["export"] + # Remove record section if empty + if not camera_config.get("record"): + del camera_config["record"] + + # Migrate camera motion masks + camera_motion = camera_config.get("motion", {}) + if camera_motion and "mask" in camera_motion: + mask = camera_motion.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["motion"]["mask"] = _convert_legacy_mask_to_dict( + mask, "motion_mask" + ) + + # Migrate camera global object masks + camera_objects = camera_config.get("objects", {}) + if camera_objects and "mask" in camera_objects: + mask = camera_objects.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["objects"]["mask"] = _convert_legacy_mask_to_dict( + mask, "object_mask" + ) + + # Migrate camera object filter masks + if camera_objects and "filters" in camera_objects: + for obj_name, filter_config in camera_objects.get("filters", {}).items(): + if isinstance(filter_config, dict) and "mask" in filter_config: + mask = filter_config.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["objects"]["filters"][obj_name]["mask"] = ( + _convert_legacy_mask_to_dict(mask, "object_mask", obj_name) + ) + + new_config["cameras"][name] = camera_config + + # Remove deprecated clean_copy from global snapshots config + if new_config.get("snapshots", {}).get("clean_copy") is not None: + del new_config["snapshots"]["clean_copy"] + if not new_config["snapshots"]: + del new_config["snapshots"] + + # Remove deprecated clean_copy from camera snapshots configs + for name, camera in new_config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, Any]] = camera.copy() + + if camera_config.get("snapshots", {}).get("clean_copy") is not None: + del camera_config["snapshots"]["clean_copy"] + if not camera_config["snapshots"]: + del camera_config["snapshots"] + + new_config["cameras"][name] = camera_config + + # Remove deprecated date_style and time_style from global ui config + global_ui = new_config.get("ui", {}) + if global_ui.get("date_style") is not None: + del new_config["ui"]["date_style"] + if global_ui.get("time_style") is not None: + del new_config["ui"]["time_style"] + # Remove ui section if empty + if "ui" in new_config and not new_config["ui"]: + del new_config["ui"] + + new_config["version"] = "0.18-0" + return new_config + + def get_relative_coordinates( - mask: Optional[Union[str, list]], frame_shape: tuple[int, int] -) -> Union[str, list]: + mask: str | list | None, + frame_shape: tuple[int, int], + camera_name: str = "", +) -> str | list: # masks and zones are saved as relative coordinates # we know if any points are > 1 then it is using the # old native resolution coordinates + where = f" for camera {camera_name}" if camera_name else "" if mask: if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): relative_masks = [] @@ -447,7 +681,7 @@ def get_relative_coordinates( if x > frame_shape[1] or y > frame_shape[0]: logger.error( - f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." + f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." ) continue @@ -470,7 +704,7 @@ def get_relative_coordinates( if x > frame_shape[1] or y > frame_shape[0]: logger.error( - f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." + f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." ) return [] @@ -486,7 +720,7 @@ def get_relative_coordinates( def convert_area_to_pixels( - area_value: Union[int, float], frame_shape: tuple[int, int] + area_value: int | float, frame_shape: tuple[int, int] ) -> int: """ Convert area specification to pixels. @@ -526,3 +760,115 @@ class StreamInfoRetriever: info = asyncio.run(get_video_properties(ffmpeg, path)) self.stream_cache[path] = info return info + + +def apply_section_update(camera_config, section: str, update: dict) -> str | None: + """Merge an update dict into a camera config section and rebuild runtime variants. + + For motion and object filter sections, the plain Pydantic models are rebuilt + as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks + are recomputed. This mirrors the logic in FrigateConfig.post_validation. + + Args: + camera_config: The CameraConfig instance to update. + section: Config section name (e.g. "motion", "objects"). + update: Nested dict of field updates to merge. + + Returns: + None on success, or an error message string on failure. + """ + from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig + + current = getattr(camera_config, section, None) + if current is None: + return f"Section '{section}' not found on camera '{camera_config.name}'" + + try: + frame_shape = camera_config.frame_shape + + if section == "motion": + merged = deep_merge( + current.model_dump(exclude_unset=True), + update, + override=True, + ) + camera_config.motion = RuntimeMotionConfig( + frame_shape=frame_shape, **merged + ) + + elif section == "objects": + merged = deep_merge( + current.model_dump(), + update, + override=True, + ) + new_objects = current.__class__.model_validate(merged) + + # Preserve private _all_objects from original config + try: + new_objects._all_objects = current._all_objects + except AttributeError: + pass + + # Rebuild RuntimeFilterConfig with merged global + per-object masks + for obj_name, filt in new_objects.filters.items(): + merged_mask = dict(filt.mask) + if new_objects.mask: + for gid, gmask in new_objects.mask.items(): + merged_mask[f"global_{gid}"] = gmask + + new_objects.filters[obj_name] = RuntimeFilterConfig( + frame_shape=frame_shape, + mask=merged_mask, + **filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}), + ) + 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)) + + except Exception: + logger.exception("Config validation error") + return "Validation error. Check logs for details." + + return None diff --git a/frigate/util/downloader.py b/frigate/util/downloader.py index ee80b38165..a8b593f159 100644 --- a/frigate/util/downloader.py +++ b/frigate/util/downloader.py @@ -1,8 +1,8 @@ import logging import os import threading +from collections.abc import Callable from pathlib import Path -from typing import Callable, List import requests @@ -19,7 +19,7 @@ class ModelDownloader: self, model_name: str, download_path: str, - file_names: List[str], + file_names: list[str], download_func: Callable[[str], None], complete_func: Callable[[], None] | None = None, silent: bool = False, diff --git a/frigate/util/ffmpeg.py b/frigate/util/ffmpeg.py new file mode 100644 index 0000000000..87601b91d6 --- /dev/null +++ b/frigate/util/ffmpeg.py @@ -0,0 +1,171 @@ +"""FFmpeg utility functions for managing ffmpeg processes.""" + +import logging +import subprocess as sp +from collections.abc import Callable +from typing import Any + +from frigate.const import PROCESS_PRIORITY_LOW +from frigate.log import LogPipe + + +def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger): + logger.info("Terminating the existing ffmpeg process...") + ffmpeg_process.terminate() + try: + logger.info("Waiting for ffmpeg to exit gracefully...") + ffmpeg_process.communicate(timeout=30) + logger.info("FFmpeg has exited") + except sp.TimeoutExpired: + logger.info("FFmpeg didn't exit. Force killing...") + ffmpeg_process.kill() + ffmpeg_process.communicate() + logger.info("FFmpeg has been killed") + ffmpeg_process = None + + +def start_or_restart_ffmpeg( + ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None +) -> sp.Popen[Any]: + if ffmpeg_process is not None: + stop_ffmpeg(ffmpeg_process, logger) + + if frame_size is None: + process = sp.Popen( + ffmpeg_cmd, + stdout=sp.DEVNULL, + stderr=logpipe, + stdin=sp.DEVNULL, + start_new_session=True, + ) + else: + process = sp.Popen( + ffmpeg_cmd, + stdout=sp.PIPE, + stderr=logpipe, + stdin=sp.DEVNULL, + bufsize=frame_size * 10, + start_new_session=True, + ) + return process + + +logger = logging.getLogger(__name__) + + +def inject_progress_flags(cmd: list[str]) -> list[str]: + """Insert `-progress pipe:2 -nostats` immediately before the output path. + + `-progress pipe:2` writes structured key=value lines to stderr; + `-nostats` suppresses the noisy default stats output. The output path + is conventionally the last token in an FFmpeg argv. + """ + if not cmd: + return cmd + return cmd[:-1] + ["-progress", "pipe:2", "-nostats", cmd[-1]] + + +def run_ffmpeg_with_progress( + cmd: list[str], + *, + expected_duration_seconds: float, + on_progress: Callable[[float], None] | None = None, + stdin_payload: str | None = None, + process_started: Callable[[sp.Popen], None] | None = None, + use_low_priority: bool = True, +) -> tuple[int, str]: + """Run an ffmpeg command, streaming progress via `-progress pipe:2`. + + Args: + cmd: ffmpeg argv. Output path must be the last token. + expected_duration_seconds: Duration of the expected output clip in + seconds. Used to convert ffmpeg's `out_time_us` into a percent. + on_progress: Optional callback invoked with a percent in [0, 100]. + Called once with 0.0 at start, again on each `out_time_us=` + stderr line, and once with 100.0 on `progress=end`. + stdin_payload: Optional string written to ffmpeg stdin (used by + export for concat playlists). + process_started: Optional callback invoked with the live `Popen` + once spawned — lets callers store the ref for cancellation. + use_low_priority: When True, prepend `nice -n PROCESS_PRIORITY_LOW` + so concat doesn't starve detection. + + Returns: + Tuple of `(returncode, captured_stderr)`. Stdout is left attached + to the parent process to avoid buffer-full deadlocks. + """ + full_cmd = inject_progress_flags(cmd) + if use_low_priority: + full_cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + full_cmd + + def emit(percent: float) -> None: + if on_progress is None: + return + try: + on_progress(max(0.0, min(100.0, percent))) + except Exception: + logger.exception("FFmpeg progress callback failed") + + emit(0.0) + + proc = sp.Popen( + full_cmd, + stdin=sp.PIPE if stdin_payload is not None else None, + stderr=sp.PIPE, + text=True, + encoding="ascii", + errors="replace", + ) + if process_started is not None: + try: + process_started(proc) + except Exception: + logger.exception("FFmpeg process_started callback failed") + + if stdin_payload is not None and proc.stdin is not None: + try: + proc.stdin.write(stdin_payload) + except (BrokenPipeError, OSError): + pass + finally: + try: + proc.stdin.close() + except (BrokenPipeError, OSError): + pass + + captured: list[str] = [] + if proc.stderr is not None: + try: + for raw_line in proc.stderr: + captured.append(raw_line) + line = raw_line.strip() + if not line: + continue + if line.startswith("out_time_us="): + if expected_duration_seconds <= 0: + continue + try: + out_time_us = int(line.split("=", 1)[1]) + except (ValueError, IndexError): + continue + if out_time_us < 0: + continue + out_seconds = out_time_us / 1_000_000.0 + emit((out_seconds / expected_duration_seconds) * 100.0) + elif line == "progress=end": + emit(100.0) + break + except Exception: + logger.exception("Failed reading FFmpeg progress stream") + + proc.wait() + + if proc.stderr is not None: + try: + remaining = proc.stderr.read() + if remaining: + captured.append(remaining) + except Exception: + pass + + return proc.returncode or 0, "".join(captured) diff --git a/frigate/util/file.py b/frigate/util/file.py index 22be3e5117..e259d13456 100644 --- a/frigate/util/file.py +++ b/frigate/util/file.py @@ -5,14 +5,16 @@ import fcntl import logging import os import time +from datetime import datetime from pathlib import Path -from typing import Optional +from typing import Any import cv2 from numpy import ndarray from frigate.const import CLIPS_DIR, THUMB_DIR from frigate.models import Event +from frigate.util.image import get_snapshot_bytes, relative_box_to_absolute logger = logging.getLogger(__name__) @@ -30,9 +32,207 @@ def get_event_thumbnail_bytes(event: Event) -> bytes | None: return None -def get_event_snapshot(event: Event) -> ndarray: - media_name = f"{event.camera}-{event.id}" - return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") +def get_event_snapshot(event: Event) -> ndarray | None: + image, _ = load_event_snapshot_image(event) + return image + + +def get_event_snapshot_path( + event: Event, *, clean_only: bool = False +) -> tuple[str | None, bool]: + clean_snapshot_paths = [ + os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}-clean.webp"), + os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}-clean.png"), + ] + + for image_path in clean_snapshot_paths: + if os.path.exists(image_path): + return image_path, True + + snapshot_path = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") + if not os.path.exists(snapshot_path): + return None, False + + # Legacy JPG snapshots may already include overlays, so they should never + # be treated as clean input for additional rendering. + if clean_only: + return None, False + + return snapshot_path, False + + +def load_event_snapshot_image( + event: Event, *, clean_only: bool = False +) -> tuple[ndarray | None, bool]: + image_path, is_clean_snapshot = get_event_snapshot_path( + event, clean_only=clean_only + ) + if image_path is None: + return None, False + + image = cv2.imread(image_path) + if image is None: + logger.warning("Unable to load snapshot from %s", image_path) + return None, False + + return image, is_clean_snapshot + + +def _get_event_snapshot_overlay_boxes( + frame_shape: tuple[int, ...], event: Event +) -> list[dict[str, Any]]: + overlay_boxes: list[dict[str, Any]] = [] + draw_data = event.data.get("draw") if event.data else {} + draw_boxes = draw_data.get("boxes", []) if isinstance(draw_data, dict) else [] + + for draw_box in draw_boxes: + box = relative_box_to_absolute(frame_shape, draw_box.get("box")) + if box is None: + continue + + draw_color = draw_box.get("color", (255, 0, 0)) + color = ( + tuple(draw_color) if isinstance(draw_color, (list, tuple)) else (255, 0, 0) + ) + overlay_boxes.append( + { + "box": box, + "label": event.label, + "score": draw_box.get("score"), + "color": color, + } + ) + + return overlay_boxes + + +def get_event_snapshot_bytes( + event: Event, + *, + ext: str, + timestamp: bool = False, + bounding_box: bool = False, + crop: bool = False, + height: int | None = None, + quality: int | None = None, + timestamp_style: Any | None = None, + colormap: dict[str, tuple[int, int, int]] | None = None, +) -> tuple[bytes | None, float]: + best_frame, is_clean_snapshot = load_event_snapshot_image(event) + if best_frame is None: + return None, 0 + + frame_time = _get_event_snapshot_frame_time(event) + box = relative_box_to_absolute( + best_frame.shape, + event.data.get("box") if event.data else None, + ) + overlay_boxes = _get_event_snapshot_overlay_boxes(best_frame.shape, event) + + if (bounding_box or crop or timestamp) and not is_clean_snapshot: + logger.warning( + "Unable to fully honor snapshot query parameters for completed event %s because the clean snapshot is unavailable.", + event.id, + ) + + return get_snapshot_bytes( + best_frame, + frame_time, + ext=ext, + timestamp=timestamp and is_clean_snapshot, + bounding_box=bounding_box and is_clean_snapshot, + crop=crop and is_clean_snapshot, + height=height, + quality=quality, + label=event.label, + box=box, + score=_get_event_snapshot_score(event), + area=_get_event_snapshot_area(event), + attributes=_get_event_snapshot_attributes( + best_frame.shape, + event.data.get("attributes") if event.data else None, + ), + color=(colormap or {}).get(event.label, (255, 255, 255)), + overlay_boxes=overlay_boxes, + timestamp_style=timestamp_style, + estimated_speed=_get_event_snapshot_estimated_speed(event), + ) + + +def _as_timestamp(value: Any) -> float: + if isinstance(value, datetime): + return value.timestamp() + + return float(value) + + +def _get_event_snapshot_frame_time(event: Event) -> float: + if event.data: + snapshot_frame_time = event.data.get("snapshot_frame_time") + if snapshot_frame_time is not None: + return _as_timestamp(snapshot_frame_time) + + frame_time = event.data.get("frame_time") + if frame_time is not None: + return _as_timestamp(frame_time) + + return _as_timestamp(event.start_time) + + +def _get_event_snapshot_attributes( + frame_shape: tuple[int, ...], attributes: list[dict[str, Any]] | None +) -> list[dict[str, Any]]: + absolute_attributes: list[dict[str, Any]] = [] + + for attribute in attributes or []: + box = relative_box_to_absolute(frame_shape, attribute.get("box")) + if box is None: + continue + + absolute_attributes.append( + { + "box": box, + "label": attribute.get("label", "attribute"), + "score": attribute.get("score", 0), + } + ) + + return absolute_attributes + + +def _get_event_snapshot_score(event: Event) -> float: + if event.data: + score = event.data.get("score") + if score is not None: + return score + + top_score = event.data.get("top_score") + if top_score is not None: + return top_score + + return event.top_score or event.score or 0 + + +def _get_event_snapshot_area(event: Event) -> int | None: + if event.data: + area = event.data.get("snapshot_area") + if area is not None: + return int(area) + + return None + + +def _get_event_snapshot_estimated_speed(event: Event) -> float: + if event.data: + estimated_speed = event.data.get("snapshot_estimated_speed") + if estimated_speed is not None: + return float(estimated_speed) + + average_speed = event.data.get("average_estimated_speed") + if average_speed is not None: + return float(average_speed) + + return 0 ### Deletion @@ -123,7 +323,7 @@ class FileLock: self.timeout = timeout self.poll_interval = poll_interval self.stale_timeout = stale_timeout - self._fd: Optional[int] = None + self._fd: int | None = None self._acquired = False if cleanup_stale_on_init: @@ -167,7 +367,7 @@ class FileLock: return False - def acquire(self, timeout: Optional[int] = None) -> bool: + def acquire(self, timeout: int | None = None) -> bool: """ Acquire the file lock using fcntl.flock(). @@ -200,7 +400,7 @@ class FileLock: self._acquired = True logger.debug(f"Acquired lock: {self.lock_path}") return True - except (OSError, IOError): + except OSError: # Lock is held by another process if time.time() - start_time >= timeout: logger.warning(f"Timeout waiting for lock: {self.lock_path}") diff --git a/frigate/util/image.py b/frigate/util/image.py index ea9fb0a0a7..b403f9750e 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from multiprocessing import resource_tracker as _mprt from multiprocessing import shared_memory as _mpshm from string import printable -from typing import Any, AnyStr, Optional +from typing import Any, AnyStr import cv2 import numpy as np @@ -67,7 +67,7 @@ def has_better_attr(current_thumb, new_obj, attr_label) -> bool: def is_better_thumbnail( - label: str, + label_attributes: list[str], current_thumb: dict[str, Any], new_obj: dict[str, Any], frame_shape: tuple[int, int], @@ -76,20 +76,12 @@ def is_better_thumbnail( # cutoff images are less ideal, but they should also be smaller? # better scores are obviously better too - # check face on person - if label == "person": - if has_better_attr(current_thumb, new_obj, "face"): + for attr_label in label_attributes: + if has_better_attr(current_thumb, new_obj, attr_label): return True - # if the current thumb has a face attr, dont update unless it gets better - if any([a["label"] == "face" for a in current_thumb["attributes"]]): - return False - # check license_plate on car - if label in ["car", "motorcycle"]: - if has_better_attr(current_thumb, new_obj, "license_plate"): - return True - # if the current thumb has a license_plate attr, dont update unless it gets better - if any([a["label"] == "license_plate" for a in current_thumb["attributes"]]): + # if the current thumb has the attr, dont update unless it gets better + if any([a["label"] == attr_label for a in current_thumb["attributes"]]): return False # if the new_thumb is on an edge, and the current thumb is not @@ -270,6 +262,229 @@ def draw_box_with_label( ) +def get_image_quality_params(ext: str, quality: int | None) -> list[int]: + if ext in ("jpg", "jpeg"): + return [int(cv2.IMWRITE_JPEG_QUALITY), quality if quality is not None else 70] + + if ext == "webp": + return [int(cv2.IMWRITE_WEBP_QUALITY), quality if quality is not None else 60] + + return [] + + +def relative_box_to_absolute( + frame_shape: tuple[int, ...], box: list[float] | tuple[float, ...] | None +) -> tuple[int, int, int, int] | None: + if box is None or len(box) != 4: + return None + + frame_height = frame_shape[0] + frame_width = frame_shape[1] + x_min = int(box[0] * frame_width) + y_min = int(box[1] * frame_height) + x_max = x_min + int(box[2] * frame_width) + y_max = y_min + int(box[3] * frame_height) + + x_min = max(0, min(frame_width - 1, x_min)) + y_min = max(0, min(frame_height - 1, y_min)) + x_max = max(x_min + 1, min(frame_width - 1, x_max)) + y_max = max(y_min + 1, min(frame_height - 1, y_max)) + + return (x_min, y_min, x_max, y_max) + + +def _format_snapshot_label( + score: float | None, + area: int | None, + box: tuple[int, int, int, int] | None, + estimated_speed: float = 0, +) -> str: + score_value = score or 0 + score_text = ( + f"{int(score_value * 100)}%" if score_value <= 1 else f"{int(score_value)}%" + ) + + if area is None and box is not None: + area = int((box[2] - box[0]) * (box[3] - box[1])) + + label = f"{score_text} {int(area or 0)}" + if estimated_speed: + label = f"{label} {estimated_speed:.1f}" + + return label + + +def draw_snapshot_bounding_boxes( + frame: np.ndarray, + label: str, + box: tuple[int, int, int, int] | None, + score: float | None, + area: int | None, + attributes: list[dict[str, Any]] | None, + color: tuple[int, int, int], + estimated_speed: float = 0, +) -> None: + if box is None: + return + + draw_box_with_label( + frame, + box[0], + box[1], + box[2], + box[3], + label, + _format_snapshot_label(score, area, box, estimated_speed), + thickness=2, + color=color, + ) + + for attribute in attributes or []: + attribute_box = attribute.get("box") + if attribute_box is None: + continue + + box_area = int( + (attribute_box[2] - attribute_box[0]) + * (attribute_box[3] - attribute_box[1]) + ) + draw_box_with_label( + frame, + attribute_box[0], + attribute_box[1], + attribute_box[2], + attribute_box[3], + attribute.get("label", "attribute"), + f"{attribute.get('score', 0):.0%} {box_area}", + thickness=2, + color=color, + ) + + +def _get_snapshot_overlay_box_label( + score: float | int | None, box: tuple[int, int, int, int] +) -> str: + area = int((box[2] - box[0]) * (box[3] - box[1])) + + if score is None: + return f"- {area}" + + score_value = float(score) + score_text = ( + f"{int(score_value * 100)}%" if score_value <= 1 else f"{int(score_value)}%" + ) + return f"{score_text} {area}" + + +def draw_snapshot_overlay_boxes( + frame: np.ndarray, + overlay_boxes: list[dict[str, Any]] | None, + default_label: str, + default_color: tuple[int, int, int], +) -> None: + for overlay_box in overlay_boxes or []: + box = overlay_box.get("box") + if box is None: + continue + + box_color = overlay_box.get("color", default_color) + color = ( + tuple(box_color) if isinstance(box_color, (list, tuple)) else default_color + ) + draw_box_with_label( + frame, + box[0], + box[1], + box[2], + box[3], + overlay_box.get("label", default_label), + _get_snapshot_overlay_box_label(overlay_box.get("score"), box), + thickness=2, + color=color, + ) + + +def get_snapshot_bytes( + frame: np.ndarray, + frame_time: float, + ext: str, + *, + timestamp: bool = False, + bounding_box: bool = False, + crop: bool = False, + height: int | None = None, + quality: int | None = None, + label: str, + box: tuple[int, int, int, int] | None, + score: float | None, + area: int | None, + attributes: list[dict[str, Any]] | None, + color: tuple[int, int, int], + overlay_boxes: list[dict[str, Any]] | None = None, + timestamp_style: Any | None = None, + estimated_speed: float = 0, +) -> tuple[bytes | None, float]: + best_frame = frame.copy() + crop_box = box + + if crop_box is None and overlay_boxes and len(overlay_boxes) == 1: + crop_box = overlay_boxes[0].get("box") + + if bounding_box and box: + draw_snapshot_bounding_boxes( + best_frame, + label, + box, + score, + area, + attributes, + color, + estimated_speed, + ) + + if bounding_box and overlay_boxes: + draw_snapshot_overlay_boxes(best_frame, overlay_boxes, label, color) + + if crop and crop_box: + region = calculate_region( + best_frame.shape, + crop_box[0], + crop_box[1], + crop_box[2], + crop_box[3], + 300, + multiplier=1.1, + ) + best_frame = best_frame[region[1] : region[3], region[0] : region[2]] + + if height: + width = int(height * best_frame.shape[1] / best_frame.shape[0]) + best_frame = cv2.resize( + best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA + ) + + if timestamp and timestamp_style is not None: + colors = timestamp_style.color + draw_timestamp( + best_frame, + frame_time, + timestamp_style.format, + font_effect=timestamp_style.effect, + font_thickness=timestamp_style.thickness, + font_color=(colors.blue, colors.green, colors.red), + position=timestamp_style.position, + ) + + ret, img = cv2.imencode( + f".{ext}", best_frame, get_image_quality_params(ext, quality) + ) + + if ret: + return img.tobytes(), frame_time + + return None, frame_time + + def grab_cv2_contours(cnts): # if the length the contours tuple returned by cv2.findContours # is '2' then we are using either OpenCV v2.4, v4-beta, or @@ -698,7 +913,7 @@ def yuv_region_2_bgr(frame, region): raise -def intersection(box_a, box_b) -> Optional[list[int]]: +def intersection(box_a, box_b) -> list[int] | None: """Return intersection box or None if boxes do not intersect.""" if ( box_a[2] < box_b[0] @@ -771,7 +986,7 @@ class FrameManager(ABC): pass @abstractmethod - def write(self, name: str) -> Optional[memoryview]: + def write(self, name: str) -> memoryview | None: pass @abstractmethod @@ -798,7 +1013,7 @@ class UntrackedSharedMemory(_mpshm.SharedMemory): def __init__( self, - name: Optional[str] = None, + name: str | None = None, create: bool = False, size: int = 0, *, @@ -852,7 +1067,7 @@ class SharedMemoryFrameManager(FrameManager): self.shm_store[name] = shm return shm.buf - def write(self, name: str) -> Optional[memoryview]: + def write(self, name: str) -> memoryview | None: try: if name in self.shm_store: shm = self.shm_store[name] @@ -864,12 +1079,27 @@ class SharedMemoryFrameManager(FrameManager): logger.info(f"the file {name} not found") return None - def get(self, name: str, shape) -> Optional[np.ndarray]: + def get(self, name: str, shape) -> np.ndarray | None: 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: @@ -947,10 +1177,10 @@ def run_ffmpeg_snapshot( ffmpeg, input_path: str, codec: str, - seek_time: Optional[float] = None, - height: Optional[int] = None, - timeout: Optional[int] = None, -) -> tuple[Optional[bytes], str]: + seek_time: float | None = None, + height: int | None = None, + timeout: int | None = None, +) -> tuple[bytes | None, str]: """Run ffmpeg to extract a snapshot/image from a video source.""" ffmpeg_cmd = [ ffmpeg.ffmpeg_path, @@ -1000,8 +1230,8 @@ def get_image_from_recording( file_path: str, relative_frame_time: float, codec: str, - height: Optional[int] = None, -) -> Optional[Any]: + height: int | None = None, +) -> Any | None: """retrieve a frame from given time in recording file.""" image_data, _ = run_ffmpeg_snapshot( @@ -1023,7 +1253,7 @@ def get_histogram(image, x_min, y_min, x_max, y_max): def create_thumbnail( yuv_frame: np.ndarray, box: tuple[int, int, int, int], height=500 -) -> Optional[bytes]: +) -> bytes | None: """Return jpg thumbnail of a region of the frame.""" frame = cv2.cvtColor(yuv_frame, cv2.COLOR_YUV2BGR_I420) region = calculate_region( diff --git a/frigate/util/media.py b/frigate/util/media.py index 406d51cf3c..e4e84e9149 100644 --- a/frigate/util/media.py +++ b/frigate/util/media.py @@ -1,8 +1,37 @@ -"""Utilities for media file inspection.""" +"""Recordings Utilities.""" +import datetime +import errno +import logging +import os import subprocess as sp +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path -from frigate.const import DEFAULT_FFMPEG_VERSION +from peewee import DatabaseError, chunked + +from frigate.const import ( + CLIPS_DIR, + DEFAULT_FFMPEG_VERSION, + EXPORT_DIR, + RECORD_DIR, + THUMB_DIR, +) +from frigate.models import ( + Event, + Export, + Previews, + Recordings, + RecordingsToDelete, + ReviewSegment, +) + +logger = logging.getLogger(__name__) + + +# Safety threshold - abort if more than 50% of files would be deleted +SAFETY_THRESHOLD = 0.5 FFPROBE_PATH = ( f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe" @@ -11,6 +40,845 @@ FFPROBE_PATH = ( ) +@dataclass +class SyncResult: + """Result of a sync operation.""" + + media_type: str + files_checked: int = 0 + orphans_found: int = 0 + orphans_deleted: int = 0 + orphan_paths: list[str] = field(default_factory=list) + orphan_db_paths: list[str] = field(default_factory=list) + aborted: bool = False + error: str | None = None + + def to_dict(self) -> dict: + return { + "media_type": self.media_type, + "files_checked": self.files_checked, + "orphans_found": self.orphans_found, + "orphans_deleted": self.orphans_deleted, + "aborted": self.aborted, + "error": self.error, + } + + +def remove_empty_directories(root: Path, paths: Iterable[Path]) -> None: + """ + Remove directories if they exist and are empty. + Silently ignores non-existent and non-empty directories. + Attempts to remove parent directories as well, stopping at the given root. + """ + count = 0 + while True: + parents = set() + for path in paths: + if path == root: + continue + + try: + path.rmdir() + count += 1 + except FileNotFoundError: + pass + except OSError as e: + if e.errno == errno.ENOTEMPTY: + continue + raise + + parents.add(path.parent) + + if not parents: + break + + paths = parents + + logger.debug(f"Removed {count} empty directories") + + +def sync_recordings( + limited: bool = False, dry_run: bool = False, force: bool = False +) -> SyncResult: + """Sync recordings between the database and disk using the SyncResult format.""" + + result = SyncResult(media_type="recordings") + + try: + logger.debug("Start sync recordings.") + + # start checking on the hour 36 hours ago + check_point = datetime.datetime.now().replace( + minute=0, second=0, microsecond=0 + ).astimezone(datetime.UTC) - datetime.timedelta(hours=36) + + # Gather DB recordings to inspect + if limited: + recordings_query = Recordings.select(Recordings.id, Recordings.path).where( + Recordings.start_time >= check_point.timestamp() + ) + else: + recordings_query = Recordings.select(Recordings.id, Recordings.path) + + recordings_count = recordings_query.count() + page_size = 1000 + num_pages = (recordings_count + page_size - 1) // page_size + recordings_to_delete: list[dict] = [] + + for page in range(num_pages): + for recording in recordings_query.paginate(page, page_size): + if not os.path.exists(recording.path): + recordings_to_delete.append( + {"id": recording.id, "path": recording.path} + ) + + result.orphans_found += len(recordings_to_delete) + result.orphan_db_paths.extend( + [ + recording["path"] + for recording in recordings_to_delete + if recording.get("path") + ] + ) + + if ( + recordings_count + and len(recordings_to_delete) / recordings_count > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Deleting {(len(recordings_to_delete) / max(1, recordings_count) * 100):.2f}% of recordings DB entries (force=True, bypassing safety threshold)" + ) + else: + logger.warning( + f"Deleting {(len(recordings_to_delete) / max(1, recordings_count) * 100):.2f}% of recordings DB entries, could be due to configuration error. Aborting..." + ) + result.aborted = True + return result + + if recordings_to_delete and not dry_run: + logger.info( + f"Deleting {len(recordings_to_delete)} recording DB entries with missing files" + ) + + RecordingsToDelete.create_table(temporary=True) + + max_inserts = 1000 + for batch in chunked(recordings_to_delete, max_inserts): + RecordingsToDelete.insert_many( + [{"id": r["id"]} for r in batch] + ).execute() + + try: + deleted = ( + Recordings.delete() + .where( + Recordings.id.in_( + RecordingsToDelete.select(RecordingsToDelete.id) + ) + ) + .execute() + ) + result.orphans_deleted += int(deleted) + except DatabaseError as e: + logger.error(f"Database error during recordings db cleanup: {e}") + result.error = str(e) + result.aborted = True + return result + + if result.aborted: + logger.warning("Recording DB sync aborted; skipping file cleanup.") + return result + + # Only try to cleanup files if db cleanup was successful or dry_run + if limited: + # get recording files from last 36 hours + hour_check = f"{RECORD_DIR}/{check_point.strftime('%Y-%m-%d/%H')}" + files_on_disk = { + os.path.join(root, file) + for root, _, files in os.walk(RECORD_DIR) + for file in files + if root > hour_check + } + else: + # get all recordings files on disk and put them in a set + files_on_disk = { + os.path.join(root, file) + for root, _, files in os.walk(RECORD_DIR) + for file in files + } + + result.files_checked = len(files_on_disk) + + files_to_delete: list[str] = [] + for file in files_on_disk: + if not Recordings.select().where(Recordings.path == file).exists(): + files_to_delete.append(file) + + result.orphans_found += len(files_to_delete) + result.orphan_paths.extend(files_to_delete) + + if ( + files_on_disk + and len(files_to_delete) / len(files_on_disk) > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Deleting {(len(files_to_delete) / max(1, len(files_on_disk)) * 100):.2f}% of recordings files (force=True, bypassing safety threshold)" + ) + else: + logger.warning( + f"Deleting {(len(files_to_delete) / max(1, len(files_on_disk)) * 100):.2f}% of recordings files, could be due to configuration error. Aborting..." + ) + result.aborted = True + return result + + if dry_run: + logger.info( + f"Recordings sync (dry run): Found {len(files_to_delete)} orphaned files" + ) + return result + + # Delete orphans + logger.info(f"Deleting {len(files_to_delete)} orphaned recordings files") + for file in files_to_delete: + try: + os.unlink(file) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file}: {e}") + + logger.debug("End sync recordings.") + + except Exception as e: + logger.error(f"Error syncing recordings: {e}") + result.error = str(e) + + return result + + +def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResult: + """Sync event snapshots - delete files not referenced by any event. + + Event snapshots are stored at: CLIPS_DIR/{camera}-{event_id}-clean.webp + Also checks legacy variants: {camera}-{event_id}.jpg and -clean.png + """ + result = SyncResult(media_type="event_snapshots") + + try: + # Get all event IDs with snapshots from DB + events_with_snapshots = set( + f"{e.camera}-{e.id}" + for e in Event.select(Event.id, Event.camera).where( + Event.has_snapshot == True + ) + ) + + # Find snapshot files on disk (directly in CLIPS_DIR, not subdirectories) + snapshot_files: list[tuple[str, str]] = [] # (full_path, base_name) + if os.path.isdir(CLIPS_DIR): + for file in os.listdir(CLIPS_DIR): + file_path = os.path.join(CLIPS_DIR, file) + if os.path.isfile(file_path) and file.endswith( + (".jpg", "-clean.webp", "-clean.png") + ): + # Extract base name (camera-event_id) from filename + base_name = file + for suffix in ["-clean.webp", "-clean.png", ".jpg"]: + if file.endswith(suffix): + base_name = file[: -len(suffix)] + break + snapshot_files.append((file_path, base_name)) + + result.files_checked = len(snapshot_files) + + # Find orphans + orphans: list[str] = [] + for file_path, base_name in snapshot_files: + if base_name not in events_with_snapshots: + orphans.append(file_path) + + result.orphans_found = len(orphans) + result.orphan_paths = orphans + + if len(orphans) == 0: + return result + + # Safety check + if ( + result.files_checked > 0 + and len(orphans) / result.files_checked > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Event snapshots sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files (force=True, bypassing safety threshold)." + ) + else: + logger.warning( + f"Event snapshots sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files. " + "Aborting due to safety threshold." + ) + result.aborted = True + return result + + if dry_run: + logger.info( + f"Event snapshots sync (dry run): Found {len(orphans)} orphaned files" + ) + return result + + # Delete orphans + logger.info(f"Deleting {len(orphans)} orphaned event snapshot files") + for file_path in orphans: + try: + os.unlink(file_path) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file_path}: {e}") + + except Exception as e: + logger.error(f"Error syncing event snapshots: {e}") + result.error = str(e) + + return result + + +def sync_event_thumbnails(dry_run: bool = False, force: bool = False) -> SyncResult: + """Sync event thumbnails - delete files not referenced by any event. + + Event thumbnails are stored at: THUMB_DIR/{camera}/{event_id}.webp + Only events without inline thumbnail (thumbnail field is None/empty) use files. + """ + result = SyncResult(media_type="event_thumbnails") + + try: + # Get all events that use file-based thumbnails + # Events with thumbnail field populated don't need files + events_with_file_thumbs = set( + (e.camera, e.id) + for e in Event.select(Event.id, Event.camera, Event.thumbnail).where( + (Event.thumbnail.is_null(True)) | (Event.thumbnail == "") + ) + ) + + # Find thumbnail files on disk + thumbnail_files: list[ + tuple[str, str, str] + ] = [] # (full_path, camera, event_id) + if os.path.isdir(THUMB_DIR): + for camera_dir in os.listdir(THUMB_DIR): + camera_path = os.path.join(THUMB_DIR, camera_dir) + if not os.path.isdir(camera_path): + continue + for file in os.listdir(camera_path): + if file.endswith(".webp"): + event_id = file[:-5] # Remove .webp + file_path = os.path.join(camera_path, file) + thumbnail_files.append((file_path, camera_dir, event_id)) + + result.files_checked = len(thumbnail_files) + + # Find orphans - files where event doesn't exist or event has inline thumbnail + orphans: list[str] = [] + for file_path, camera, event_id in thumbnail_files: + if (camera, event_id) not in events_with_file_thumbs: + # Check if event exists with inline thumbnail + event_exists = Event.select().where(Event.id == event_id).exists() + if not event_exists: + orphans.append(file_path) + # If event exists with inline thumbnail, the file is also orphaned + elif event_exists: + event = Event.get_or_none(Event.id == event_id) + if event and event.thumbnail: + orphans.append(file_path) + + result.orphans_found = len(orphans) + result.orphan_paths = orphans + + if len(orphans) == 0: + return result + + # Safety check + if ( + result.files_checked > 0 + and len(orphans) / result.files_checked > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Event thumbnails sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files (force=True, bypassing safety threshold)." + ) + else: + logger.warning( + f"Event thumbnails sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files. " + "Aborting due to safety threshold." + ) + result.aborted = True + return result + + if dry_run: + logger.info( + f"Event thumbnails sync (dry run): Found {len(orphans)} orphaned files" + ) + return result + + # Delete orphans + logger.info(f"Deleting {len(orphans)} orphaned event thumbnail files") + for file_path in orphans: + try: + os.unlink(file_path) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file_path}: {e}") + + except Exception as e: + logger.error(f"Error syncing event thumbnails: {e}") + result.error = str(e) + + return result + + +def sync_review_thumbnails(dry_run: bool = False, force: bool = False) -> SyncResult: + """Sync review segment thumbnails - delete files not referenced by any review segment. + + Review thumbnails are stored at: CLIPS_DIR/review/thumb-{camera}-{review_id}.webp + The full path is stored in ReviewSegment.thumb_path + """ + result = SyncResult(media_type="review_thumbnails") + + try: + # Get all thumb paths from DB + review_thumb_paths = set( + r.thumb_path + for r in ReviewSegment.select(ReviewSegment.thumb_path) + if r.thumb_path + ) + + # Find review thumbnail files on disk + review_dir = os.path.join(CLIPS_DIR, "review") + thumbnail_files: list[str] = [] + if os.path.isdir(review_dir): + for file in os.listdir(review_dir): + if file.startswith("thumb-") and file.endswith(".webp"): + file_path = os.path.join(review_dir, file) + thumbnail_files.append(file_path) + + result.files_checked = len(thumbnail_files) + + # Find orphans + orphans: list[str] = [] + for file_path in thumbnail_files: + if file_path not in review_thumb_paths: + orphans.append(file_path) + + result.orphans_found = len(orphans) + result.orphan_paths = orphans + + if len(orphans) == 0: + return result + + # Safety check + if ( + result.files_checked > 0 + and len(orphans) / result.files_checked > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Review thumbnails sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files (force=True, bypassing safety threshold)." + ) + else: + logger.warning( + f"Review thumbnails sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files. " + "Aborting due to safety threshold." + ) + result.aborted = True + return result + + if dry_run: + logger.info( + f"Review thumbnails sync (dry run): Found {len(orphans)} orphaned files" + ) + return result + + # Delete orphans + logger.info(f"Deleting {len(orphans)} orphaned review thumbnail files") + for file_path in orphans: + try: + os.unlink(file_path) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file_path}: {e}") + + except Exception as e: + logger.error(f"Error syncing review thumbnails: {e}") + result.error = str(e) + + return result + + +def sync_previews(dry_run: bool = False, force: bool = False) -> SyncResult: + """Sync preview files - delete files not referenced by any preview record. + + Previews are stored at: CLIPS_DIR/previews/{camera}/*.mp4 + The full path is stored in Previews.path + """ + result = SyncResult(media_type="previews") + + try: + # Get all preview paths from DB + preview_paths = set(p.path for p in Previews.select(Previews.path) if p.path) + + # Find preview files on disk + previews_dir = os.path.join(CLIPS_DIR, "previews") + preview_files: list[str] = [] + if os.path.isdir(previews_dir): + for camera_dir in os.listdir(previews_dir): + camera_path = os.path.join(previews_dir, camera_dir) + if not os.path.isdir(camera_path): + continue + for file in os.listdir(camera_path): + if file.endswith(".mp4"): + file_path = os.path.join(camera_path, file) + preview_files.append(file_path) + + result.files_checked = len(preview_files) + + # Find orphans + orphans: list[str] = [] + for file_path in preview_files: + if file_path not in preview_paths: + orphans.append(file_path) + + result.orphans_found = len(orphans) + result.orphan_paths = orphans + + if len(orphans) == 0: + return result + + # Safety check + if ( + result.files_checked > 0 + and len(orphans) / result.files_checked > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Previews sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files (force=True, bypassing safety threshold)." + ) + else: + logger.warning( + f"Previews sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files. " + "Aborting due to safety threshold." + ) + result.aborted = True + return result + + if dry_run: + logger.info(f"Previews sync (dry run): Found {len(orphans)} orphaned files") + return result + + # Delete orphans + logger.info(f"Deleting {len(orphans)} orphaned preview files") + for file_path in orphans: + try: + os.unlink(file_path) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file_path}: {e}") + + except Exception as e: + logger.error(f"Error syncing previews: {e}") + result.error = str(e) + + return result + + +def sync_exports(dry_run: bool = False, force: bool = False) -> SyncResult: + """Sync export files - delete files not referenced by any export record. + + Export videos are stored at: EXPORT_DIR/*.mp4 + Export thumbnails are stored at: CLIPS_DIR/export/*.jpg + The paths are stored in Export.video_path and Export.thumb_path + """ + result = SyncResult(media_type="exports") + + try: + # Get all export paths from DB + export_video_paths = set() + export_thumb_paths = set() + for e in Export.select(Export.video_path, Export.thumb_path): + if e.video_path: + export_video_paths.add(e.video_path) + if e.thumb_path: + export_thumb_paths.add(e.thumb_path) + + # Find export video files on disk + export_files: list[str] = [] + if os.path.isdir(EXPORT_DIR): + for file in os.listdir(EXPORT_DIR): + if file.endswith(".mp4"): + file_path = os.path.join(EXPORT_DIR, file) + export_files.append(file_path) + + # Find export thumbnail files on disk + export_thumb_dir = os.path.join(CLIPS_DIR, "export") + thumb_files: list[str] = [] + if os.path.isdir(export_thumb_dir): + for file in os.listdir(export_thumb_dir): + if file.endswith(".jpg"): + file_path = os.path.join(export_thumb_dir, file) + thumb_files.append(file_path) + + result.files_checked = len(export_files) + len(thumb_files) + + # Find orphans + orphans: list[str] = [] + for file_path in export_files: + if file_path not in export_video_paths: + orphans.append(file_path) + for file_path in thumb_files: + if file_path not in export_thumb_paths: + orphans.append(file_path) + + result.orphans_found = len(orphans) + result.orphan_paths = orphans + + if len(orphans) == 0: + return result + + # Safety check + if ( + result.files_checked > 0 + and len(orphans) / result.files_checked > SAFETY_THRESHOLD + ): + if force: + logger.warning( + f"Exports sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files (force=True, bypassing safety threshold)." + ) + else: + logger.warning( + f"Exports sync: Would delete {len(orphans)}/{result.files_checked} " + f"({len(orphans) / result.files_checked * 100:.2f}%) files. " + "Aborting due to safety threshold." + ) + result.aborted = True + return result + + if dry_run: + logger.info(f"Exports sync (dry run): Found {len(orphans)} orphaned files") + return result + + # Delete orphans + logger.info(f"Deleting {len(orphans)} orphaned export files") + for file_path in orphans: + try: + os.unlink(file_path) + result.orphans_deleted += 1 + except OSError as e: + logger.error(f"Failed to delete {file_path}: {e}") + + except Exception as e: + logger.error(f"Error syncing exports: {e}") + result.error = str(e) + + return result + + +@dataclass +class MediaSyncResults: + """Combined results from all media sync operations.""" + + event_snapshots: SyncResult | None = None + event_thumbnails: SyncResult | None = None + review_thumbnails: SyncResult | None = None + previews: SyncResult | None = None + exports: SyncResult | None = None + recordings: SyncResult | None = None + + @property + def total_files_checked(self) -> int: + total = 0 + for result in [ + self.event_snapshots, + self.event_thumbnails, + self.review_thumbnails, + self.previews, + self.exports, + self.recordings, + ]: + if result: + total += result.files_checked + return total + + @property + def total_orphans_found(self) -> int: + total = 0 + for result in [ + self.event_snapshots, + self.event_thumbnails, + self.review_thumbnails, + self.previews, + self.exports, + self.recordings, + ]: + if result: + total += result.orphans_found + return total + + @property + def total_orphans_deleted(self) -> int: + total = 0 + for result in [ + self.event_snapshots, + self.event_thumbnails, + self.review_thumbnails, + self.previews, + self.exports, + self.recordings, + ]: + if result: + total += result.orphans_deleted + return total + + def to_dict(self) -> dict: + """Convert results to dictionary for API response.""" + results = {} + for name, result in [ + ("event_snapshots", self.event_snapshots), + ("event_thumbnails", self.event_thumbnails), + ("review_thumbnails", self.review_thumbnails), + ("previews", self.previews), + ("exports", self.exports), + ("recordings", self.recordings), + ]: + if result: + results[name] = { + "files_checked": result.files_checked, + "orphans_found": result.orphans_found, + "orphans_deleted": result.orphans_deleted, + "aborted": result.aborted, + "error": result.error, + } + results["totals"] = { + "files_checked": self.total_files_checked, + "orphans_found": self.total_orphans_found, + "orphans_deleted": self.total_orphans_deleted, + } + return results + + +def write_orphan_report( + results: "MediaSyncResults", + path: str, + job_id: str = "", + dry_run: bool = False, +) -> None: + """Write a verbose orphan report file listing all orphan paths by media type. + + Args: + results: The completed MediaSyncResults. + path: File path to write the report to. + job_id: Job ID for the report header. + dry_run: Whether the sync was a dry run, for the report header. + """ + try: + with open(path, "w") as f: + f.write("# Media Sync Orphan Report\n") + f.write(f"# Job: {job_id}\n") + f.write( + f"# Date: {datetime.datetime.now().astimezone(datetime.UTC).isoformat()}\n" + ) + f.write(f"# Mode: dry_run={dry_run}\n\n") + + for name, result in [ + ("recordings", results.recordings), + ("event_snapshots", results.event_snapshots), + ("event_thumbnails", results.event_thumbnails), + ("review_thumbnails", results.review_thumbnails), + ("previews", results.previews), + ("exports", results.exports), + ]: + if result is None: + continue + + if result.orphan_db_paths: + f.write( + f"## {name} - orphaned db entries ({len(result.orphan_db_paths)})\n" + ) + for orphan_path in result.orphan_db_paths: + f.write(f"{orphan_path}\n") + f.write("\n") + + if result.orphan_paths: + f.write( + f"## {name} - orphaned files ({len(result.orphan_paths)})\n" + ) + for orphan_path in result.orphan_paths: + f.write(f"{orphan_path}\n") + f.write("\n") + + logger.debug("Wrote verbose orphan report to %s", path) + except OSError as e: + logger.error("Failed to write orphan report to %s: %s", path, e) + + +def sync_all_media( + dry_run: bool = False, media_types: list[str] = ["all"], force: bool = False +) -> MediaSyncResults: + """Sync specified media types with the database. + + Args: + dry_run: If True, only report orphans without deleting them. + media_types: List of media types to sync. Can include: 'all', 'event_snapshots', + 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', 'recordings' + force: If True, bypass safety threshold checks. + + Returns: + MediaSyncResults with details of each sync operation. + """ + logger.debug( + f"Starting media sync (dry_run={dry_run}, media_types={media_types}, force={force})" + ) + + results = MediaSyncResults() + + # Determine which media types to sync + sync_all = "all" in media_types + + if sync_all or "event_snapshots" in media_types: + results.event_snapshots = sync_event_snapshots(dry_run=dry_run, force=force) + + if sync_all or "event_thumbnails" in media_types: + results.event_thumbnails = sync_event_thumbnails(dry_run=dry_run, force=force) + + if sync_all or "review_thumbnails" in media_types: + results.review_thumbnails = sync_review_thumbnails(dry_run=dry_run, force=force) + + if sync_all or "previews" in media_types: + results.previews = sync_previews(dry_run=dry_run, force=force) + + if sync_all or "exports" in media_types: + results.exports = sync_exports(dry_run=dry_run, force=force) + + if sync_all or "recordings" in media_types: + results.recordings = sync_recordings(dry_run=dry_run, force=force) + + logger.info( + f"Media sync complete: checked {results.total_files_checked} files, " + f"found {results.total_orphans_found} orphans, " + f"deleted {results.total_orphans_deleted}" + ) + + return results + + def get_keyframe_before(path: str, offset_ms: int) -> int | None: """Get the timestamp (ms) of the last keyframe at or before offset_ms. diff --git a/frigate/util/model.py b/frigate/util/model.py index 338303e2d7..80f0d29a04 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -16,6 +16,31 @@ logger = logging.getLogger(__name__) ### Post Processing +def xyxy_to_xywh_for_nms(boxes: np.ndarray | list) -> np.ndarray: + """Convert [x1, y1, x2, y2] boxes to the [x, y, width, height] format + that cv2.dnn.NMSBoxes expects. + + Passing corner coordinates directly makes OpenCV treat x2/y2 as the box + size, inflating every box toward the bottom-right by its distance from + the origin, which suppresses valid detections near other objects. + + Args: + boxes: Array-like of shape (N, 4) in corner format. + + Returns: + Float32 array of shape (N, 4) in top-left plus size format. + """ + boxes = np.asarray(boxes, dtype=np.float32) + + if boxes.size == 0: + return np.zeros((0, 4), dtype=np.float32) + + xywh = boxes.copy() + xywh[:, 2] -= xywh[:, 0] + xywh[:, 3] -= xywh[:, 1] + return xywh + + def post_process_dfine( tensor_output: np.ndarray, width: int, height: int ) -> np.ndarray: @@ -25,7 +50,9 @@ def post_process_dfine( input_shape = np.array([height, width, height, width]) boxes = np.divide(boxes, input_shape, dtype=np.float32) - indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4) + indices = cv2.dnn.NMSBoxes( + xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4 + ) detections = np.zeros((20, 6), np.float32) for i, (bbox, confidence, class_id) in enumerate( @@ -78,7 +105,10 @@ def post_process_rfdetr(tensor_output: list[np.ndarray, np.ndarray]) -> np.ndarr # apply nms indices = cv2.dnn.NMSBoxes( - filtered_boxes, filtered_scores, score_threshold=0.4, nms_threshold=0.4 + xyxy_to_xywh_for_nms(filtered_boxes), + filtered_scores, + score_threshold=0.4, + nms_threshold=0.4, ) detections = np.zeros((20, 6), np.float32) @@ -159,7 +189,7 @@ def __post_process_multipart_yolo( all_class_ids.append(class_id) indices = cv2.dnn.NMSBoxes( - bboxes=all_boxes, + bboxes=xyxy_to_xywh_for_nms(all_boxes), scores=all_scores, score_threshold=0.4, nms_threshold=0.4, @@ -206,7 +236,9 @@ def __post_process_nms_yolo(predictions: np.ndarray, width, height) -> np.ndarra boxes = boxes_xyxy # run NMS - indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4) + indices = cv2.dnn.NMSBoxes( + xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4 + ) detections = np.zeros((20, 6), np.float32) for i, (bbox, confidence, class_id) in enumerate( zip(boxes[indices], scores[indices], class_ids[indices]) @@ -258,7 +290,7 @@ def post_process_yolox( scores = scores[np.arange(len(cls_inds)), cls_inds] indices = cv2.dnn.NMSBoxes( - boxes_xyxy, scores, score_threshold=0.4, nms_threshold=0.4 + xyxy_to_xywh_for_nms(boxes_xyxy), scores, score_threshold=0.4, nms_threshold=0.4 ) detections = np.zeros((20, 6), np.float32) @@ -326,7 +358,7 @@ def get_ort_providers( { "device_id": device_id, "trt_fp16_enable": requires_fp16 - and os.environ.get("USE_FP_16", "True") != "False", + and os.environ.get("USE_FP16", "True") != "False", "trt_timing_cache_enable": True, "trt_engine_cache_enable": True, "trt_timing_cache_path": os.path.join( diff --git a/frigate/util/object.py b/frigate/util/object.py index b8f41e2c32..ecb71003c8 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -35,6 +35,11 @@ logger = logging.getLogger(__name__) GRID_SIZE = 8 +def create_empty_regions_grid() -> list[list[dict[str, Any]]]: + """Create a region grid with no learned sizes.""" + return [[{"sizes": []} for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)] + + def get_camera_regions_grid( name: str, detect: DetectConfig, @@ -47,12 +52,7 @@ def get_camera_regions_grid( grid = regions.grid last_update = regions.last_update except DoesNotExist: - grid = [] - for x in range(GRID_SIZE): - row = [] - for y in range(GRID_SIZE): - row.append({"sizes": []}) - grid.append(row) + grid = create_empty_regions_grid() last_update = 0 # get events for timeline entries @@ -62,11 +62,12 @@ def get_camera_regions_grid( .where((Event.false_positive == None) | (Event.false_positive == False)) .where(Event.start_time > last_update) ) - valid_event_ids = [e["id"] for e in events.dicts()] - logger.debug(f"Found {len(valid_event_ids)} new events for {name}") + + event_count = events.count() + logger.debug(f"Found {event_count} new events for {name}") # no new events, return as is - if not valid_event_ids: + if event_count == 0: return grid new_update = datetime.datetime.now().timestamp() @@ -78,7 +79,7 @@ def get_camera_regions_grid( Timeline.data, ] ) - .where(Timeline.source_id << valid_event_ids) + .where(Timeline.source_id << events) .limit(10000) .dicts() ) @@ -248,20 +249,20 @@ def is_object_filtered(obj, objects_to_track, object_filters): if obj_settings.max_ratio < object_ratio: return True - if obj_settings.mask is not None: + if obj_settings.rasterized_mask is not None: # compute the coordinates of the object and make sure # the location isn't outside the bounds of the image (can happen from rounding) object_xmin = object_box[0] object_xmax = object_box[2] object_ymax = object_box[3] - y_location = min(int(object_ymax), len(obj_settings.mask) - 1) + y_location = min(int(object_ymax), len(obj_settings.rasterized_mask) - 1) x_location = min( int((object_xmax + object_xmin) / 2.0), - len(obj_settings.mask[0]) - 1, + len(obj_settings.rasterized_mask[0]) - 1, ) # if the object is in a masked location, don't add it to detected objects - if obj_settings.mask[y_location][x_location] == 0: + if obj_settings.rasterized_mask[y_location][x_location] == 0: return True return False @@ -338,18 +339,13 @@ def reduce_boxes(boxes, iou_threshold=0.0): def average_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]: """Return a box that is the average of a list of boxes.""" - x_mins = [] - y_mins = [] - x_max = [] - y_max = [] - - for box in boxes: - x_mins.append(box[0]) - y_mins.append(box[1]) - x_max.append(box[2]) - y_max.append(box[3]) - - return [np.mean(x_mins), np.mean(y_mins), np.mean(x_max), np.mean(y_max)] + n = len(boxes) + return [ + sum(box[0] for box in boxes) / n, + sum(box[1] for box in boxes) / n, + sum(box[2] for box in boxes) / n, + sum(box[3] for box in boxes) / n, + ] def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]: @@ -400,13 +396,13 @@ def get_cluster_candidates(frame_shape, min_region, boxes): # determined by the max_region size minus half the box + 20% # TODO: see if we can do this with numpy cluster_candidates = [] - used_boxes = [] + used_boxes = set() # loop over each box for current_index, b in enumerate(boxes): if current_index in used_boxes: continue cluster = [current_index] - used_boxes.append(current_index) + used_boxes.add(current_index) cluster_boundary = get_cluster_boundary(b, min_region) # find all other boxes that fit inside the boundary for compare_index, compare_box in enumerate(boxes): @@ -435,7 +431,7 @@ def get_cluster_candidates(frame_shape, min_region, boxes): if should_cluster: cluster.append(compare_index) - used_boxes.append(compare_index) + used_boxes.add(compare_index) cluster_candidates.append(cluster) # return the unique clusters only @@ -557,6 +553,7 @@ def reduce_detections( current_detection = sorted_by_area[current_detection_idx] current_label = current_detection[0] current_box = current_detection[2] + current_area = area(current_box) overlap = 0 for to_check_idx in range( min(current_detection_idx + 1, len(sorted_by_area)), @@ -567,14 +564,14 @@ def reduce_detections( # if area of current detection / area of check < 5% they should not be compared # this covers cases where a large car parked in a driveway doesn't block detections # of cars in the street behind it - if area(current_box) / area(to_check) < 0.05: + if current_area / area(to_check) < 0.05: continue intersect_box = intersection(current_box, to_check) # if % of smaller detection is inside of another detection, consolidate - if intersect_box is not None and area(intersect_box) / area( - current_box - ) > LABEL_CONSOLIDATION_MAP.get( + if intersect_box is not None and area( + intersect_box + ) / current_area > LABEL_CONSOLIDATION_MAP.get( current_label, LABEL_CONSOLIDATION_DEFAULT ): overlap = 1 diff --git a/frigate/util/path.py b/frigate/util/path.py new file mode 100644 index 0000000000..c5e1c388cf --- /dev/null +++ b/frigate/util/path.py @@ -0,0 +1,134 @@ +"""Helpers for building filesystem paths out of user supplied values.""" + +import os + +from pathvalidate import ValidationError, sanitize_filename, sanitize_filepath + +from frigate.const import TRIGGER_DIR + +# Components that name a directory relative to its parent instead of a child. +# pathvalidate strips separators and reserved characters but leaves these +# intact, and it collapses values like "..:" down to "..", so they have to be +# rejected after sanitizing rather than before. +RELATIVE_COMPONENTS = {"", ".", ".."} + + +def sanitize_path_component(value: str | None) -> str | None: + """Reduce a user supplied value to a single path component. + + Args: + value: The untrusted value, such as a path parameter or body field + + Returns: + A component that is safe to join onto a base directory, or None when + nothing usable remains so the caller can reject the request. + """ + if not value: + return None + + try: + component = sanitize_filename(value) + except (ValidationError, ValueError): + return None + + if component.strip() in RELATIVE_COMPONENTS: + return None + + if os.sep in component or (os.altsep and os.altsep in component): + return None + + return component + + +def is_contained_in(path: str, base: str) -> bool: + """Check that a path sits inside a base directory. + + Compares whole path components, so a sibling directory that merely shares a + name prefix with base is not treated as contained. + """ + resolved = os.path.normpath(path) + root = os.path.normpath(base) + + try: + # commonpath compares components, and unlike a prefix test it stays + # correct for a base that already ends in a separator such as "/". + return os.path.commonpath([resolved, root]) == root + except ValueError: + # Raised when the paths cannot be compared, such as one relative and + # one absolute, or two different Windows drives. + return False + + +def safe_join(base: str, *parts: str | None) -> str | None: + """Join user supplied parts beneath a trusted base directory. + + Args: + base: Trusted base directory the result must stay inside of + parts: Untrusted values, each becoming one path component + + Returns: + The joined path, or None if any part is unusable or the result would + land outside base. + """ + components: list[str] = [] + + for part in parts: + component = sanitize_path_component(part) + + if component is None: + return None + + components.append(component) + + resolved = os.path.normpath(os.path.join(base, *components)) + + # normpath rather than realpath so symlinked media roots keep working; the + # per component checks above are what actually prevent traversal. + if not is_contained_in(resolved, base): + return None + + return resolved + + +def sanitize_contained_path(path: str | None, base: str) -> str | None: + """Validate a whole user supplied path that must already sit under base. + + Unlike safe_join this keeps the directory structure the caller sent, so it + suits values that name an existing file rather than one component. + + Args: + path: The untrusted path + base: Directory the path has to stay inside of + + Returns: + The sanitized path, or None if it is unusable or escapes base. + """ + if not path: + return None + + # sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path + # like "clips\..\..\etc/passwd" would pass the containment check yet still + # escape once resolved. A valid path here never uses "..". + if ".." in path: + return None + + sanitized = sanitize_filepath(path) + + if not is_contained_in(sanitized, base): + return None + + return sanitized + + +def get_trigger_thumbnail_path(camera_name: str, data: str) -> str | None: + """Path of the thumbnail stored for a semantic search trigger. + + Args: + camera_name: Camera the trigger belongs to + data: The trigger's data value, which is free-form text supplied by the + client and persisted verbatim + + Returns: + The thumbnail path, or None if it cannot be built safely. + """ + return safe_join(TRIGGER_DIR, camera_name, f"{data}.webp") diff --git a/frigate/util/process.py b/frigate/util/process.py index 1613c1e431..060c39c7b1 100644 --- a/frigate/util/process.py +++ b/frigate/util/process.py @@ -6,9 +6,9 @@ import os import pathlib import subprocess import threading +from collections.abc import Callable from logging.handlers import QueueHandler from multiprocessing.synchronize import Event as MpEvent -from typing import Callable, Optional from setproctitle import setproctitle @@ -23,11 +23,11 @@ class BaseProcess(mp.Process): stop_event: MpEvent, priority: int, *, - name: Optional[str] = None, - target: Optional[Callable] = None, + name: str | None = None, + target: Callable | None = None, args: tuple = (), kwargs: dict = {}, - daemon: Optional[bool] = None, + daemon: bool | None = None, ): self.priority = priority self.stop_event = stop_event @@ -121,7 +121,7 @@ class FrigateProcess(BaseProcess): f"If process crashes, manually generate with: memray flamegraph {binary_file}" ) except Exception as e: - self.logger.error(f"Failed to setup memray profiling: {e}", exc_info=True) + self.logger.exception(f"Failed to setup memray profiling: {e}") def _cleanup_memray(self, safe_name: str, binary_file: pathlib.Path) -> None: """Stop memray tracking and generate HTML report.""" @@ -156,4 +156,4 @@ class FrigateProcess(BaseProcess): except subprocess.TimeoutExpired: self.logger.error("Memray report generation timed out") except Exception as e: - self.logger.error(f"Failed to cleanup memray profiling: {e}", exc_info=True) + self.logger.exception(f"Failed to cleanup memray profiling: {e}") diff --git a/frigate/util/rknn_converter.py b/frigate/util/rknn_converter.py index f7ebbf5e65..ad387413d4 100644 --- a/frigate/util/rknn_converter.py +++ b/frigate/util/rknn_converter.py @@ -6,7 +6,6 @@ import subprocess import sys import time from pathlib import Path -from typing import Optional from frigate.const import SUPPORTED_RK_SOCS from frigate.util.file import FileLock @@ -110,6 +109,7 @@ def ensure_torch_dependencies() -> bool: "pip", "install", "--break-system-packages", + "setuptools<81", "torch", "torchvision", ], @@ -138,7 +138,7 @@ def ensure_rknn_toolkit() -> bool: return False -def get_soc_type() -> Optional[str]: +def get_soc_type() -> str | None: """Get the SoC type from device tree.""" try: with open("/proc/device-tree/compatible") as file: @@ -159,7 +159,7 @@ def convert_onnx_to_rknn( output_path: str, model_type: str, quantization: bool = False, - soc: Optional[str] = None, + soc: str | None = None, ) -> bool: """ Convert ONNX model to RKNN format. @@ -344,7 +344,7 @@ def wait_for_conversion_completion( def auto_convert_model( model_path: str, model_type: str | None = None, quantization: bool = False -) -> Optional[str]: +) -> str | None: """ Automatically convert a model to RKNN format if needed. diff --git a/frigate/util/schema.py b/frigate/util/schema.py new file mode 100644 index 0000000000..9af651ea07 --- /dev/null +++ b/frigate/util/schema.py @@ -0,0 +1,46 @@ +"""JSON schema utilities for Frigate.""" + +from typing import Any + +from pydantic import BaseModel, TypeAdapter + + +def get_config_schema(config_class: type[BaseModel]) -> dict[str, Any]: + """ + Returns the JSON schema for FrigateConfig with polymorphic detectors. + + This utility patches the FrigateConfig schema to include the full polymorphic + definitions for detectors. By default, Pydantic's schema for Dict[str, BaseDetectorConfig] + only includes the base class fields. This function replaces it with a reference + to the DetectorConfig union, which includes all available detector subclasses. + """ + # Import here to ensure all detector plugins are loaded through the detectors module + from frigate.detectors import DetectorConfig + + # Get the base schema for FrigateConfig + schema = config_class.model_json_schema() + + # Get the schema for the polymorphic DetectorConfig union + detector_adapter: TypeAdapter = TypeAdapter(DetectorConfig) + detector_schema = detector_adapter.json_schema() + + # Ensure $defs exists in FrigateConfig schema + if "$defs" not in schema: + schema["$defs"] = {} + + # Merge $defs from DetectorConfig into FrigateConfig schema + # This includes the specific schemas for each detector plugin (OvDetectorConfig, etc.) + if "$defs" in detector_schema: + schema["$defs"].update(detector_schema["$defs"]) + + # Extract the union schema (oneOf/discriminator) and add it as a definition + detector_union_schema = {k: v for k, v in detector_schema.items() if k != "$defs"} + schema["$defs"]["DetectorConfig"] = detector_union_schema + + # Update the 'detectors' property to use the polymorphic DetectorConfig definition + if "detectors" in schema.get("properties", {}): + schema["properties"]["detectors"]["additionalProperties"] = { + "$ref": "#/$defs/DetectorConfig" + } + + return schema diff --git a/frigate/util/services.py b/frigate/util/services.py index 5bf958198c..38737907ef 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -12,7 +12,7 @@ import subprocess as sp import time import traceback from datetime import datetime -from typing import Any, List, Optional, Tuple +from typing import Any import cv2 import psutil @@ -59,7 +59,7 @@ def get_cgroups_version() -> str: return "unknown" try: - with open("/proc/mounts", "r") as f: + with open("/proc/mounts") as f: mounts = f.readlines() for mount in mounts: @@ -89,7 +89,7 @@ def get_docker_memlimit_bytes() -> int: memlimit_path = "/sys/fs/cgroup/memory.max" try: - with open(memlimit_path, "r") as f: + with open(memlimit_path) as f: value = f.read().strip() if value.isnumeric(): @@ -117,13 +117,17 @@ def get_cpu_stats() -> dict[str, dict]: "mem": str(system_mem.percent), } + keywords = ["ffmpeg", "go2rtc", "frigate.", "python3"] for process in psutil.process_iter(["pid", "name", "cpu_percent", "cmdline"]): pid = str(process.info["pid"]) try: cpu_percent = process.info["cpu_percent"] - cmdline = process.info["cmdline"] + cmdline = " ".join(process.info["cmdline"]).rstrip() - with open(f"/proc/{pid}/stat", "r") as f: + if not any(keyword in cmdline for keyword in keywords): + continue + + with open(f"/proc/{pid}/stat") as f: stats = f.readline().split() utime = int(stats[13]) stime = int(stats[14]) @@ -142,7 +146,7 @@ def get_cpu_stats() -> dict[str, dict]: process_usage_sec = process_utime_sec + process_stime_sec cpu_average_usage = process_usage_sec * 100 // process_elapsed_sec - with open(f"/proc/{pid}/statm", "r") as f: + with open(f"/proc/{pid}/statm") as f: mem_stats = f.readline().split() mem_res = int(mem_stats[1]) * os.sysconf("SC_PAGE_SIZE") / 1024 @@ -155,7 +159,7 @@ def get_cpu_stats() -> dict[str, dict]: "cpu": str(cpu_percent), "cpu_average": str(round(cpu_average_usage, 2)), "mem": f"{mem_pct}", - "cmdline": clean_camera_user_pass(" ".join(cmdline)), + "cmdline": clean_camera_user_pass(cmdline), } except Exception: continue @@ -167,7 +171,7 @@ def get_physical_interfaces(interfaces) -> list: if not interfaces: return [] - with open("/proc/net/dev", "r") as file: + with open("/proc/net/dev") as file: lines = file.readlines() physical_interfaces = [] @@ -234,7 +238,7 @@ def is_vaapi_amd_driver() -> bool: return any("AMD Radeon Graphics" in line for line in output) -def get_amd_gpu_stats() -> Optional[dict[str, str]]: +def get_amd_gpu_stats() -> dict[str, str] | None: """Get stats using radeontop.""" radeontop_command = ["radeontop", "-d", "-", "-l", "1"] @@ -260,141 +264,416 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]: return results -def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]: - """Get stats using intel_gpu_top.""" +_INTEL_FDINFO_SAMPLE_SECONDS = 1.0 - def get_stats_manually(output: str) -> dict[str, str]: - """Find global stats via regex when json fails to parse.""" - reading = "".join(output) - results: dict[str, str] = {} +# Engines we track. Render/3D and Compute are pooled into "compute"; Video and +# VideoEnhance into "dec" (VideoEnhance is the post-process engine that handles +# VAAPI scaling/deinterlace/CSC, e.g. ffmpeg `-vf scale_vaapi=...`). The Copy +# (DMA blitter) engine is intentionally ignored — it represents transparent +# memory transfers, not user-visible GPU work. +# i915 fdinfo keys (cumulative ns) → logical engine name. +_I915_ENGINE_KEYS = { + "drm-engine-render": "render", + "drm-engine-video": "video", + "drm-engine-video-enhance": "video-enhance", + "drm-engine-compute": "compute", +} +# Xe fdinfo suffixes (cumulative cycles, paired with drm-total-cycles-*). +_XE_ENGINE_KEYS = { + "rcs": "render", + "vcs": "video", + "vecs": "video-enhance", + "ccs": "compute", +} +_INTEL_DRM_DRIVERS = ("i915", "xe") +_PCI_ADDRESS_RE = re.compile( + r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$" +) - # render is used for qsv - render = [] - for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading): - packet = json.loads(result[14:]) - single = packet.get("busy", 0.0) - render.append(float(single)) - if render: - render_avg = sum(render) / len(render) - else: - render_avg = 1 +def _resolve_intel_gpu_pdev(device: str | None) -> str | None: + """Map a configured GPU hint (/dev/dri/card1, renderD128, or a PCI bus + address) to its drm-pdev string so we can filter fdinfo entries to that + device. Returns None when no hint is supplied or it cannot be resolved.""" + if not device: + return None - # video is used for vaapi - video = [] - for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading): - packet = json.loads(result[10:]) - single = packet.get("busy", 0.0) - video.append(float(single)) + if _PCI_ADDRESS_RE.match(device): + return device - if video: - video_avg = sum(video) / len(video) - else: - video_avg = 1 + name = os.path.basename(device.rstrip("/")) + try: + pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device")) + except OSError: + return None - results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%" - results["mem"] = "-%" - return results + # realpath does not raise on a nonexistent node; it returns the input + # path unchanged, so validate the result actually looks like a PCI + # address before trusting it. + return pdev if _PCI_ADDRESS_RE.match(pdev) else None - intel_gpu_top_command = [ - "timeout", - "0.5s", - "intel_gpu_top", - "-J", - "-o", - "-", - "-s", - "1000", # Intel changed this from seconds to milliseconds in 2024+ versions - ] - if intel_gpu_device: - intel_gpu_top_command += ["-d", intel_gpu_device] +def _enumerate_drm_devices() -> dict[str, str]: + """Map each PCI-attached DRM device to its bound kernel driver. + + Reads /sys/class/drm, which reflects every GPU on the host even when only + some render nodes are mapped into the container, so device presence can be + verified without /dev access. Returns {pdev: driver}, e.g. + {"0000:00:02.0": "i915"}. + """ + devices: dict[str, str] = {} try: - p = sp.run( - intel_gpu_top_command, - encoding="ascii", - capture_output=True, - ) - except UnicodeDecodeError: - return None + entries = os.listdir("/sys/class/drm") + except OSError: + return devices - # timeout has a non-zero returncode when timeout is reached - if p.returncode != 124: - logger.error(f"Unable to poll intel GPU stats: {p.stderr}") - return None - else: - output = "".join(p.stdout.split()) + for entry in entries: + device_dir = f"/sys/class/drm/{entry}/device" + pdev = os.path.basename(os.path.realpath(device_dir)) + + if not _PCI_ADDRESS_RE.match(pdev): + continue try: - data = json.loads(f"[{output}]") - except json.JSONDecodeError: - return get_stats_manually(output) + driver = os.path.basename(os.readlink(f"{device_dir}/driver")) + except OSError: + continue - results: dict[str, str] = {} - render = {"global": []} - video = {"global": []} + devices[pdev] = driver - for block in data: - global_engine = block.get("engines") + return devices - if global_engine: - render_frame = global_engine.get("Render/3D/0", {}).get("busy") - video_frame = global_engine.get("Video/0", {}).get("busy") - if render_frame is not None: - render["global"].append(float(render_frame)) +def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None: + """Snapshot DRM fdinfo for every Intel client visible in /proc. - if video_frame is not None: - video["global"].append(float(video_frame)) + Returns a dict keyed by (pdev, drm-client-id, pid) so the same context + seen via multiple file descriptors on a single process collapses to one + entry. Clients whose fdinfo carries no engine counters are still included + with an empty "engines" dict so the caller can distinguish "clients exist + but the kernel publishes no busyness" from "no clients at all". Returns + None when /proc itself cannot be scanned, which is a different failure + than a scan that finds nothing. + """ + snapshot: dict = {} - clients = block.get("clients", {}) + try: + proc_entries = os.listdir("/proc") + except OSError: + return None - if clients and len(clients): - for client_block in clients.values(): - key = client_block["pid"] + for entry in proc_entries: + if not entry.isdigit(): + continue - if render.get(key) is None: - render[key] = [] - video[key] = [] + fdinfo_dir = f"/proc/{entry}/fdinfo" + try: + fds = os.listdir(fdinfo_dir) + except (FileNotFoundError, PermissionError, NotADirectoryError, OSError): + continue - client_engine = client_block.get("engine-classes", {}) + for fd in fds: + try: + with open(f"{fdinfo_dir}/{fd}") as f: + content = f.read() + except (FileNotFoundError, PermissionError, OSError): + continue - render_frame = client_engine.get("Render/3D", {}).get("busy") - video_frame = client_engine.get("Video", {}).get("busy") + if "drm-driver" not in content: + continue - if render_frame is not None: - render[key].append(float(render_frame)) + fields: dict[str, str] = {} + for line in content.splitlines(): + key, sep, value = line.partition(":") + if sep: + fields[key.strip()] = value.strip() - if video_frame is not None: - video[key].append(float(video_frame)) + driver = fields.get("drm-driver") + if driver not in ("i915", "xe"): + continue - if render["global"] and video["global"]: - results["gpu"] = ( - f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%" + pdev = fields.get("drm-pdev", "") + if target_pdev and pdev != target_pdev: + continue + + client_id = fields.get("drm-client-id") + if not client_id: + continue + + key = (pdev, client_id, entry) + if key in snapshot: + continue + + engines: dict[str, tuple[int, int, int]] = {} + + if driver == "i915": + for fkey, engine in _I915_ENGINE_KEYS.items(): + raw = fields.get(fkey) + if not raw: + continue + try: + engines[engine] = (int(raw.split()[0]), 0, 1) + except (ValueError, IndexError): + continue + else: + for suffix, engine in _XE_ENGINE_KEYS.items(): + busy_raw = fields.get(f"drm-cycles-{suffix}") + total_raw = fields.get(f"drm-total-cycles-{suffix}") + + if not (busy_raw and total_raw): + continue + + # drm-cycles-* is summed across every instance of the engine + # class while drm-total-cycles-* tracks a single instance, so + # busy/total scales up to the capacity (e.g. Battlemage + # reports 2 for vcs/vecs). Capture it to divide back out; + # absent means a single engine, so default to 1. + capacity_raw = fields.get(f"drm-engine-capacity-{suffix}") + + try: + capacity = int(capacity_raw.split()[0]) if capacity_raw else 1 + except (ValueError, IndexError): + capacity = 1 + + try: + engines[engine] = ( + int(busy_raw.split()[0]), + int(total_raw.split()[0]), + max(1, capacity), + ) + except (ValueError, IndexError): + continue + + snapshot[key] = {"driver": driver, "pid": entry, "engines": engines} + + return snapshot + + +def _idle_intel_gpu_stats( + target_pdev: str | None, intel_pdevs: dict[str, str] +) -> dict[str, dict[str, Any]]: + """Build a 0% reading for the configured (or every) Intel GPU. + + Used when the device is confirmed present but no DRM client is currently + attached, e.g. while camera processes are restarting. That is an idle + state, not a collection failure, so it must produce a valid reading: + returning None would latch the hwaccel error cooldown and blank GPU stats + for an hour over a momentary gap. + """ + from frigate.stats.intel_gpu_info import intel_gpu_name_resolver + + names = intel_gpu_name_resolver.get_names() + pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs) + + return { + pdev: { + "name": names.get(pdev) or "Intel iGPU", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + } + for pdev in pdevs + } + + +def get_intel_gpu_stats( + intel_gpu_device: str | None, +) -> dict[str, dict[str, Any]] | None: + """Get stats by reading DRM fdinfo files, bucketed per-pdev. + + Each DRM client FD exposes monotonic per-engine busy counters via + /proc//fdinfo/. For i915 this requires kernel 6.5 or newer: + earlier kernels omit the per-engine counters whenever GuC submission is + active, which is the default on 12th gen and newer. Xe has exposed them + since its first release. We sample twice and divide busy-time deltas by + wall-clock to derive utilization. Render/3D and Compute are pooled into + "compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of + those pools (clamped to 100%). + + The return value is keyed by the GPU's drm-pdev string so multiple Intel + GPUs in the same system are reported separately. Each entry carries a + "name" populated from OpenVINO (falling back to the pdev) so callers can + surface a real device name in the UI. + + A device that exists but has no attached DRM clients reports an idle 0% + reading. None is returned only for durable failures (no Intel GPU, a bad + intel_gpu_device config, unreadable /proc, or a kernel that publishes no + counters), each of which logs a distinct warning, and the caller latches + it against retries for an hour. + """ + from frigate.stats.intel_gpu_info import intel_gpu_name_resolver + + target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device) + if intel_gpu_device and not target_pdev: + logger.warning( + "Unable to collect Intel GPU stats: configured intel_gpu_device %s " + "does not exist or could not be resolved to a PCI device", + intel_gpu_device, + ) + return None + + drm_devices = _enumerate_drm_devices() + intel_pdevs = { + pdev: driver + for pdev, driver in drm_devices.items() + if driver in _INTEL_DRM_DRIVERS + } + + if not intel_pdevs: + logger.warning( + "Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in " + "/sys/class/drm. Check that the driver is loaded on the host" + ) + return None + + if target_pdev and target_pdev not in intel_pdevs: + logger.warning( + "Unable to collect Intel GPU stats: configured intel_gpu_device %s " + "resolved to %s (driver: %s), which is not an Intel GPU", + intel_gpu_device, + target_pdev, + drm_devices.get(target_pdev, "unknown"), + ) + return None + + snapshot_a = _read_intel_drm_fdinfo(target_pdev) + if snapshot_a is None: + logger.warning("Unable to collect Intel GPU stats: /proc could not be read") + return None + + if not snapshot_a: + # No process currently holds the GPU open, e.g. while camera processes + # are restarting. The device is confirmed present, so report idle + # rather than an error; the next stats cycle re-samples normally. + logger.debug("No active DRM clients for Intel GPU, reporting idle") + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) + + if not any(client["engines"] for client in snapshot_a.values()): + # Clients exist but the kernel published no busyness for them, so + # there is nothing to sample and a second snapshot would not help. + # i915 suppresses per-client engine counters while GuC submission is + # active on kernels older than 6.5 (kernel commit 1324680a80eb lifted + # this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen + # and newer. + logger.warning( + "Unable to collect Intel GPU stats: found %d DRM client(s) for %s but " + "no per-engine counters. Kernel 6.5 or newer is required.", + len(snapshot_a), + "/".join(sorted({client["driver"] for client in snapshot_a.values()})), + ) + return None + + start = time.monotonic() + time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS) + elapsed_ns = (time.monotonic() - start) * 1e9 + + snapshot_b = _read_intel_drm_fdinfo(target_pdev) + if snapshot_b is None: + logger.warning("Unable to collect Intel GPU stats: /proc could not be read") + return None + + if not snapshot_b or elapsed_ns <= 0: + # Every client disappeared during the sample window; transient by + # definition, so report idle instead of latching an error. + logger.debug( + "No DRM clients persisted across Intel GPU samples, reporting idle" + ) + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) + + def _new_engine_pct() -> dict[str, float]: + return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0} + + per_pdev_engine_pct: dict[str, dict[str, float]] = {} + per_pdev_pid_pct: dict[str, dict[str, float]] = {} + + for key, data_b in snapshot_b.items(): + data_a = snapshot_a.get(key) + if not data_a or data_a["driver"] != data_b["driver"]: + continue + + # Skip before the setdefault below so a counter-less client cannot + # register its pdev on its own. + if not data_b["engines"]: + continue + + pdev = key[0] + engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct()) + pid_pct = per_pdev_pid_pct.setdefault(pdev, {}) + + client_total = 0.0 + for engine, (busy_b, total_b, capacity) in data_b["engines"].items(): + if engine not in engine_pct: + continue + + busy_a, total_a, _ = data_a["engines"].get( + engine, (busy_b, total_b, capacity) ) - results["mem"] = "-%" - if len(render.keys()) > 1: - results["clients"] = {} - - for key in render.keys(): - if key == "global" or not render[key] or not video[key]: + if data_b["driver"] == "i915": + delta = max(0, busy_b - busy_a) + pct = min(100.0, delta / elapsed_ns * 100.0) + else: + delta_busy = max(0, busy_b - busy_a) + delta_total = total_b - total_a + if delta_total <= 0: continue + # Normalize by capacity so a class with N engine instances + # (busy summed across all N) reports 0-100%, not 0-N*100%. + pct = min(100.0, delta_busy / (delta_total * capacity) * 100.0) - results["clients"][key] = ( - f"{round(((sum(render[key]) / len(render[key])) + (sum(video[key]) / len(video[key]))) / 2, 2)}%" - ) + engine_pct[engine] += pct + client_total += pct - return results + pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total + + if not per_pdev_engine_pct: + # Clients were seen in both snapshots but none persisted as the same + # (pdev, client-id, pid); process churn, so report idle. + logger.debug( + "No DRM clients persisted across Intel GPU samples, reporting idle" + ) + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) + + names = intel_gpu_name_resolver.get_names() + results: dict[str, dict[str, Any]] = {} + + for pdev, engine_pct in per_pdev_engine_pct.items(): + for engine in engine_pct: + engine_pct[engine] = min(100.0, engine_pct[engine]) + + compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"]) + dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"]) + overall_pct = min(100.0, compute_pct + dec_pct) + + entry: dict[str, Any] = { + "name": names.get(pdev) or "Intel iGPU", + "vendor": "intel", + "gpu": f"{round(overall_pct, 2)}%", + "mem": "-%", + "compute": f"{round(compute_pct, 2)}%", + "dec": f"{round(dec_pct, 2)}%", + } + + pid_pct = per_pdev_pid_pct.get(pdev) + if pid_pct: + entry["clients"] = { + pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items() + } + + results[pdev] = entry + + return results -def get_openvino_npu_stats() -> Optional[dict[str, str]]: +def get_openvino_npu_stats() -> dict[str, str] | None: """Get NPU stats using openvino.""" NPU_RUNTIME_PATH = "/sys/devices/pci0000:00/0000:00:0b.0/power/runtime_active_time" try: - with open(NPU_RUNTIME_PATH, "r") as f: + with open(NPU_RUNTIME_PATH) as f: initial_runtime = float(f.read().strip()) initial_time = time.time() @@ -403,7 +682,7 @@ def get_openvino_npu_stats() -> Optional[dict[str, str]]: time.sleep(1.0) # Read runtime value again - with open(NPU_RUNTIME_PATH, "r") as f: + with open(NPU_RUNTIME_PATH) as f: current_runtime = float(f.read().strip()) current_time = time.time() @@ -417,15 +696,15 @@ def get_openvino_npu_stats() -> Optional[dict[str, str]]: else: usage = 0.0 - return {"npu": f"{round(usage, 2)}", "mem": "-"} + return {"npu": f"{round(usage, 2)}", "mem": "-%"} except (FileNotFoundError, PermissionError, ValueError): return None -def get_rockchip_gpu_stats() -> Optional[dict[str, str]]: +def get_rockchip_gpu_stats() -> dict[str, str | float] | None: """Get GPU stats using rk.""" try: - with open("/sys/kernel/debug/rkrga/load", "r") as f: + with open("/sys/kernel/debug/rkrga/load") as f: content = f.read() except FileNotFoundError: return None @@ -440,13 +719,22 @@ def get_rockchip_gpu_stats() -> Optional[dict[str, str]]: return None average_load = f"{round(sum(load_values) / len(load_values), 2)}%" - return {"gpu": average_load, "mem": "-"} + stats: dict[str, str | float] = {"gpu": average_load, "mem": "-%"} + + try: + with open("/sys/class/thermal/thermal_zone5/temp") as f: + line = f.readline().strip() + stats["temp"] = round(int(line) / 1000, 1) + except (FileNotFoundError, OSError, ValueError): + pass + + return stats -def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]: +def get_rockchip_npu_stats() -> dict[str, float | str] | None: """Get NPU stats using rk.""" try: - with open("/sys/kernel/debug/rknpu/load", "r") as f: + with open("/sys/kernel/debug/rknpu/load") as f: npu_output = f.read() if "Core0:" in npu_output: @@ -463,13 +751,62 @@ def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]: percentages = [int(load) for load in core_loads] mean = round(sum(percentages) / len(percentages), 2) - return {"npu": mean, "mem": "-"} + stats: dict[str, float | str] = {"npu": mean, "mem": "-%"} + + try: + with open("/sys/class/thermal/thermal_zone6/temp") as f: + line = f.readline().strip() + stats["temp"] = round(int(line) / 1000, 1) + except (FileNotFoundError, OSError, ValueError): + pass + + return stats -def try_get_info(f, h, default="N/A"): +def get_axcl_npu_stats() -> dict[str, str | float] | None: + """Get NPU stats using axcl.""" + # Check if axcl-smi exists + axcl_smi_path = "/usr/bin/axcl/axcl-smi" + if not os.path.exists(axcl_smi_path): + return None + + try: + # Run axcl-smi command to get NPU stats + axcl_command = [axcl_smi_path, "sh", "cat", "/proc/ax_proc/npu/top"] + p = sp.run( + axcl_command, + capture_output=True, + text=True, + ) + + if p.returncode != 0: + pass + else: + utilization = None + + for line in p.stdout.strip().splitlines(): + line = line.strip() + if line.startswith("utilization:"): + match = re.search(r"utilization:(\d+)%", line) + if match: + utilization = float(match.group(1)) + + if utilization is not None: + stats: dict[str, str | float] = {"npu": utilization, "mem": "-%"} + return stats + except Exception: + pass + + return None + + +def try_get_info(f, h, default="N/A", sensor=None): try: if h: - v = f(h) + if sensor is not None: + v = f(h, sensor) + else: + v = f(h) else: v = f() except nvml.NVMLError_NotSupported: @@ -498,6 +835,9 @@ def get_nvidia_gpu_stats() -> dict[int, dict]: util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle) enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle) dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle) + temp = try_get_info( + nvml.nvmlDeviceGetTemperature, handle, default=None, sensor=0 + ) pstate = try_get_info(nvml.nvmlDeviceGetPowerState, handle, default=None) if util != "N/A": @@ -510,6 +850,11 @@ def get_nvidia_gpu_stats() -> dict[int, dict]: else: gpu_mem_util = -1 + if temp != "N/A" and temp is not None: + temp = float(temp) + else: + temp = None + if enc != "N/A": enc_util = enc[0] else: @@ -527,6 +872,7 @@ def get_nvidia_gpu_stats() -> dict[int, dict]: "enc": enc_util, "dec": dec_util, "pstate": pstate or "unknown", + "temp": temp, } except Exception: pass @@ -534,18 +880,18 @@ def get_nvidia_gpu_stats() -> dict[int, dict]: return results -def get_jetson_stats() -> Optional[dict[int, dict]]: +def get_jetson_stats() -> dict[int, dict] | None: results = {} try: results["mem"] = "-" # no discrete gpu memory if os.path.exists("/sys/devices/gpu.0/load"): - with open("/sys/devices/gpu.0/load", "r") as f: + with open("/sys/devices/gpu.0/load") as f: gpuload = float(f.readline()) / 10 results["gpu"] = f"{gpuload}%" elif os.path.exists("/sys/devices/platform/gpu.0/load"): - with open("/sys/devices/platform/gpu.0/load", "r") as f: + with open("/sys/devices/platform/gpu.0/load") as f: gpuload = float(f.readline()) / 10 results["gpu"] = f"{gpuload}%" else: @@ -556,10 +902,57 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: return results +def get_hailo_temps() -> dict[str, float]: + """Get temperatures for Hailo devices.""" + try: + from hailo_platform import Device + except ModuleNotFoundError: + return {} + + temps = {} + + try: + device_ids = Device.scan() + for i, device_id in enumerate(device_ids): + try: + with Device(device_id) as device: + temp_info = device.control.get_chip_temperature() + + # Get board name and normalise it + identity = device.control.identify() + board_name = None + for line in str(identity).split("\n"): + if line.startswith("Board Name:"): + board_name = ( + line.split(":", 1)[1].strip().lower().replace("-", "") + ) + break + + if not board_name: + board_name = f"hailo{i}" + + # Use indexed name if multiple devices, otherwise just the board name + device_name = ( + f"{board_name}-{i}" if len(device_ids) > 1 else board_name + ) + + # ts1_temperature is also available, but appeared to be the same as ts0 in testing. + temps[device_name] = round(temp_info.ts0_temperature, 1) + except Exception as e: + logger.debug( + f"Failed to get temperature for Hailo device {device_id}: {e}" + ) + continue + except Exception as e: + logger.debug(f"Failed to scan for Hailo devices: {e}") + + return temps + + def is_go2rtc_arbitrary_exec_allowed() -> bool: """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker secrets, or the Home Assistant add-on options file.""" - raw: Optional[str] = None + raw: str | None = None if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") elif ( @@ -605,33 +998,184 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro else: format_entries = None - ffprobe_cmd = [ + def run(rtsp_transport: str | None = None) -> sp.CompletedProcess: + cmd = [ffmpeg.ffprobe_path] + if rtsp_transport: + cmd += ["-rtsp_transport", rtsp_transport] + cmd += [ + "-timeout", + "1000000", + "-print_format", + "json", + "-show_entries", + f"stream={stream_entries}", + ] + if detailed and format_entries: + cmd.extend(["-show_entries", f"format={format_entries}"]) + cmd.extend(["-loglevel", "error", clean_path]) + try: + return sp.run(cmd, capture_output=True, timeout=6) + except sp.TimeoutExpired as e: + logger.info( + "ffprobe timed out while probing %s (transport=%s)", + clean_camera_user_pass(path), + rtsp_transport or "default", + ) + return sp.CompletedProcess( + args=cmd, + returncode=1, + stdout=e.stdout or b"", + stderr=(e.stderr or b"") + b"\nffprobe timed out", + ) + + result = run() + + # For RTSP: retry with explicit TCP transport if the first attempt failed + # (default UDP may be blocked) + if result.returncode != 0 and clean_path.startswith("rtsp://"): + result = run(rtsp_transport="tcp") + + return result + + +KEYFRAME_PROBE_WINDOW_SECONDS = 20 +KEYFRAME_GAP_WARNING_SECONDS = 4.0 + + +def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]: + """Parse ffprobe CSV `pts_time,flags` output. + + Returns the presentation timestamps of keyframes (flags containing "K") + and the maximum timestamp observed across all packets. + """ + keyframe_pts: list[float] = [] + max_pts: float | None = None + + for line in output.splitlines(): + parts = line.split(",") + if len(parts) < 2: + continue + try: + pts = float(parts[0]) + except ValueError: + continue + if max_pts is None or pts > max_pts: + max_pts = pts + if "K" in parts[1]: + keyframe_pts.append(pts) + + return keyframe_pts, max_pts + + +def classify_keyframe_gaps( + keyframe_pts: list[float], segment_time: int +) -> dict[str, Any]: + """Classify keyframe spacing for recording suitability. + + A camera using a smart/+ codec or a long/variable GOP produces large or + irregular gaps between keyframes, which breaks time-based recording + segmentation. Severity: + - "unknown" when fewer than two keyframes were observed + - "error" when the longest gap exceeds the record segment length + - "warning" when the longest gap exceeds the warning threshold + - "ok" otherwise + """ + thresholds = { + "warning": KEYFRAME_GAP_WARNING_SECONDS, + "error": segment_time, + } + + if len(keyframe_pts) < 2: + return { + "keyframe_count": len(keyframe_pts), + "max_gap": None, + "mean_gap": None, + "min_gap": None, + "segment_time": segment_time, + "severity": "unknown", + "thresholds": thresholds, + } + + gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])] + max_gap = max(gaps) + + if max_gap > segment_time: + severity = "error" + elif max_gap > KEYFRAME_GAP_WARNING_SECONDS: + severity = "warning" + else: + severity = "ok" + + return { + "keyframe_count": len(keyframe_pts), + "max_gap": round(max_gap, 2), + "mean_gap": round(sum(gaps) / len(gaps), 2), + "min_gap": round(min(gaps), 2), + "segment_time": segment_time, + "severity": severity, + "thresholds": thresholds, + } + + +async def analyze_record_keyframes( + ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS +) -> dict[str, Any]: + """Probe a stream for ~`window` seconds and classify its keyframe spacing. + + Reads video packet flags via ffprobe to find keyframes, then measures the + gaps between them. On timeout or failure returns an "unknown" result rather + than a false all-clear. + """ + clean_url = escape_special_characters(url) + cmd = [ ffmpeg.ffprobe_path, - "-timeout", - "1000000", - "-print_format", - "json", + "-v", + "error", + "-select_streams", + "v:0", + "-read_intervals", + f"%+{window}", "-show_entries", - f"stream={stream_entries}", + "packet=pts_time,flags", + "-of", + "csv=p=0", + clean_url, ] - # Add format entries for detailed mode - if detailed and format_entries: - ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"]) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15) + except TimeoutError: + logger.warning("Keyframe probe timed out for record stream") + proc.kill() + return classify_keyframe_gaps([], segment_time) + except OSError as err: + logger.error("Keyframe probe failed: %s", err) + return classify_keyframe_gaps([], segment_time) - ffprobe_cmd.extend(["-loglevel", "error", clean_path]) - - return sp.run(ffprobe_cmd, capture_output=True) + keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace")) + result = classify_keyframe_gaps(keyframe_pts, segment_time) + result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None + return result -def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: +def vainfo_hwaccel(device_name: str | None = None) -> sp.CompletedProcess: """Run vainfo.""" - ffprobe_cmd = ( - ["vainfo"] - if not device_name - else ["vainfo", "--display", "drm", "--device", f"/dev/dri/{device_name}"] - ) - return sp.run(ffprobe_cmd, capture_output=True) + if not device_name: + cmd = ["vainfo"] + else: + if os.path.isabs(device_name) and device_name.startswith("/dev/dri/"): + device_path = device_name + else: + device_path = f"/dev/dri/{device_name}" + + cmd = ["vainfo", "--display", "drm", "--device", device_path] + + return sp.run(cmd, capture_output=True) def get_nvidia_driver_info() -> dict[str, Any]: @@ -696,10 +1240,15 @@ async def get_video_properties( ) -> dict[str, Any]: async def probe_with_ffprobe( url: str, - ) -> tuple[bool, int, int, Optional[str], float]: + rtsp_transport: str | None = None, + ) -> tuple[bool, int, int, str | None, float]: """Fallback using ffprobe: returns (valid, width, height, codec, duration).""" - cmd = [ - ffmpeg.ffprobe_path, + cmd = [ffmpeg.ffprobe_path] + if rtsp_transport: + cmd += ["-rtsp_transport", rtsp_transport] + cmd += [ + "-rw_timeout", + "5000000", "-v", "quiet", "-print_format", @@ -708,11 +1257,23 @@ async def get_video_properties( "-show_streams", url, ] + proc = None try: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - stdout, _ = await proc.communicate() + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6) + except TimeoutError: + logger.info( + "ffprobe timed out while probing %s (transport=%s)", + clean_camera_user_pass(url), + rtsp_transport or "default", + ) + proc.kill() + await proc.wait() + return False, 0, 0, None, -1 + if proc.returncode != 0: return False, 0, 0, None, -1 @@ -732,10 +1293,10 @@ async def get_video_properties( duration = float(duration_str) if duration_str else -1.0 return True, width, height, codec, duration - except (json.JSONDecodeError, ValueError, KeyError, asyncio.SubprocessError): + except (json.JSONDecodeError, ValueError, KeyError, sp.SubprocessError): return False, 0, 0, None, -1 - def probe_with_cv2(url: str) -> tuple[bool, int, int, Optional[str], float]: + def probe_with_cv2(url: str) -> tuple[bool, int, int, str | None, float]: """Primary attempt using cv2: returns (valid, width, height, fourcc, duration).""" cap = cv2.VideoCapture(url) if not cap.isOpened(): @@ -761,12 +1322,26 @@ async def get_video_properties( cap.release() return valid, width, height, fourcc, duration - # try cv2 first - has_video, width, height, fourcc, duration = probe_with_cv2(url) + is_rtsp = url.startswith("rtsp://") - # fallback to ffprobe if needed - if not has_video or (get_duration and duration < 0): + if is_rtsp: + # skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal + # timeout that cannot be shortened per-call, and ffprobe bounded by + # -rw_timeout handles RTSP probing reliably has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + else: + # try cv2 first for local files, HTTP, RTMP + has_video, width, height, fourcc, duration = probe_with_cv2(url) + + # fallback to ffprobe if needed + if not has_video or (get_duration and duration < 0): + has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + + # last resort for RTSP: try TCP transport, since default UDP may be blocked + if (not has_video or (get_duration and duration < 0)) and is_rtsp: + has_video, width, height, fourcc, duration = await probe_with_ffprobe( + url, rtsp_transport="tcp" + ) result: dict[str, Any] = {"has_valid_video": has_video} if has_video: @@ -781,10 +1356,10 @@ async def get_video_properties( def process_logs( contents: str, - service: Optional[str] = None, - start: Optional[int] = None, - end: Optional[int] = None, -) -> Tuple[int, List[str]]: + service: str | None = None, + start: int | None = None, + end: int | None = None, +) -> tuple[int, list[str]]: log_lines = [] last_message = None last_timestamp = None diff --git a/frigate/util/time.py b/frigate/util/time.py index 1e7b49c243..861bec17f6 100644 --- a/frigate/util/time.py +++ b/frigate/util/time.py @@ -2,7 +2,6 @@ import datetime import logging -from typing import Tuple from zoneinfo import ZoneInfoNotFoundError import pytz @@ -11,7 +10,7 @@ from tzlocal import get_localzone logger = logging.getLogger(__name__) -def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]: +def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]: seconds_offset = ( datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds() ) @@ -27,24 +26,18 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime: try: tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1) except ZoneInfoNotFoundError: - tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( - days=1 - ) + tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) logger.warning( "Using utc for maintenance due to missing or incorrect timezone set" ) - return tomorrow.replace(hour=hour, minute=0, second=0).astimezone( - datetime.timezone.utc - ) + return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(datetime.UTC) def is_current_hour(timestamp: int) -> bool: """Returns if timestamp is in the current UTC hour.""" start_of_next_hour = ( - datetime.datetime.now(datetime.timezone.utc).replace( - minute=0, second=0, microsecond=0 - ) + datetime.datetime.now(datetime.UTC).replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) ).timestamp() return timestamp < start_of_next_hour diff --git a/frigate/video.py b/frigate/video.py deleted file mode 100755 index 38a3974041..0000000000 --- a/frigate/video.py +++ /dev/null @@ -1,1112 +0,0 @@ -import logging -import queue -import subprocess as sp -import threading -import time -from datetime import datetime, timedelta, timezone -from multiprocessing import Queue, Value -from multiprocessing.synchronize import Event as MpEvent -from typing import Any - -import cv2 - -from frigate.camera import CameraMetrics, PTZMetrics -from frigate.comms.inter_process import InterProcessRequestor -from frigate.comms.recordings_updater import ( - RecordingsDataSubscriber, - RecordingsDataTypeEnum, -) -from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig -from frigate.config.camera.camera import CameraTypeEnum -from frigate.config.camera.updater import ( - CameraConfigUpdateEnum, - CameraConfigUpdateSubscriber, -) -from frigate.const import ( - PROCESS_PRIORITY_HIGH, - REQUEST_REGION_GRID, -) -from frigate.log import LogPipe -from frigate.motion import MotionDetector -from frigate.motion.improved_motion import ImprovedMotionDetector -from frigate.object_detection.base import RemoteObjectDetector -from frigate.ptz.autotrack import ptz_moving_at_frame_time -from frigate.track import ObjectTracker -from frigate.track.norfair_tracker import NorfairTracker -from frigate.track.tracked_object import TrackedObjectAttribute -from frigate.util.builtin import EventsPerSecond -from frigate.util.image import ( - FrameManager, - SharedMemoryFrameManager, - draw_box_with_label, -) -from frigate.util.object import ( - create_tensor_input, - get_cluster_candidates, - get_cluster_region, - get_cluster_region_from_grid, - get_min_region_size, - get_startup_regions, - inside_any, - intersects_any, - is_object_filtered, - reduce_detections, -) -from frigate.util.process import FrigateProcess -from frigate.util.time import get_tomorrow_at_time - -logger = logging.getLogger(__name__) - - -def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger): - logger.info("Terminating the existing ffmpeg process...") - ffmpeg_process.terminate() - try: - logger.info("Waiting for ffmpeg to exit gracefully...") - ffmpeg_process.communicate(timeout=30) - logger.info("FFmpeg has exited") - except sp.TimeoutExpired: - logger.info("FFmpeg didn't exit. Force killing...") - ffmpeg_process.kill() - ffmpeg_process.communicate() - logger.info("FFmpeg has been killed") - ffmpeg_process = None - - -def start_or_restart_ffmpeg( - ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None -) -> sp.Popen[Any]: - if ffmpeg_process is not None: - stop_ffmpeg(ffmpeg_process, logger) - - if frame_size is None: - process = sp.Popen( - ffmpeg_cmd, - stdout=sp.DEVNULL, - stderr=logpipe, - stdin=sp.DEVNULL, - start_new_session=True, - ) - else: - process = sp.Popen( - ffmpeg_cmd, - stdout=sp.PIPE, - stderr=logpipe, - stdin=sp.DEVNULL, - bufsize=frame_size * 10, - start_new_session=True, - ) - return process - - -def capture_frames( - ffmpeg_process: sp.Popen[Any], - config: CameraConfig, - shm_frame_count: int, - frame_index: int, - frame_shape: tuple[int, int], - frame_manager: FrameManager, - frame_queue, - fps: Value, - skipped_fps: Value, - current_frame: Value, - stop_event: MpEvent, -) -> None: - frame_size = frame_shape[0] * frame_shape[1] - frame_rate = EventsPerSecond() - frame_rate.start() - skipped_eps = EventsPerSecond() - skipped_eps.start() - config_subscriber = CameraConfigUpdateSubscriber( - None, {config.name: config}, [CameraConfigUpdateEnum.enabled] - ) - - def get_enabled_state(): - """Fetch the latest enabled state from ZMQ.""" - config_subscriber.check_for_updates() - return config.enabled - - try: - while not stop_event.is_set(): - if not get_enabled_state(): - logger.debug(f"Stopping capture thread for disabled {config.name}") - break - - fps.value = frame_rate.eps() - skipped_fps.value = skipped_eps.eps() - current_frame.value = datetime.now().timestamp() - frame_name = f"{config.name}_frame{frame_index}" - frame_buffer = frame_manager.write(frame_name) - try: - frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) - except Exception: - # shutdown has been initiated - if stop_event.is_set(): - break - - logger.error( - f"{config.name}: Unable to read frames from ffmpeg process." - ) - - if ffmpeg_process.poll() is not None: - logger.error( - f"{config.name}: ffmpeg process is not running. exiting capture thread..." - ) - break - - continue - - frame_rate.update() - - # don't lock the queue to check, just try since it should rarely be full - try: - # add to the queue - frame_queue.put((frame_name, current_frame.value), False) - frame_manager.close(frame_name) - except queue.Full: - # if the queue is full, skip this frame - skipped_eps.update() - - frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 - finally: - config_subscriber.stop() - - -class CameraWatchdog(threading.Thread): - def __init__( - self, - config: CameraConfig, - shm_frame_count: int, - frame_queue: Queue, - camera_fps, - skipped_fps, - ffmpeg_pid, - stop_event, - ): - threading.Thread.__init__(self) - self.logger = logging.getLogger(f"watchdog.{config.name}") - self.config = config - self.shm_frame_count = shm_frame_count - self.capture_thread = None - self.ffmpeg_detect_process = None - self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.detect") - self.ffmpeg_other_processes: list[dict[str, Any]] = [] - self.camera_fps = camera_fps - self.skipped_fps = skipped_fps - self.ffmpeg_pid = ffmpeg_pid - self.frame_queue = frame_queue - self.frame_shape = self.config.frame_shape_yuv - self.frame_size = self.frame_shape[0] * self.frame_shape[1] - self.fps_overflow_count = 0 - self.frame_index = 0 - self.stop_event = stop_event - self.sleeptime = self.config.ffmpeg.retry_interval - - self.config_subscriber = CameraConfigUpdateSubscriber( - None, - {config.name: config}, - [CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record], - ) - self.requestor = InterProcessRequestor() - self.was_enabled = self.config.enabled - - self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) - self.latest_valid_segment_time: float = 0 - self.latest_invalid_segment_time: float = 0 - self.latest_cache_segment_time: float = 0 - self.record_enable_time: datetime | None = None - - def _update_enabled_state(self) -> bool: - """Fetch the latest config and update enabled state.""" - self.config_subscriber.check_for_updates() - return self.config.enabled - - def reset_capture_thread( - self, terminate: bool = True, drain_output: bool = True - ) -> None: - if terminate: - self.ffmpeg_detect_process.terminate() - try: - self.logger.info("Waiting for ffmpeg to exit gracefully...") - - if drain_output: - self.ffmpeg_detect_process.communicate(timeout=30) - else: - self.ffmpeg_detect_process.wait(timeout=30) - except sp.TimeoutExpired: - self.logger.info("FFmpeg did not exit. Force killing...") - self.ffmpeg_detect_process.kill() - - if drain_output: - self.ffmpeg_detect_process.communicate() - else: - self.ffmpeg_detect_process.wait() - - # Wait for old capture thread to fully exit before starting a new one - if self.capture_thread is not None and self.capture_thread.is_alive(): - self.logger.info("Waiting for capture thread to exit...") - self.capture_thread.join(timeout=5) - - if self.capture_thread.is_alive(): - self.logger.warning( - f"Capture thread for {self.config.name} did not exit in time" - ) - - self.logger.error( - "The following ffmpeg logs include the last 100 lines prior to exit." - ) - self.logpipe.dump() - self.logger.info("Restarting ffmpeg...") - self.start_ffmpeg_detect() - - def run(self) -> None: - if self._update_enabled_state(): - self.start_all_ffmpeg() - # If recording is enabled at startup, set the grace period timer - if self.config.record.enabled: - self.record_enable_time = datetime.now().astimezone(timezone.utc) - - time.sleep(self.sleeptime) - while not self.stop_event.wait(self.sleeptime): - enabled = self._update_enabled_state() - if enabled != self.was_enabled: - if enabled: - self.logger.debug(f"Enabling camera {self.config.name}") - self.start_all_ffmpeg() - - # reset all timestamps and record the enable time for grace period - self.latest_valid_segment_time = 0 - self.latest_invalid_segment_time = 0 - self.latest_cache_segment_time = 0 - self.record_enable_time = datetime.now().astimezone(timezone.utc) - else: - self.logger.debug(f"Disabling camera {self.config.name}") - self.stop_all_ffmpeg() - self.record_enable_time = None - - # update camera status - self.requestor.send_data( - f"{self.config.name}/status/detect", "disabled" - ) - self.requestor.send_data( - f"{self.config.name}/status/record", "disabled" - ) - self.was_enabled = enabled - continue - - if not enabled: - continue - - while True: - update = self.segment_subscriber.check_for_update(timeout=0) - - if update == (None, None): - break - - raw_topic, payload = update - if raw_topic and payload: - topic = str(raw_topic) - camera, segment_time, _ = payload - - if camera != self.config.name: - continue - - if topic.endswith(RecordingsDataTypeEnum.valid.value): - self.logger.debug( - f"Latest valid recording segment time on {camera}: {segment_time}" - ) - self.latest_valid_segment_time = segment_time - elif topic.endswith(RecordingsDataTypeEnum.invalid.value): - self.logger.warning( - f"Invalid recording segment detected for {camera} at {segment_time}" - ) - self.latest_invalid_segment_time = segment_time - elif topic.endswith(RecordingsDataTypeEnum.latest.value): - if segment_time is not None: - self.latest_cache_segment_time = segment_time - else: - self.latest_cache_segment_time = 0 - - now = datetime.now().timestamp() - - if not self.capture_thread.is_alive(): - self.requestor.send_data(f"{self.config.name}/status/detect", "offline") - self.camera_fps.value = 0 - self.logger.error( - f"Ffmpeg process crashed unexpectedly for {self.config.name}." - ) - self.reset_capture_thread(terminate=False) - elif self.camera_fps.value >= (self.config.detect.fps + 10): - self.fps_overflow_count += 1 - - if self.fps_overflow_count == 3: - self.requestor.send_data( - f"{self.config.name}/status/detect", "offline" - ) - self.fps_overflow_count = 0 - self.camera_fps.value = 0 - self.logger.info( - f"{self.config.name} exceeded fps limit. Exiting ffmpeg..." - ) - self.reset_capture_thread(drain_output=False) - elif now - self.capture_thread.current_frame.value > 20: - self.requestor.send_data(f"{self.config.name}/status/detect", "offline") - self.camera_fps.value = 0 - self.logger.info( - f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..." - ) - self.reset_capture_thread() - else: - # process is running normally - self.requestor.send_data(f"{self.config.name}/status/detect", "online") - self.fps_overflow_count = 0 - - for p in self.ffmpeg_other_processes: - poll = p["process"].poll() - - if self.config.record.enabled and "record" in p["roles"]: - now_utc = datetime.now().astimezone(timezone.utc) - - # Check if we're within the grace period after enabling recording - # Grace period: 90 seconds allows time for ffmpeg to start and create first segment - in_grace_period = self.record_enable_time is not None and ( - now_utc - self.record_enable_time - ) < timedelta(seconds=90) - - latest_cache_dt = ( - datetime.fromtimestamp( - self.latest_cache_segment_time, tz=timezone.utc - ) - if self.latest_cache_segment_time > 0 - else now_utc - timedelta(seconds=1) - ) - - latest_valid_dt = ( - datetime.fromtimestamp( - self.latest_valid_segment_time, tz=timezone.utc - ) - if self.latest_valid_segment_time > 0 - else now_utc - timedelta(seconds=1) - ) - - latest_invalid_dt = ( - datetime.fromtimestamp( - self.latest_invalid_segment_time, tz=timezone.utc - ) - if self.latest_invalid_segment_time > 0 - else now_utc - timedelta(seconds=1) - ) - - # ensure segments are still being created and that they have valid video data - # Skip checks during grace period to allow segments to start being created - cache_stale = not in_grace_period and now_utc > ( - latest_cache_dt + timedelta(seconds=120) - ) - valid_stale = not in_grace_period and now_utc > ( - latest_valid_dt + timedelta(seconds=120) - ) - invalid_stale_condition = ( - self.latest_invalid_segment_time > 0 - and not in_grace_period - and now_utc > (latest_invalid_dt + timedelta(seconds=120)) - and self.latest_valid_segment_time - <= self.latest_invalid_segment_time - ) - invalid_stale = invalid_stale_condition - - if cache_stale or valid_stale or invalid_stale: - if cache_stale: - reason = "No new recording segments were created" - elif valid_stale: - reason = "No new valid recording segments were created" - else: # invalid_stale - reason = ( - "No valid segments created since last invalid segment" - ) - - self.logger.error( - f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..." - ) - p["process"] = start_or_restart_ffmpeg( - p["cmd"], - self.logger, - p["logpipe"], - ffmpeg_process=p["process"], - ) - - for role in p["roles"]: - self.requestor.send_data( - f"{self.config.name}/status/{role.value}", "offline" - ) - - continue - else: - self.requestor.send_data( - f"{self.config.name}/status/record", "online" - ) - p["latest_segment_time"] = self.latest_cache_segment_time - - if poll is None: - continue - - for role in p["roles"]: - self.requestor.send_data( - f"{self.config.name}/status/{role.value}", "offline" - ) - - p["logpipe"].dump() - p["process"] = start_or_restart_ffmpeg( - p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] - ) - - self.stop_all_ffmpeg() - self.logpipe.close() - self.config_subscriber.stop() - self.segment_subscriber.stop() - - def start_ffmpeg_detect(self): - ffmpeg_cmd = [ - c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"] - ][0] - self.ffmpeg_detect_process = start_or_restart_ffmpeg( - ffmpeg_cmd, self.logger, self.logpipe, self.frame_size - ) - self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid - self.capture_thread = CameraCaptureRunner( - self.config, - self.shm_frame_count, - self.frame_index, - self.ffmpeg_detect_process, - self.frame_shape, - self.frame_queue, - self.camera_fps, - self.skipped_fps, - self.stop_event, - ) - self.capture_thread.start() - - def start_all_ffmpeg(self): - """Start all ffmpeg processes (detection and others).""" - logger.debug(f"Starting all ffmpeg processes for {self.config.name}") - self.start_ffmpeg_detect() - for c in self.config.ffmpeg_cmds: - if "detect" in c["roles"]: - continue - logpipe = LogPipe( - f"ffmpeg.{self.config.name}.{'_'.join(sorted(c['roles']))}" - ) - self.ffmpeg_other_processes.append( - { - "cmd": c["cmd"], - "roles": c["roles"], - "logpipe": logpipe, - "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), - } - ) - - def stop_all_ffmpeg(self): - """Stop all ffmpeg processes (detection and others).""" - logger.debug(f"Stopping all ffmpeg processes for {self.config.name}") - if self.capture_thread is not None and self.capture_thread.is_alive(): - self.capture_thread.join(timeout=5) - if self.capture_thread.is_alive(): - self.logger.warning( - f"Capture thread for {self.config.name} did not stop gracefully." - ) - if self.ffmpeg_detect_process is not None: - stop_ffmpeg(self.ffmpeg_detect_process, self.logger) - self.ffmpeg_detect_process = None - for p in self.ffmpeg_other_processes[:]: - if p["process"] is not None: - stop_ffmpeg(p["process"], self.logger) - p["logpipe"].close() - self.ffmpeg_other_processes.clear() - - -class CameraCaptureRunner(threading.Thread): - def __init__( - self, - config: CameraConfig, - shm_frame_count: int, - frame_index: int, - ffmpeg_process, - frame_shape: tuple[int, int], - frame_queue: Queue, - fps: Value, - skipped_fps: Value, - stop_event: MpEvent, - ): - threading.Thread.__init__(self) - self.name = f"capture:{config.name}" - self.config = config - self.shm_frame_count = shm_frame_count - self.frame_index = frame_index - self.frame_shape = frame_shape - self.frame_queue = frame_queue - self.fps = fps - self.stop_event = stop_event - self.skipped_fps = skipped_fps - self.frame_manager = SharedMemoryFrameManager() - self.ffmpeg_process = ffmpeg_process - self.current_frame = Value("d", 0.0) - self.last_frame = 0 - - def run(self): - capture_frames( - self.ffmpeg_process, - self.config, - self.shm_frame_count, - self.frame_index, - self.frame_shape, - self.frame_manager, - self.frame_queue, - self.fps, - self.skipped_fps, - self.current_frame, - self.stop_event, - ) - - -class CameraCapture(FrigateProcess): - def __init__( - self, - config: CameraConfig, - shm_frame_count: int, - camera_metrics: CameraMetrics, - stop_event: MpEvent, - log_config: LoggerConfig | None = None, - ) -> None: - super().__init__( - stop_event, - PROCESS_PRIORITY_HIGH, - name=f"frigate.capture:{config.name}", - daemon=True, - ) - self.config = config - self.shm_frame_count = shm_frame_count - self.camera_metrics = camera_metrics - self.log_config = log_config - - def run(self) -> None: - self.pre_run_setup(self.log_config) - camera_watchdog = CameraWatchdog( - self.config, - self.shm_frame_count, - self.camera_metrics.frame_queue, - self.camera_metrics.camera_fps, - self.camera_metrics.skipped_fps, - self.camera_metrics.ffmpeg_pid, - self.stop_event, - ) - camera_watchdog.start() - camera_watchdog.join() - - -class CameraTracker(FrigateProcess): - def __init__( - self, - config: CameraConfig, - model_config: ModelConfig, - labelmap: dict[int, str], - detection_queue: Queue, - detected_objects_queue, - camera_metrics: CameraMetrics, - ptz_metrics: PTZMetrics, - region_grid: list[list[dict[str, Any]]], - stop_event: MpEvent, - log_config: LoggerConfig | None = None, - ) -> None: - super().__init__( - stop_event, - PROCESS_PRIORITY_HIGH, - name=f"frigate.process:{config.name}", - daemon=True, - ) - self.config = config - self.model_config = model_config - self.labelmap = labelmap - self.detection_queue = detection_queue - self.detected_objects_queue = detected_objects_queue - self.camera_metrics = camera_metrics - self.ptz_metrics = ptz_metrics - self.region_grid = region_grid - self.log_config = log_config - - def run(self) -> None: - self.pre_run_setup(self.log_config) - frame_queue = self.camera_metrics.frame_queue - frame_shape = self.config.frame_shape - - motion_detector = ImprovedMotionDetector( - frame_shape, - self.config.motion, - self.config.detect.fps, - name=self.config.name, - ptz_metrics=self.ptz_metrics, - ) - object_detector = RemoteObjectDetector( - self.config.name, - self.labelmap, - self.detection_queue, - self.model_config, - self.stop_event, - ) - - object_tracker = NorfairTracker(self.config, self.ptz_metrics) - - frame_manager = SharedMemoryFrameManager() - - # create communication for region grid updates - requestor = InterProcessRequestor() - - process_frames( - requestor, - frame_queue, - frame_shape, - self.model_config, - self.config, - frame_manager, - motion_detector, - object_detector, - object_tracker, - self.detected_objects_queue, - self.camera_metrics, - self.stop_event, - self.ptz_metrics, - self.region_grid, - ) - - # empty the frame queue - logger.info(f"{self.config.name}: emptying frame queue") - while not frame_queue.empty(): - (frame_name, _) = frame_queue.get(False) - frame_manager.delete(frame_name) - - logger.info(f"{self.config.name}: exiting subprocess") - - -def detect( - detect_config: DetectConfig, - object_detector, - frame, - model_config: ModelConfig, - region, - objects_to_track, - object_filters, -): - tensor_input = create_tensor_input(frame, model_config, region) - - detections = [] - region_detections = object_detector.detect(tensor_input) - for d in region_detections: - box = d[2] - size = region[2] - region[0] - x_min = int(max(0, (box[1] * size) + region[0])) - y_min = int(max(0, (box[0] * size) + region[1])) - x_max = int(min(detect_config.width - 1, (box[3] * size) + region[0])) - y_max = int(min(detect_config.height - 1, (box[2] * size) + region[1])) - - # ignore objects that were detected outside the frame - if (x_min >= detect_config.width - 1) or (y_min >= detect_config.height - 1): - continue - - width = x_max - x_min - height = y_max - y_min - area = width * height - ratio = width / max(1, height) - det = (d[0], d[1], (x_min, y_min, x_max, y_max), area, ratio, region) - # apply object filters - if is_object_filtered(det, objects_to_track, object_filters): - continue - detections.append(det) - return detections - - -def process_frames( - requestor: InterProcessRequestor, - frame_queue: Queue, - frame_shape: tuple[int, int], - model_config: ModelConfig, - camera_config: CameraConfig, - frame_manager: FrameManager, - motion_detector: MotionDetector, - object_detector: RemoteObjectDetector, - object_tracker: ObjectTracker, - detected_objects_queue: Queue, - camera_metrics: CameraMetrics, - stop_event: MpEvent, - ptz_metrics: PTZMetrics, - region_grid: list[list[dict[str, Any]]], - exit_on_empty: bool = False, -): - next_region_update = get_tomorrow_at_time(2) - config_subscriber = CameraConfigUpdateSubscriber( - None, - {camera_config.name: camera_config}, - [ - CameraConfigUpdateEnum.detect, - CameraConfigUpdateEnum.enabled, - CameraConfigUpdateEnum.motion, - CameraConfigUpdateEnum.objects, - ], - ) - - fps_tracker = EventsPerSecond() - fps_tracker.start() - - startup_scan = True - stationary_frame_counter = 0 - camera_enabled = True - - region_min_size = get_min_region_size(model_config) - - attributes_map = model_config.attributes_map - all_attributes = model_config.all_attributes - - # remove license_plate from attributes if this camera is a dedicated LPR cam - if camera_config.type == CameraTypeEnum.lpr: - modified_attributes_map = model_config.attributes_map.copy() - - if ( - "car" in modified_attributes_map - and "license_plate" in modified_attributes_map["car"] - ): - modified_attributes_map["car"] = [ - attr - for attr in modified_attributes_map["car"] - if attr != "license_plate" - ] - - attributes_map = modified_attributes_map - - all_attributes = [ - attr for attr in model_config.all_attributes if attr != "license_plate" - ] - - while not stop_event.is_set(): - updated_configs = config_subscriber.check_for_updates() - - if "enabled" in updated_configs: - prev_enabled = camera_enabled - camera_enabled = camera_config.enabled - - if "motion" in updated_configs: - motion_detector.config = camera_config.motion - motion_detector.update_mask() - - if ( - not camera_enabled - and prev_enabled != camera_enabled - and camera_metrics.frame_queue.empty() - ): - logger.debug( - f"Camera {camera_config.name} disabled, clearing tracked objects" - ) - prev_enabled = camera_enabled - - # Clear norfair's dictionaries - object_tracker.tracked_objects.clear() - object_tracker.disappeared.clear() - object_tracker.stationary_box_history.clear() - object_tracker.positions.clear() - object_tracker.track_id_map.clear() - - # Clear internal norfair states - for trackers_by_type in object_tracker.trackers.values(): - for tracker in trackers_by_type.values(): - tracker.tracked_objects = [] - for tracker in object_tracker.default_tracker.values(): - tracker.tracked_objects = [] - - if not camera_enabled: - time.sleep(0.1) - continue - - if datetime.now().astimezone(timezone.utc) > next_region_update: - region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) - next_region_update = get_tomorrow_at_time(2) - - try: - if exit_on_empty: - frame_name, frame_time = frame_queue.get(False) - else: - frame_name, frame_time = frame_queue.get(True, 1) - except queue.Empty: - if exit_on_empty: - logger.info("Exiting track_objects...") - break - continue - - camera_metrics.detection_frame.value = frame_time - ptz_metrics.frame_time.value = frame_time - - frame = frame_manager.get(frame_name, (frame_shape[0] * 3 // 2, frame_shape[1])) - - if frame is None: - logger.debug( - f"{camera_config.name}: frame {frame_time} is not in memory store." - ) - continue - - # look for motion if enabled - motion_boxes = motion_detector.detect(frame) - - regions = [] - consolidated_detections = [] - - # if detection is disabled - if not camera_config.detect.enabled: - object_tracker.match_and_update(frame_name, frame_time, []) - else: - # get stationary object ids - # check every Nth frame for stationary objects - # disappeared objects are not stationary - # also check for overlapping motion boxes - if stationary_frame_counter == camera_config.detect.stationary.interval: - stationary_frame_counter = 0 - stationary_object_ids = [] - else: - stationary_frame_counter += 1 - stationary_object_ids = [ - obj["id"] - for obj in object_tracker.tracked_objects.values() - # if it has exceeded the stationary threshold - if obj["motionless_count"] - >= camera_config.detect.stationary.threshold - # and it hasn't disappeared - and object_tracker.disappeared[obj["id"]] == 0 - # and it doesn't overlap with any current motion boxes when not calibrating - and not intersects_any( - obj["box"], - [] if motion_detector.is_calibrating() else motion_boxes, - ) - ] - - # get tracked object boxes that aren't stationary - tracked_object_boxes = [ - ( - # use existing object box for stationary objects - obj["estimate"] - if obj["motionless_count"] - < camera_config.detect.stationary.threshold - else obj["box"] - ) - for obj in object_tracker.tracked_objects.values() - if obj["id"] not in stationary_object_ids - ] - object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes - - # get consolidated regions for tracked objects - regions = [ - get_cluster_region( - frame_shape, region_min_size, candidate, object_boxes - ) - for candidate in get_cluster_candidates( - frame_shape, region_min_size, object_boxes - ) - ] - - # only add in the motion boxes when not calibrating and a ptz is not moving via autotracking - # ptz_moving_at_frame_time() always returns False for non-autotracking cameras - if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time( - frame_time, - ptz_metrics.start_time.value, - ptz_metrics.stop_time.value, - ): - # find motion boxes that are not inside tracked object regions - standalone_motion_boxes = [ - b for b in motion_boxes if not inside_any(b, regions) - ] - - if standalone_motion_boxes: - motion_clusters = get_cluster_candidates( - frame_shape, - region_min_size, - standalone_motion_boxes, - ) - motion_regions = [ - get_cluster_region_from_grid( - frame_shape, - region_min_size, - candidate, - standalone_motion_boxes, - region_grid, - ) - for candidate in motion_clusters - ] - regions += motion_regions - - # if starting up, get the next startup scan region - if startup_scan: - for region in get_startup_regions( - frame_shape, region_min_size, region_grid - ): - regions.append(region) - startup_scan = False - - # resize regions and detect - # seed with stationary objects - detections = [ - ( - obj["label"], - obj["score"], - obj["box"], - obj["area"], - obj["ratio"], - obj["region"], - ) - for obj in object_tracker.tracked_objects.values() - if obj["id"] in stationary_object_ids - ] - - for region in regions: - detections.extend( - detect( - camera_config.detect, - object_detector, - frame, - model_config, - region, - camera_config.objects.track, - camera_config.objects.filters, - ) - ) - - consolidated_detections = reduce_detections(frame_shape, detections) - - # if detection was run on this frame, consolidate - if len(regions) > 0: - tracked_detections = [ - d for d in consolidated_detections if d[0] not in all_attributes - ] - # now that we have refined our detections, we need to track objects - object_tracker.match_and_update( - frame_name, frame_time, tracked_detections - ) - # else, just update the frame times for the stationary objects - else: - object_tracker.update_frame_times(frame_name, frame_time) - - # group the attribute detections based on what label they apply to - attribute_detections: dict[str, list[TrackedObjectAttribute]] = {} - for label, attribute_labels in attributes_map.items(): - attribute_detections[label] = [ - TrackedObjectAttribute(d) - for d in consolidated_detections - if d[0] in attribute_labels - ] - - # build detections - detections = {} - for obj in object_tracker.tracked_objects.values(): - detections[obj["id"]] = {**obj, "attributes": []} - - # find the best object for each attribute to be assigned to - all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values() - for attributes in attribute_detections.values(): - for attribute in attributes: - filtered_objects = filter( - lambda o: attribute.label in attributes_map.get(o["label"], []), - all_objects, - ) - selected_object_id = attribute.find_best_object(filtered_objects) - - if selected_object_id is not None: - detections[selected_object_id]["attributes"].append( - attribute.get_tracking_data() - ) - - # debug object tracking - if False: - bgr_frame = cv2.cvtColor( - frame, - cv2.COLOR_YUV2BGR_I420, - ) - object_tracker.debug_draw(bgr_frame, frame_time) - cv2.imwrite( - f"debug/frames/track-{'{:.6f}'.format(frame_time)}.jpg", bgr_frame - ) - # debug - if False: - bgr_frame = cv2.cvtColor( - frame, - cv2.COLOR_YUV2BGR_I420, - ) - - for m_box in motion_boxes: - cv2.rectangle( - bgr_frame, - (m_box[0], m_box[1]), - (m_box[2], m_box[3]), - (0, 0, 255), - 2, - ) - - for b in tracked_object_boxes: - cv2.rectangle( - bgr_frame, - (b[0], b[1]), - (b[2], b[3]), - (255, 0, 0), - 2, - ) - - for obj in object_tracker.tracked_objects.values(): - if obj["frame_time"] == frame_time: - thickness = 2 - color = model_config.colormap.get(obj["label"], (255, 255, 255)) - else: - thickness = 1 - color = (255, 0, 0) - - # draw the bounding boxes on the frame - box = obj["box"] - - draw_box_with_label( - bgr_frame, - box[0], - box[1], - box[2], - box[3], - obj["label"], - obj["id"], - thickness=thickness, - color=color, - ) - - for region in regions: - cv2.rectangle( - bgr_frame, - (region[0], region[1]), - (region[2], region[3]), - (0, 255, 0), - 2, - ) - - cv2.imwrite( - f"debug/frames/{camera_config.name}-{'{:.6f}'.format(frame_time)}.jpg", - bgr_frame, - ) - # add to the queue if not full - if detected_objects_queue.full(): - frame_manager.close(frame_name) - continue - else: - fps_tracker.update() - camera_metrics.process_fps.value = fps_tracker.eps() - detected_objects_queue.put( - ( - camera_config.name, - frame_name, - frame_time, - detections, - motion_boxes, - regions, - ) - ) - camera_metrics.detection_fps.value = object_detector.fps.eps() - frame_manager.close(frame_name) - - motion_detector.stop() - requestor.stop() - config_subscriber.stop() diff --git a/frigate/video/__init__.py b/frigate/video/__init__.py new file mode 100644 index 0000000000..24589835c5 --- /dev/null +++ b/frigate/video/__init__.py @@ -0,0 +1,2 @@ +from .detect import * # noqa: F403 +from .ffmpeg import * # noqa: F403 diff --git a/frigate/video/detect.py b/frigate/video/detect.py new file mode 100644 index 0000000000..2fca30debb --- /dev/null +++ b/frigate/video/detect.py @@ -0,0 +1,556 @@ +"""Manages camera object detection processes.""" + +import logging +import queue +import time +from datetime import UTC, datetime +from multiprocessing import Queue +from multiprocessing.synchronize import Event as MpEvent +from typing import Any + +import cv2 + +from frigate.camera import CameraMetrics, PTZMetrics +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig +from frigate.config.camera.camera import CameraTypeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) +from frigate.const import ( + PROCESS_PRIORITY_HIGH, + REQUEST_REGION_GRID, +) +from frigate.motion import MotionDetector +from frigate.motion.improved_motion import ImprovedMotionDetector +from frigate.object_detection.base import RemoteObjectDetector +from frigate.ptz.autotrack import ptz_moving_at_frame_time +from frigate.track import ObjectTracker +from frigate.track.norfair_tracker import NorfairTracker +from frigate.track.tracked_object import TrackedObjectAttribute +from frigate.util.builtin import EventsPerSecond +from frigate.util.image import ( + FrameManager, + SharedMemoryFrameManager, + draw_box_with_label, +) +from frigate.util.object import ( + create_tensor_input, + get_cluster_candidates, + get_cluster_region, + get_cluster_region_from_grid, + get_min_region_size, + get_startup_regions, + inside_any, + intersects_any, + is_object_filtered, + reduce_detections, +) +from frigate.util.process import FrigateProcess +from frigate.util.time import get_tomorrow_at_time + +logger = logging.getLogger(__name__) + + +class CameraTracker(FrigateProcess): + def __init__( + self, + config: CameraConfig, + model_config: ModelConfig, + labelmap: dict[int, str], + detection_queue: Queue, + detected_objects_queue, + camera_metrics: CameraMetrics, + ptz_metrics: PTZMetrics, + region_grid: list[list[dict[str, Any]]], + stop_event: MpEvent, + log_config: LoggerConfig | None = None, + ) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name=f"frigate.process:{config.name}", + daemon=True, + ) + self.config = config + self.model_config = model_config + self.labelmap = labelmap + self.detection_queue = detection_queue + self.detected_objects_queue = detected_objects_queue + self.camera_metrics = camera_metrics + self.ptz_metrics = ptz_metrics + self.region_grid = region_grid + self.log_config = log_config + + def run(self) -> None: + self.pre_run_setup(self.log_config) + frame_queue = self.camera_metrics.frame_queue + frame_shape = self.config.frame_shape + + motion_detector = ImprovedMotionDetector( + frame_shape, + self.config.motion, + self.config.detect.fps, + name=self.config.name, + ptz_metrics=self.ptz_metrics, + ) + object_detector = RemoteObjectDetector( + self.config.name, + self.labelmap, + self.detection_queue, + self.model_config, + self.stop_event, + ) + + object_tracker = NorfairTracker(self.config, self.ptz_metrics) + + frame_manager = SharedMemoryFrameManager() + + # create communication for region grid updates + requestor = InterProcessRequestor() + + process_frames( + requestor, + frame_queue, + frame_shape, + self.model_config, + self.config, + frame_manager, + motion_detector, + object_detector, + object_tracker, + self.detected_objects_queue, + self.camera_metrics, + self.stop_event, + self.ptz_metrics, + self.region_grid, + ) + + # empty the frame queue + logger.info(f"{self.config.name}: emptying frame queue") + while not frame_queue.empty(): + (frame_name, _) = frame_queue.get(False) + frame_manager.delete(frame_name) + + logger.info(f"{self.config.name}: exiting subprocess") + + +def detect( + detect_config: DetectConfig, + object_detector, + frame, + model_config: ModelConfig, + region, + objects_to_track, + object_filters, +): + tensor_input = create_tensor_input(frame, model_config, region) + + detections = [] + region_detections = object_detector.detect(tensor_input) + for d in region_detections: + box = d[2] + size = region[2] - region[0] + x_min = int(max(0, (box[1] * size) + region[0])) + y_min = int(max(0, (box[0] * size) + region[1])) + x_max = int(min(detect_config.width - 1, (box[3] * size) + region[0])) + y_max = int(min(detect_config.height - 1, (box[2] * size) + region[1])) + + # ignore objects that were detected outside the frame + if (x_min >= detect_config.width - 1) or (y_min >= detect_config.height - 1): + continue + + width = x_max - x_min + height = y_max - y_min + area = width * height + ratio = width / max(1, height) + det = (d[0], d[1], (x_min, y_min, x_max, y_max), area, ratio, region) + # apply object filters + if is_object_filtered(det, objects_to_track, object_filters): + continue + detections.append(det) + return detections + + +def process_frames( + requestor: InterProcessRequestor, + frame_queue: Queue, + frame_shape: tuple[int, int], + model_config: ModelConfig, + camera_config: CameraConfig, + frame_manager: FrameManager, + motion_detector: MotionDetector, + object_detector: RemoteObjectDetector, + object_tracker: ObjectTracker, + detected_objects_queue: Queue, + camera_metrics: CameraMetrics, + stop_event: MpEvent, + ptz_metrics: PTZMetrics, + region_grid: list[list[dict[str, Any]]], + exit_on_empty: bool = False, +): + next_region_update = get_tomorrow_at_time(2) + config_subscriber = CameraConfigUpdateSubscriber( + None, + {camera_config.name: camera_config}, + [ + CameraConfigUpdateEnum.detect, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.motion, + CameraConfigUpdateEnum.objects, + ], + ) + + fps_tracker = EventsPerSecond() + fps_tracker.start() + + startup_scan = True + stationary_frame_counter = 0 + camera_enabled = True + + region_min_size = get_min_region_size(model_config) + + attributes_map = model_config.attributes_map + all_attributes = model_config.all_attributes + + # remove license_plate from attributes if this camera is a dedicated LPR cam + if camera_config.type == CameraTypeEnum.lpr: + attributes_map = { + label: [attr for attr in attributes if attr != "license_plate"] + for label, attributes in model_config.attributes_map.items() + } + all_attributes = [ + attr for attr in model_config.all_attributes if attr != "license_plate" + ] + + while not stop_event.is_set(): + updated_configs = config_subscriber.check_for_updates() + + if "enabled" in updated_configs: + prev_enabled = camera_enabled + camera_enabled = camera_config.enabled + + if "motion" in updated_configs: + motion_detector.config = camera_config.motion + motion_detector.update_mask() + + if ( + not camera_enabled + and prev_enabled != camera_enabled + and camera_metrics.frame_queue.empty() + ): + logger.debug( + f"Camera {camera_config.name} disabled, clearing tracked objects" + ) + prev_enabled = camera_enabled + + # Clear norfair's dictionaries + object_tracker.tracked_objects.clear() + object_tracker.disappeared.clear() + object_tracker.stationary_box_history.clear() + object_tracker.positions.clear() + object_tracker.track_id_map.clear() + + # Clear internal norfair states + for trackers_by_type in object_tracker.trackers.values(): + for tracker in trackers_by_type.values(): + tracker.tracked_objects = [] + for tracker in object_tracker.default_tracker.values(): + tracker.tracked_objects = [] + + if not camera_enabled: + time.sleep(0.1) + continue + + if datetime.now().astimezone(UTC) > next_region_update: + region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) + next_region_update = get_tomorrow_at_time(2) + + try: + if exit_on_empty: + frame_name, frame_time = frame_queue.get(False) + else: + frame_name, frame_time = frame_queue.get(True, 1) + except queue.Empty: + if exit_on_empty: + logger.info("Exiting track_objects...") + break + continue + + camera_metrics.detection_frame.value = frame_time + ptz_metrics.frame_time.value = frame_time + + frame = frame_manager.get(frame_name, (frame_shape[0] * 3 // 2, frame_shape[1])) + + if frame is None: + logger.debug( + f"{camera_config.name}: frame {frame_time} is not in memory store." + ) + continue + + # look for motion if enabled + motion_boxes = motion_detector.detect(frame) + + regions = [] + consolidated_detections = [] + + # if detection is disabled + if not camera_config.detect.enabled: + object_tracker.match_and_update(frame_name, frame_time, []) + else: + # get stationary object ids + # check every Nth frame for stationary objects + # disappeared objects are not stationary + # also check for overlapping motion boxes + if stationary_frame_counter == camera_config.detect.stationary.interval: + stationary_frame_counter = 0 + stationary_object_ids = [] + else: + stationary_frame_counter += 1 + stationary_object_ids = [ + obj["id"] + for obj in object_tracker.tracked_objects.values() + # if it has exceeded the stationary threshold + if obj["motionless_count"] + >= camera_config.detect.stationary.threshold + # and it hasn't disappeared + and object_tracker.disappeared[obj["id"]] == 0 + # and it doesn't overlap with any current motion boxes when not calibrating + and not intersects_any( + obj["box"], + [] if motion_detector.is_calibrating() else motion_boxes, + ) + ] + + # get tracked object boxes that aren't stationary + tracked_object_boxes = [ + ( + # use existing object box for stationary objects + obj["estimate"] + if obj["motionless_count"] + < camera_config.detect.stationary.threshold + else obj["box"] + ) + for obj in object_tracker.tracked_objects.values() + if obj["id"] not in stationary_object_ids + ] + object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes + + # get consolidated regions for tracked objects + regions = [ + get_cluster_region( + frame_shape, region_min_size, candidate, object_boxes + ) + for candidate in get_cluster_candidates( + frame_shape, region_min_size, object_boxes + ) + ] + + # only add in the motion boxes when not calibrating and a ptz is not moving via autotracking + # the ptz timestamps are only maintained while autotracking is on, so gate + # on the metric rather than trusting them to be reset otherwise + ptz_moving = ptz_metrics.autotracker_enabled.value and ( + ptz_moving_at_frame_time( + frame_time, + ptz_metrics.start_time.value, + ptz_metrics.stop_time.value, + ) + ) + + if not motion_detector.is_calibrating() and not ptz_moving: + # find motion boxes that are not inside tracked object regions + standalone_motion_boxes = [ + b for b in motion_boxes if not inside_any(b, regions) + ] + + if standalone_motion_boxes: + motion_clusters = get_cluster_candidates( + frame_shape, + region_min_size, + standalone_motion_boxes, + ) + motion_regions = [ + get_cluster_region_from_grid( + frame_shape, + region_min_size, + candidate, + standalone_motion_boxes, + region_grid, + ) + for candidate in motion_clusters + ] + regions += motion_regions + + # if starting up, get the next startup scan region + if startup_scan: + for region in get_startup_regions( + frame_shape, region_min_size, region_grid + ): + regions.append(region) + startup_scan = False + + # resize regions and detect + # seed with stationary objects + detections = [ + ( + obj["label"], + obj["score"], + obj["box"], + obj["area"], + obj["ratio"], + obj["region"], + ) + for obj in object_tracker.tracked_objects.values() + if obj["id"] in stationary_object_ids + ] + + for region in regions: + detections.extend( + detect( + camera_config.detect, + object_detector, + frame, + model_config, + region, + camera_config.objects.track, + camera_config.objects.filters, + ) + ) + + consolidated_detections = reduce_detections(frame_shape, detections) + + # if detection was run on this frame, consolidate + if len(regions) > 0: + tracked_detections = [ + d for d in consolidated_detections if d[0] not in all_attributes + ] + # now that we have refined our detections, we need to track objects + object_tracker.match_and_update( + frame_name, frame_time, tracked_detections + ) + # else, just update the frame times for the stationary objects + else: + object_tracker.update_frame_times(frame_name, frame_time) + + # build detections + detections = {} + for obj in object_tracker.tracked_objects.values(): + detections[obj["id"]] = {**obj, "attributes": []} + + # assign each detected attribute to the best matching object. + # iterate consolidated_detections once so attributes that appear under + # multiple parent labels in attributes_map (e.g. license_plate is in + # both "car" and "motorcycle") are not appended more than once + all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values() + detected_attributes = [ + TrackedObjectAttribute(d) + for d in consolidated_detections + if d[0] in all_attributes + ] + for attribute in detected_attributes: + filtered_objects = filter( + lambda o: attribute.label in attributes_map.get(o["label"], []), + all_objects, + ) + selected_object_id = attribute.find_best_object(filtered_objects) + + if selected_object_id is not None: + detections[selected_object_id]["attributes"].append( + attribute.get_tracking_data() + ) + + # debug object tracking + if False: + bgr_frame = cv2.cvtColor( + frame, + cv2.COLOR_YUV2BGR_I420, + ) + object_tracker.debug_draw(bgr_frame, frame_time) + cv2.imwrite( + f"debug/frames/track-{'{:.6f}'.format(frame_time)}.jpg", bgr_frame + ) + # debug + if False: + bgr_frame = cv2.cvtColor( + frame, + cv2.COLOR_YUV2BGR_I420, + ) + + for m_box in motion_boxes: + cv2.rectangle( + bgr_frame, + (m_box[0], m_box[1]), + (m_box[2], m_box[3]), + (0, 0, 255), + 2, + ) + + for b in tracked_object_boxes: + cv2.rectangle( + bgr_frame, + (b[0], b[1]), + (b[2], b[3]), + (255, 0, 0), + 2, + ) + + for obj in object_tracker.tracked_objects.values(): + if obj["frame_time"] == frame_time: + thickness = 2 + color = model_config.colormap.get(obj["label"], (255, 255, 255)) + else: + thickness = 1 + color = (255, 0, 0) + + # draw the bounding boxes on the frame + box = obj["box"] + + draw_box_with_label( + bgr_frame, + box[0], + box[1], + box[2], + box[3], + obj["label"], + obj["id"], + thickness=thickness, + color=color, + ) + + for region in regions: + cv2.rectangle( + bgr_frame, + (region[0], region[1]), + (region[2], region[3]), + (0, 255, 0), + 2, + ) + + cv2.imwrite( + f"debug/frames/{camera_config.name}-{'{:.6f}'.format(frame_time)}.jpg", + bgr_frame, + ) + # add to the queue if not full + if detected_objects_queue.full(): + frame_manager.close(frame_name) + continue + else: + fps_tracker.update() + camera_metrics.process_fps.value = fps_tracker.eps() + detected_objects_queue.put( + ( + camera_config.name, + frame_name, + frame_time, + detections, + motion_boxes, + regions, + ) + ) + camera_metrics.detection_fps.value = object_detector.fps.eps() + frame_manager.close(frame_name) + + motion_detector.stop() + requestor.stop() + config_subscriber.stop() diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py new file mode 100644 index 0000000000..24b7805333 --- /dev/null +++ b/frigate/video/ffmpeg.py @@ -0,0 +1,671 @@ +"""Manages ffmpeg processes for camera frame capture.""" + +import logging +import queue +import subprocess as sp +import threading +import time +from collections import deque +from datetime import UTC, datetime, timedelta +from multiprocessing import Queue, Value +from multiprocessing.synchronize import Event as MpEvent +from typing import Any + +from frigate.camera import CameraMetrics +from frigate.comms.inter_process import InterProcessRequestor +from frigate.comms.recordings_updater import ( + RecordingsDataSubscriber, + RecordingsDataTypeEnum, +) +from frigate.config import CameraConfig, LoggerConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) +from frigate.const import PROCESS_PRIORITY_HIGH +from frigate.log import LogPipe +from frigate.util.builtin import EventsPerSecond, get_record_segment_time +from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg +from frigate.util.image import ( + FrameManager, + SharedMemoryFrameManager, +) +from frigate.util.process import FrigateProcess + +logger = logging.getLogger(__name__) + + +def capture_frames( + ffmpeg_process: sp.Popen[Any], + config: CameraConfig, + shm_frame_count: int, + frame_index: int, + frame_shape: tuple[int, int], + frame_manager: FrameManager, + frame_queue, + fps: Value, + skipped_fps: Value, + current_frame: Value, + stop_event: MpEvent, +) -> None: + frame_size = frame_shape[0] * frame_shape[1] + frame_rate = EventsPerSecond() + frame_rate.start() + skipped_eps = EventsPerSecond() + skipped_eps.start() + + config_subscriber = CameraConfigUpdateSubscriber( + None, {config.name: config}, [CameraConfigUpdateEnum.enabled] + ) + + def get_enabled_state(): + """Fetch the latest enabled state from ZMQ.""" + config_subscriber.check_for_updates() + return config.enabled + + try: + while not stop_event.is_set(): + if not get_enabled_state(): + logger.debug(f"Stopping capture thread for disabled {config.name}") + break + + fps.value = frame_rate.eps() + skipped_fps.value = skipped_eps.eps() + current_frame.value = datetime.now().timestamp() + frame_name = f"{config.name}_frame{frame_index}" + frame_buffer = frame_manager.write(frame_name) + try: + frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) + except Exception: + # shutdown has been initiated + if stop_event.is_set(): + break + + logger.error( + f"{config.name}: Unable to read frames from ffmpeg process." + ) + + if ffmpeg_process.poll() is not None: + logger.error( + f"{config.name}: ffmpeg process is not running. exiting capture thread..." + ) + break + + continue + + frame_rate.update() + + # don't lock the queue to check, just try since it should rarely be full + try: + # add to the queue + frame_queue.put((frame_name, current_frame.value), False) + frame_manager.close(frame_name) + except queue.Full: + # if the queue is full, skip this frame + skipped_eps.update() + + frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 + finally: + config_subscriber.stop() + + +class CameraWatchdog(threading.Thread): + def __init__( + self, + config: CameraConfig, + shm_frame_count: int, + frame_queue: Queue, + camera_fps, + skipped_fps, + ffmpeg_pid, + stalls, + reconnects, + detection_frame, + stop_event, + ): + threading.Thread.__init__(self) + self.logger = logging.getLogger(f"watchdog.{config.name}") + self.config = config + self.shm_frame_count = shm_frame_count + self.capture_thread = None + self.ffmpeg_detect_process = None + self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.detect") + self.ffmpeg_other_processes: list[dict[str, Any]] = [] + self.camera_fps = camera_fps + self.skipped_fps = skipped_fps + self.ffmpeg_pid = ffmpeg_pid + self.frame_queue = frame_queue + self.frame_shape = self.config.frame_shape_yuv + self.frame_size = self.frame_shape[0] * self.frame_shape[1] + self.fps_overflow_count = 0 + self.frame_index = 0 + self.stop_event = stop_event + self.sleeptime = self.config.ffmpeg.retry_interval + self.reconnect_timestamps = deque() + self.stalls = stalls + self.reconnects = reconnects + self.detection_frame = detection_frame + + self.config_subscriber = CameraConfigUpdateSubscriber( + None, + {config.name: config}, + [ + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.ffmpeg, + CameraConfigUpdateEnum.record, + ], + ) + self.requestor = InterProcessRequestor() + self.was_enabled = self.config.enabled + self.was_record_enabled_in_config = self.config.record.enabled_in_config + + self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) + self.latest_valid_segment_time: float = 0 + self.latest_invalid_segment_time: float = 0 + self.latest_cache_segment_time: float = 0 + self.record_enable_time: datetime | None = None + + # `valid` segments are published with the segment's start time, so the + # gap between consecutive publishes can reach 2 * segment_time. Pad the + # staleness threshold so it's never tighter than that worst case. + segment_time = get_record_segment_time(self.config) + self.record_stale_threshold = max(120, 2 * segment_time + 30) + + # Stall tracking (based on last processed frame) + self._stall_timestamps: deque[float] = deque() + self._stall_active: bool = False + + # Status caching to reduce message volume + self._last_detect_status: str | None = None + self._last_record_status: str | None = None + self._last_status_update_time: float = 0.0 + + def _send_detect_status(self, status: str, now: float) -> None: + """Send detect status only if changed or retry_interval has elapsed.""" + if ( + status != self._last_detect_status + or (now - self._last_status_update_time) >= self.sleeptime + ): + self.requestor.send_data(f"{self.config.name}/status/detect", status) + self._last_detect_status = status + self._last_status_update_time = now + + def _send_record_status(self, status: str, now: float) -> None: + """Send record status only if changed or retry_interval has elapsed.""" + if ( + status != self._last_record_status + or (now - self._last_status_update_time) >= self.sleeptime + ): + self.requestor.send_data(f"{self.config.name}/status/record", status) + self._last_record_status = status + self._last_status_update_time = now + + def _check_config_updates(self) -> dict[str, list[str]]: + """Check for config updates and return the update dict.""" + return self.config_subscriber.check_for_updates() + + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + self._check_config_updates() + return self.config.enabled + + def reset_capture_thread( + self, terminate: bool = True, drain_output: bool = True + ) -> None: + if terminate: + self.ffmpeg_detect_process.terminate() + try: + self.logger.info("Waiting for ffmpeg to exit gracefully...") + + if drain_output: + self.ffmpeg_detect_process.communicate(timeout=30) + else: + self.ffmpeg_detect_process.wait(timeout=30) + except sp.TimeoutExpired: + self.logger.info("FFmpeg did not exit. Force killing...") + self.ffmpeg_detect_process.kill() + + if drain_output: + self.ffmpeg_detect_process.communicate() + else: + self.ffmpeg_detect_process.wait() + + # Update reconnects + now = datetime.now().timestamp() + self.reconnect_timestamps.append(now) + while self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600: + self.reconnect_timestamps.popleft() + if self.reconnects: + self.reconnects.value = len(self.reconnect_timestamps) + + # Wait for old capture thread to fully exit before starting a new one + if self.capture_thread is not None and self.capture_thread.is_alive(): + self.logger.info("Waiting for capture thread to exit...") + self.capture_thread.join(timeout=5) + + if self.capture_thread.is_alive(): + self.logger.warning( + f"Capture thread for {self.config.name} did not exit in time" + ) + + self.logger.error( + "The following ffmpeg logs include the last 100 lines prior to exit." + ) + self.logpipe.dump() + self.logger.info("Restarting ffmpeg...") + self.start_ffmpeg_detect() + + def run(self) -> None: + if self._update_enabled_state(): + self.start_all_ffmpeg() + # If recording is enabled at startup, set the grace period timer + if self.config.record.enabled: + self.record_enable_time = datetime.now().astimezone(UTC) + + time.sleep(self.sleeptime) + last_restart_time = datetime.now().timestamp() + + # 1 second watchdog loop + while not self.stop_event.wait(1): + updates = self._check_config_updates() + + # Handle ffmpeg config changes by restarting all ffmpeg processes + if "ffmpeg" in updates and self.config.enabled: + self.logger.debug( + "FFmpeg config updated for %s, restarting ffmpeg processes", + self.config.name, + ) + self.stop_all_ffmpeg() + self.start_all_ffmpeg() + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(UTC) + last_restart_time = datetime.now().timestamp() + continue + + enabled = self.config.enabled + if enabled != self.was_enabled: + if enabled: + self.logger.debug(f"Enabling camera {self.config.name}") + self.start_all_ffmpeg() + + # reset all timestamps and record the enable time for grace period + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(UTC) + else: + self.logger.debug(f"Disabling camera {self.config.name}") + self.stop_all_ffmpeg() + self.record_enable_time = None + + # update camera status + now = datetime.now().timestamp() + self._send_detect_status("disabled", now) + self._send_record_status("disabled", now) + self.was_enabled = enabled + continue + + record_enabled_in_config = self.config.record.enabled_in_config + if record_enabled_in_config != self.was_record_enabled_in_config: + if record_enabled_in_config and enabled: + self.logger.debug( + f"Record enabled in config for {self.config.name}, restarting ffmpeg" + ) + self.stop_all_ffmpeg() + self.start_all_ffmpeg() + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(UTC) + last_restart_time = datetime.now().timestamp() + self.was_record_enabled_in_config = record_enabled_in_config + continue + + if not enabled: + continue + + while True: + update = self.segment_subscriber.check_for_update(timeout=0) + + if update == (None, None): + break + + raw_topic, payload = update + if raw_topic and payload: + topic = str(raw_topic) + camera, segment_time, _ = payload + + if camera != self.config.name: + continue + + if topic.endswith(RecordingsDataTypeEnum.invalid.value): + self.logger.warning( + f"Invalid recording segment detected for {camera} at {segment_time}" + ) + self.latest_invalid_segment_time = segment_time + elif topic.endswith(RecordingsDataTypeEnum.valid.value): + self.logger.debug( + f"Latest valid recording segment time on {camera}: {segment_time}" + ) + self.latest_valid_segment_time = segment_time + elif topic.endswith(RecordingsDataTypeEnum.latest.value): + if segment_time is not None: + self.latest_cache_segment_time = segment_time + else: + self.latest_cache_segment_time = 0 + + now = datetime.now().timestamp() + + # Check if enough time has passed to allow ffmpeg restart (backoff pacing) + time_since_last_restart = now - last_restart_time + can_restart = time_since_last_restart >= self.sleeptime + + if not self.capture_thread.is_alive(): + self._send_detect_status("offline", now) + self.camera_fps.value = 0 + self.logger.error( + f"Ffmpeg process crashed unexpectedly for {self.config.name}." + ) + if can_restart: + self.reset_capture_thread(terminate=False) + last_restart_time = now + elif self.camera_fps.value >= (self.config.detect.fps + 10): + self.fps_overflow_count += 1 + + if self.fps_overflow_count == 3: + self._send_detect_status("offline", now) + self.fps_overflow_count = 0 + self.camera_fps.value = 0 + self.logger.info( + f"{self.config.name} exceeded fps limit. Exiting ffmpeg..." + ) + if can_restart: + self.reset_capture_thread(drain_output=False) + last_restart_time = now + elif now - self.capture_thread.current_frame.value > 20: + self._send_detect_status("offline", now) + self.camera_fps.value = 0 + self.logger.info( + f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..." + ) + if can_restart: + self.reset_capture_thread() + last_restart_time = now + else: + # process is running normally + self._send_detect_status("online", now) + self.fps_overflow_count = 0 + + for p in self.ffmpeg_other_processes: + poll = p["process"].poll() + + if self.config.record.enabled and "record" in p["roles"]: + now_utc = datetime.now().astimezone(UTC) + + # Check if we're within the grace period after enabling recording + # Grace period: 90 seconds allows time for ffmpeg to start and create first segment + in_grace_period = self.record_enable_time is not None and ( + now_utc - self.record_enable_time + ) < timedelta(seconds=90) + + latest_cache_dt = ( + datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC) + if self.latest_cache_segment_time > 0 + else now_utc - timedelta(seconds=1) + ) + + latest_valid_dt = ( + datetime.fromtimestamp(self.latest_valid_segment_time, tz=UTC) + if self.latest_valid_segment_time > 0 + else now_utc - timedelta(seconds=1) + ) + + latest_invalid_dt = ( + datetime.fromtimestamp(self.latest_invalid_segment_time, tz=UTC) + if self.latest_invalid_segment_time > 0 + else now_utc - timedelta(seconds=1) + ) + + # ensure segments are still being created and that they have valid video data + # Skip checks during grace period to allow segments to start being created + stale_window = timedelta(seconds=self.record_stale_threshold) + cache_stale = not in_grace_period and now_utc > ( + latest_cache_dt + stale_window + ) + valid_stale = not in_grace_period and now_utc > ( + latest_valid_dt + stale_window + ) + invalid_stale_condition = ( + self.latest_invalid_segment_time > 0 + and not in_grace_period + and now_utc > (latest_invalid_dt + stale_window) + and self.latest_valid_segment_time + <= self.latest_invalid_segment_time + ) + invalid_stale = invalid_stale_condition + + if cache_stale or valid_stale or invalid_stale: + if cache_stale: + reason = "No new recording segments were created" + elif valid_stale: + reason = "No new valid recording segments were created" + else: # invalid_stale + reason = ( + "No valid segments created since last invalid segment" + ) + + self.logger.error( + f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..." + ) + p["process"] = start_or_restart_ffmpeg( + p["cmd"], + self.logger, + p["logpipe"], + ffmpeg_process=p["process"], + ) + + for role in p["roles"]: + self.requestor.send_data( + f"{self.config.name}/status/{role.value}", "offline" + ) + + continue + else: + self._send_record_status("online", now) + p["latest_segment_time"] = self.latest_cache_segment_time + + if poll is None: + continue + + for role in p["roles"]: + self.requestor.send_data( + f"{self.config.name}/status/{role.value}", "offline" + ) + + p["logpipe"].dump() + p["process"] = start_or_restart_ffmpeg( + p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] + ) + + # Prune expired reconnect timestamps + now = datetime.now().timestamp() + while ( + self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600 + ): + self.reconnect_timestamps.popleft() + if self.reconnects: + self.reconnects.value = len(self.reconnect_timestamps) + + # Update stall metrics based on last processed frame timestamp + processed_ts = ( + float(self.detection_frame.value) if self.detection_frame else 0.0 + ) + if processed_ts > 0: + delta = now - processed_ts + observed_fps = ( + self.camera_fps.value + if self.camera_fps.value > 0 + else self.config.detect.fps + ) + interval = 1.0 / max(observed_fps, 0.1) + stall_threshold = max(2.0 * interval, 2.0) + + if delta > stall_threshold: + if not self._stall_active: + self._stall_timestamps.append(now) + self._stall_active = True + else: + self._stall_active = False + + while self._stall_timestamps and self._stall_timestamps[0] < now - 3600: + self._stall_timestamps.popleft() + + if self.stalls: + self.stalls.value = len(self._stall_timestamps) + + self.stop_all_ffmpeg() + self.logpipe.close() + self.config_subscriber.stop() + self.segment_subscriber.stop() + + def start_ffmpeg_detect(self): + ffmpeg_cmd = [ + c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"] + ][0] + self.ffmpeg_detect_process = start_or_restart_ffmpeg( + ffmpeg_cmd, self.logger, self.logpipe, self.frame_size + ) + self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid + self.capture_thread = CameraCaptureRunner( + self.config, + self.shm_frame_count, + self.frame_index, + self.ffmpeg_detect_process, + self.frame_shape, + self.frame_queue, + self.camera_fps, + self.skipped_fps, + self.stop_event, + ) + self.capture_thread.start() + + def start_all_ffmpeg(self): + """Start all ffmpeg processes (detection and others).""" + logger.debug(f"Starting all ffmpeg processes for {self.config.name}") + self.start_ffmpeg_detect() + for c in self.config.ffmpeg_cmds: + if "detect" in c["roles"]: + continue + logpipe = LogPipe( + f"ffmpeg.{self.config.name}.{'_'.join(sorted(c['roles']))}" + ) + self.ffmpeg_other_processes.append( + { + "cmd": c["cmd"], + "roles": c["roles"], + "logpipe": logpipe, + "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), + } + ) + + def stop_all_ffmpeg(self): + """Stop all ffmpeg processes (detection and others).""" + logger.debug(f"Stopping all ffmpeg processes for {self.config.name}") + if self.capture_thread is not None and self.capture_thread.is_alive(): + self.capture_thread.join(timeout=5) + if self.capture_thread.is_alive(): + self.logger.warning( + f"Capture thread for {self.config.name} did not stop gracefully." + ) + if self.ffmpeg_detect_process is not None: + stop_ffmpeg(self.ffmpeg_detect_process, self.logger) + self.ffmpeg_detect_process = None + for p in self.ffmpeg_other_processes[:]: + if p["process"] is not None: + stop_ffmpeg(p["process"], self.logger) + p["logpipe"].close() + self.ffmpeg_other_processes.clear() + + +class CameraCaptureRunner(threading.Thread): + def __init__( + self, + config: CameraConfig, + shm_frame_count: int, + frame_index: int, + ffmpeg_process, + frame_shape: tuple[int, int], + frame_queue: Queue, + fps: Value, + skipped_fps: Value, + stop_event: MpEvent, + ): + threading.Thread.__init__(self) + self.name = f"capture:{config.name}" + self.config = config + self.shm_frame_count = shm_frame_count + self.frame_index = frame_index + self.frame_shape = frame_shape + self.frame_queue = frame_queue + self.fps = fps + self.stop_event = stop_event + self.skipped_fps = skipped_fps + self.frame_manager = SharedMemoryFrameManager() + self.ffmpeg_process = ffmpeg_process + self.current_frame = Value("d", 0.0) + self.last_frame = 0 + + def run(self): + capture_frames( + self.ffmpeg_process, + self.config, + self.shm_frame_count, + self.frame_index, + self.frame_shape, + self.frame_manager, + self.frame_queue, + self.fps, + self.skipped_fps, + self.current_frame, + self.stop_event, + ) + + +class CameraCapture(FrigateProcess): + def __init__( + self, + config: CameraConfig, + shm_frame_count: int, + camera_metrics: CameraMetrics, + stop_event: MpEvent, + log_config: LoggerConfig | None = None, + ) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name=f"frigate.capture:{config.name}", + daemon=True, + ) + self.config = config + self.shm_frame_count = shm_frame_count + self.camera_metrics = camera_metrics + self.log_config = log_config + + def run(self) -> None: + self.pre_run_setup(self.log_config) + camera_watchdog = CameraWatchdog( + self.config, + self.shm_frame_count, + self.camera_metrics.frame_queue, + self.camera_metrics.camera_fps, + self.camera_metrics.skipped_fps, + self.camera_metrics.ffmpeg_pid, + self.camera_metrics.stalls_last_hour, + self.camera_metrics.reconnects_last_hour, + self.camera_metrics.detection_frame, + self.stop_event, + ) + camera_watchdog.start() + camera_watchdog.join() diff --git a/frigate/watchdog.py b/frigate/watchdog.py index 4c49de1a03..88023d5122 100644 --- a/frigate/watchdog.py +++ b/frigate/watchdog.py @@ -2,19 +2,114 @@ import datetime import logging import threading import time +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field from multiprocessing.synchronize import Event as MpEvent from frigate.object_detection.base import ObjectDetectProcess +from frigate.util.process import FrigateProcess from frigate.util.services import restart_frigate logger = logging.getLogger(__name__) +MAX_RESTARTS = 5 +RESTART_WINDOW_S = 60 + + +@dataclass +class MonitoredProcess: + """A process monitored by the watchdog for automatic restart.""" + + name: str + process: FrigateProcess + factory: Callable[[], FrigateProcess] + on_restart: Callable[[FrigateProcess], None] | None = None + restart_timestamps: deque[float] = field( + default_factory=lambda: deque(maxlen=MAX_RESTARTS) + ) + clean_exit_logged: bool = False + + def is_restarting_too_fast(self, now: float) -> bool: + while ( + self.restart_timestamps + and now - self.restart_timestamps[0] > RESTART_WINDOW_S + ): + self.restart_timestamps.popleft() + return len(self.restart_timestamps) >= MAX_RESTARTS + class FrigateWatchdog(threading.Thread): - def __init__(self, detectors: dict[str, ObjectDetectProcess], stop_event: MpEvent): + def __init__( + self, + detectors: dict[str, ObjectDetectProcess], + stop_event: MpEvent, + ): super().__init__(name="frigate_watchdog") self.detectors = detectors self.stop_event = stop_event + self._monitored: list[MonitoredProcess] = [] + + def register( + self, + name: str, + process: FrigateProcess, + factory: Callable[[], FrigateProcess], + on_restart: Callable[[FrigateProcess], None] | None = None, + ) -> None: + """Register a FrigateProcess for monitoring and automatic restart.""" + self._monitored.append( + MonitoredProcess( + name=name, + process=process, + factory=factory, + on_restart=on_restart, + ) + ) + + def _check_process(self, entry: MonitoredProcess) -> None: + if entry.process.is_alive(): + return + + exitcode = entry.process.exitcode + if exitcode == 0: + if not entry.clean_exit_logged: + logger.info("Process %s exited cleanly, not restarting", entry.name) + entry.clean_exit_logged = True + return + + logger.warning( + "Process %s (PID %s) exited with code %s", + entry.name, + entry.process.pid, + exitcode, + ) + + now = datetime.datetime.now().timestamp() + + if entry.is_restarting_too_fast(now): + logger.error( + "Process %s restarting too frequently (%d times in %ds), backing off", + entry.name, + MAX_RESTARTS, + RESTART_WINDOW_S, + ) + return + + try: + entry.process.close() + new_process = entry.factory() + new_process.start() + + entry.process = new_process + entry.restart_timestamps.append(now) + + if entry.on_restart: + entry.on_restart(new_process) + + logger.info("Restarted %s (PID %s)", entry.name, new_process.pid) + except Exception: + logger.exception("Failed to restart %s", entry.name) def run(self) -> None: time.sleep(10) @@ -38,4 +133,7 @@ class FrigateWatchdog(threading.Thread): logger.info("Detection appears to have stopped. Exiting Frigate...") restart_frigate() + for entry in self._monitored: + self._check_process(entry) + logger.info("Exiting watchdog...") diff --git a/generate_api_auth_spec.py b/generate_api_auth_spec.py new file mode 100644 index 0000000000..b1f11ebfe7 --- /dev/null +++ b/generate_api_auth_spec.py @@ -0,0 +1,612 @@ +"""Generate the OpenAPI spec from the app, annotated with auth requirements. + +This generator builds the FastAPI application, exports its OpenAPI document via +``app.openapi()``, and enriches every operation with authentication metadata: + + * a ``components.securitySchemes`` block, + * a per-operation ``security`` requirement (so the docs render a lock badge), + * an ``x-required-role`` extension for machine readers, and + * a short bold ``Access:`` note prepended to each operation description. + +The committed docs/static/frigate-api.yaml is the output of this script. It is +generated rather than hand-maintained so it stays complete and current; the docs +build (docusaurus-plugin-openapi-docs) consumes it as-is. + +The access level for an endpoint is determined by BOTH its route-level +dependency (``require_role``/``allow_any_authenticated``/``allow_public``/ +``require_camera_access``) AND the global "secure by default" admin dependency, +which is bypassed only for the paths listed in ``require_admin_by_default``. +Those exempt lists are read directly from the function's closure so this script +stays in lockstep with ``frigate/api/auth.py`` instead of duplicating them. + +Many handlers enforce per-camera access by calling ``require_camera_access`` +inside the handler body rather than as a route dependency, which dependency +introspection cannot see. We recover those from the handler's bytecode (see +``_handler_enforces_camera``) and promote an otherwise "any authenticated" +operation to camera-scoped. + +Usage (from the repository root): + + python3 generate_api_auth_spec.py # write the spec + python3 generate_api_auth_spec.py --check # CI guard: fail if stale + +The process exits non-zero if the generated document fails structural +validation, or (in --check mode) if the committed spec is out of date. +""" + +import argparse +import difflib +import inspect +import io +import logging +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.routing import APIRoute +from ruamel.yaml import YAML +from ruamel.yaml.scalarstring import LiteralScalarString + +from frigate.api import app as main_app +from frigate.api import ( + auth, + camera, + chat, + classification, + debug_replay, + event, + export, + media, + motion_search, + notification, + preview, + record, + review, +) +from frigate.api.auth import require_admin_by_default + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("generate_api_auth_spec") + +REPO_ROOT = Path(__file__).resolve().parent +OUTPUT_SPEC = REPO_ROOT / "docs" / "static" / "frigate-api.yaml" + +HTTP_METHODS = {"get", "post", "put", "delete", "patch"} + +# Banner written at the top of the generated spec. +HEADER = ( + "# Generated by generate_api_auth_spec.py — do not edit by hand.\n" + "# Regenerate with: python3 generate_api_auth_spec.py\n" + "# The empty info.title is intentional: a docusaurus-openapi-docs convention\n" + "# that suppresses the generated API introduction page.\n" +) + +# Post-processing applied on top of the raw app.openapi() export. These live +# only in the published spec, not in the app, so they are reproduced here. +SPEC_TITLE = "" +SPEC_SERVERS = [ + {"url": "https://demo.frigate.video/api"}, + {"url": "http://localhost:5001/api"}, +] + +# Access levels, ordered from least to most privileged. The string values are +# also what we emit as ``x-required-role``. +PUBLIC = "public" +AUTHENTICATED = "any" +CAMERA = "camera" +ALL_CAMERAS = "all_cameras" +ADMIN = "admin" + +ADMIN_SCHEME = "frigateAdminAuth" +USER_SCHEME = "frigateUserAuth" + +SECURITY_SCHEMES = { + ADMIN_SCHEME: { + "type": "apiKey", + "in": "cookie", + "name": "frigate_token", + "description": ( + "Authenticated session whose resolved role is 'admin'. The session " + "is established via the JWT cookie issued by POST /login, or via " + "proxy auth headers (remote-user / remote-role) when Frigate runs " + "behind an authenticating reverse proxy." + ), + }, + USER_SCHEME: { + "type": "apiKey", + "in": "cookie", + "name": "frigate_token", + "description": ( + "Any authenticated session (role 'viewer' or higher), established " + "via the JWT cookie issued by POST /login, or via proxy auth " + "headers when Frigate runs behind an authenticating reverse proxy." + ), + }, +} + +# How each access level maps to a rendered note. +ACCESS_NOTES = { + PUBLIC: "**Access:** Public — no authentication required.", + AUTHENTICATED: "**Access:** Any authenticated user.", + CAMERA: "**Access:** Authenticated user with access to the referenced camera.", + ALL_CAMERAS: "**Access:** Authenticated user with access to all cameras.", + ADMIN: "**Access:** Admin role required.", +} + + +def build_app() -> FastAPI: + """Build a bare app with every router mounted. + + This mirrors the router set wired up in frigate.api.fastapi_app. It omits + the global admin dependency and all runtime state; the OpenAPI route table + and the per-route dependencies are all we need to export and classify. + """ + app = FastAPI() + routers = [ + auth.router, + camera.router, + chat.router, + classification.router, + review.router, + main_app.router, + preview.router, + notification.router, + export.router, + event.router, + media.router, + motion_search.router, + record.router, + debug_replay.router, + ] + for router in routers: + app.include_router(router) + return app + + +def read_exempt_rules() -> tuple[set[str], tuple[str, ...]]: + """Read the admin-exemption lists straight from the auth dependency closure. + + Reading them here (rather than copying) keeps this generator in sync with + frigate/api/auth.py automatically. + """ + closure = inspect.getclosurevars(require_admin_by_default()).nonlocals + exempt_paths = set(closure["EXEMPT_PATHS"]) + exempt_prefixes = tuple(closure["EXEMPT_PREFIXES"]) + return exempt_paths, exempt_prefixes + + +def _first_segment(path: str) -> str: + return path.split("/", 2)[1] if path.startswith("/") and len(path) > 1 else "" + + +def _route_markers(route: APIRoute) -> tuple[set[str], list[str] | None]: + """Return the set of recognized auth markers on a route's dependencies.""" + markers: set[str] = set() + admin_roles: list[str] | None = None + + for dep in route.dependant.dependencies: + call = dep.call + qualname = getattr(call, "__qualname__", "") or "" + name = getattr(call, "__name__", "") or "" + + if "role_checker" in qualname: + markers.add(ADMIN) + try: + roles = inspect.getclosurevars(call).nonlocals.get("required_roles") + if roles: + admin_roles = list(roles) + except (TypeError, ValueError): + pass + elif name in ("require_camera_access", "require_go2rtc_stream_access"): + markers.add(CAMERA) + elif name == "require_full_camera_access": + markers.add(ALL_CAMERAS) + elif "auth_checker" in qualname: + markers.add(AUTHENTICATED) + elif "public_checker" in qualname: + markers.add(PUBLIC) + + return markers, admin_roles + + +def _handler_enforces_camera(route: APIRoute) -> bool: + """True if the route handler calls require_camera_access in its body. + + Such calls are invisible to dependency introspection. We detect them from + the handler's compiled bytecode: a global name referenced anywhere in the + function appears in ``__code__.co_names``. This catches direct calls (all of + them, currently); a call hidden behind a helper function would be missed. + """ + code = getattr(route.endpoint, "__code__", None) + return bool(code and "require_camera_access" in code.co_names) + + +def classify_route( + route: APIRoute, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> tuple[str, list[str] | None, str | None]: + """Resolve the effective access level for a route. + + Returns (access_level, roles, flag). ``flag`` is a human-readable note when + the result needed inference or revealed a possible inconsistency. + """ + level, roles, flag = _classify_base(route, exempt_paths, exempt_prefixes) + + # In-body require_camera_access enforcement is invisible to dependency + # introspection. When the effective access would otherwise be "any + # authenticated", the handler's per-camera check is the real constraint, so + # promote it to camera-scoped. Admin/public are left alone: for admin the + # role is the binding requirement and the camera check is only defensive. + if level == AUTHENTICATED and _handler_enforces_camera(route): + return CAMERA, None, None + + return level, roles, flag + + +def _classify_base( + route: APIRoute, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> tuple[str, list[str] | None, str | None]: + """Resolve the access level from route-level dependencies and exempt rules.""" + markers, admin_roles = _route_markers(route) + path = route.path + is_camera_path = _first_segment(path) == "{camera_name}" + exempt = path in exempt_paths or path.startswith(exempt_prefixes) or is_camera_path + + # Explicit route-level markers win, in order of specificity. + if ADMIN in markers: + return ADMIN, admin_roles or ["admin"], None + if ALL_CAMERAS in markers: + return ALL_CAMERAS, None, None + if CAMERA in markers: + return CAMERA, None, None + if AUTHENTICATED in markers: + if exempt: + return AUTHENTICATED, None, None + # The route opts in to any-authenticated, but the global admin check is + # not bypassed for this path, so admin is what actually gets enforced. + return ( + ADMIN, + ["admin"], + ( + "route declares allow_any_authenticated but path is not exempt from " + "the global admin check; admin is effectively enforced" + ), + ) + if PUBLIC in markers: + if exempt: + return PUBLIC, None, None + return ( + ADMIN, + ["admin"], + ( + "route declares allow_public but path is not exempt from the global " + "admin check; admin is effectively enforced" + ), + ) + + # No explicit auth marker: governed purely by the global default. + if not exempt: + return ADMIN, ["admin"], None + + # Exempt with no route dependency: the global admin check is bypassed and + # there is no route-level gate, so authorization (if any) happens inside the + # handler. Infer from the path shape and flag for confirmation. + if is_camera_path: + return ( + CAMERA, + None, + ( + "no route-level dependency; camera-scoped path, authorization " + "assumed to be enforced in the handler" + ), + ) + return ( + AUTHENTICATED, + None, + ( + "path is exempt from the global admin check but has no route-level " + "dependency; confirm authorization is enforced in the handler" + ), + ) + + +def build_access_map( + app: FastAPI, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> dict[tuple[str, str], dict]: + """Map (path, lowercase method) -> classification details.""" + access_map: dict[tuple[str, str], dict] = {} + for route in app.routes: + if not isinstance(route, APIRoute): + continue + level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes) + for method in route.methods: + if method in ("HEAD", "OPTIONS"): + continue + access_map[(route.path, method.lower())] = { + "level": level, + "roles": roles, + "flag": flag, + "path": route.path, + "method": method, + } + return access_map + + +def security_for(level: str) -> list: + """Build the OpenAPI ``security`` value for an access level.""" + if level == PUBLIC: + return [] + if level == ADMIN: + return [{ADMIN_SCHEME: []}] + # AUTHENTICATED, CAMERA and ALL_CAMERAS all require any authenticated + # session; the camera scoping is conveyed in the note and x-required-role. + return [{USER_SCHEME: []}] + + +def required_role_value(level: str, roles: list[str] | None): + if level == ADMIN and roles and roles != ["admin"]: + return roles + return level + + +def annotate_description(operation: dict, note: str) -> None: + existing = operation.get("description") + if not existing: + operation["description"] = note + return + operation["description"] = LiteralScalarString( + f"{note}\n\n{str(existing).rstrip()}" + ) + + +def base_document(raw: dict) -> dict: + """Apply the docs pipeline post-processing with a stable top-level order.""" + info = dict(raw.get("info", {})) + info["title"] = SPEC_TITLE + return { + "openapi": raw["openapi"], + "info": info, + "servers": [dict(server) for server in SPEC_SERVERS], + "paths": raw["paths"], + "components": raw.get("components", {}), + } + + +def enrich(spec: dict, access_map: dict) -> tuple[dict, list, list]: + """Add security schemes and per-operation auth metadata in place.""" + components = spec.setdefault("components", {}) + components["securitySchemes"] = dict(SECURITY_SCHEMES) + + counts: dict[str, int] = {} + flagged: list[dict] = [] + unmatched: list[tuple[str, str]] = [] + + for path, path_item in spec["paths"].items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS: + continue + details = access_map.get((path, method.lower())) + if details is None: + unmatched.append((method.upper(), path)) + continue + + level = details["level"] + counts[level] = counts.get(level, 0) + 1 + operation["security"] = security_for(level) + operation["x-required-role"] = required_role_value(level, details["roles"]) + annotate_description(operation, ACCESS_NOTES[level]) + + if details["flag"]: + flagged.append(details) + + return counts, flagged, unmatched + + +# Numeric defaults at or above this magnitude are treated as live Unix +# timestamps baked into the schema at import time (e.g. the /{camera_name} +# /recordings after/before params default to datetime.now()). They make the +# export non-deterministic and document a meaningless frozen epoch, so they are +# stripped. The proper fix is to default those route params to None and resolve +# "now" inside the handler. +VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000 + + +def strip_volatile_defaults(node, trail: str = "") -> list[tuple[str, float]]: + """Remove epoch-like numeric ``default`` values so the export is stable. + + Returns the (location, value) pairs that were removed, for reporting. + """ + removed: list[tuple[str, float]] = [] + if isinstance(node, dict): + default = node.get("default") + if ( + isinstance(default, (int, float)) + and not isinstance(default, bool) + and default >= VOLATILE_DEFAULT_THRESHOLD + ): + removed.append((trail, default)) + del node["default"] + for key, value in node.items(): + removed.extend(strip_volatile_defaults(value, f"{trail}/{key}")) + elif isinstance(node, list): + for index, value in enumerate(node): + removed.extend(strip_volatile_defaults(value, f"{trail}[{index}]")) + return removed + + +def to_block_scalars(node): + """Recursively render multi-line strings as literal block scalars. + + Produces readable, deterministic YAML (``|-`` blocks) instead of long + double-quoted lines with escaped newlines. + """ + if isinstance(node, dict): + return {key: to_block_scalars(value) for key, value in node.items()} + if isinstance(node, list): + return [to_block_scalars(value) for value in node] + if isinstance(node, str) and "\n" in node: + return LiteralScalarString(node) + return node + + +def _iter_refs(node): + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str): + yield value + else: + yield from _iter_refs(value) + elif isinstance(node, list): + for value in node: + yield from _iter_refs(value) + + +def validate(spec: dict) -> list[str]: + """Structural sanity checks on the generated document.""" + problems: list[str] = [] + schemas = set(spec.get("components", {}).get("schemas", {})) + defined_schemes = set(spec.get("components", {}).get("securitySchemes", {})) + + for ref in _iter_refs(spec): + if ref.startswith("#/components/schemas/"): + name = ref.rsplit("/", 1)[-1] + if name not in schemas: + problems.append(f"dangling $ref: {ref}") + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS or not isinstance(operation, dict): + continue + location = f"{method.upper()} {path}" + if "x-required-role" not in operation: + problems.append(f"missing x-required-role: {location}") + if "security" not in operation: + problems.append(f"missing security: {location}") + continue + for requirement in operation["security"]: + for scheme in requirement: + if scheme not in defined_schemes: + problems.append( + f"undefined security scheme {scheme}: {location}" + ) + + return sorted(set(problems)) + + +def render(spec: dict) -> str: + """Serialize the spec to the canonical YAML string (with the header).""" + yaml = YAML() + yaml.width = 80 + yaml.indent(mapping=2, sequence=4, offset=2) + stream = io.StringIO() + yaml.dump(spec, stream) + return HEADER + stream.getvalue() + + +def build_spec() -> tuple[dict, dict, list, list, list]: + app = build_app() + exempt_paths, exempt_prefixes = read_exempt_rules() + access_map = build_access_map(app, exempt_paths, exempt_prefixes) + + spec = base_document(app.openapi()) + normalized = strip_volatile_defaults(spec) + counts, flagged, unmatched = enrich(spec, access_map) + spec = to_block_scalars(spec) + return spec, counts, flagged, unmatched, normalized + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate the annotated OpenAPI spec.") + parser.add_argument( + "--check", + action="store_true", + help="verify the committed spec is up to date without writing; " + "exit non-zero if it would change", + ) + args = parser.parse_args(argv) + + spec, counts, flagged, unmatched, normalized = build_spec() + problems = validate(spec) + rendered = render(spec) + + if args.check: + return _check(rendered, problems) + + if problems: + logger.error("Refusing to write — generated spec failed validation:") + for problem in problems: + logger.error(" %s", problem) + return 1 + + OUTPUT_SPEC.write_text(rendered) + _report(counts, flagged, unmatched, normalized) + logger.info("\nWrote %s", OUTPUT_SPEC.relative_to(REPO_ROOT)) + return 0 + + +def _check(rendered: str, problems: list[str]) -> int: + name = OUTPUT_SPEC.relative_to(REPO_ROOT) + if problems: + logger.error("Generated spec failed validation:") + for problem in problems: + logger.error(" %s", problem) + return 1 + + current = OUTPUT_SPEC.read_text() if OUTPUT_SPEC.exists() else "" + if current == rendered: + logger.info("%s is up to date", name) + return 0 + + logger.error( + "%s is out of date. Regenerate with: python3 %s", + name, + Path(__file__).name, + ) + diff = difflib.unified_diff( + current.splitlines(), + rendered.splitlines(), + fromfile=f"{name} (committed)", + tofile=f"{name} (generated)", + lineterm="", + n=2, + ) + for shown, line in enumerate(diff): + if shown >= 60: + logger.error(" ... (diff truncated)") + break + logger.error(" %s", line) + return 1 + + +def _report(counts, flagged, unmatched, normalized) -> None: + logger.info("Access levels applied:") + for level in (PUBLIC, AUTHENTICATED, CAMERA, ADMIN): + logger.info(" %-14s %d", level, counts.get(level, 0)) + logger.info(" %-14s %d", "total", sum(counts.values())) + + if normalized: + logger.info("\nStripped volatile timestamp defaults (%d):", len(normalized)) + for location, value in normalized: + logger.info(" %s = %s", location.lstrip("/"), value) + + if flagged: + logger.info("\nFlagged for manual confirmation (%d):", len(flagged)) + for item in flagged: + logger.info(" %-6s %s", item["method"], item["path"]) + logger.info(" -> %s (%s)", item["level"], item["flag"]) + + if unmatched: + logger.info( + "\nOperations with no classification (%d) [unexpected]:", len(unmatched) + ) + for method, path in unmatched: + logger.info(" %-6s %s", method, path) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/generate_config_translations.py b/generate_config_translations.py index c19578f1a6..aa115e44c3 100644 --- a/generate_config_translations.py +++ b/generate_config_translations.py @@ -8,20 +8,18 @@ and generates JSON translation files with titles and descriptions for the web UI import json import logging -import shutil +import sys from pathlib import Path -from typing import Any, Dict, Optional, get_args, get_origin - -from pydantic import BaseModel -from pydantic.fields import FieldInfo +from typing import Any, get_args, get_origin from frigate.config.config import FrigateConfig +from frigate.util.schema import get_config_schema logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def get_field_translations(field_info: FieldInfo) -> Dict[str, str]: +def get_field_translations(field_info) -> dict[str, str]: """Extract title and description from a Pydantic field.""" translations = {} @@ -34,50 +32,169 @@ def get_field_translations(field_info: FieldInfo) -> Dict[str, str]: return translations -def process_model_fields(model: type[BaseModel]) -> Dict[str, Any]: +def extract_translations_from_schema( + schema: dict[str, Any], defs: dict[str, Any] = None +) -> dict[str, Any]: """ - Recursively process a Pydantic model to extract translations. + Recursively extract translations (titles and descriptions) from a JSON schema. - Returns a nested dictionary structure matching the config schema, - with title and description for each field. + Returns a dictionary structure with label and description for each field, + and nested fields directly under their parent keys. """ + if defs is None: + defs = schema.get("$defs", {}) + translations = {} - model_fields = model.model_fields + # Add top-level title and description if present + if "title" in schema: + translations["label"] = schema["title"] + if "description" in schema: + translations["description"] = schema["description"] - for field_name, field_info in model_fields.items(): - field_translations = get_field_translations(field_info) + # Process nested properties + properties = schema.get("properties", {}) + for field_name, field_schema in properties.items(): + field_translations = {} - # Get the field's type annotation - field_type = field_info.annotation + # Handle $ref references + if "$ref" in field_schema: + ref_path = field_schema["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + ref_schema = defs[ref_name] + # Extract from the referenced schema + ref_translations = extract_translations_from_schema( + ref_schema, defs=defs + ) + # Use the $ref field's own title/description if present + if "title" in field_schema: + field_translations["label"] = field_schema["title"] + elif "label" in ref_translations: + field_translations["label"] = ref_translations["label"] + if "description" in field_schema: + field_translations["description"] = field_schema["description"] + elif "description" in ref_translations: + field_translations["description"] = ref_translations[ + "description" + ] + # Add nested properties from referenced schema + nested_without_root = { + k: v + for k, v in ref_translations.items() + if k not in ("label", "description") + } + field_translations.update(nested_without_root) + # Handle additionalProperties with $ref (for dict types) + elif "additionalProperties" in field_schema: + additional_props = field_schema["additionalProperties"] + # Extract title and description from the field itself + if "title" in field_schema: + field_translations["label"] = field_schema["title"] + if "description" in field_schema: + field_translations["description"] = field_schema["description"] - # Handle Optional types - origin = get_origin(field_type) + # If additionalProperties contains a $ref, extract nested translations + if "$ref" in additional_props: + ref_path = additional_props["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + ref_schema = defs[ref_name] + nested = extract_translations_from_schema(ref_schema, defs=defs) + nested_without_root = { + k: v + for k, v in nested.items() + if k not in ("label", "description") + } + field_translations.update(nested_without_root) + # Handle items with $ref (for array types) + elif "items" in field_schema: + items = field_schema["items"] + # Extract title and description from the field itself + if "title" in field_schema: + field_translations["label"] = field_schema["title"] + if "description" in field_schema: + field_translations["description"] = field_schema["description"] - if origin is Optional or ( - hasattr(origin, "__name__") and origin.__name__ == "UnionType" - ): - args = get_args(field_type) - field_type = next( - (arg for arg in args if arg is not type(None)), field_type - ) + # If items contains a $ref, extract nested translations + if "$ref" in items: + ref_path = items["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + ref_schema = defs[ref_name] + nested = extract_translations_from_schema(ref_schema, defs=defs) + nested_without_root = { + k: v + for k, v in nested.items() + if k not in ("label", "description") + } + field_translations.update(nested_without_root) + else: + # Extract title and description + if "title" in field_schema: + field_translations["label"] = field_schema["title"] + if "description" in field_schema: + field_translations["description"] = field_schema["description"] - # Handle Dict types (like Dict[str, CameraConfig]) - if get_origin(field_type) is dict: - dict_args = get_args(field_type) + # Recursively process nested properties + if "properties" in field_schema: + nested = extract_translations_from_schema(field_schema, defs=defs) + # Merge nested translations + nested_without_root = { + k: v for k, v in nested.items() if k not in ("label", "description") + } + field_translations.update(nested_without_root) + # Handle anyOf cases + elif "anyOf" in field_schema: + for item in field_schema["anyOf"]: + nested = None + if item.get("type") == "null": + continue + if "properties" in item: + nested = extract_translations_from_schema(item, defs=defs) + elif "$ref" in item: + ref_path = item["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) + elif ( + "additionalProperties" in item + and isinstance(item["additionalProperties"], dict) + and "$ref" in item["additionalProperties"] + ): + ref_path = item["additionalProperties"]["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) + elif ( + "items" in item + and isinstance(item["items"], dict) + and ("$ref" in item["items"]) + ): + ref_path = item["items"]["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) - if len(dict_args) >= 2: - value_type = dict_args[1] - - if isinstance(value_type, type) and issubclass(value_type, BaseModel): - nested_translations = process_model_fields(value_type) - - if nested_translations: - field_translations["properties"] = nested_translations - elif isinstance(field_type, type) and issubclass(field_type, BaseModel): - nested_translations = process_model_fields(field_type) - if nested_translations: - field_translations["properties"] = nested_translations + if nested: + nested_without_root = { + k: v + for k, v in nested.items() + if k not in ("label", "description") + } + field_translations.update(nested_without_root) if field_translations: translations[field_name] = field_translations @@ -85,76 +202,447 @@ def process_model_fields(model: type[BaseModel]) -> Dict[str, Any]: return translations -def generate_section_translation( - section_name: str, field_info: FieldInfo -) -> Dict[str, Any]: +def generate_section_translation(config_class: type) -> dict[str, Any]: """ - Generate translation structure for a top-level config section. + Generate translation structure for a config section using its JSON schema. """ - section_translations = get_field_translations(field_info) - field_type = field_info.annotation - origin = get_origin(field_type) + schema = config_class.model_json_schema() + return extract_translations_from_schema(schema) - if origin is Optional or ( - hasattr(origin, "__name__") and origin.__name__ == "UnionType" - ): - args = get_args(field_type) - field_type = next((arg for arg in args if arg is not type(None)), field_type) - # Handle Dict types (like detectors, cameras, camera_groups) - if get_origin(field_type) is dict: - dict_args = get_args(field_type) - if len(dict_args) >= 2: - value_type = dict_args[1] - if isinstance(value_type, type) and issubclass(value_type, BaseModel): - nested = process_model_fields(value_type) - if nested: - section_translations["properties"] = nested +def get_detector_translations( + config_schema: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], set[str]]: + """Build detector type translations with nested fields based on schema definitions. - # If the field itself is a BaseModel, process it - elif isinstance(field_type, type) and issubclass(field_type, BaseModel): - nested = process_model_fields(field_type) - if nested: - section_translations["properties"] = nested + Returns a tuple of (type_translations, shared_fields, nested_field_keys). + Shared fields (identical across all detector types) are returned separately + to avoid duplication in the output. + """ + defs = config_schema.get("$defs", {}) + detector_schema = defs.get("DetectorConfig", {}) + discriminator = detector_schema.get("discriminator", {}) + mapping = discriminator.get("mapping", {}) - return section_translations + # First pass: collect all nested fields per detector type + all_nested: dict[str, dict[str, Any]] = {} + type_meta: dict[str, dict[str, str]] = {} + + for detector_type, ref in mapping.items(): + if not isinstance(ref, str) or not ref.startswith("#/$defs/"): + continue + + ref_name = ref.split("/")[-1] + ref_schema = defs.get(ref_name, {}) + if not ref_schema: + continue + + meta: dict[str, str] = {} + title = ref_schema.get("title") + description = ref_schema.get("description") + if title: + meta["label"] = title + if description: + meta["description"] = description + type_meta[detector_type] = meta + + nested = extract_translations_from_schema(ref_schema, defs=defs) + all_nested[detector_type] = { + k: v for k, v in nested.items() if k not in ("label", "description") + } + + # Find fields that are identical across all types that have them + shared_fields: dict[str, Any] = {} + if all_nested: + # Collect all field keys across all types + all_keys: set[str] = set() + for nested in all_nested.values(): + all_keys.update(nested.keys()) + + for key in all_keys: + values = [nested[key] for nested in all_nested.values() if key in nested] + if len(values) == len(all_nested) and all(v == values[0] for v in values): + shared_fields[key] = values[0] + + # Build per-type translations with only unique (non-shared) fields + type_translations: dict[str, Any] = {} + nested_field_keys: set[str] = set() + for detector_type, nested in all_nested.items(): + type_entry: dict[str, Any] = {} + type_entry.update(type_meta.get(detector_type, {})) + + unique_fields = {k: v for k, v in nested.items() if k not in shared_fields} + if unique_fields: + type_entry.update(unique_fields) + nested_field_keys.update(unique_fields.keys()) + + if type_entry: + type_translations[detector_type] = type_entry + + return type_translations, shared_fields, nested_field_keys def main(): """Main function to generate config translations.""" # Define output directory - output_dir = Path(__file__).parent / "web" / "public" / "locales" / "en" / "config" + if len(sys.argv) > 1: + output_dir = Path(sys.argv[1]) + else: + output_dir = ( + Path(__file__).parent / "web" / "public" / "locales" / "en" / "config" + ) logger.info(f"Output directory: {output_dir}") - # Clean and recreate the output directory - if output_dir.exists(): - logger.info(f"Removing existing directory: {output_dir}") - shutil.rmtree(output_dir) - - logger.info(f"Creating directory: {output_dir}") + # Ensure the output directory exists; do not delete existing files. output_dir.mkdir(parents=True, exist_ok=True) + logger.info( + f"Using output directory (existing files will be overwritten): {output_dir}" + ) config_fields = FrigateConfig.model_fields + config_schema = get_config_schema(FrigateConfig) logger.info(f"Found {len(config_fields)} top-level config sections") + global_translations = {} + for field_name, field_info in config_fields.items(): if field_name.startswith("_"): continue logger.info(f"Processing section: {field_name}") - section_data = generate_section_translation(field_name, field_info) + + # Get the field's type + field_type = field_info.annotation + from typing import Optional, Union + + origin = get_origin(field_type) + if ( + origin is Optional + or origin is Union + or ( + hasattr(origin, "__name__") + and origin.__name__ in ("UnionType", "Union") + ) + ): + args = get_args(field_type) + field_type = next( + (arg for arg in args if arg is not type(None)), field_type + ) + + # Handle Dict[str, SomeModel] - extract the value type + if origin is dict: + args = get_args(field_type) + if args and len(args) > 1: + field_type = args[1] # Get value type from Dict[key, value] + + # Start with field's top-level metadata (label, description) + section_data = get_field_translations(field_info) + + # Generate nested translations from the field type's schema + if hasattr(field_type, "model_json_schema"): + schema = field_type.model_json_schema() + # Extract nested properties from schema + nested = extract_translations_from_schema(schema) + # Remove top-level label/description from nested since we got those from field_info + nested_without_root = { + k: v for k, v in nested.items() if k not in ("label", "description") + } + section_data.update(nested_without_root) + + if field_name == "detectors": + detector_types, shared_fields, detector_field_keys = ( + get_detector_translations(config_schema) + ) + # Add shared fields at the base detectors level + section_data.update(shared_fields) + # Add per-type translations (only unique fields per type) + section_data.update(detector_types) + for key in detector_field_keys: + if key == "type": + continue + section_data.pop(key, None) + + if field_name == "objects": + # Produce a parallel `filters_attribute` block alongside `filters`, + # with object-wording rewritten for attribute filters (face, + # license_plate, courier logos). The frontend's + # buildTranslationPath routes `filters..` lookups to + # `filters_attribute.` when `` is in + # `model.all_attributes`. Keep this rewrite list explicit rather + # than running a blanket s/object/attribute/ so unrelated + # descriptions (e.g. "JSON object") never accidentally flip. + filters_block = section_data.get("filters") + if isinstance(filters_block, dict): + attribute_rewrites = [ + ("Object filters", "Attribute filters"), + ("detected objects", "detected attributes"), + ("object area", "attribute area"), + ("object type", "attribute"), + ("the object", "the attribute"), + ] + + # Per-field overrides for cases where the generic rewrite + # doesn't capture the attribute-specific semantics. Keys + # match the FilterConfig field name; values are partial + # overrides applied AFTER the generic rewrites. + attribute_field_overrides: dict[str, dict[str, str]] = { + "min_score": { + "description": ( + "Minimum single-frame detection confidence required " + "to associate this attribute with its parent object." + ), + }, + } + + def rewrite(text: str) -> str: + for source, replacement in attribute_rewrites: + text = text.replace(source, replacement) + return text + + attribute_variant: dict[str, Any] = {} + for key, value in filters_block.items(): + if key in ("label", "description"): + if isinstance(value, str): + attribute_variant[key] = rewrite(value) + continue + if not isinstance(value, dict): + continue + field_trans: dict[str, str] = {} + if isinstance(value.get("label"), str): + field_trans["label"] = rewrite(value["label"]) + if isinstance(value.get("description"), str): + field_trans["description"] = rewrite(value["description"]) + overrides = attribute_field_overrides.get(key) + if overrides: + field_trans.update(overrides) + if field_trans: + attribute_variant[key] = field_trans + if attribute_variant: + section_data["filters_attribute"] = attribute_variant if not section_data: logger.warning(f"No translations found for section: {field_name}") continue - output_file = output_dir / f"{field_name}.json" - with open(output_file, "w", encoding="utf-8") as f: - json.dump(section_data, f, indent=2, ensure_ascii=False) + # Add camera-level fields to global config documentation if applicable + CAMERA_LEVEL_FIELDS = { + "birdseye": ( + "frigate.config.camera.birdseye", + "BirdseyeCameraConfig", + ["order"], + ), + "ffmpeg": ( + "frigate.config.camera.ffmpeg", + "CameraFfmpegConfig", + ["inputs"], + ), + "lpr": ( + "frigate.config.classification", + "CameraLicensePlateRecognitionConfig", + ["expire_time"], + ), + "semantic_search": ( + "frigate.config.classification", + "CameraSemanticSearchConfig", + ["triggers"], + ), + } - logger.info(f"Generated: {output_file}") + if field_name in CAMERA_LEVEL_FIELDS: + module_path, class_name, field_names = CAMERA_LEVEL_FIELDS[field_name] + try: + import importlib + + module = importlib.import_module(module_path) + camera_class = getattr(module, class_name) + schema = camera_class.model_json_schema() + camera_fields = schema.get("properties", {}) + defs = schema.get("$defs", {}) + + for fname in field_names: + if fname in camera_fields: + field_schema = camera_fields[fname] + field_trans = {} + if "title" in field_schema: + field_trans["label"] = field_schema["title"] + if "description" in field_schema: + field_trans["description"] = field_schema["description"] + + # Extract nested properties based on schema type + nested_to_extract = None + + # Handle direct $ref + if "$ref" in field_schema: + ref_path = field_schema["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested_to_extract = defs[ref_name] + + # Handle additionalProperties with $ref (for dict types) + elif "additionalProperties" in field_schema: + additional_props = field_schema["additionalProperties"] + if "$ref" in additional_props: + ref_path = additional_props["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested_to_extract = defs[ref_name] + + # Handle items with $ref (for array types) + elif "items" in field_schema: + items = field_schema["items"] + if "$ref" in items: + ref_path = items["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested_to_extract = defs[ref_name] + + # Extract nested properties if we found a schema to use + if nested_to_extract: + nested = extract_translations_from_schema( + nested_to_extract, defs=defs + ) + nested_without_root = { + k: v + for k, v in nested.items() + if k not in ("label", "description") + } + field_trans.update(nested_without_root) + + if field_trans: + section_data[fname] = field_trans + except Exception as e: + logger.warning( + f"Could not add camera-level fields for {field_name}: {e}" + ) + + # Add to global translations instead of writing separate files + global_translations[field_name] = section_data + + logger.info(f"Added section to global translations: {field_name}") + + # Handle camera-level configs that aren't top-level FrigateConfig fields + # These are defined as fields in CameraConfig, so we extract title/description from there + camera_level_configs = { + "camera_mqtt": ("frigate.config.camera.mqtt", "CameraMqttConfig", "mqtt"), + "camera_ui": ("frigate.config.camera.ui", "CameraUiConfig", "ui"), + "onvif": ("frigate.config.camera.onvif", "OnvifConfig", "onvif"), + } + + # Import CameraConfig to extract field metadata + from frigate.config.camera.camera import CameraConfig + + camera_config_schema = CameraConfig.model_json_schema() + camera_properties = camera_config_schema.get("properties", {}) + + for config_name, ( + module_path, + class_name, + camera_field_name, + ) in camera_level_configs.items(): + try: + logger.info(f"Processing camera-level section: {config_name}") + import importlib + + module = importlib.import_module(module_path) + config_class = getattr(module, class_name) + + section_data = {} + + # Extract top-level label and description from CameraConfig field definition + if camera_field_name in camera_properties: + field_schema = camera_properties[camera_field_name] + if "title" in field_schema: + section_data["label"] = field_schema["title"] + if "description" in field_schema: + section_data["description"] = field_schema["description"] + + # Process model fields from schema + schema = config_class.model_json_schema() + nested = extract_translations_from_schema(schema) + # Remove top-level label/description since we got those from CameraConfig + nested_without_root = { + k: v for k, v in nested.items() if k not in ("label", "description") + } + section_data.update(nested_without_root) + + # Add camera-level section into global translations (do not write separate file) + global_translations[config_name] = section_data + logger.info( + f"Added camera-level section to global translations: {config_name}" + ) + except Exception as e: + logger.error(f"Failed to generate {config_name}: {e}") + + # Remove top-level 'cameras' field if present so it remains a separate file + if "cameras" in global_translations: + logger.info( + "Removing top-level 'cameras' from global translations to keep it as a separate cameras.json" + ) + del global_translations["cameras"] + + # Write consolidated global.json with per-section keys + global_file = output_dir / "global.json" + with open(global_file, "w", encoding="utf-8") as f: + json.dump(global_translations, f, indent=2, ensure_ascii=False) + f.write("\n") + + logger.info(f"Generated consolidated translations: {global_file}") + + if not global_translations: + logger.warning("No global translations were generated!") + else: + logger.info(f"Global contains {len(global_translations)} sections") + + # Generate cameras.json from CameraConfig schema + cameras_file = output_dir / "cameras.json" + logger.info(f"Generating cameras.json: {cameras_file}") + try: + if "camera_config_schema" in locals(): + camera_schema = camera_config_schema + else: + from frigate.config.camera.camera import CameraConfig + + camera_schema = CameraConfig.model_json_schema() + + camera_translations = extract_translations_from_schema(camera_schema) + + # Change descriptions to use 'for this camera' for fields that are global + def sanitize_camera_descriptions(obj): + if isinstance(obj, dict): + for k, v in list(obj.items()): + if k == "description" and isinstance(v, str): + obj[k] = v.replace( + "for all cameras; can be overridden per-camera", + "for this camera", + ) + else: + sanitize_camera_descriptions(v) + elif isinstance(obj, list): + for item in obj: + sanitize_camera_descriptions(item) + + sanitize_camera_descriptions(camera_translations) + + # Profiles contain the same sections as the camera itself; only keep + # label and description to avoid duplicating every camera section. + if "profiles" in camera_translations: + camera_translations["profiles"] = { + k: v + for k, v in camera_translations["profiles"].items() + if k in ("label", "description") + } + + with open(cameras_file, "w", encoding="utf-8") as f: + json.dump(camera_translations, f, indent=2, ensure_ascii=False) + f.write("\n") + logger.info(f"Generated cameras.json: {cameras_file}") + except Exception as e: + logger.error(f"Failed to generate cameras.json: {e}") logger.info("Translation generation complete!") diff --git a/migrations/033_create_export_case_table.py b/migrations/033_create_export_case_table.py new file mode 100644 index 0000000000..08edcbc32d --- /dev/null +++ b/migrations/033_create_export_case_table.py @@ -0,0 +1,50 @@ +"""Peewee migrations -- 033_create_export_case_table.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + """ + CREATE TABLE IF NOT EXISTS "exportcase" ( + "id" VARCHAR(30) NOT NULL PRIMARY KEY, + "name" VARCHAR(100) NOT NULL, + "description" TEXT NULL, + "created_at" DATETIME NOT NULL, + "updated_at" DATETIME NOT NULL + ) + """ + ) + migrator.sql( + 'CREATE INDEX IF NOT EXISTS "exportcase_name" ON "exportcase" ("name")' + ) + migrator.sql( + 'CREATE INDEX IF NOT EXISTS "exportcase_created_at" ON "exportcase" ("created_at")' + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/migrations/034_add_export_case_to_exports.py b/migrations/034_add_export_case_to_exports.py new file mode 100644 index 0000000000..da9e1d4ac1 --- /dev/null +++ b/migrations/034_add_export_case_to_exports.py @@ -0,0 +1,40 @@ +"""Peewee migrations -- 034_add_export_case_to_exports.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + # Add nullable export_case_id column to export table + migrator.sql('ALTER TABLE "export" ADD COLUMN "export_case_id" VARCHAR(30) NULL') + + # Index for faster case-based queries + migrator.sql( + 'CREATE INDEX IF NOT EXISTS "export_export_case_id" ON "export" ("export_case_id")' + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/migrations/035_add_motion_heatmap.py b/migrations/035_add_motion_heatmap.py new file mode 100644 index 0000000000..b6962083ed --- /dev/null +++ b/migrations/035_add_motion_heatmap.py @@ -0,0 +1,34 @@ +"""Peewee migrations -- 035_add_motion_heatmap.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "motion_heatmap" TEXT NULL') + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/pyproject.toml b/pyproject.toml index d17a60e726..775db6026c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +[tool.ruff] +target-version = "py311" + [tool.ruff.lint] -ignore = ["E501","E711","E712"] -extend-select = ["I"] +ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"] +extend-select = ["I", "UP", "G", "ASYNC210", "B904"] diff --git a/testing-scripts/analyze_recording_keyframes.py b/testing-scripts/analyze_recording_keyframes.py new file mode 100644 index 0000000000..982cac82f3 --- /dev/null +++ b/testing-scripts/analyze_recording_keyframes.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Analyze keyframe and timestamp structure of Frigate recording segments. + +This is a diagnostic tool for investigating seek precision / GOP behavior on +recorded segments. It does not modify anything. + +ffprobe is only available inside the Frigate container, at + /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe +This script auto-resolves that path from the DEFAULT_FFMPEG_VERSION env var +(or falls back to scanning /usr/lib/ffmpeg/*/bin/ffprobe). Pass --ffprobe to +override if needed. + +All recording segments on the filesystem are in UTC. The --timestamp flag +expects a UTC Unix timestamp. + +Typical use: + # Inside the Frigate container (or wherever recordings are mounted) + python3 analyze_recording_keyframes.py + + # Analyze 10 most recent segments + python3 analyze_recording_keyframes.py --count 10 + + # Locate the segment that contains a specific UTC Unix timestamp and + # show it plus surrounding segments + python3 analyze_recording_keyframes.py --timestamp 1713471234.567 + + # Custom recordings directory + python3 analyze_recording_keyframes.py --recordings-dir /media/frigate/recordings + + # Override the ffprobe path explicitly + python3 analyze_recording_keyframes.py --ffprobe /usr/lib/ffmpeg/7.0/bin/ffprobe +""" + +import argparse +import datetime +import json +import os +import subprocess +import sys +from pathlib import Path +from statistics import mean, median, stdev + + +def resolve_ffprobe_path(override: str | None) -> str: + """Resolve the ffprobe binary path. + + Inside the Frigate container, ffprobe lives at + /usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe — the exact version + depends on the image build and is exposed as an env var. + """ + if override: + return override + version = os.environ.get("DEFAULT_FFMPEG_VERSION", "") + if version: + path = f"/usr/lib/ffmpeg/{version}/bin/ffprobe" + if Path(path).is_file(): + return path + # Fall back to scanning the Frigate ffmpeg install root. + for candidate in sorted(Path("/usr/lib/ffmpeg").glob("*/bin/ffprobe")): + if candidate.is_file(): + return str(candidate) + print( + "Could not locate ffprobe. Pass --ffprobe or set " + "DEFAULT_FFMPEG_VERSION.", + file=sys.stderr, + ) + sys.exit(1) + + +def find_recent_segments(recordings_dir: Path, camera: str, count: int) -> list[Path]: + """Return the N most recent .mp4 segments for the given camera. + + Expected layout: ////..mp4 + """ + pattern = f"*/*/{camera}/*.mp4" + segments = sorted(recordings_dir.glob(pattern)) + return segments[-count:] + + +def find_segments_near_timestamp( + recordings_dir: Path, camera: str, target_ts: float, count: int +) -> tuple[list[Path], Path | None]: + """Return `count` segments centered on the one containing `target_ts`. + + Also returns the specific segment that should contain the timestamp, so + callers can highlight it in output. + """ + pattern = f"*/*/{camera}/*.mp4" + with_ts: list[tuple[float, Path]] = [] + for seg in sorted(recordings_dir.glob(pattern)): + ts = filename_to_timestamp(seg) + if ts is not None: + with_ts.append((ts, seg)) + + if not with_ts: + return [], None + + # Largest filename_ts that is <= target_ts — that's the segment that + # should contain the timestamp (Frigate catalogs segments by filename). + target_idx = -1 + for i, (ts, _) in enumerate(with_ts): + if ts <= target_ts: + target_idx = i + else: + break + + if target_idx < 0: + # target_ts is before the earliest segment we have — just return the + # first `count` segments so the user can see what's available. + window = with_ts[:count] + return [seg for _, seg in window], None + + half = count // 2 + start = max(0, target_idx - half) + end = min(len(with_ts), start + count) + start = max(0, end - count) + + window = with_ts[start:end] + return [seg for _, seg in window], with_ts[target_idx][1] + + +def filename_to_timestamp(segment: Path) -> float | None: + """Parse the wall-clock time from Frigate's segment path layout.""" + try: + date = segment.parent.parent.parent.name # YYYY-MM-DD + hour = segment.parent.parent.name # HH + mm_ss = segment.stem # MM.SS + minute, second = mm_ss.split(".") + dt = datetime.datetime.strptime( + f"{date} {hour}:{minute}:{second}", + "%Y-%m-%d %H:%M:%S", + ).replace(tzinfo=datetime.timezone.utc) + return dt.timestamp() + except (ValueError, IndexError): + return None + + +def run_ffprobe(ffprobe: str, args: list[str]) -> dict: + """Run ffprobe and return parsed JSON, or empty dict on failure.""" + result = subprocess.run( + [ffprobe, "-v", "error", *args, "-of", "json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f" ffprobe error: {result.stderr.strip()}", file=sys.stderr) + return {} + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {} + + +def get_format_info(ffprobe: str, segment: Path) -> tuple[dict, dict]: + """Return (format_dict, stream_dict) for the first video stream.""" + data = run_ffprobe( + ffprobe, + [ + "-show_entries", + "format=duration,start_time", + "-show_entries", + "stream=codec_name,profile,r_frame_rate,width,height", + "-select_streams", + "v:0", + str(segment), + ], + ) + fmt = data.get("format", {}) + streams = data.get("streams") or [{}] + return fmt, streams[0] + + +def get_video_packets(ffprobe: str, segment: Path) -> list[dict]: + """Return video packets with pts_time and flags.""" + data = run_ffprobe( + ffprobe, + [ + "-select_streams", + "v", + "-show_entries", + "packet=pts_time,dts_time,flags", + str(segment), + ], + ) + return data.get("packets", []) + + +def analyze(ffprobe: str, segment: Path, highlight: bool = False) -> None: + marker = " <-- contains target timestamp" if highlight else "" + print(f"\n=== {segment} ==={marker}") + + fmt, stream = get_format_info(ffprobe, segment) + duration = float(fmt.get("duration", 0) or 0) + start_time = float(fmt.get("start_time", 0) or 0) + codec = stream.get("codec_name", "?") + profile = stream.get("profile", "?") + width = stream.get("width", "?") + height = stream.get("height", "?") + fps = stream.get("r_frame_rate", "?/1") + + filename_ts = filename_to_timestamp(segment) + filename_iso = ( + datetime.datetime.fromtimestamp( + filename_ts, tz=datetime.timezone.utc + ).isoformat() + if filename_ts is not None + else "?" + ) + + print(f" Codec: {codec} ({profile}) {width}x{height} {fps}") + print(f" Filename time: {filename_ts} ({filename_iso})") + print(f" Format duration: {duration:.3f}s") + print(f" Format start: {start_time:.3f}s (PTS offset of first packet)") + + packets = get_video_packets(ffprobe, segment) + if not packets: + print(" (no video packets)") + return + + keyframe_times: list[float] = [] + first_pts: float | None = None + last_pts: float | None = None + + for pkt in packets: + pts_str = pkt.get("pts_time") + if pts_str is None or pts_str == "N/A": + continue + pts = float(pts_str) + if first_pts is None: + first_pts = pts + last_pts = pts + if "K" in pkt.get("flags", ""): + keyframe_times.append(pts) + + total_packets = len(packets) + kf_count = len(keyframe_times) + + print(f" Video packets: {total_packets}") + print(f" Keyframes: {kf_count}") + if first_pts is not None and last_pts is not None: + print( + f" Packet PTS: first={first_pts:.3f}s last={last_pts:.3f}s " + f"span={last_pts - first_pts:.3f}s" + ) + + if keyframe_times: + print( + f" Keyframe PTS: first={keyframe_times[0]:.3f}s " + f"last={keyframe_times[-1]:.3f}s" + ) + formatted = ", ".join(f"{t:.3f}" for t in keyframe_times) + print(f" Keyframe times: [{formatted}]") + + if len(keyframe_times) >= 2: + gaps = [b - a for a, b in zip(keyframe_times, keyframe_times[1:])] + avg_fps_estimate = ( + total_packets / (last_pts - first_pts) + if last_pts and first_pts is not None and last_pts > first_pts + else 0 + ) + print( + f" GOP gaps (s): min={min(gaps):.3f} max={max(gaps):.3f} " + f"mean={mean(gaps):.3f} median={median(gaps):.3f}" + ) + if len(gaps) > 1: + print(f" stdev={stdev(gaps):.3f}") + print( + f" Est. mean GOP: ~{mean(gaps) * avg_fps_estimate:.1f} frames" + if avg_fps_estimate + else "" + ) + if max(gaps) > 5: + print( + " !! Max GOP > 5s — consistent with adaptive/smart codec " + "(even if 'Smart Codec' is off in the UI, some cameras still " + "produce irregular GOPs under specific encoder profiles)" + ) + elif kf_count == 1: + print(" !! Only one keyframe in segment — very long GOP") + + # Report how well filename time aligns with first-packet PTS. + # (Filename time is what Frigate uses as recording.start_time in the DB.) + if filename_ts is not None and first_pts is not None: + print( + f" Notes: first packet PTS is {first_pts:.3f}s into the file; " + f"Frigate treats filename time as PTS=0 for seek math." + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("camera", help="Camera name (matches the recordings subfolder)") + parser.add_argument( + "--count", + type=int, + default=5, + help="Number of most recent segments to analyze (default: 5)", + ) + parser.add_argument( + "--recordings-dir", + default="/media/frigate/recordings", + help="Path to the recordings directory (default: /media/frigate/recordings)", + ) + parser.add_argument( + "--ffprobe", + default=None, + help=( + "Full path to the ffprobe binary. Defaults to the Frigate-bundled " + "binary at /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe." + ), + ) + parser.add_argument( + "--timestamp", + type=float, + default=None, + help=( + "Unix timestamp (UTC seconds, decimals allowed) to locate. The " + "script finds the segment that should contain this time and " + "analyzes it plus surrounding segments (count controls the " + "window). All on-disk segments are stored in UTC, so pass a UTC " + "Unix timestamp." + ), + ) + args = parser.parse_args() + + ffprobe = resolve_ffprobe_path(args.ffprobe) + + recordings_dir = Path(args.recordings_dir) + if not recordings_dir.is_dir(): + print( + f"Recordings directory not found: {recordings_dir}", + file=sys.stderr, + ) + sys.exit(1) + + target_segment: Path | None = None + if args.timestamp is not None: + segments, target_segment = find_segments_near_timestamp( + recordings_dir, args.camera, args.timestamp, args.count + ) + target_iso = datetime.datetime.fromtimestamp( + args.timestamp, tz=datetime.timezone.utc + ).isoformat() + mode = f"around timestamp {args.timestamp} ({target_iso})" + else: + segments = find_recent_segments(recordings_dir, args.camera, args.count) + mode = "most recent" + + if not segments: + print( + f"No segments found for camera '{args.camera}' under {recordings_dir}", + file=sys.stderr, + ) + sys.exit(1) + + if args.timestamp is not None and target_segment is None: + print( + f"!! Target timestamp {args.timestamp} is before the earliest " + f"segment on disk; showing the earliest available segments instead.", + file=sys.stderr, + ) + + print( + f"Analyzing {len(segments)} {mode} segment(s) for camera " + f"'{args.camera}' under {recordings_dir} (ffprobe: {ffprobe})" + ) + for segment in segments: + analyze(ffprobe, segment, highlight=(segment == target_segment)) + + +if __name__ == "__main__": + main() diff --git a/benchmark.py b/testing-scripts/benchmark.py similarity index 100% rename from benchmark.py rename to testing-scripts/benchmark.py diff --git a/benchmark_motion.py b/testing-scripts/benchmark_motion.py similarity index 100% rename from benchmark_motion.py rename to testing-scripts/benchmark_motion.py diff --git a/testing-scripts/face_dataset.py b/testing-scripts/face_dataset.py new file mode 100644 index 0000000000..cc684cbd3e --- /dev/null +++ b/testing-scripts/face_dataset.py @@ -0,0 +1,775 @@ +""" +Face recognition investigation script. + +Standalone replica of Frigate's ArcFace pipeline (see +frigate/data_processing/common/face/model.py and +frigate/embeddings/onnx/face_embedding.py) for analyzing a face collection +outside the running service. Useful for: + + - Diagnosing why a person's collection produces false positives + - Finding outlier/contaminating training images + - Inspecting the effect of the shipped vector-wise outlier filter + +Layout: + - Core pipeline: LandmarkAligner, ArcFaceEmbedder, arcface_preprocess, + similarity_to_confidence, blur_reduction — all mirroring the production + code exactly + - Default run: summarize positive and negative sets against a baseline + trim_mean class representation + - Optional diagnostics (flags): vector-outlier filter behavior, degenerate + "tiny crop" embedding clustering, and multi-identity contamination + +Usage: + python3 face_investigate.py \\ + --positive \\ + --negative \\ + [--model-cache /path/to/model_cache] \\ + [--vector-outlier] [--degenerate] [--contamination] + +The positive folder should contain training images for a single identity +(same layout as FACE_DIR//*.webp). The negative folder should contain +runtime crops to test against — a mix of true matches and misfires. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Iterable +from dataclasses import dataclass + +import cv2 +import numpy as np +import onnxruntime as ort +from PIL import Image +from scipy import stats + +ARCFACE_INPUT_SIZE = 112 + + +# --------------------------------------------------------------------------- +# Replicated Frigate pipeline +# --------------------------------------------------------------------------- + + +def _bgr_to_rgb(frame: np.ndarray) -> np.ndarray: + """Mirror BaseEmbedding._bgr_to_rgb.""" + if isinstance(frame, np.ndarray) and frame.ndim == 3: + return np.ascontiguousarray(frame[:, :, ::-1]) + + return frame + + +def _process_image_frigate(image: np.ndarray) -> Image.Image: + """Mirror BaseEmbedding._process_image for an ndarray input. + + `Image.fromarray` does not reorder channels, so whatever order it is + handed is what reaches the model. Callers swap to RGB first, exactly as + ArcfaceEmbedding._preprocess_inputs does. + """ + return Image.fromarray(image) + + +def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray: + """Mirror ArcfaceEmbedding._preprocess_inputs. + + Face crops arrive BGR from cv2 and #23712 added the swap to RGB before + embedding, so this script has to do it too. + """ + pil = _process_image_frigate(_bgr_to_rgb(image_bgr)) + + width, height = pil.size + if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE: + if width > height: + new_height = int(((height / width) * ARCFACE_INPUT_SIZE) // 4 * 4) + pil = pil.resize((ARCFACE_INPUT_SIZE, new_height)) + else: + new_width = int(((width / height) * ARCFACE_INPUT_SIZE) // 4 * 4) + pil = pil.resize((new_width, ARCFACE_INPUT_SIZE)) + + og = np.array(pil).astype(np.float32) + og_h, og_w, channels = og.shape + + frame = np.zeros( + (ARCFACE_INPUT_SIZE, ARCFACE_INPUT_SIZE, channels), dtype=np.float32 + ) + x_center = (ARCFACE_INPUT_SIZE - og_w) // 2 + y_center = (ARCFACE_INPUT_SIZE - og_h) // 2 + frame[y_center : y_center + og_h, x_center : x_center + og_w] = og + + frame = (frame / 127.5) - 1.0 + frame = np.transpose(frame, (2, 0, 1)) + frame = np.expand_dims(frame, axis=0) + return frame + + +class LandmarkAligner: + """Mirror FaceRecognizer.align_face.""" + + def __init__(self, landmark_model_path: str): + if not os.path.exists(landmark_model_path): + raise FileNotFoundError(landmark_model_path) + self.detector = cv2.face.createFacemarkLBF() + self.detector.loadModel(landmark_model_path) + + def align( + self, image: np.ndarray, out_w: int, out_h: int + ) -> tuple[np.ndarray, dict]: + land_image = ( + cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + ) + _, lands = self.detector.fit( + land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])]) + ) + landmarks = lands[0][0] + + leftEyePts = landmarks[42:48] + rightEyePts = landmarks[36:42] + leftEyeCenter = leftEyePts.mean(axis=0).astype("int") + rightEyeCenter = rightEyePts.mean(axis=0).astype("int") + + dY = rightEyeCenter[1] - leftEyeCenter[1] + dX = rightEyeCenter[0] - leftEyeCenter[0] + angle = np.degrees(np.arctan2(dY, dX)) - 180 + dist = float(np.sqrt((dX**2) + (dY**2))) + + desiredRightEyeX = 1.0 - 0.35 + desiredDist = (desiredRightEyeX - 0.35) * out_w + scale = desiredDist / dist if dist > 0 else 1.0 + + eyesCenter = ( + int((leftEyeCenter[0] + rightEyeCenter[0]) // 2), + int((leftEyeCenter[1] + rightEyeCenter[1]) // 2), + ) + M = cv2.getRotationMatrix2D(eyesCenter, angle, scale) + tX = out_w * 0.5 + tY = out_h * 0.35 + M[0, 2] += tX - eyesCenter[0] + M[1, 2] += tY - eyesCenter[1] + + aligned = cv2.warpAffine(image, M, (out_w, out_h), flags=cv2.INTER_CUBIC) + info = dict( + angle=float(angle), + eye_dist_px=dist, + scale=float(scale), + landmarks=landmarks, + ) + return aligned, info + + +class ArcFaceEmbedder: + def __init__(self, model_path: str): + self.session = ort.InferenceSession( + model_path, providers=["CPUExecutionProvider"] + ) + self.input_name = self.session.get_inputs()[0].name + + def embed(self, image_bgr: np.ndarray) -> np.ndarray: + tensor = arcface_preprocess(image_bgr) + out = self.session.run(None, {self.input_name: tensor})[0] + return out.squeeze() + + +def similarity_to_confidence( + cos_sim: float, + median: float = 0.3, + range_width: float = 0.6, + slope_factor: float = 12, +) -> float: + slope = slope_factor / range_width + return float(1.0 / (1.0 + np.exp(-slope * (cos_sim - median)))) + + +def laplacian_variance(image: np.ndarray) -> float: + return float(cv2.Laplacian(image, cv2.CV_64F).var()) + + +def blur_reduction(variance: float) -> float: + if variance < 120: + return 0.06 + elif variance < 160: + return 0.04 + elif variance < 200: + return 0.02 + elif variance < 250: + return 0.01 + return 0.0 + + +def cosine(a: np.ndarray, b: np.ndarray) -> float: + denom = np.linalg.norm(a) * np.linalg.norm(b) + if denom == 0: + return 0.0 + return float(np.dot(a, b) / denom) + + +def l2(v: np.ndarray) -> np.ndarray: + return v / (np.linalg.norm(v) + 1e-9) + + +# --------------------------------------------------------------------------- +# Sample loading +# --------------------------------------------------------------------------- + + +@dataclass +class FaceSample: + path: str + shape: tuple[int, int] + embedding: np.ndarray + blur_var: float + align_info: dict + + +def load_folder( + folder: str, aligner: LandmarkAligner, embedder: ArcFaceEmbedder +) -> list[FaceSample]: + samples: list[FaceSample] = [] + names = sorted(os.listdir(folder)) + for name in names: + if name.startswith("."): + continue + path = os.path.join(folder, name) + if not os.path.isfile(path): + continue + img = cv2.imread(path) + if img is None: + print(f" [skip unreadable] {name}") + continue + aligned, info = aligner.align(img, img.shape[1], img.shape[0]) + emb = embedder.embed(aligned) + samples.append( + FaceSample( + path=path, + shape=(img.shape[1], img.shape[0]), + embedding=emb, + blur_var=laplacian_variance(img), + align_info=info, + ) + ) + return samples + + +def trimmed_mean(embs: Iterable[np.ndarray], trim: float = 0.15) -> np.ndarray: + arr = np.stack(list(embs), axis=0) + return stats.trim_mean(arr, trim, axis=0) + + +# --------------------------------------------------------------------------- +# Baseline analyses (always run) +# --------------------------------------------------------------------------- + + +def summarize_positive(samples: list[FaceSample], mean_emb: np.ndarray) -> None: + """Summary of training set: per-sample cos to class mean, intra-class stats. + + Outliers with cos far below the rest are likely degrading the mean — + they'd be the first candidates the shipped vector-outlier filter drops. + """ + print("\n" + "=" * 78) + print(f"POSITIVE SET ANALYSIS ({len(samples)} images)") + print("=" * 78) + + rows = [] + for s in samples: + cs = cosine(s.embedding, mean_emb) + conf = similarity_to_confidence(cs) + red = blur_reduction(s.blur_var) + rows.append( + dict( + name=os.path.basename(s.path), + shape=f"{s.shape[0]}x{s.shape[1]}", + eye_px=s.align_info["eye_dist_px"], + angle=s.align_info["angle"] + 180, + blur=s.blur_var, + cos=cs, + conf=conf, + red=red, + adj_conf=max(0.0, conf - red), + ) + ) + + rows.sort(key=lambda r: r["cos"]) + sims = np.array([r["cos"] for r in rows]) + print( + f"\nCosine-to-trimmed-mean: mean={sims.mean():.3f} std={sims.std():.3f} " + f"min={sims.min():.3f} max={sims.max():.3f}" + ) + + print("\n-- Worst matches (bottom 10, most likely hurting the mean) --") + print( + f"{'cos':>6} {'conf':>6} {'blur':>7} {'eyes':>6} " + f"{'angle':>6} {'shape':>9} name" + ) + for r in rows[:10]: + print( + f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} " + f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}" + ) + + print("\n-- Best matches (top 5) --") + for r in rows[-5:][::-1]: + print( + f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} " + f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}" + ) + + # Pairwise analysis — flags embeddings poorly correlated with the rest + print("\n-- Pairwise intra-class similarity (mean cos vs. other positives) --") + embs = np.stack([s.embedding for s in samples], axis=0) + norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9) + sim_matrix = norms @ norms.T + np.fill_diagonal(sim_matrix, np.nan) + mean_pairwise = np.nanmean(sim_matrix, axis=1) + names = [os.path.basename(s.path) for s in samples] + ordered = sorted(zip(names, mean_pairwise), key=lambda t: t[1]) + print(f"{'mean_cos':>9} name") + for nm, mp in ordered[:10]: + print(f"{mp:9.3f} {nm}") + print(f"\n overall mean pairwise cos: {np.nanmean(sim_matrix):.3f}") + print(f" median pairwise cos: {np.nanmedian(sim_matrix):.3f}") + + +def summarize_negative( + neg_samples: list[FaceSample], + mean_emb: np.ndarray, + pos_samples: list[FaceSample], +) -> None: + """Score each negative against the class mean, then show its top-3 + nearest positives. High-scoring negatives that match specific outlier + positives hint at training-set contamination. + """ + print("\n" + "=" * 78) + print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images)") + print("=" * 78) + print( + f"\n{'cos':>6} {'conf':>6} {'red':>5} {'adj':>5} " + f"{'blur':>7} {'eyes':>6} {'shape':>9} name" + ) + for s in neg_samples: + cs = cosine(s.embedding, mean_emb) + conf = similarity_to_confidence(cs) + red = blur_reduction(s.blur_var) + print( + f"{cs:6.3f} {conf:6.3f} {red:5.2f} {max(0, conf - red):5.2f} " + f"{s.blur_var:7.1f} {s.align_info['eye_dist_px']:6.1f} " + f"{s.shape[0]}x{s.shape[1]:<5} {os.path.basename(s.path)}" + ) + + print("\n-- For each negative, top-3 most similar positives --") + pos_embs = np.stack([p.embedding for p in pos_samples]) + pos_norm = pos_embs / (np.linalg.norm(pos_embs, axis=1, keepdims=True) + 1e-9) + for s in neg_samples: + v = s.embedding / (np.linalg.norm(s.embedding) + 1e-9) + sims = pos_norm @ v + idx = np.argsort(-sims)[:3] + print(f"\n {os.path.basename(s.path)}:") + for i in idx: + print( + f" {sims[i]:6.3f} {os.path.basename(pos_samples[i].path)} " + f"blur={pos_samples[i].blur_var:.1f} " + f"eyes={pos_samples[i].align_info['eye_dist_px']:.1f}" + ) + + +# --------------------------------------------------------------------------- +# Optional diagnostics +# --------------------------------------------------------------------------- + + +def vector_outlier_test( + pos: list[FaceSample], neg: list[FaceSample], base_trim: float = 0.15 +) -> None: + """Measure the shipped vector-wise outlier filter at various thresholds. + + The production filter at `build_class_mean` in + frigate/data_processing/common/face/model.py uses T=0.30. This test + sweeps T so you can see which images would be dropped on a new collection + and how that affects the negative scores. + + Algorithm: iteratively recompute trim_mean on the kept set, drop any + embedding with cos < T to that mean, repeat until converged. Floor at + 50% of the collection to avoid collapse. + """ + print("\n" + "=" * 78) + print("VECTOR-WISE OUTLIER PRE-FILTER — layered on trim_mean(0.15)") + print("=" * 78) + + all_embs = np.stack([s.embedding for s in pos]) + + def iterative_mean( + embs: np.ndarray, + threshold: float, + iters: int = 3, + min_keep_frac: float = 0.5, + ) -> tuple[np.ndarray, np.ndarray]: + keep = np.ones(len(embs), dtype=bool) + floor = max(5, int(np.ceil(min_keep_frac * len(embs)))) + for _ in range(iters): + m = stats.trim_mean(embs[keep], base_trim, axis=0) + m_norm = m / (np.linalg.norm(m) + 1e-9) + e_norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9) + cos_to_mean = e_norms @ m_norm + new_keep = cos_to_mean >= threshold + if new_keep.sum() < floor: + top_idx = np.argsort(-cos_to_mean)[:floor] + new_keep = np.zeros_like(new_keep) + new_keep[top_idx] = True + if np.array_equal(new_keep, keep): + break + keep = new_keep + final = stats.trim_mean(embs[keep], base_trim, axis=0) + return final, keep + + provisional = stats.trim_mean(all_embs, base_trim, axis=0) + p_norm = provisional / (np.linalg.norm(provisional) + 1e-9) + e_norms_all = all_embs / (np.linalg.norm(all_embs, axis=1, keepdims=True) + 1e-9) + cos_to_prov = e_norms_all @ p_norm + print("\nDistribution of cos(positive, provisional trim_mean):") + print( + f" min={cos_to_prov.min():.3f} p10={np.percentile(cos_to_prov, 10):.3f} " + f"p25={np.percentile(cos_to_prov, 25):.3f} " + f"median={np.median(cos_to_prov):.3f} " + f"p75={np.percentile(cos_to_prov, 75):.3f} max={cos_to_prov.max():.3f}" + ) + + baseline_mean = stats.trim_mean(all_embs, base_trim, axis=0) + baseline_pos = np.array([cosine(p.embedding, baseline_mean) for p in pos]) + baseline_neg = ( + np.array([cosine(n.embedding, baseline_mean) for n in neg]) + if neg + else np.array([]) + ) + baseline_conf_neg = np.array([similarity_to_confidence(c) for c in baseline_neg]) + + print( + f"\nBaseline (trim_mean only, {len(pos)} images):" + f"\n pos cos min={baseline_pos.min():.3f} " + f"mean={baseline_pos.mean():.3f} max={baseline_pos.max():.3f}" + ) + if len(neg): + print( + f" neg cos min={baseline_neg.min():.3f} " + f"mean={baseline_neg.mean():.3f} max={baseline_neg.max():.3f}" + ) + print( + f" neg conf min={baseline_conf_neg.min():.3f} " + f"mean={baseline_conf_neg.mean():.3f} max={baseline_conf_neg.max():.3f}" + ) + print( + f" margin (pos.min - neg.max): " + f"{baseline_pos.min() - baseline_neg.max():+.3f}" + ) + + print("\nIterative (refine mean → drop vectors with cos5} {'kept':>6} {'pos min':>7} {'pos mean':>8} " + f"{'neg max':>7} {'neg mean':>8} {'neg conf.max':>12} {'margin':>7}" + ) + for T in [0.15, 0.20, 0.25, 0.28, 0.30, 0.33, 0.36, 0.40]: + mean, keep = iterative_mean(all_embs, T) + pos_sims = np.array([cosine(p.embedding, mean) for p in pos]) + neg_sims = ( + np.array([cosine(n.embedding, mean) for n in neg]) if neg else np.array([]) + ) + neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims]) + margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0) + print( + f"{T:5.2f} {int(keep.sum()):>3}/{len(pos):<2} " + f"{pos_sims.min():7.3f} {pos_sims.mean():8.3f} " + f"{neg_sims.max() if len(neg_sims) else float('nan'):7.3f} " + f"{neg_sims.mean() if len(neg_sims) else float('nan'):8.3f} " + f"{neg_conf.max() if len(neg_conf) else float('nan'):12.3f} " + f"{margin:+7.3f}" + ) + + # Show which images get dropped at the shipped threshold + neighbors + for T_show in (0.25, 0.30, 0.33): + _, keep = iterative_mean(all_embs, T_show) + print(f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:") + final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0) + m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9) + for i, (p, k) in enumerate(zip(pos, keep)): + if not k: + e_n = p.embedding / (np.linalg.norm(p.embedding) + 1e-9) + cos_final = float(e_n @ m_n) + print( + f" cos_to_clean_mean={cos_final:6.3f} " + f"shape={p.shape[0]}x{p.shape[1]} " + f"eyes={p.align_info['eye_dist_px']:6.1f} " + f"blur={p.blur_var:7.1f} " + f"{os.path.basename(p.path)}" + ) + + +def degenerate_embedding_test(pos: list[FaceSample], neg: list[FaceSample]) -> None: + """Detect whether negatives and low-quality positives share a degenerate + 'tiny/noisy face' region of the embedding space. + + Signal: if neg-to-neg cos is higher than pos-to-pos cos, the negatives + aren't really per-identity embeddings — they're dominated by upsample / + low-resolution artifacts that all map to a similar corner of embedding + space regardless of who the face belongs to. + + Also rebuilds the mean using only high-intra-similarity positives to + show whether a cleaner training set separates the negatives. + """ + print("\n" + "=" * 78) + print("DEGENERATE-EMBEDDING TEST") + print("=" * 78) + + pos_embs = np.stack([l2(s.embedding) for s in pos]) + neg_embs = np.stack([l2(s.embedding) for s in neg]) + + nn = neg_embs @ neg_embs.T + np.fill_diagonal(nn, np.nan) + pp = pos_embs @ pos_embs.T + np.fill_diagonal(pp, np.nan) + pn = pos_embs @ neg_embs.T + + print( + f"\n neg<->neg mean cos : {np.nanmean(nn):.3f} " + f"(how tightly negatives cluster together)" + ) + print( + f" pos<->pos mean cos : {np.nanmean(pp):.3f} (how tightly positives cluster)" + ) + print( + f" pos<->neg mean cos : {pn.mean():.3f} " + f"(cross-class — should be low for a clean class)" + ) + if np.nanmean(nn) > np.nanmean(pp): + print( + "\n >> neg<->neg > pos<->pos: negatives cluster more tightly than\n" + " positives. This is the degenerate-embedding signature —\n" + " upsampled tiny crops share a common 'face-like blob' region\n" + " regardless of identity." + ) + + mean_intra = np.nanmean(pp, axis=1) + for thresh in (0.30, 0.33, 0.36): + keep = mean_intra >= thresh + if keep.sum() < 5: + continue + clean_embs = [pos[i].embedding for i in range(len(pos)) if keep[i]] + clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0) + neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg]) + neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores]) + pos_scores = np.array( + [cosine(pos[i].embedding, clean_mean) for i in range(len(pos)) if keep[i]] + ) + print( + f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives" + ) + print( + f" pos cos vs mean : min={pos_scores.min():.3f} " + f"mean={pos_scores.mean():.3f} max={pos_scores.max():.3f}" + ) + print( + f" neg cos vs mean : min={neg_scores.min():.3f} " + f"mean={neg_scores.mean():.3f} max={neg_scores.max():.3f}" + ) + print( + f" neg conf : min={neg_confs.min():.3f} " + f"mean={neg_confs.mean():.3f} max={neg_confs.max():.3f}" + ) + print( + f" margin (pos.min - neg.max): " + f"{pos_scores.min() - neg_scores.max():+.3f}" + ) + + +def contamination_analysis(pos: list[FaceSample], neg: list[FaceSample]) -> None: + """Check whether the positive collection contains a second identity. + + Two signals: + (a) Per-positive: if an image is closer to at least one negative than + to the rest of the positive class, it's likely a mislabeled face. + (b) 2-means split of the positive embeddings: if one cluster center + lands close to the negative mean, that cluster is a contaminating + sub-identity that's pulling the class mean toward the negatives. + """ + print("\n" + "=" * 78) + print("CONTAMINATION ANALYSIS") + print("=" * 78) + + pos_embs = np.stack([l2(s.embedding) for s in pos]) + neg_embs = np.stack([l2(s.embedding) for s in neg]) + pos_names = [os.path.basename(s.path) for s in pos] + + pos_pos = pos_embs @ pos_embs.T + np.fill_diagonal(pos_pos, np.nan) + pos_neg = pos_embs @ neg_embs.T + + mean_intra = np.nanmean(pos_pos, axis=1) + max_to_neg = pos_neg.max(axis=1) + mean_to_neg = pos_neg.mean(axis=1) + + print( + "\nPositives closer to a negative than to their own class avg" + "\n(these are candidates for mislabeled images):" + ) + print(f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} {'delta':>6} name") + rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra)) + rows.sort(key=lambda r: -(r[1] - r[3])) + for nm, mxn, mnn, mi in rows[:15]: + delta = mxn - mi + marker = " <<" if delta > 0 else "" + print(f"{mxn:7.3f} {mnn:8.3f} {mi:10.3f} {delta:6.3f} {nm}{marker}") + + # 2-means in cosine space (no sklearn dependency). + print("\n2-means split of positive embeddings (cosine space):") + rng = np.random.default_rng(0) + best = None + for _ in range(5): + idx = rng.choice(len(pos_embs), 2, replace=False) + centers = pos_embs[idx].copy() + for _ in range(50): + sims = pos_embs @ centers.T + labels = np.argmax(sims, axis=1) + new_centers = np.stack( + [ + l2(pos_embs[labels == k].mean(axis=0)) + if np.any(labels == k) + else centers[k] + for k in range(2) + ] + ) + if np.allclose(new_centers, centers): + break + centers = new_centers + tight = float(np.mean([sims[i, labels[i]] for i in range(len(labels))])) + if best is None or tight > best[0]: + best = (tight, labels.copy(), centers.copy()) + + _, labels, centers = best + sizes = [int((labels == k).sum()) for k in range(2)] + neg_mean = l2(neg_embs.mean(axis=0)) + print( + f" cluster 0: size={sizes[0]:>2} " + f"center<->other_center_cos={float(centers[0] @ centers[1]):.3f} " + f"center<->neg_mean_cos={float(centers[0] @ neg_mean):.3f}" + ) + print( + f" cluster 1: size={sizes[1]:>2} " + f"center<->neg_mean_cos={float(centers[1] @ neg_mean):.3f}" + ) + + neg_aligned = 0 if centers[0] @ neg_mean > centers[1] @ neg_mean else 1 + print( + f"\n cluster {neg_aligned} is more similar to the negatives — " + f"its members are the contamination candidates:" + ) + for i, lbl in enumerate(labels): + if lbl == neg_aligned: + print( + f" max_to_neg={max_to_neg[i]:.3f} " + f"mean_intra={mean_intra[i]:.3f} {pos_names[i]}" + ) + + keep_mask = labels != neg_aligned + if keep_mask.sum() >= 3: + clean_embs = [pos[i].embedding for i in range(len(pos)) if keep_mask[i]] + clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0) + print( + f"\n Rebuilding class mean from the OTHER cluster " + f"({keep_mask.sum()} images):" + ) + print(f" {'cos':>6} {'conf':>6} name") + for n in neg: + cs = cosine(n.embedding, clean_mean) + cf = similarity_to_confidence(cs) + print(f" {cs:6.3f} {cf:6.3f} {os.path.basename(n.path)}") + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Analyze a face recognition collection outside Frigate.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument( + "--positive", required=True, help="Training folder for one identity" + ) + ap.add_argument( + "--negative", + default=None, + help="Runtime-crop folder to score against (optional)", + ) + ap.add_argument( + "--model-cache", + default="/config/model_cache", + help="Directory containing facedet/arcface.onnx and facedet/landmarkdet.yaml", + ) + ap.add_argument( + "--trim", + type=float, + default=0.15, + help="trim_mean proportion (Frigate uses 0.15)", + ) + ap.add_argument( + "--vector-outlier", + action="store_true", + help="Sweep the vector-wise outlier filter threshold", + ) + ap.add_argument( + "--degenerate", + action="store_true", + help="Test whether negatives share a degenerate embedding region", + ) + ap.add_argument( + "--contamination", + action="store_true", + help="Check whether the positive folder contains a second identity", + ) + args = ap.parse_args() + + arcface_path = os.path.join(args.model_cache, "facedet", "arcface.onnx") + landmark_path = os.path.join(args.model_cache, "facedet", "landmarkdet.yaml") + for p in (arcface_path, landmark_path): + if not os.path.exists(p): + print(f"ERROR: model file not found: {p}") + return 1 + + print(f"Loading ArcFace from {arcface_path}") + embedder = ArcFaceEmbedder(arcface_path) + print(f"Loading landmark model from {landmark_path}") + aligner = LandmarkAligner(landmark_path) + + print(f"\nLoading positives from {args.positive} ...") + pos = load_folder(args.positive, aligner, embedder) + print(f" {len(pos)} positives loaded") + + neg: list[FaceSample] = [] + if args.negative: + print(f"\nLoading negatives from {args.negative} ...") + neg = load_folder(args.negative, aligner, embedder) + print(f" {len(neg)} negatives loaded") + + if not pos: + print("no positive samples — aborting") + return 1 + + mean_emb = trimmed_mean([s.embedding for s in pos], trim=args.trim) + summarize_positive(pos, mean_emb) + if neg: + summarize_negative(neg, mean_emb, pos) + + if args.vector_outlier: + vector_outlier_test(pos, neg, args.trim) + if args.degenerate and neg: + degenerate_embedding_test(pos, neg) + if args.contamination and neg: + contamination_analysis(pos, neg) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/testing-scripts/object_dataset.py b/testing-scripts/object_dataset.py new file mode 100644 index 0000000000..dd4071c441 --- /dev/null +++ b/testing-scripts/object_dataset.py @@ -0,0 +1,1022 @@ +""" +Object classification investigation script. + +Standalone replica of Frigate's custom object classification inference pipeline +(see frigate/data_processing/real_time/custom_classification.py and +frigate/util/classification.py) for analyzing a training dataset outside the +running service. Useful for: + + - Diagnosing why a class produces false positives / misidentifications + - Finding the training images that the deployed model itself misclassifies + (these are the worst offenders — usually mislabeled or low-quality crops) + - Inspecting borderline-correct images that sit near the decision boundary + - Spotting class-pair confusion (which classes get mixed up) + +Layout: + - Core pipeline: load_tflite, preprocess_for_inference, classify_image — + all mirroring CustomObjectClassificationProcessor exactly + - Default run: scan the dataset, classify every image with the deployed + model.tflite, report misclassified + borderline images per class, and + print a confusion matrix + - Optional diagnostics (flags): image-quality breakdown, scoring an + unlabeled "negative" folder, cross-class contamination analysis (find + training images in class A that visually look like class B and pull + inference toward A), and copying worst offenders out for review + +Recommended workflow when troubleshooting misclassifications: + + 1. Run the basic scan first (no extra flags). Read top-down: + - Class balance ratio. If > 3x, balance counts before anything else. + The dominant class will absorb borderline predictions otherwise. + - Per-class accuracy. Any class < 50% needs attention. + - Confusion matrix. If multiple classes all over-predict the same + class (e.g. Buddy->Rex, Bailey->Rex, none->Rex), you have + feature collapse, not "a few bad photos." Don't bother with + contamination analysis yet — fix the collapse first. + + 2. Check for "degenerate blob" upsampling. Look at the SHAPE column on + worst-offender rows. If most misclassified crops are < 80x80, the + small originals are being stretched 3-7x to fit the 224x224 model + input. Upsampled crops collapse to a similar region of feature space + regardless of identity — the model can't tell them apart and defaults + them to whichever class has the most of them. + + Fix: quarantine every image where min(w, h) < 80 (or 100 for a + stricter cut) and retrain. This works when the named class has + plenty of non-small examples to fall back on AND the small crops + are mostly degenerate blobs (target unrecognizable at that size). + + CAVEAT — sometimes small crops ARE the signal, not the noise: if + your target naturally appears small at the camera distance (cats + indoors, distant subjects, wide-FOV setups), the small crops in + the named class ARE the typical inference-time input. Removing them + leaves the model unable to recognize the target at its natural + detection size, and accuracy on the named class collapses after + retraining. If that happens — named-class accuracy drops sharply + after size cut + retrain — restore the quarantine and switch to + visual review of just the misclassified small crops instead of + bulk size filtering. The size threshold is a tool for "tons of + accidental tiny blobs polluting a class with otherwise large + examples," not a universal cleanup. + + 3. Verify the "none" class exists and is healthy. Without a strong + "none" class, every unknown crop at inference gets forced into one of + your real classes — the model has no "I don't know" option. Aim for: + - Count similar to your other classes (don't let it be the smallest) + - Images >= 100x100, well-framed + - Visual variety: other dogs/objects, partial views, empty scenes, + not just one type of negative + + 4. Look for cross-class duplicates from the same Frigate event. If the + same timestamp prefix appears across multiple class folders (e.g. + "1772052999.x" present in Buddy AND Bailey AND Rex AND none), those + crops came from one moment in time. Either they were extracted from a + multi-object frame and labeled inconsistently, or they're near- + duplicates of one scene cropped slightly differently. Inspect them as + a group and decide together. + + 5. Only after (1)-(4) are clean, run --confuses : for + targeted contamination analysis. The "ringleaders" section at the + bottom is the actionable part: a short list of images appearing + repeatedly as nearest neighbors of the wrong class. Those are the + few photos doing most of the damage. + + 6. Stop deleting when the contamination delta column shows ALL negative + values for the source class. That means dataset images in + are already visually distinct from in fixed-backbone + embedding space — the trained model just hasn't learned to use that + separation. The fix from that point is to ADD more training data for + the underperforming class, not delete more. Aim for at least 20 well- + framed images per class. + +The dataset must be the same layout Frigate trains from: + //dataset//*.{webp,png,jpg,jpeg} + +The model must already be trained: + //model.tflite + //labelmap.txt + +Command-line examples (mirror the workflow steps above): + + One-time setup — download the ImageNet-pretrained MobileNetV2 backbone + that --confuses uses for model-independent embeddings: + + curl -L -o /config/model_cache/mobilenetv2-7.onnx \\ + https://github.com/onnx/models/raw/main/validated/vision/classification/mobilenet/model/mobilenetv2-7.onnx + + Step 1 — Basic scan. Always start here. Reads class balance, accuracy, + confusion matrix, and per-class worst offenders: + + python3 object_dataset.py --name "" --top-n 25 + + Step 2 — Same scan plus image-quality stats (blur, brightness, aspect + distortion) for correct vs misclassified rows. Use when you suspect + systematic quality issues are driving the misses: + + python3 object_dataset.py --name "" --top-n 25 --quality + + Step 2 (cleanup) — Quarantine crops below 80x80 (the upsampling-blob + fix). Mirrors the class folder structure so individual images can be + restored. Change `threshold = 80` to 64 (looser) or 100 (stricter): + + python3 - <<'EOF' + import cv2 + from pathlib import Path + dataset = Path("/media/frigate/clips//dataset") + quarantine = Path("/media/frigate/clips//quarantine_small") + threshold = 80 + moved = 0 + for cls_dir in sorted(dataset.iterdir()): + if not cls_dir.is_dir(): + continue + for img_path in sorted(cls_dir.iterdir()): + if img_path.suffix.lower() not in (".png", ".jpg", ".jpeg", ".webp"): + continue + img = cv2.imread(str(img_path)) + if img is None: + continue + h, w = img.shape[:2] + if min(h, w) < threshold: + dest_dir = quarantine / cls_dir.name + dest_dir.mkdir(parents=True, exist_ok=True) + img_path.rename(dest_dir / img_path.name) + moved += 1 + print(f"moved {moved} images") + EOF + + Revert any quarantine directory (puts everything back into dataset/): + + python3 - <<'EOF' + from pathlib import Path + quarantine = Path("/media/frigate/clips//quarantine_small") + dataset = Path("/media/frigate/clips//dataset") + for cls_dir in sorted(quarantine.iterdir()): + if not cls_dir.is_dir(): + continue + target = dataset / cls_dir.name + target.mkdir(parents=True, exist_ok=True) + for img_path in sorted(cls_dir.iterdir()): + img_path.rename(target / img_path.name) + EOF + + Step 4 — Inspect a same-timestamp cluster across all classes (replace + TIMESTAMP with the prefix you saw in worst-offenders, e.g. "1772052999"): + + mkdir -p /tmp/timestamp_cluster + cd "/media/frigate/clips//dataset" + for f in */*TIMESTAMP*; do + cls=$(dirname "$f"); fn=$(basename "$f") + cp "$f" "/tmp/timestamp_cluster/${cls}__${fn}" + done + + Step 5 — Cross-class contamination. Lists specific images that + look like , plus a ringleader summary of the few worst offenders. + Also copies all misclassified images into a flat browse-able folder + bucketed by (true_class)__as__(predicted_class): + + python3 object_dataset.py --name "" \\ + --embedding-model /config/model_cache/mobilenetv2-7.onnx \\ + --confuses Rex:Buddy --top-n 15 \\ + --save-misclassified /tmp/_offenders + + Or let the script pick the worst-confused class pair from the matrix: + + python3 object_dataset.py --name "" \\ + --embedding-model /config/model_cache/mobilenetv2-7.onnx \\ + --confuses auto + + Score an unlabeled folder of runtime crops against the trained model — + useful for analyzing why specific inference-time misfires happened. + Prints full per-class probability vectors and threshold-pass status: + + python3 object_dataset.py --name "" \\ + --negative /path/to/runtime_misfires --threshold 0.8 + +Full flag reference: + python3 object_dataset.py \\ + --name \\ + [--clips-dir /media/frigate/clips] \\ + [--model-cache /config/model_cache] \\ + [--threshold 0.8] [--top-n 15] \\ + [--quality] [--negative ] [--save-misclassified ] \\ + [--confuses :] [--embedding-model ] +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import sys +from dataclasses import dataclass + +import cv2 +import numpy as np + +try: + from tflite_runtime.interpreter import Interpreter +except ModuleNotFoundError: + from ai_edge_litert.interpreter import Interpreter + +CLASSIFIER_INPUT_SIZE = 224 +IMAGE_EXTS = (".webp", ".png", ".jpg", ".jpeg") + + +# --------------------------------------------------------------------------- +# Replicated Frigate pipeline +# --------------------------------------------------------------------------- + + +def load_tflite(model_path: str) -> tuple[Interpreter, list[dict], list[dict]]: + """Mirror CustomObjectClassificationProcessor.__build_detector.""" + interpreter = Interpreter(model_path=model_path, num_threads=2) + interpreter.allocate_tensors() + return ( + interpreter, + interpreter.get_input_details(), + interpreter.get_output_details(), + ) + + +def load_labelmap(path: str) -> dict[int, str]: + """Mirror util.builtin.load_labels(prefill=0, indexed=False).""" + with open(path, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f.readlines() if line.strip()] + return {idx: line for idx, line in enumerate(lines)} + + +def preprocess_for_inference(image_bgr: np.ndarray) -> np.ndarray: + """Mirror the inference preprocessing in process_frame. + + Frigate decodes the camera frame YUV->RGB, crops, then cv2.resize to + 224x224, and passes the uint8 array directly to the int8-quantized + interpreter. On disk we read BGR via cv2.imread, so we must convert + to RGB to match the channel order the model was trained on. + """ + rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + resized = cv2.resize(rgb, (CLASSIFIER_INPUT_SIZE, CLASSIFIER_INPUT_SIZE)) + return resized + + +class MobileNetEmbedder: + """ImageNet-pretrained MobileNetV2 backbone via cv2.dnn. + + Used as a model-independent visual embedder for cross-class contamination + analysis. The user's trained classifier may have memorized contaminating + training images and place them inside the right class in its own embedding + space — a fixed external backbone keeps the analysis honest. + + Expects the standard ONNX Model Zoo MobileNetV2-7 file (PyTorch-style + preprocessing: ImageNet mean/std on /255 input). Output is 1000-d ImageNet + logits; L2-normalized for cosine-similarity comparisons. + """ + + IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) + IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) + + def __init__(self, model_path: str): + if not os.path.exists(model_path): + raise FileNotFoundError(model_path) + self.net = cv2.dnn.readNetFromONNX(model_path) + + def embed(self, image_bgr: np.ndarray) -> np.ndarray: + rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + resized = cv2.resize(rgb, (224, 224)).astype(np.float32) / 255.0 + normalized = (resized - self.IMAGENET_MEAN) / self.IMAGENET_STD + # NHWC -> NCHW + blob = np.transpose(normalized, (2, 0, 1))[np.newaxis, :, :, :] + self.net.setInput(blob) + out = self.net.forward().squeeze().astype(np.float32) + norm = float(np.linalg.norm(out)) + return out / norm if norm > 0 else out + + +def classify_image( + interpreter: Interpreter, + input_details: list[dict], + output_details: list[dict], + image_bgr: np.ndarray, +) -> np.ndarray: + """Mirror _classify_object's tensor flow. + + Returns the per-class probability vector (length = num_classes) after + the exact `probs = res / res.sum(axis=0)` renormalization Frigate uses + on the int8-quantized output. + """ + resized = preprocess_for_inference(image_bgr) + tensor = np.expand_dims(resized, axis=0) + interpreter.set_tensor(input_details[0]["index"], tensor) + interpreter.invoke() + res = interpreter.get_tensor(output_details[0]["index"])[0].astype(np.float32) + total = res.sum(axis=0) + if total <= 0: + # Defensive: all zeros from a degenerate quantization step. + return np.full_like(res, 1.0 / len(res)) + return res / total + + +# --------------------------------------------------------------------------- +# Sample loading +# --------------------------------------------------------------------------- + + +@dataclass +class ImageSample: + path: str + true_label: str | None # None for the unlabeled negative folder + shape: tuple[int, int] + probs: np.ndarray + pred_idx: int + pred_label: str + pred_score: float + true_idx: int | None + true_score: float | None + + +def laplacian_variance(image_bgr: np.ndarray) -> float: + gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +def mean_brightness(image_bgr: np.ndarray) -> float: + return float(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY).mean()) + + +def aspect_distortion(shape: tuple[int, int]) -> float: + """How far the crop is from square; |1 - max(w,h)/min(w,h)|. + + A wide or tall crop gets squashed to 224x224 by the inference resize, + which can be a hidden source of misclassification. + """ + w, h = shape + if w <= 0 or h <= 0: + return float("inf") + return float(max(w, h) / min(w, h) - 1.0) + + +def iter_dataset(dataset_dir: str) -> list[tuple[str, str]]: + """Yield (class_name, image_path) pairs from the dataset directory.""" + pairs: list[tuple[str, str]] = [] + if not os.path.isdir(dataset_dir): + return pairs + for cls in sorted(os.listdir(dataset_dir)): + cls_dir = os.path.join(dataset_dir, cls) + if not os.path.isdir(cls_dir) or cls.startswith("."): + continue + for name in sorted(os.listdir(cls_dir)): + if name.startswith("."): + continue + if not name.lower().endswith(IMAGE_EXTS): + continue + pairs.append((cls, os.path.join(cls_dir, name))) + return pairs + + +def classify_folder( + folder: str, + interpreter: Interpreter, + input_details: list[dict], + output_details: list[dict], + labelmap: dict[int, str], + label_to_idx: dict[str, int], + true_label: str | None = None, +) -> list[ImageSample]: + """Classify every image directly under `folder`. Used for the negative set.""" + samples: list[ImageSample] = [] + if not os.path.isdir(folder): + return samples + for name in sorted(os.listdir(folder)): + if name.startswith(".") or not name.lower().endswith(IMAGE_EXTS): + continue + path = os.path.join(folder, name) + img = cv2.imread(path) + if img is None: + print(f" [skip unreadable] {name}") + continue + probs = classify_image(interpreter, input_details, output_details, img) + pred_idx = int(np.argmax(probs)) + true_idx = label_to_idx.get(true_label) if true_label is not None else None + true_score = float(probs[true_idx]) if true_idx is not None else None + samples.append( + ImageSample( + path=path, + true_label=true_label, + shape=(img.shape[1], img.shape[0]), + probs=probs, + pred_idx=pred_idx, + pred_label=labelmap[pred_idx], + pred_score=float(probs[pred_idx]), + true_idx=true_idx, + true_score=true_score, + ) + ) + return samples + + +def classify_dataset( + dataset_dir: str, + interpreter: Interpreter, + input_details: list[dict], + output_details: list[dict], + labelmap: dict[int, str], + label_to_idx: dict[str, int], +) -> list[ImageSample]: + samples: list[ImageSample] = [] + pairs = iter_dataset(dataset_dir) + for cls, path in pairs: + img = cv2.imread(path) + if img is None: + print(f" [skip unreadable] {cls}/{os.path.basename(path)}") + continue + probs = classify_image(interpreter, input_details, output_details, img) + pred_idx = int(np.argmax(probs)) + true_idx = label_to_idx.get(cls) + true_score = float(probs[true_idx]) if true_idx is not None else None + samples.append( + ImageSample( + path=path, + true_label=cls, + shape=(img.shape[1], img.shape[0]), + probs=probs, + pred_idx=pred_idx, + pred_label=labelmap[pred_idx], + pred_score=float(probs[pred_idx]), + true_idx=true_idx, + true_score=true_score, + ) + ) + return samples + + +# --------------------------------------------------------------------------- +# Baseline analyses (always run) +# --------------------------------------------------------------------------- + + +def summarize_dataset(samples: list[ImageSample], labelmap: dict[int, str]) -> None: + """Per-class counts, accuracy, mean confidence on the true class.""" + print("\n" + "=" * 78) + print(f"DATASET OVERVIEW ({len(samples)} images)") + print("=" * 78) + + by_class: dict[str, list[ImageSample]] = {} + for s in samples: + by_class.setdefault(s.true_label or "", []).append(s) + + print( + f"\n{'class':<20} {'count':>6} {'acc':>6} {'mean_p_true':>12} " + f"{'min_p_true':>10} {'mislabeled':>11}" + ) + for cls in sorted(by_class): + rows = by_class[cls] + correct = sum(1 for r in rows if r.pred_label == cls) + mean_pt = ( + np.mean([r.true_score for r in rows if r.true_score is not None]) + if any(r.true_score is not None for r in rows) + else float("nan") + ) + min_pt = ( + np.min([r.true_score for r in rows if r.true_score is not None]) + if any(r.true_score is not None for r in rows) + else float("nan") + ) + acc = correct / len(rows) if rows else 0.0 + bad = len(rows) - correct + print( + f"{cls:<20} {len(rows):>6} {acc:>6.2%} {mean_pt:>12.3f} " + f"{min_pt:>10.3f} {bad:>11}" + ) + + # Class balance — large skew can hide poor minority-class accuracy in the totals. + counts = [len(by_class[c]) for c in by_class] + if counts: + print( + f"\nClass balance: min={min(counts)} max={max(counts)} " + f"ratio={max(counts) / max(1, min(counts)):.1f}x" + ) + + +def confusion_matrix(samples: list[ImageSample], labelmap: dict[int, str]) -> None: + print("\n" + "=" * 78) + print("CONFUSION MATRIX (rows = true class, cols = predicted class)") + print("=" * 78) + + classes = [labelmap[i] for i in sorted(labelmap)] + idx = {c: i for i, c in enumerate(classes)} + mat = np.zeros((len(classes), len(classes)), dtype=int) + for s in samples: + if s.true_label is None or s.true_label not in idx: + continue + mat[idx[s.true_label], s.pred_idx] += 1 + + col_w = max(8, max(len(c) for c in classes) + 1) + header = " " * (col_w + 2) + "".join(f"{c[: col_w - 1]:>{col_w}}" for c in classes) + print("\n" + header) + for i, cls in enumerate(classes): + row = "".join(f"{mat[i, j]:>{col_w}}" for j in range(len(classes))) + print(f" {cls[: col_w - 1]:<{col_w}}{row}") + + # Top class-pair confusions, in both directions. + pairs: list[tuple[str, str, int]] = [] + for i, src in enumerate(classes): + for j, dst in enumerate(classes): + if i != j and mat[i, j] > 0: + pairs.append((src, dst, int(mat[i, j]))) + pairs.sort(key=lambda r: -r[2]) + if pairs: + print("\nTop class-pair confusions:") + for src, dst, n in pairs[:10]: + print(f" {n:>4} {src} -> {dst}") + + +def worst_offenders( + samples: list[ImageSample], + labelmap: dict[int, str], + top_n: int, + quality: bool, +) -> list[ImageSample]: + """Print the worst-offender images grouped by class. + + Two buckets per class: + (a) Misclassified — predicted label differs from folder. Sorted by the + confidence in the WRONG class (highest first). These are the most + confidently wrong images, the strongest candidates for relabeling + or deletion. + (b) Borderline-correct — predicted label matches but p_true is low. + These sit near the decision boundary; they're not actively wrong + but they make the class harder to learn cleanly. + + Returns the union of (a) lists across classes, for optional copying. + """ + print("\n" + "=" * 78) + print(f"WORST OFFENDERS (top {top_n} per class)") + print("=" * 78) + + by_class: dict[str, list[ImageSample]] = {} + for s in samples: + if s.true_label is None: + continue + by_class.setdefault(s.true_label, []).append(s) + + all_misclassified: list[ImageSample] = [] + for cls in sorted(by_class): + rows = by_class[cls] + miscls = [r for r in rows if r.pred_label != cls] + miscls.sort(key=lambda r: -r.pred_score) + all_misclassified.extend(miscls[:top_n]) + + print(f"\n-- class '{cls}': {len(miscls)}/{len(rows)} misclassified --") + if miscls: + print( + f"{'p_pred':>7} {'pred':<18} {'p_true':>7} {'shape':>11}" + + (" blur bright aspect " if quality else " ") + + "name" + ) + for r in miscls[:top_n]: + shape = f"{r.shape[0]}x{r.shape[1]}" + extra = "" + if quality: + img = cv2.imread(r.path) + blur = laplacian_variance(img) if img is not None else float("nan") + bright = mean_brightness(img) if img is not None else float("nan") + aspect = aspect_distortion(r.shape) + extra = f" {blur:5.0f} {bright:6.1f} {aspect:6.2f} " + pt = r.true_score if r.true_score is not None else float("nan") + print( + f"{r.pred_score:7.3f} {r.pred_label:<18} " + f"{pt:7.3f} {shape:>11}{extra}{os.path.basename(r.path)}" + ) + + # Borderline-correct: labeled right but the model isn't confident. + correct = [r for r in rows if r.pred_label == cls and r.true_score is not None] + correct.sort(key=lambda r: r.true_score or 0.0) + borderline = correct[: max(5, top_n // 3)] + if borderline: + print("\n borderline-correct (lowest p_true while still labeled right):") + for r in borderline: + # Second-best class names the neighbor that's pulling on this image. + if len(r.probs) > 1: + second = int(np.argsort(-r.probs)[1]) + second_lbl = labelmap[second] + second_p = float(r.probs[second]) + else: + second_lbl = "-" + second_p = 0.0 + print( + f" p_true={r.true_score:.3f} " + f"p_2nd={second_p:.3f} ({second_lbl}) " + f"{os.path.basename(r.path)}" + ) + + return all_misclassified + + +# --------------------------------------------------------------------------- +# Optional diagnostics +# --------------------------------------------------------------------------- + + +def quality_summary(samples: list[ImageSample]) -> None: + """Compare image-quality stats for correct vs misclassified images. + + Helps answer: are the worst offenders systematically blurrier / darker / + more squashed than the rest of the class? If so, the fix is to tighten + the data-collection criteria, not just delete individual images. + """ + print("\n" + "=" * 78) + print("IMAGE QUALITY — correct vs misclassified") + print("=" * 78) + + rows: list[tuple[str, bool, float, float, float]] = [] + for s in samples: + if s.true_label is None: + continue + img = cv2.imread(s.path) + if img is None: + continue + blur = laplacian_variance(img) + bright = mean_brightness(img) + aspect = aspect_distortion(s.shape) + rows.append((s.true_label, s.pred_label == s.true_label, blur, bright, aspect)) + + if not rows: + print(" (no readable images)") + return + + correct = [r for r in rows if r[1]] + wrong = [r for r in rows if not r[1]] + + def stats(name: str, getter, group: list) -> None: + if not group: + print(f" {name:<14} (no samples)") + return + vals = np.array([getter(r) for r in group]) + print( + f" {name:<14} n={len(vals):>4} " + f"mean={vals.mean():8.2f} median={np.median(vals):8.2f} " + f"p10={np.percentile(vals, 10):8.2f} p90={np.percentile(vals, 90):8.2f}" + ) + + print("\nBlur (laplacian variance — higher = sharper):") + stats("correct", lambda r: r[2], correct) + stats("misclassified", lambda r: r[2], wrong) + print("\nBrightness (0..255):") + stats("correct", lambda r: r[3], correct) + stats("misclassified", lambda r: r[3], wrong) + print("\nAspect distortion (0 = square; higher = more squashed by 224x224):") + stats("correct", lambda r: r[4], correct) + stats("misclassified", lambda r: r[4], wrong) + + +def summarize_negative( + neg_samples: list[ImageSample], + threshold: float, + labelmap: dict[int, str], +) -> None: + """Score an unlabeled folder of runtime crops against the model. + + Equivalent to face_dataset.py's negative-set analysis: each image is + classified, and we print its full probability vector plus whether it + would clear the configured threshold. High-confidence predictions on + crops the user knows are wrong indicate the training set is leaking + a representative image into the wrong class. + """ + print("\n" + "=" * 78) + print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images, threshold={threshold})") + print("=" * 78) + + classes = [labelmap[i] for i in sorted(labelmap)] + print(f"\n{'pass':>4} {'score':>6} {'pred':<18} full prob vector / name") + for s in neg_samples: + passes = "yes" if s.pred_score >= threshold else "no" + full = " ".join(f"{c}={float(s.probs[i]):.2f}" for i, c in enumerate(classes)) + print( + f"{passes:>4} {s.pred_score:6.3f} {s.pred_label:<18} " + f"{full} :: {os.path.basename(s.path)}" + ) + + +def pick_worst_confusion_pair( + samples: list[ImageSample], + labelmap: dict[int, str], +) -> tuple[int, str | None, str | None]: + """Return (count, source, target) for the most-confused class pair.""" + classes = [labelmap[i] for i in sorted(labelmap)] + pairs: list[tuple[int, str, str]] = [] + for src in classes: + for tgt in classes: + if src == tgt: + continue + n = sum(1 for s in samples if s.true_label == src and s.pred_label == tgt) + if n > 0: + pairs.append((n, src, tgt)) + pairs.sort(reverse=True) + return pairs[0] if pairs else (0, None, None) + + +def cross_class_contamination( + samples: list[ImageSample], + source_class: str, + target_class: str, + label_to_idx: dict[str, int], + embedder: MobileNetEmbedder, + top_n: int, +) -> None: + """Find training images in source_class that visually look like target_class. + + Generalizes face_dataset.py's contamination_analysis to N classes. Uses a + fixed ImageNet backbone (NOT the user's trained classifier) so that + contaminators which the trained model has memorized into the source class + still surface — the trained model's own embedding would hide them. + + Three sections: + 1. Source-image culprits ranked by `cos(img, target_centroid) - + cos(img, source_centroid)`. Positive delta = the image looks more + like the target class than its own class — prime relabeling + candidates. + 2. For each target image, the top-3 nearest source training images. + Shows the visual chain of confusion image-by-image. + 3. Ringleader summary: source images that appear most often as a top-3 + neighbor across the target set. These few photos are responsible + for the bulk of the confusion. + """ + src = [s for s in samples if s.true_label == source_class] + tgt = [s for s in samples if s.true_label == target_class] + + if not src or not tgt: + print( + f"\nERROR: need both classes populated; got {len(src)} " + f"'{source_class}' and {len(tgt)} '{target_class}'" + ) + return + + print("\n" + "=" * 78) + print( + f"CROSS-CLASS CONTAMINATION '{source_class}' leaning toward '{target_class}'" + ) + print(" (model-independent embeddings via ImageNet MobileNetV2)") + print("=" * 78) + + target_idx = label_to_idx.get(target_class) + source_idx = label_to_idx.get(source_class) + + print(f"\nEmbedding {len(src) + len(tgt)} images...") + src_embs = np.stack([embedder.embed(cv2.imread(s.path)) for s in src]) + tgt_embs = np.stack([embedder.embed(cv2.imread(s.path)) for s in tgt]) + + src_centroid = src_embs.mean(axis=0) + src_centroid /= np.linalg.norm(src_centroid) + 1e-9 + tgt_centroid = tgt_embs.mean(axis=0) + tgt_centroid /= np.linalg.norm(tgt_centroid) + 1e-9 + + src_to_src = src_embs @ src_centroid + src_to_tgt = src_embs @ tgt_centroid + delta = src_to_tgt - src_to_src + + print(f"\n-- '{source_class}' images sorted by '{target_class}'-likeness --") + print(f" positive delta = visually closer to '{target_class}' centroid") + print( + f" p_{target_class} = trained model's probability for '{target_class}' " + f"on this image\n" + ) + delta_label = "delta" + tgt_cos_label = f"cos_{target_class}"[:12] + src_cos_label = f"cos_{source_class}"[:12] + p_tgt_label = f"p_{target_class}"[:10] + print( + f" {delta_label:>7} {tgt_cos_label:>12} {src_cos_label:>12} " + f"{p_tgt_label:>10} name" + ) + order = np.argsort(-delta) + for i in order[:top_n]: + s = src[i] + p_tgt = float(s.probs[target_idx]) if target_idx is not None else float("nan") + print( + f" {delta[i]:+7.3f} {src_to_tgt[i]:12.3f} {src_to_src[i]:12.3f} " + f"{p_tgt:10.3f} {os.path.basename(s.path)}" + ) + + print(f"\n-- nearest '{source_class}' neighbors for each '{target_class}' image --") + neighbor_counts: dict[str, int] = {} + src_paths = [os.path.basename(s.path) for s in src] + for i, t in enumerate(tgt): + sims = src_embs @ tgt_embs[i] + top3 = np.argsort(-sims)[:3] + p_src = float(t.probs[source_idx]) if source_idx is not None else float("nan") + marker = " <5} name") + ranked = sorted(neighbor_counts.items(), key=lambda r: -r[1]) + for name, count in ranked[:top_n]: + print(f" {count:>5} {name}") + + +def save_misclassified(samples: list[ImageSample], out_dir: str) -> None: + """Copy misclassified images to /__as__/. + + Lets you browse the worst offenders in a file manager and bulk-delete or + relabel them without poking through the original dataset tree. + """ + print("\n" + "=" * 78) + print(f"SAVING MISCLASSIFIED IMAGES -> {out_dir}") + print("=" * 78) + count = 0 + for s in samples: + if s.true_label is None or s.pred_label == s.true_label: + continue + bucket = os.path.join(out_dir, f"{s.true_label}__as__{s.pred_label}") + os.makedirs(bucket, exist_ok=True) + score_tag = f"{int(round(s.pred_score * 100)):03d}" + dest = os.path.join(bucket, f"{score_tag}_{os.path.basename(s.path)}") + try: + shutil.copy2(s.path, dest) + count += 1 + except OSError as err: + print(f" [copy failed] {s.path}: {err}") + print(f" copied {count} images into {out_dir}") + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main() -> int: + ap = argparse.ArgumentParser( + description=( + "Analyze a Frigate object-classification training dataset against its " + "deployed TFLite model." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument( + "--name", + required=True, + help="Classification model name (matches the key in classification.custom.)", + ) + ap.add_argument( + "--clips-dir", + default="/media/frigate/clips", + help="Frigate clips directory; dataset is read from //dataset", + ) + ap.add_argument( + "--model-cache", + default="/config/model_cache", + help="Frigate model_cache; model is read from //model.tflite", + ) + ap.add_argument( + "--threshold", + type=float, + default=0.8, + help="Score threshold (matches model_config.threshold; default 0.8)", + ) + ap.add_argument( + "--top-n", + type=int, + default=15, + help="Worst-offender images to show per class", + ) + ap.add_argument( + "--quality", + action="store_true", + help="Include blur/brightness/aspect stats for correct vs misclassified", + ) + ap.add_argument( + "--negative", + default=None, + help="Score an unlabeled folder of crops against the model", + ) + ap.add_argument( + "--save-misclassified", + default=None, + help="Copy every misclassified image into this directory for review", + ) + ap.add_argument( + "--confuses", + default=None, + help=( + "Cross-class contamination analysis. Format ':', " + "e.g. 'rex:buddy' to find Rex training images that look like " + "Buddy. Use 'auto' to pick the worst pair from the confusion matrix. " + "Requires --embedding-model." + ), + ) + ap.add_argument( + "--embedding-model", + default=None, + help=( + "Path to ONNX MobileNetV2 file for model-independent embeddings " + "(required by --confuses). Download once with: curl -L -o " + "/config/model_cache/mobilenetv2-7.onnx https://github.com/onnx/" + "models/raw/main/validated/vision/classification/mobilenet/model/" + "mobilenetv2-7.onnx" + ), + ) + args = ap.parse_args() + + dataset_dir = os.path.join(args.clips_dir, args.name, "dataset") + model_path = os.path.join(args.model_cache, args.name, "model.tflite") + labelmap_path = os.path.join(args.model_cache, args.name, "labelmap.txt") + + for required in (dataset_dir, model_path, labelmap_path): + if not os.path.exists(required): + print(f"ERROR: required path not found: {required}") + return 1 + + print(f"Loading model from {model_path}") + interpreter, input_details, output_details = load_tflite(model_path) + labelmap = load_labelmap(labelmap_path) + label_to_idx = {v: k for k, v in labelmap.items()} + print(f" labels: {sorted(labelmap.values())}") + + print(f"\nScanning dataset at {dataset_dir} ...") + samples = classify_dataset( + dataset_dir, interpreter, input_details, output_details, labelmap, label_to_idx + ) + if not samples: + print("no images found — aborting") + return 1 + print(f" classified {len(samples)} images") + + summarize_dataset(samples, labelmap) + confusion_matrix(samples, labelmap) + misclassified = worst_offenders(samples, labelmap, args.top_n, args.quality) + + if args.quality: + quality_summary(samples) + + if args.negative: + print(f"\nLoading negatives from {args.negative} ...") + neg = classify_folder( + args.negative, + interpreter, + input_details, + output_details, + labelmap, + label_to_idx, + true_label=None, + ) + if neg: + summarize_negative(neg, args.threshold, labelmap) + + if args.confuses: + if not args.embedding_model: + print( + "\nERROR: --confuses requires --embedding-model (path to ONNX " + "MobileNetV2). See --help for the download command." + ) + return 1 + try: + embedder = MobileNetEmbedder(args.embedding_model) + except (FileNotFoundError, cv2.error) as err: + print(f"\nERROR: failed to load embedding model: {err}") + return 1 + + if args.confuses == "auto": + n, src, tgt = pick_worst_confusion_pair(samples, labelmap) + if src is None: + print( + "\nNo misclassifications in dataset — " + "nothing to investigate via --confuses auto" + ) + else: + print(f"\nAuto-picked worst confusion: {src} -> {tgt} ({n} cases)") + cross_class_contamination( + samples, src, tgt, label_to_idx, embedder, args.top_n + ) + else: + if ":" not in args.confuses: + print("\nERROR: --confuses expects ':' or 'auto'") + return 1 + src, tgt = args.confuses.split(":", 1) + if src not in label_to_idx or tgt not in label_to_idx: + print( + f"\nERROR: class names must be in the labelmap " + f"({sorted(label_to_idx)})" + ) + return 1 + cross_class_contamination( + samples, src, tgt, label_to_idx, embedder, args.top_n + ) + + if args.save_misclassified: + save_misclassified(misclassified, args.save_misclassified) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/process_clip.py b/testing-scripts/process_clip.py similarity index 100% rename from process_clip.py rename to testing-scripts/process_clip.py diff --git a/web/.gitignore b/web/.gitignore index 1cac5597ea..ca98c7b96a 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Playwright +playwright-report +test-results + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/web/e2e/fixtures/error-allowlist.ts b/web/e2e/fixtures/error-allowlist.ts new file mode 100644 index 0000000000..4e6523bd08 --- /dev/null +++ b/web/e2e/fixtures/error-allowlist.ts @@ -0,0 +1,116 @@ +/** + * Global allowlist of regex patterns that the error collector ignores. + * + * Each entry MUST include a comment explaining what it silences and why. + * The allowlist is filtered at collection time, so failure messages list + * only unfiltered errors. + * + * Per-spec additions go through the `expectedErrors` test fixture parameter + * (see error-collector.ts), not by editing this file. That keeps allowlist + * drift visible per-PR rather than buried in shared infrastructure. + * + * NOTE ON CONSOLE vs REQUEST ERRORS: + * When a network request returns a 5xx response, the browser emits two + * events that the error collector captures: + * [request] "500 Internal Server Error " — from onResponse (URL included) + * [console] "Failed to load resource: ..." — from onConsole (URL NOT included) + * + * The request-level message includes the URL, so those patterns are specific. + * The console-level message text (from ConsoleMessage.text()) does NOT include + * the URL — the URL is stored separately in e.url. Therefore the console + * pattern for HTTP 500s cannot be URL-discriminated, and a single pattern + * covers all such browser echoes. This is safe because every such console + * error is already caught (and specifically matched) by its paired [request] + * entry below. + */ + +export const GLOBAL_ALLOWLIST: RegExp[] = [ + // ------------------------------------------------------------------------- + // Browser echo of HTTP 5xx responses (console mirror of [request] events). + // + // Whenever the browser receives a 5xx response it emits a console error: + // "Failed to load resource: the server responded with a status of 500 + // (Internal Server Error)" + // The URL is NOT part of ConsoleMessage.text() — it is stored separately. + // Every console error of this form is therefore paired with a specific + // [request] 500 entry below that names the exact endpoint. Allowlisting + // this pattern here silences the browser echo; the request-level entries + // enforce specificity. + // ------------------------------------------------------------------------- + /Failed to load resource: the server responded with a status of 500/, + + // ------------------------------------------------------------------------- + // Mock infrastructure gaps — API endpoints not yet covered by ApiMocker. + // + // These produce 500s because Vite's preview server has no handler for them. + // Each is a TODO(real-bug): the mock should be extended so these endpoints + // return sensible fixture data in tests. + // + // Only [request] patterns are listed here; the paired [console] mirror is + // covered by the "Failed to load resource" entry above. + // ------------------------------------------------------------------------- + + // TODO(real-bug): ApiMocker registers "**/api/reviews**" (plural) but the + // app fetches /api/review (singular) for the review list and timeline. + // Affects: review.spec.ts, navigation.spec.ts, live.spec.ts, auth.spec.ts. + // Fix: add route handlers for /api/review and /api/review/** in api-mocker.ts. + /500 Internal Server Error.*\/api\/review(\?|\/|$)/, + + // TODO(real-bug): /api/stats/history is not mocked; the system page fetches + // it for the detector/process history charts. + // Fix: add route handler for /api/stats/history in api-mocker.ts. + /500 Internal Server Error.*\/api\/stats\/history/, + + // TODO(real-bug): /api/event_ids is not mocked; the explore/search page + // fetches it to resolve event IDs for display. + // Fix: add route handler for /api/event_ids in api-mocker.ts. + /500 Internal Server Error.*\/api\/event_ids/, + + // TODO(real-bug): /api/sub_labels?split_joined=1 returns 500; the mock + // registers "**/api/sub_labels" which may not match when a query string is + // present, or route registration order causes the catch-all to win first. + // Fix: change the mock route to "**/api/sub_labels**" in api-mocker.ts. + /500 Internal Server Error.*\/api\/sub_labels/, + + // TODO(real-bug): MediaMocker handles /api/*/latest.jpg but the app also + // requests /api/*/latest.webp (webp format) for camera snapshots. + // Affects: live.spec.ts, review.spec.ts, auth.spec.ts, navigation.spec.ts. + // Fix: add route handler for /api/*/latest.webp in MediaMocker.install(). + /500 Internal Server Error.*\/api\/[^/]+\/latest\.webp/, + /failed: net::ERR_ABORTED.*\/api\/[^/]+\/latest\.webp/, + + // ------------------------------------------------------------------------- + // Mock infrastructure gap — WebSocket streams. + // + // Playwright's page.route() does not intercept WebSocket connections. + // The jsmpeg live-stream WS connections to /live/jsmpeg/* always fail + // with a 500 handshake error because the Vite preview server has no WS + // handler. TODO(real-bug): add WsMocker support for jsmpeg WebSocket + // connections, or suppress the connection attempt in the test environment. + // Affects: live.spec.ts (single camera view), auth.spec.ts. + // ------------------------------------------------------------------------- + /WebSocket connection to '.*\/live\/jsmpeg\/.*' failed/, + + // ------------------------------------------------------------------------- + // Benign — lazy-loaded chunk aborts during navigation. + // + // When a test navigates away from a page while the browser is still + // fetching lazily-split JS/CSS asset chunks, the in-flight fetch is + // cancelled (net::ERR_ABORTED). This is normal browser behaviour on + // navigation and does not indicate a real error; the assets load fine + // on a stable connection. + // ------------------------------------------------------------------------- + /failed: net::ERR_ABORTED.*\/assets\//, + + // ------------------------------------------------------------------------- + // Real app bug — Radix UI DialogContent missing accessible title. + // + // TODO(real-bug): A dialog somewhere in the app renders + // without a , violating Radix UI's accessibility contract. + // The warning originates from the bundled main-*.js. Investigate which + // dialog component is missing the title and add a VisuallyHidden DialogTitle. + // Likely candidate: face-library or search-detail dialog in explore page. + // See: https://radix-ui.com/primitives/docs/components/dialog + // ------------------------------------------------------------------------- + /`DialogContent` requires a `DialogTitle`/, +]; diff --git a/web/e2e/fixtures/error-collector.ts b/web/e2e/fixtures/error-collector.ts new file mode 100644 index 0000000000..7cba526642 --- /dev/null +++ b/web/e2e/fixtures/error-collector.ts @@ -0,0 +1,122 @@ +/** + * Collects console errors, page errors, and failed network requests + * during a Playwright test, with regex-based allowlist filtering. + * + * Usage: + * const collector = installErrorCollector(page, [...GLOBAL_ALLOWLIST]); + * // ... run test ... + * collector.assertClean(); // throws if any non-allowlisted error + * + * The collector is wired into the `frigateApp` fixture so every test + * gets it for free. Tests that intentionally trigger an error pass + * additional regexes via the `expectedErrors` fixture parameter. + */ + +import type { Page, Request, Response, ConsoleMessage } from "@playwright/test"; + +export type CollectedError = { + kind: "console" | "pageerror" | "request"; + message: string; + url?: string; + stack?: string; +}; + +export type ErrorCollector = { + errors: CollectedError[]; + assertClean(): void; +}; + +function isAllowlisted(message: string, allowlist: RegExp[]): boolean { + return allowlist.some((pattern) => pattern.test(message)); +} + +function firstStackFrame(stack: string | undefined): string | undefined { + if (!stack) return undefined; + const lines = stack + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + // Skip the error message line (line 0); return the first "at ..." frame + return lines.find((l) => l.startsWith("at ")); +} + +function isSameOrigin(url: string, baseURL: string | undefined): boolean { + if (!baseURL) return true; + try { + return new URL(url).origin === new URL(baseURL).origin; + } catch { + return false; + } +} + +export function installErrorCollector( + page: Page, + allowlist: RegExp[], +): ErrorCollector { + const errors: CollectedError[] = []; + const baseURL = ( + page.context() as unknown as { _options?: { baseURL?: string } } + )._options?.baseURL; + + const onConsole = (msg: ConsoleMessage) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (isAllowlisted(text, allowlist)) return; + errors.push({ + kind: "console", + message: text, + url: msg.location().url, + }); + }; + + const onPageError = (err: Error) => { + const text = err.message; + if (isAllowlisted(text, allowlist)) return; + errors.push({ + kind: "pageerror", + message: text, + stack: firstStackFrame(err.stack), + }); + }; + + const onResponse = (response: Response) => { + const status = response.status(); + if (status < 500) return; + const url = response.url(); + if (!isSameOrigin(url, baseURL)) return; + const text = `${status} ${response.statusText()} ${url}`; + if (isAllowlisted(text, allowlist)) return; + errors.push({ kind: "request", message: text, url }); + }; + + const onRequestFailed = (request: Request) => { + const url = request.url(); + if (!isSameOrigin(url, baseURL)) return; + const failure = request.failure(); + const text = `failed: ${failure?.errorText ?? "unknown"} ${url}`; + if (isAllowlisted(text, allowlist)) return; + errors.push({ kind: "request", message: text, url }); + }; + + page.on("console", onConsole); + page.on("pageerror", onPageError); + page.on("response", onResponse); + page.on("requestfailed", onRequestFailed); + + return { + errors, + assertClean() { + if (errors.length === 0) return; + const formatted = errors + .map((e, i) => { + const stack = e.stack ? `\n ${e.stack}` : ""; + const url = e.url && e.url !== e.message ? ` (${e.url})` : ""; + return ` ${i + 1}. [${e.kind}] ${e.message}${url}${stack}`; + }) + .join("\n"); + throw new Error( + `Page emitted ${errors.length} unexpected error${errors.length === 1 ? "" : "s"}:\n${formatted}`, + ); + }, + }; +} diff --git a/web/e2e/fixtures/frigate-test.ts b/web/e2e/fixtures/frigate-test.ts new file mode 100644 index 0000000000..892df68417 --- /dev/null +++ b/web/e2e/fixtures/frigate-test.ts @@ -0,0 +1,123 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/** + * Extended Playwright test fixture with FrigateApp. + * + * Every test imports `test` and `expect` from this file instead of + * @playwright/test directly. The `frigateApp` fixture provides a + * fully mocked Frigate frontend ready for interaction. + * + * The fixture also installs the error collector (see error-collector.ts). + * Any console error, page error, or same-origin failed request that is + * not on the global allowlist or the test's `expectedErrors` list will + * fail the test in the fixture's teardown. + * + * CRITICAL: All route/WS handlers are registered before page.goto() + * to prevent AuthProvider from redirecting to login.html. + */ + +import { test as base, expect, type Page } from "@playwright/test"; +import { + ApiMocker, + MediaMocker, + type ApiMockOverrides, +} from "../helpers/api-mocker"; +import { WsMocker } from "../helpers/ws-mocker"; +import { installErrorCollector, type ErrorCollector } from "./error-collector"; +import { GLOBAL_ALLOWLIST } from "./error-allowlist"; + +export class FrigateApp { + public api: ApiMocker; + public media: MediaMocker; + public ws: WsMocker; + public page: Page; + + private isDesktop: boolean; + + constructor(page: Page, projectName: string) { + this.page = page; + this.api = new ApiMocker(page); + this.media = new MediaMocker(page); + this.ws = new WsMocker(); + this.isDesktop = projectName === "desktop"; + } + + get isMobile() { + return !this.isDesktop; + } + + /** Install all mocks with default data. Call before goto(). */ + async installDefaults(overrides?: ApiMockOverrides) { + // Mock i18n locale files to prevent 404s + await this.page.route("**/locales/**", async (route) => { + // Let the request through to the built files + return route.fallback(); + }); + + await this.ws.install(this.page); + await this.api.install(overrides); + // media goes last so its per-event routes win over the broader + // `**/api/events**` list route, which otherwise answers thumbnail and + // snapshot requests with the events JSON + await this.media.install(); + } + + /** Navigate to a page. Always call installDefaults() first. */ + async goto(path: string) { + await this.page.goto(path); + // Wait for the app to render past the loading indicator + await this.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + } + + /** Navigate to a page that may show a loading indicator */ + async gotoAndWait(path: string, selector: string) { + await this.page.goto(path); + await this.page.waitForSelector(selector, { timeout: 10_000 }); + } +} + +type FrigateFixtures = { + frigateApp: FrigateApp; + /** + * Per-test additional allowlist regex patterns. Tests that intentionally + * trigger errors (e.g. error-state tests that hit a mocked 500) declare + * their expected errors here so the collector ignores them. + * + * Default is `[]` — most tests should not need this. + */ + expectedErrors: RegExp[]; + errorCollector: ErrorCollector; +}; + +export const test = base.extend({ + expectedErrors: [[], { option: true }], + + errorCollector: async ({ page, expectedErrors }, use, testInfo) => { + const collector = installErrorCollector(page, [ + ...GLOBAL_ALLOWLIST, + ...expectedErrors, + ]); + await use(collector); + if (process.env.E2E_STRICT_ERRORS === "1") { + collector.assertClean(); + } else if (collector.errors.length > 0) { + // Soft mode: attach errors to the test report so they're visible + // without failing the run. + await testInfo.attach("collected-errors.txt", { + body: collector.errors + .map((e) => `[${e.kind}] ${e.message}${e.url ? ` (${e.url})` : ""}`) + .join("\n"), + contentType: "text/plain", + }); + } + }, + + frigateApp: async ({ page, errorCollector }, use, testInfo) => { + // Reference the collector so its `use()` runs and teardown fires + void errorCollector; + const app = new FrigateApp(page, testInfo.project.name); + await app.installDefaults(); + await use(app); + }, +}); + +export { expect }; diff --git a/web/e2e/fixtures/mock-data/camera-activity.ts b/web/e2e/fixtures/mock-data/camera-activity.ts new file mode 100644 index 0000000000..425e931a86 --- /dev/null +++ b/web/e2e/fixtures/mock-data/camera-activity.ts @@ -0,0 +1,77 @@ +/** + * Camera activity WebSocket payload factory. + * + * The camera_activity topic payload is double-serialized: + * the WS message contains { topic: "camera_activity", payload: JSON.stringify(activityMap) } + */ + +export interface CameraActivityState { + config: { + enabled: boolean; + detect: boolean; + record: boolean; + snapshots: boolean; + audio: boolean; + audio_transcription: boolean; + notifications: boolean; + notifications_suspended: number; + autotracking: boolean; + alerts: boolean; + detections: boolean; + object_descriptions: boolean; + review_descriptions: boolean; + }; + motion: boolean; + objects: Array<{ + label: string; + score: number; + box: [number, number, number, number]; + area: number; + ratio: number; + region: [number, number, number, number]; + current_zones: string[]; + id: string; + }>; + audio_detections: Array<{ + label: string; + score: number; + }>; +} + +function defaultCameraActivity(): CameraActivityState { + return { + config: { + enabled: true, + detect: true, + record: true, + snapshots: true, + audio: false, + audio_transcription: false, + notifications: false, + notifications_suspended: 0, + autotracking: false, + alerts: true, + detections: true, + object_descriptions: false, + review_descriptions: false, + }, + motion: false, + objects: [], + audio_detections: [], + }; +} + +export function cameraActivityPayload( + cameras: string[], + overrides?: Partial>>, +): string { + const activity: Record = {}; + for (const name of cameras) { + activity[name] = { + ...defaultCameraActivity(), + ...overrides?.[name], + } as CameraActivityState; + } + // Double-serialize: the WS payload is a JSON string + return JSON.stringify(activity); +} diff --git a/web/e2e/fixtures/mock-data/cases.json b/web/e2e/fixtures/mock-data/cases.json new file mode 100644 index 0000000000..6174cdebf3 --- /dev/null +++ b/web/e2e/fixtures/mock-data/cases.json @@ -0,0 +1 @@ +[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1780597809.365581, "updated_at": 1780673409.365581}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json new file mode 100644 index 0000000000..04d72b1e5a --- /dev/null +++ b/web/e2e/fixtures/mock-data/config-schema.json @@ -0,0 +1 @@ +{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BaseDetectorConfig": {"additionalProperties": true, "properties": {"type": {"default": "cpu", "description": "Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino').", "title": "Detector Type", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/ModelConfig"}, {"type": "null"}], "default": null, "description": "Detector-specific model configuration options (path, input size, etc.).", "title": "Detector specific model configuration"}, "model_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "File path to the detector model binary if required by the chosen detector.", "title": "Detector specific model path"}}, "title": "BaseDetectorConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeEnum", "default": "objects", "description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", "title": "Tracking mode"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeEnum", "default": "objects", "description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", "title": "Tracking mode"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "BirdseyeModeEnum": {"enum": ["objects", "motion", "continuous"], "title": "BirdseyeModeEnum", "type": "string"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", "title": "Show in UI", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "detectors": {"additionalProperties": {"$ref": "#/$defs/BaseDetectorConfig"}, "default": {"cpu": {"type": "cpu"}}, "description": "Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", "title": "Detector hardware", "type": "object"}, "model": {"$ref": "#/$defs/ModelConfig", "description": "Settings to configure a custom object detection model and its input shape.", "title": "Detection model"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config-snapshot.json b/web/e2e/fixtures/mock-data/config-snapshot.json new file mode 100644 index 0000000000..2df402bc99 --- /dev/null +++ b/web/e2e/fixtures/mock-data/config-snapshot.json @@ -0,0 +1 @@ +{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "detectors": {"cpu": {"type": "cpu", "model": {"path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd"}, "model_path": null}}, "model": {"path": null, "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}, "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "mode": "objects", "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config.ts b/web/e2e/fixtures/mock-data/config.ts new file mode 100644 index 0000000000..e337218e5a --- /dev/null +++ b/web/e2e/fixtures/mock-data/config.ts @@ -0,0 +1,79 @@ +/** + * FrigateConfig factory for E2E tests. + * + * Uses a real config snapshot generated from the Python backend's FrigateConfig + * model. This guarantees all fields are present and match what the app expects. + * Tests override specific fields via DeepPartial. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const configSnapshot = JSON.parse( + readFileSync(resolve(__dirname, "config-snapshot.json"), "utf-8"), +); + +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +function deepMerge>( + base: T, + overrides?: DeepPartial, +): T { + if (!overrides) return base; + const result = { ...base }; + for (const key of Object.keys(overrides) as (keyof T)[]) { + const val = overrides[key]; + if ( + val !== undefined && + typeof val === "object" && + val !== null && + !Array.isArray(val) && + typeof base[key] === "object" && + base[key] !== null && + !Array.isArray(base[key]) + ) { + result[key] = deepMerge( + base[key] as Record, + val as DeepPartial>, + ) as T[keyof T]; + } else if (val !== undefined) { + result[key] = val as T[keyof T]; + } + } + return result; +} + +// The base config is a real snapshot from the Python backend. +// Apply test-specific overrides: friendly names, camera groups, version. +export const BASE_CONFIG = { + ...configSnapshot, + version: "0.15.0-test", + // injected by the /config endpoint rather than the Pydantic model, so it + // is absent from the snapshot + plus: { enabled: false }, + cameras: { + ...configSnapshot.cameras, + front_door: { + ...configSnapshot.cameras.front_door, + friendly_name: "Front Door", + }, + backyard: { + ...configSnapshot.cameras.backyard, + friendly_name: "Backyard", + }, + garage: { + ...configSnapshot.cameras.garage, + friendly_name: "Garage", + }, + }, +}; + +export function configFactory( + overrides?: DeepPartial, +): typeof BASE_CONFIG { + return deepMerge(BASE_CONFIG, overrides); +} diff --git a/web/e2e/fixtures/mock-data/debug-replay.ts b/web/e2e/fixtures/mock-data/debug-replay.ts new file mode 100644 index 0000000000..9b9d2c650d --- /dev/null +++ b/web/e2e/fixtures/mock-data/debug-replay.ts @@ -0,0 +1,54 @@ +/** + * Debug replay status factory. + * + * The Replay page polls /api/debug_replay/status every 1s via SWR. + * The no-session state shows an empty state; the active state + * renders the live camera image + debug toggles + objects/messages + * tabs. Used by replay.spec.ts. + */ + +export type DebugReplayStatus = { + active: boolean; + replay_camera: string | null; + source_camera: string | null; + start_time: number | null; + end_time: number | null; + live_ready: boolean; +}; + +export function noSessionStatus(): DebugReplayStatus { + return { + active: false, + replay_camera: null, + source_camera: null, + start_time: null, + end_time: null, + live_ready: false, + }; +} + +export function activeSessionStatus( + opts: { + camera?: string; + sourceCamera?: string; + startTime?: number; + endTime?: number; + liveReady?: boolean; + } = {}, +): DebugReplayStatus { + const { + camera = "front_door", + sourceCamera = "front_door", + startTime = Date.now() / 1000 - 3600, + endTime = Date.now() / 1000 - 1800, + liveReady = true, + } = opts; + return { + active: true, + replay_camera: camera, + source_camera: sourceCamera, + start_time: startTime, + end_time: endTime, + live_ready: liveReady, + }; +} diff --git a/web/e2e/fixtures/mock-data/events.json b/web/e2e/fixtures/mock-data/events.json new file mode 100644 index 0000000000..fa698a9b41 --- /dev/null +++ b/web/e2e/fixtures/mock-data/events.json @@ -0,0 +1 @@ +[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1780677009.365581, "end_time": 1780677039.365581, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1780673409.365581, "end_time": 1780673454.365581, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1780669809.365581, "end_time": 1780669829.365581, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/exports.json b/web/e2e/fixtures/mock-data/exports.json new file mode 100644 index 0000000000..7e6c787088 --- /dev/null +++ b/web/e2e/fixtures/mock-data/exports.json @@ -0,0 +1 @@ +[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1780680609.365581, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1780673409.365581, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1780682409.365581, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/faces.ts b/web/e2e/fixtures/mock-data/faces.ts new file mode 100644 index 0000000000..ed2f944cb1 --- /dev/null +++ b/web/e2e/fixtures/mock-data/faces.ts @@ -0,0 +1,45 @@ +/** + * Face library factories. + * + * The /api/faces endpoint returns a record keyed by collection name + * with the list of face image filenames. Grouped training attempts + * live under the "train" key with filenames of the form + * `${event_id}-${timestamp}-${label}-${score}.webp`. + * + * Used by face-library.spec.ts and chat.spec.ts (attachment chip). + */ + +export type FacesMock = Record; + +export function basicFacesMock(): FacesMock { + return { + alice: ["alice-1.webp", "alice-2.webp"], + bob: ["bob-1.webp"], + charlie: ["charlie-1.webp"], + }; +} + +export function emptyFacesMock(): FacesMock { + return {}; +} + +/** + * Adds a grouped recent-recognition training attempt to an existing + * faces mock. The grouping key on the backend is the event id — so + * images with the same event-id prefix render as one dialog-able card. + */ +export function withGroupedTrainingAttempt( + base: FacesMock, + opts: { + eventId: string; + attempts: Array<{ timestamp: number; label: string; score: number }>; + }, +): FacesMock { + const trainImages = opts.attempts.map( + (a) => `${opts.eventId}-${a.timestamp}-${a.label}-${a.score}.webp`, + ); + return { + ...base, + train: [...(base.train ?? []), ...trainImages], + }; +} diff --git a/web/e2e/fixtures/mock-data/generate-mock-data.py b/web/e2e/fixtures/mock-data/generate-mock-data.py new file mode 100644 index 0000000000..bba488d3d9 --- /dev/null +++ b/web/e2e/fixtures/mock-data/generate-mock-data.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Generate E2E mock data from backend Pydantic and Peewee models. + +Run from the repo root: + PYTHONPATH=/workspace/frigate python3 web/e2e/fixtures/mock-data/generate-mock-data.py + +Strategy: + - FrigateConfig: instantiate the Pydantic config model, then model_dump() + - API responses: instantiate Pydantic response models (ReviewSegmentResponse, + EventResponse, ExportModel, ExportCaseModel) to validate all required fields + - If the backend adds a required field, this script fails at instantiation time + - The Peewee model field list is checked to detect new columns that would + appear in .dicts() API responses but aren't in our mock data +""" + +import json +import sys +import time +import warnings +from datetime import datetime, timedelta +from pathlib import Path + +warnings.filterwarnings("ignore") + +OUTPUT_DIR = Path(__file__).parent +NOW = time.time() +HOUR = 3600 + +CAMERAS = ["front_door", "backyard", "garage"] + + +def check_pydantic_fields(pydantic_class, mock_keys, model_name): + """Verify mock data covers all fields declared in the Pydantic response model. + + The Pydantic response model is what the frontend actually receives. + Peewee models may have extra legacy columns that are filtered out by + FastAPI's response_model validation. + """ + required_fields = set() + for name, field_info in pydantic_class.model_fields.items(): + required_fields.add(name) + + missing = required_fields - mock_keys + if missing: + print( + f" ERROR: {model_name} response model has fields not in mock data: {missing}", + file=sys.stderr, + ) + print( + f" Add these fields to the mock data in this script.", + file=sys.stderr, + ) + sys.exit(1) + + extra = mock_keys - required_fields + if extra: + print( + f" NOTE: {model_name} mock data has extra fields (not in response model): {extra}", + ) + + +def generate_config(): + """Generate FrigateConfig from the Python backend model.""" + from frigate.config import FrigateConfig + + config = FrigateConfig.model_validate_json( + json.dumps( + { + "mqtt": {"host": "mqtt"}, + "cameras": { + cam: { + "ffmpeg": { + "inputs": [ + { + "path": f"rtsp://10.0.0.{i+1}:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 720, "width": 1280, "fps": 5}, + } + for i, cam in enumerate(CAMERAS) + }, + "camera_groups": { + "default": { + "cameras": CAMERAS, + "icon": "generic", + "order": 0, + }, + "outdoor": { + "cameras": ["front_door", "backyard"], + "icon": "generic", + "order": 1, + }, + }, + } + ) + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + snapshot = config.model_dump() + + # Runtime-computed fields not in the Pydantic dump + all_attrs = set() + for attrs in snapshot.get("model", {}).get("attributes_map", {}).values(): + all_attrs.update(attrs) + snapshot["model"]["all_attributes"] = sorted(all_attrs) + snapshot["model"]["colormap"] = {} + + return snapshot + + +def generate_config_schema(): + """Generate the JSON Schema for FrigateConfig from the backend model. + + This is what the app fetches from /api/config/schema.json to drive the + RJSF-based config form. Generating it here keeps the e2e fixture in sync + with the backend whenever config models change. + """ + from frigate.config import FrigateConfig + + return FrigateConfig.model_json_schema() + + +def generate_reviews(): + """Generate ReviewSegmentResponse[] validated against Pydantic + Peewee.""" + from frigate.api.defs.response.review_response import ReviewSegmentResponse + + reviews = [ + ReviewSegmentResponse( + id="review-alert-001", + camera="front_door", + severity="alert", + start_time=datetime.fromtimestamp(NOW - 2 * HOUR), + end_time=datetime.fromtimestamp(NOW - 2 * HOUR + 30), + has_been_reviewed=False, + thumb_path="/clips/front_door/review-alert-001-thumb.jpg", + data=json.dumps( + { + "audio": [], + "detections": ["person-abc123"], + "objects": ["person"], + "sub_labels": [], + "significant_motion_areas": [], + "zones": ["front_yard"], + } + ), + ), + ReviewSegmentResponse( + id="review-alert-002", + camera="backyard", + severity="alert", + start_time=datetime.fromtimestamp(NOW - 3 * HOUR), + end_time=datetime.fromtimestamp(NOW - 3 * HOUR + 45), + has_been_reviewed=True, + thumb_path="/clips/backyard/review-alert-002-thumb.jpg", + data=json.dumps( + { + "audio": [], + "detections": ["car-def456"], + "objects": ["car"], + "sub_labels": [], + "significant_motion_areas": [], + "zones": ["driveway"], + } + ), + ), + ReviewSegmentResponse( + id="review-detect-001", + camera="garage", + severity="detection", + start_time=datetime.fromtimestamp(NOW - 4 * HOUR), + end_time=datetime.fromtimestamp(NOW - 4 * HOUR + 20), + has_been_reviewed=False, + thumb_path="/clips/garage/review-detect-001-thumb.jpg", + data=json.dumps( + { + "audio": [], + "detections": ["person-ghi789"], + "objects": ["person"], + "sub_labels": [], + "significant_motion_areas": [], + "zones": [], + } + ), + ), + ReviewSegmentResponse( + id="review-detect-002", + camera="front_door", + severity="detection", + start_time=datetime.fromtimestamp(NOW - 5 * HOUR), + end_time=datetime.fromtimestamp(NOW - 5 * HOUR + 15), + has_been_reviewed=False, + thumb_path="/clips/front_door/review-detect-002-thumb.jpg", + data=json.dumps( + { + "audio": [], + "detections": ["car-jkl012"], + "objects": ["car"], + "sub_labels": [], + "significant_motion_areas": [], + "zones": ["front_yard"], + } + ), + ), + ] + + result = [r.model_dump(mode="json") for r in reviews] + + # Verify mock data covers all Pydantic response model fields + check_pydantic_fields( + ReviewSegmentResponse, set(result[0].keys()), "ReviewSegment" + ) + + return result + + +def generate_events(): + """Generate EventResponse[] validated against Pydantic + Peewee.""" + from frigate.api.defs.response.event_response import EventResponse + + events = [ + EventResponse( + id="event-person-001", + label="person", + sub_label=None, + camera="front_door", + start_time=NOW - 2 * HOUR, + end_time=NOW - 2 * HOUR + 30, + false_positive=False, + zones=["front_yard"], + thumbnail=None, + has_clip=True, + has_snapshot=True, + retain_indefinitely=False, + plus_id=None, + model_hash="abc123", + detector_type="cpu", + model_type="ssd", + data={ + "top_score": 0.92, + "score": 0.92, + "region": [0.1, 0.1, 0.5, 0.8], + "box": [0.2, 0.15, 0.45, 0.75], + "area": 0.18, + "ratio": 0.6, + "type": "object", + "description": "A person walking toward the front door", + "average_estimated_speed": 1.2, + "velocity_angle": 45.0, + "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]], + }, + ), + EventResponse( + id="event-car-001", + label="car", + sub_label=None, + camera="backyard", + start_time=NOW - 3 * HOUR, + end_time=NOW - 3 * HOUR + 45, + false_positive=False, + zones=["driveway"], + thumbnail=None, + has_clip=True, + has_snapshot=True, + retain_indefinitely=False, + plus_id=None, + model_hash="def456", + detector_type="cpu", + model_type="ssd", + data={ + "top_score": 0.87, + "score": 0.87, + "region": [0.3, 0.2, 0.9, 0.7], + "box": [0.35, 0.25, 0.85, 0.65], + "area": 0.2, + "ratio": 1.25, + "type": "object", + "description": "A car parked in the driveway", + "average_estimated_speed": 0.0, + "velocity_angle": 0.0, + "path_data": [], + }, + ), + EventResponse( + id="event-person-002", + label="person", + sub_label=None, + camera="garage", + start_time=NOW - 4 * HOUR, + end_time=NOW - 4 * HOUR + 20, + false_positive=False, + zones=[], + thumbnail=None, + has_clip=False, + has_snapshot=True, + retain_indefinitely=False, + plus_id=None, + model_hash="ghi789", + detector_type="cpu", + model_type="ssd", + data={ + "top_score": 0.78, + "score": 0.78, + "region": [0.0, 0.0, 0.6, 0.9], + "box": [0.1, 0.05, 0.5, 0.85], + "area": 0.32, + "ratio": 0.5, + "type": "object", + "description": None, + "average_estimated_speed": 0.5, + "velocity_angle": 90.0, + "path_data": [[[0.1, 0.4], 0.0]], + }, + ), + ] + + result = [e.model_dump(mode="json") for e in events] + + check_pydantic_fields(EventResponse, set(result[0].keys()), "Event") + + return result + + +def generate_exports(): + """Generate ExportModel[] validated against Pydantic + Peewee.""" + from frigate.api.defs.response.export_response import ExportModel + + exports = [ + ExportModel( + id="export-001", + camera="front_door", + name="Front Door - Person Alert", + date=NOW - 1 * HOUR, + video_path="/exports/export-001.mp4", + thumb_path="/exports/export-001-thumb.jpg", + in_progress=False, + export_case_id=None, + ), + ExportModel( + id="export-002", + camera="backyard", + name="Backyard - Car Detection", + date=NOW - 3 * HOUR, + video_path="/exports/export-002.mp4", + thumb_path="/exports/export-002-thumb.jpg", + in_progress=False, + export_case_id="case-001", + ), + ExportModel( + id="export-003", + camera="garage", + name="Garage - In Progress", + date=NOW - 0.5 * HOUR, + video_path="/exports/export-003.mp4", + thumb_path="/exports/export-003-thumb.jpg", + in_progress=True, + export_case_id=None, + ), + ] + + result = [e.model_dump(mode="json") for e in exports] + + check_pydantic_fields(ExportModel, set(result[0].keys()), "Export") + + return result + + +def generate_cases(): + """Generate ExportCaseModel[] validated against Pydantic + Peewee.""" + from frigate.api.defs.response.export_case_response import ExportCaseModel + + cases = [ + ExportCaseModel( + id="case-001", + name="Package Theft Investigation", + description="Review of suspicious activity near the front porch", + created_at=NOW - 24 * HOUR, + updated_at=NOW - 3 * HOUR, + ), + ] + + result = [c.model_dump(mode="json") for c in cases] + + check_pydantic_fields(ExportCaseModel, set(result[0].keys()), "ExportCase") + + return result + + +def generate_review_summary(): + """Generate ReviewSummary for the calendar filter.""" + today = datetime.now().strftime("%Y-%m-%d") + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + + return { + today: { + "day": today, + "reviewed_alert": 1, + "reviewed_detection": 0, + "total_alert": 2, + "total_detection": 2, + }, + yesterday: { + "day": yesterday, + "reviewed_alert": 3, + "reviewed_detection": 2, + "total_alert": 3, + "total_detection": 4, + }, + } + + +def write_json(filename, data): + path = OUTPUT_DIR / filename + path.write_text(json.dumps(data, default=str)) + print(f" {path.name} ({path.stat().st_size} bytes)") + + +def main(): + print("Generating E2E mock data from backend models...") + print(" Validating against Pydantic response models + Peewee DB columns") + print() + + write_json("config-snapshot.json", generate_config()) + write_json("config-schema.json", generate_config_schema()) + write_json("reviews.json", generate_reviews()) + write_json("events.json", generate_events()) + write_json("exports.json", generate_exports()) + write_json("cases.json", generate_cases()) + write_json("review-summary.json", generate_review_summary()) + + print() + print("All mock data validated against backend schemas.") + print("If this script fails, update the mock data to match the new schema.") + + +if __name__ == "__main__": + main() diff --git a/web/e2e/fixtures/mock-data/profile.ts b/web/e2e/fixtures/mock-data/profile.ts new file mode 100644 index 0000000000..62d70e3a07 --- /dev/null +++ b/web/e2e/fixtures/mock-data/profile.ts @@ -0,0 +1,39 @@ +/** + * User profile factories for E2E tests. + */ + +export interface UserProfile { + username: string; + role: string; + allowed_cameras: string[] | null; +} + +export function adminProfile(overrides?: Partial): UserProfile { + return { + username: "admin", + role: "admin", + allowed_cameras: null, + ...overrides, + }; +} + +export function viewerProfile(overrides?: Partial): UserProfile { + return { + username: "viewer", + role: "viewer", + allowed_cameras: null, + ...overrides, + }; +} + +export function restrictedProfile( + cameras: string[], + overrides?: Partial, +): UserProfile { + return { + username: "restricted", + role: "viewer", + allowed_cameras: cameras, + ...overrides, + }; +} diff --git a/web/e2e/fixtures/mock-data/review-summary.json b/web/e2e/fixtures/mock-data/review-summary.json new file mode 100644 index 0000000000..bb3afc2ea3 --- /dev/null +++ b/web/e2e/fixtures/mock-data/review-summary.json @@ -0,0 +1 @@ +{"2026-06-05": {"day": "2026-06-05", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-06-04": {"day": "2026-06-04", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/reviews.json b/web/e2e/fixtures/mock-data/reviews.json new file mode 100644 index 0000000000..0b60850cfa --- /dev/null +++ b/web/e2e/fixtures/mock-data/reviews.json @@ -0,0 +1 @@ +[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-06-05T11:30:09.365581", "end_time": "2026-06-05T11:30:39.365581", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-06-05T10:30:09.365581", "end_time": "2026-06-05T10:30:54.365581", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-06-05T09:30:09.365581", "end_time": "2026-06-05T09:30:29.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-06-05T08:30:09.365581", "end_time": "2026-06-05T08:30:24.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/stats.ts b/web/e2e/fixtures/mock-data/stats.ts new file mode 100644 index 0000000000..d34ea25fc0 --- /dev/null +++ b/web/e2e/fixtures/mock-data/stats.ts @@ -0,0 +1,76 @@ +/** + * FrigateStats factory for E2E tests. + */ + +import type { DeepPartial } from "./config"; + +function cameraStats(_name: string) { + return { + audio_dBFPS: 0, + audio_rms: 0, + camera_fps: 5.0, + capture_pid: 100, + detection_enabled: 1, + detection_fps: 5.0, + ffmpeg_pid: 101, + pid: 102, + process_fps: 5.0, + skipped_fps: 0, + connection_quality: "excellent" as const, + expected_fps: 5, + reconnects_last_hour: 0, + stalls_last_hour: 0, + }; +} + +export const BASE_STATS = { + cameras: { + front_door: cameraStats("front_door"), + backyard: cameraStats("backyard"), + garage: cameraStats("garage"), + }, + cpu_usages: { + "1": { cmdline: "frigate.app", cpu: "5.0", cpu_average: "4.5", mem: "2.1" }, + }, + detectors: { + cpu: { + detection_start: 0, + inference_speed: 75.5, + pid: 200, + }, + }, + gpu_usages: {}, + npu_usages: {}, + processes: {}, + service: { + last_updated: Date.now() / 1000, + storage: { + "/media/frigate/recordings": { + free: 50000000000, + total: 100000000000, + used: 50000000000, + mount_type: "ext4", + }, + "/tmp/cache": { + free: 500000000, + total: 1000000000, + used: 500000000, + mount_type: "tmpfs", + }, + }, + uptime: 86400, + latest_version: "0.15.0", + version: "0.15.0-test", + }, + camera_fps: 15.0, + process_fps: 15.0, + skipped_fps: 0, + detection_fps: 15.0, +}; + +export function statsFactory( + overrides?: DeepPartial, +): typeof BASE_STATS { + if (!overrides) return BASE_STATS; + return { ...BASE_STATS, ...overrides } as typeof BASE_STATS; +} diff --git a/web/e2e/global-setup.ts b/web/e2e/global-setup.ts new file mode 100644 index 0000000000..ef8f546b5d --- /dev/null +++ b/web/e2e/global-setup.ts @@ -0,0 +1,7 @@ +import { execSync } from "child_process"; +import path from "path"; + +export default function globalSetup() { + const webDir = path.resolve(__dirname, ".."); + execSync("npm run e2e:build", { cwd: webDir, stdio: "inherit" }); +} diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts new file mode 100644 index 0000000000..34b15380e2 --- /dev/null +++ b/web/e2e/helpers/api-mocker.ts @@ -0,0 +1,285 @@ +/** + * REST API mock using Playwright's page.route(). + * + * Intercepts all /api/* requests and returns factory-generated responses. + * Must be installed BEFORE page.goto() to prevent auth redirects. + */ + +import type { Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BASE_CONFIG, + type DeepPartial, + configFactory, +} from "../fixtures/mock-data/config"; +import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile"; +import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MOCK_DATA_DIR = resolve(__dirname, "../fixtures/mock-data"); + +function loadMockJson(filename: string): unknown { + return JSON.parse(readFileSync(resolve(MOCK_DATA_DIR, filename), "utf-8")); +} + +// 1x1 transparent PNG +const PLACEHOLDER_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +export interface ApiMockOverrides { + config?: DeepPartial; + profile?: UserProfile; + stats?: DeepPartial; + reviews?: unknown[]; + events?: unknown[]; + exports?: unknown[]; + cases?: unknown[]; + faces?: Record; + configRaw?: string; + configSchema?: Record; +} + +export class ApiMocker { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + async install(overrides?: ApiMockOverrides) { + const config = configFactory(overrides?.config); + const profile = overrides?.profile ?? adminProfile(); + const stats = statsFactory(overrides?.stats); + const reviews = + overrides?.reviews ?? (loadMockJson("reviews.json") as unknown[]); + const events = + overrides?.events ?? (loadMockJson("events.json") as unknown[]); + const exports = + overrides?.exports ?? (loadMockJson("exports.json") as unknown[]); + const cases = overrides?.cases ?? (loadMockJson("cases.json") as unknown[]); + const reviewSummary = loadMockJson("review-summary.json"); + + // Config endpoint + await this.page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: config }); + } + return route.fulfill({ json: { success: true } }); + }); + + // Profile endpoint (AuthProvider fetches /profile directly via axios, + // which resolves to /api/profile due to axios.defaults.baseURL) + await this.page.route("**/profile", (route) => + route.fulfill({ json: profile }), + ); + + // Stats endpoint + await this.page.route("**/api/stats", (route) => + route.fulfill({ json: stats }), + ); + + // Reviews. The real backend exposes /review (singular) for the main + // list and /review/summary for the summary — the previous plural glob + // (**/api/reviews**) never matched either endpoint, so review-dependent + // tests silently ran without data. The POST mutations at /reviews/viewed + // and /reviews/delete (plural) still fall through to the generic + // mutation catch-all further down the file. + await this.page.route(/\/api\/review\/summary/, (route) => + route.fulfill({ json: reviewSummary }), + ); + await this.page.route(/\/api\/review(\?|$)/, (route) => + route.fulfill({ json: reviews }), + ); + + // Export jobs. The Exports page polls this every 2s while any export + // is in_progress; without a mock route it falls through to the preview + // server which returns 500 and makes the page flap between loading and + // rendered state, breaking tests that navigate to /export. + await this.page.route("**/api/jobs/export", (route) => + route.fulfill({ json: [] }), + ); + + // Recordings summary + await this.page.route("**/api/recordings/summary**", (route) => + route.fulfill({ json: {} }), + ); + + // Previews (needed for review page event cards) + await this.page.route("**/api/preview/**", (route) => + route.fulfill({ json: [] }), + ); + + // Sub-labels and attributes (for explore filters). + // Use trailing ** so query-string variants (e.g. ?split_joined=1) match. + await this.page.route("**/api/sub_labels**", (route) => + route.fulfill({ json: [] }), + ); + await this.page.route("**/api/labels**", (route) => + route.fulfill({ json: ["person", "car"] }), + ); + await this.page.route("**/api/*/attributes", (route) => + route.fulfill({ json: [] }), + ); + await this.page.route("**/api/recognized_license_plates", (route) => + route.fulfill({ json: [] }), + ); + + // Events / search + await this.page.route("**/api/events**", (route) => + route.fulfill({ json: events }), + ); + + // Exports + await this.page.route("**/api/export**", (route) => + route.fulfill({ json: exports }), + ); + + // Cases + await this.page.route("**/api/cases", (route) => + route.fulfill({ json: cases }), + ); + + // Faces + await this.page.route("**/api/faces", (route) => + route.fulfill({ json: overrides?.faces ?? {} }), + ); + + // Logs + await this.page.route("**/api/logs/**", (route) => + route.fulfill({ + contentType: "text/plain", + body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n", + }), + ); + + // Config raw + await this.page.route("**/api/config/raw", (route) => + route.fulfill({ + contentType: "text/plain", + body: + overrides?.configRaw ?? + "mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n", + }), + ); + + // Config schema + await this.page.route("**/api/config/schema.json", (route) => + route.fulfill({ + json: overrides?.configSchema ?? { type: "object", properties: {} }, + }), + ); + + // Config set (mutation) + await this.page.route("**/api/config/set", (route) => + route.fulfill({ json: { success: true, require_restart: false } }), + ); + + // Go2RTC streams + await this.page.route("**/api/go2rtc/streams**", (route) => + route.fulfill({ json: {} }), + ); + + // Profiles + await this.page.route("**/api/profiles**", (route) => + route.fulfill({ + json: { profiles: [], active_profile: null, last_activated: {} }, + }), + ); + + // Motion search + await this.page.route("**/api/motion_search**", (route) => + route.fulfill({ json: { job_id: "test-job" } }), + ); + + // Region grid + await this.page.route("**/api/*/region_grid", (route) => + route.fulfill({ json: {} }), + ); + + // Debug replay + await this.page.route("**/api/debug_replay/**", (route) => + route.fulfill({ json: {} }), + ); + + // Generic mutation catch-all for remaining endpoints. + // Uses route.fallback() to defer to more specific routes registered above. + // Playwright matches routes in reverse registration order (last wins), + // so this catch-all must use fallback() to let specific routes take precedence. + await this.page.route("**/api/**", (route) => { + const method = route.request().method(); + if ( + method === "POST" || + method === "PUT" || + method === "PATCH" || + method === "DELETE" + ) { + return route.fulfill({ json: { success: true } }); + } + // Fall through to more specific routes for GET requests + return route.fallback(); + }); + } +} + +export class MediaMocker { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + async install() { + // Camera snapshots + await this.page.route("**/api/*/latest.jpg**", (route) => + route.fulfill({ + contentType: "image/png", + body: PLACEHOLDER_PNG, + }), + ); + + // Clips and thumbnails + await this.page.route("**/clips/**", (route) => + route.fulfill({ + contentType: "image/png", + body: PLACEHOLDER_PNG, + }), + ); + + // Event thumbnails. The explore grid and detail dialog request .webp, + // everything else requests .jpg. + await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) => + route.fulfill({ + contentType: "image/png", + body: PLACEHOLDER_PNG, + }), + ); + + // Event snapshots + await this.page.route("**/api/events/*/snapshot.jpg**", (route) => + route.fulfill({ + contentType: "image/png", + body: PLACEHOLDER_PNG, + }), + ); + + // VOD / recordings + await this.page.route("**/vod/**", (route) => + route.fulfill({ + contentType: "application/vnd.apple.mpegurl", + body: "#EXTM3U\n#EXT-X-ENDLIST\n", + }), + ); + + // Live streams + await this.page.route("**/live/**", (route) => + route.fulfill({ + contentType: "application/vnd.apple.mpegurl", + body: "#EXTM3U\n#EXT-X-ENDLIST\n", + }), + ); + } +} diff --git a/web/e2e/helpers/clipboard.ts b/web/e2e/helpers/clipboard.ts new file mode 100644 index 0000000000..9099073b32 --- /dev/null +++ b/web/e2e/helpers/clipboard.ts @@ -0,0 +1,25 @@ +/** + * Clipboard read helper for e2e tests. + * + * Clipboard API requires a browser permission in headless mode. + * grantClipboardPermissions() must be called before any readClipboard() + * attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts + * (Copy button). + */ + +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Grant clipboard-read + clipboard-write permissions on the context. + * Call in beforeEach or at the top of a test before the Copy action. + */ +export async function grantClipboardPermissions( + context: BrowserContext, +): Promise { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); +} + +/** Read the current clipboard contents via the page's navigator.clipboard. */ +export async function readClipboard(page: Page): Promise { + return page.evaluate(async () => await navigator.clipboard.readText()); +} diff --git a/web/e2e/helpers/mock-overrides.ts b/web/e2e/helpers/mock-overrides.ts new file mode 100644 index 0000000000..71ea38e38d --- /dev/null +++ b/web/e2e/helpers/mock-overrides.ts @@ -0,0 +1,56 @@ +/** + * Per-test mock overrides for driving empty / loading / error states. + * + * Playwright route handlers are LIFO: the most recently registered handler + * matching a URL takes precedence. The frigateApp fixture installs default + * mocks before the test body runs, so these helpers — called inside the + * test body — register AFTER the defaults and therefore win. + * + * Always call these BEFORE the navigation that triggers the request. + * + * Example: + * await mockEmpty(page, "**\/api\/exports**"); + * await frigateApp.goto("/export"); + * // Page now renders the empty state + */ + +import type { Page } from "@playwright/test"; + +/** Return an empty array for the matched endpoint. */ +export async function mockEmpty( + page: Page, + urlPattern: string | RegExp, +): Promise { + await page.route(urlPattern, (route) => route.fulfill({ json: [] })); +} + +/** Return an HTTP error for the matched endpoint. Default status 500. */ +export async function mockError( + page: Page, + urlPattern: string | RegExp, + status = 500, +): Promise { + await page.route(urlPattern, (route) => + route.fulfill({ + status, + json: { success: false, message: "Mocked error" }, + }), + ); +} + +/** + * Delay the response by `ms` milliseconds before fulfilling with the + * provided body. Use to assert loading-state UI is visible during the + * delay window. + */ +export async function mockDelay( + page: Page, + urlPattern: string | RegExp, + ms: number, + body: unknown = [], +): Promise { + await page.route(urlPattern, async (route) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + await route.fulfill({ json: body }); + }); +} diff --git a/web/e2e/helpers/monaco.ts b/web/e2e/helpers/monaco.ts new file mode 100644 index 0000000000..6e9bcd871a --- /dev/null +++ b/web/e2e/helpers/monaco.ts @@ -0,0 +1,58 @@ +/** + * Monaco editor DOM helpers for e2e tests. + * + * Monaco is imported as a module-local object in the app and is NOT + * exposed on window; we drive + read through the rendered DOM and + * keyboard instead. Used by config-editor.spec.ts only. + */ + +import { expect, type Page } from "@playwright/test"; + +/** + * Returns the current visible text of the first Monaco editor on the + * page. Monaco virtualizes long files — this reads only the rendered + * lines. For short configs (our mocks) that's the full content. + */ +export async function getMonacoVisibleText(page: Page): Promise { + return page.locator(".monaco-editor .view-lines").first().innerText(); +} + +/** + * Focus the editor and replace its full content with `value` via + * keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent) + * + Delete + type. Works cross-platform because Playwright normalizes. + */ +export async function replaceMonacoValue( + page: Page, + value: string, +): Promise { + const editor = page.locator(".monaco-editor").first(); + await editor.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Delete"); + // Use `type` with zero delay — Monaco handles each key. + await page.keyboard.type(value, { delay: 0 }); +} + +/** + * Returns true when the editor shows at least one error-severity + * marker. Monaco renders error underlines as `.squiggly-error` in + * the `.view-overlays` layer. + */ +export async function hasErrorMarkers(page: Page): Promise { + const count = await page.locator(".monaco-editor .squiggly-error").count(); + return count > 0; +} + +/** + * Poll until an error marker appears. Monaco schedules marker updates + * asynchronously after content changes (debounce + schema validation). + */ +export async function waitForErrorMarker( + page: Page, + timeoutMs: number = 10_000, +): Promise { + await expect + .poll(() => hasErrorMarkers(page), { timeout: timeoutMs }) + .toBe(true); +} diff --git a/web/e2e/helpers/overlay-interaction.ts b/web/e2e/helpers/overlay-interaction.ts new file mode 100644 index 0000000000..81a01d8415 --- /dev/null +++ b/web/e2e/helpers/overlay-interaction.ts @@ -0,0 +1,41 @@ +/** + * Overlay interaction helpers for Radix-based UI tests. + * + * These helpers exist to guard the class of bugs fixed by de-duping + * `@radix-ui/react-dismissable-layer` across the tree: body pointer-events + * getting stuck, dropdown typeahead breaking, tooltips re-popping after a + * dropdown closes, and related nested-overlay regressions. + */ + +import { expect, type Page } from "@playwright/test"; + +/** + * Assert that `` is interactive (no stuck `pointer-events: none`). + * + * Call after closing any overlay. This is the fast secondary assertion — + * test specs should also assert a user-visible behavior like "a button + * responded to a click" so the test fails on meaningful breakage rather + * than just a CSS invariant. + */ +export async function expectBodyInteractive(page: Page) { + const stuck = await page.evaluate( + () => document.body.style.pointerEvents === "none", + ); + expect(stuck, "body.style.pointer-events stuck after overlay close").toBe( + false, + ); +} + +/** + * Wait until the `` is no longer marked with `pointer-events: none`. + * + * Useful right after closing an overlay when Radix's cleanup runs in the + * next frame. Throws if the style does not clear within `timeoutMs`. + */ +export async function waitForBodyInteractive(page: Page, timeoutMs = 2000) { + await page.waitForFunction( + () => document.body.style.pointerEvents !== "none", + null, + { timeout: timeoutMs }, + ); +} diff --git a/web/e2e/helpers/ws-frames.ts b/web/e2e/helpers/ws-frames.ts new file mode 100644 index 0000000000..d46376d871 --- /dev/null +++ b/web/e2e/helpers/ws-frames.ts @@ -0,0 +1,65 @@ +/** + * WebSocket frame capture helper. + * + * The ws-mocker intercepts the /ws route, so Playwright's page-level + * `websocket` event never fires. This helper patches client-side + * WebSocket.prototype.send before any app code runs and mirrors every + * sent frame into a window-level array the test can read back. + * + * Used by live.spec.ts (feature toggles, PTZ preset commands) and + * config-editor.spec.ts (restart command via useRestart). + */ + +import { expect, type Page } from "@playwright/test"; + +export type CapturedFrame = string; + +declare global { + interface Window { + __sentWsFrames: CapturedFrame[]; + } +} + +/** + * Patch WebSocket.prototype.send to capture every outbound frame into + * window.__sentWsFrames. Must be called BEFORE page.goto(). + */ +export async function installWsFrameCapture(page: Page): Promise { + await page.addInitScript(() => { + window.__sentWsFrames = []; + const origSend = WebSocket.prototype.send; + WebSocket.prototype.send = function (data) { + try { + window.__sentWsFrames.push( + typeof data === "string" ? data : "(binary)", + ); + } catch { + // ignore — best-effort tracing + } + return origSend.call(this, data); + }; + }); +} + +/** Read all captured frames at call time. */ +export async function readWsFrames(page: Page): Promise { + return page.evaluate(() => window.__sentWsFrames ?? []); +} + +/** + * Poll until at least one captured frame matches the predicate. + * Throws via expect if the frame never arrives within timeout. + */ +export async function waitForWsFrame( + page: Page, + matcher: (frame: CapturedFrame) => boolean, + opts: { timeout?: number; message?: string } = {}, +): Promise { + const { timeout = 2_000, message } = opts; + await expect + .poll(async () => (await readWsFrames(page)).some(matcher), { + timeout, + message, + }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-mocker.ts b/web/e2e/helpers/ws-mocker.ts new file mode 100644 index 0000000000..03db9f7410 --- /dev/null +++ b/web/e2e/helpers/ws-mocker.ts @@ -0,0 +1,138 @@ +/** + * WebSocket mock using Playwright's native page.routeWebSocket(). + * + * Intercepts the app's WebSocket connection and simulates the Frigate + * WS protocol: onConnect handshake, camera_activity expansion, and + * topic-based state updates. + */ + +import type { Page, WebSocketRoute } from "@playwright/test"; +import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity"; + +export class WsMocker { + private mockWs: WebSocketRoute | null = null; + private cameras: string[]; + + constructor(cameras: string[] = ["front_door", "backyard", "garage"]) { + this.cameras = cameras; + } + + async install(page: Page) { + await page.routeWebSocket("**/ws", (ws) => { + this.mockWs = ws; + + ws.onMessage((msg) => { + this.handleClientMessage(msg.toString()); + }); + }); + } + + private handleClientMessage(raw: string) { + let data: { topic: string; payload?: unknown; message?: string }; + try { + data = JSON.parse(raw); + } catch { + return; + } + + if (data.topic === "onConnect") { + // Send initial camera_activity state + this.sendCameraActivity(); + + // Send initial stats + this.send( + "stats", + JSON.stringify({ + cameras: Object.fromEntries( + this.cameras.map((c) => [ + c, + { + camera_fps: 5, + detection_fps: 5, + process_fps: 5, + skipped_fps: 0, + detection_enabled: 1, + connection_quality: "excellent", + }, + ]), + ), + service: { + last_updated: Date.now() / 1000, + uptime: 86400, + version: "0.15.0-test", + latest_version: "0.15.0", + storage: {}, + }, + detectors: {}, + cpu_usages: {}, + gpu_usages: {}, + camera_fps: 15, + process_fps: 15, + skipped_fps: 0, + detection_fps: 15, + }), + ); + } + + // Echo back state commands (e.g., modelState, jobState, etc.) + if (data.topic === "modelState") { + this.send("model_state", JSON.stringify({})); + } + if (data.topic === "embeddingsReindexProgress") { + // Send a completed reindex state so Explore renders when + // semantic_search.enabled is true. A null payload leaves the page + // in a permanent loading spinner because !reindexState is truthy. + this.send( + "embeddings_reindex_progress", + JSON.stringify({ + status: "completed", + processed_objects: 0, + total_objects: 0, + thumbnails: 0, + descriptions: 0, + time_remaining: null, + }), + ); + } + if (data.topic === "birdseyeLayout") { + this.send("birdseye_layout", JSON.stringify(null)); + } + if (data.topic === "jobState") { + this.send("job_state", JSON.stringify({})); + } + if (data.topic === "audioTranscriptionState") { + this.send("audio_transcription_state", JSON.stringify("idle")); + } + + // Camera toggle commands: echo back the new state + const toggleMatch = data.topic?.match( + /^(.+)\/(detect|recordings|snapshots|audio|enabled|notifications|ptz_autotracker|review_alerts|review_detections|object_descriptions|review_descriptions|audio_transcription)\/set$/, + ); + if (toggleMatch) { + const [, camera, feature] = toggleMatch; + this.send(`${camera}/${feature}/state`, data.payload); + } + } + + /** Send a raw WS message to the app */ + send(topic: string, payload: unknown) { + if (!this.mockWs) return; + this.mockWs.send(JSON.stringify({ topic, payload })); + } + + /** Send camera_activity with default or custom state */ + sendCameraActivity(overrides?: Parameters[1]) { + const payload = cameraActivityPayload(this.cameras, overrides); + this.send("camera_activity", payload); + } + + /** Send a review update */ + sendReview(review: unknown) { + this.send("reviews", JSON.stringify(review)); + } + + /** Send an event update */ + sendEvent(event: unknown) { + this.send("events", JSON.stringify(event)); + } +} diff --git a/web/e2e/pages/base.page.ts b/web/e2e/pages/base.page.ts new file mode 100644 index 0000000000..e5628cb8c8 --- /dev/null +++ b/web/e2e/pages/base.page.ts @@ -0,0 +1,135 @@ +/** + * Base page object with viewport-aware navigation helpers. + * + * Desktop: clicks sidebar NavLink elements. + * Mobile: clicks bottombar NavLink elements. + */ + +import type { Page, Locator } from "@playwright/test"; + +export class BasePage { + constructor( + protected page: Page, + public isDesktop: boolean, + ) {} + + get isMobile() { + return !this.isDesktop; + } + + /** The sidebar (desktop only) */ + get sidebar(): Locator { + return this.page.locator("aside"); + } + + /** The bottombar (mobile only) */ + get bottombar(): Locator { + return this.page + .locator('[data-bottombar="true"]') + .or(this.page.locator(".absolute.inset-x-4.bottom-0").first()); + } + + /** The main page content area */ + get pageRoot(): Locator { + return this.page.locator("#pageRoot"); + } + + /** Navigate using a NavLink by its href */ + async navigateTo(path: string) { + // Wait for any in-progress React renders to settle before clicking + await this.page.waitForLoadState("domcontentloaded"); + // Use page.click with a CSS selector to avoid stale element issues + // when React re-renders the nav during route transitions. + // force: true bypasses actionability checks that fail when React + // detaches and reattaches nav elements during re-renders. + const selector = this.isDesktop + ? `aside a[href="${path}"]` + : `a[href="${path}"]`; + // Use dispatchEvent to bypass actionability checks that fail when + // React tooltip wrappers detach/reattach nav elements during re-renders + await this.page.locator(selector).first().dispatchEvent("click"); + // React Router navigates client-side, wait for URL update + if (path !== "/") { + const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await this.page.waitForURL(new RegExp(escaped), { timeout: 10_000 }); + } + } + + /** Navigate to Live page */ + async goToLive() { + await this.navigateTo("/"); + } + + /** Navigate to Review page */ + async goToReview() { + await this.navigateTo("/review"); + } + + /** Navigate to Explore page */ + async goToExplore() { + await this.navigateTo("/explore"); + } + + /** Navigate to Export page */ + async goToExport() { + await this.navigateTo("/export"); + } + + /** Check if the page has loaded */ + async waitForPageLoad() { + await this.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + } + + /** + * Open the mobile-only export pane / sheet that slides up from the + * bottom on the export page. No-op on desktop. Returns the pane locator + * so the caller can assert against its contents. + */ + async openMobilePane(): Promise { + if (this.isDesktop) { + // Return the desktop equivalent (the main content area itself) + return this.pageRoot; + } + // Look for any element that opens a sheet/dialog on tap. + // Specific views override this with their own selector. + const pane = this.page.locator('[role="dialog"]').first(); + return pane; + } + + /** + * Open a side drawer (e.g. mobile filter drawer). View-specific page + * objects should override this with their actual trigger selector. + * The default implementation looks for a button labelled "Open menu" + * or "Filters" and clicks it, then returns the drawer locator. + */ + async openDrawer(): Promise { + if (this.isDesktop) { + return this.pageRoot; + } + const trigger = this.page + .getByRole("button", { name: /menu|filter/i }) + .first(); + if (await trigger.count()) { + await trigger.click(); + } + return this.page.locator('[role="dialog"], [data-state="open"]').first(); + } + + /** + * Open a bottom sheet (vaul). View-specific page objects should + * override this with their actual trigger selector. + */ + async openBottomSheet(): Promise { + if (this.isDesktop) { + return this.pageRoot; + } + return this.page.locator("[vaul-drawer]").first(); + } + + /** Close any currently-open mobile overlay (drawer, sheet, dialog). */ + async closeMobileOverlay(): Promise { + if (this.isDesktop) return; + // Press Escape — Radix dialogs and vaul both close on Escape + await this.page.keyboard.press("Escape"); + } +} diff --git a/web/e2e/pages/live.page.ts b/web/e2e/pages/live.page.ts new file mode 100644 index 0000000000..814064944b --- /dev/null +++ b/web/e2e/pages/live.page.ts @@ -0,0 +1,55 @@ +/** + * Live dashboard + single-camera page object. + * + * Encapsulates selectors and viewport-conditional openers for the + * Live route. Does NOT own assertions — specs call expect on the + * locators returned from these getters. + */ + +import type { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base.page"; + +export class LivePage extends BasePage { + constructor(page: Page, isDesktop: boolean) { + super(page, isDesktop); + } + + /** The camera card wrapper on the dashboard, keyed by camera name. */ + cameraCard(name: string): Locator { + return this.page.locator(`[data-camera='${name}']`); + } + + /** Back button on the single-camera view header (desktop text). */ + get backButton(): Locator { + return this.page.getByText("Back", { exact: true }); + } + + /** History button on the single-camera view header (desktop text). */ + get historyButton(): Locator { + return this.page.getByText("History", { exact: true }); + } + + /** All CameraFeatureToggle elements (active + inactive). */ + get featureToggles(): Locator { + // Use div selector to exclude NavItem anchor elements that share the same classes. + return this.page.locator( + "div.flex.flex-col.items-center.justify-center.bg-selected, div.flex.flex-col.items-center.justify-center.bg-secondary", + ); + } + + /** Only the active (bg-selected) feature toggles. */ + get activeFeatureToggles(): Locator { + // Use div selector to exclude NavItem anchor elements that share the same classes. + return this.page.locator( + "div.flex.flex-col.items-center.justify-center.bg-selected", + ); + } + + /** Open the right-click context menu on a camera card (desktop only). */ + async openContextMenuOn(cameraName: string): Promise { + await this.cameraCard(cameraName).first().click({ button: "right" }); + return this.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + } +} diff --git a/web/e2e/pages/review.page.ts b/web/e2e/pages/review.page.ts new file mode 100644 index 0000000000..6d9cfb7c24 --- /dev/null +++ b/web/e2e/pages/review.page.ts @@ -0,0 +1,52 @@ +/** + * Review/events page object. + * + * Encapsulates severity tab, filter bar, calendar, and mobile filter + * drawer selectors. Does NOT own assertions. + */ + +import type { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base.page"; + +export class ReviewPage extends BasePage { + constructor(page: Page, isDesktop: boolean) { + super(page, isDesktop); + } + + get alertsTab(): Locator { + return this.page.getByLabel("Alerts"); + } + + get detectionsTab(): Locator { + return this.page.getByLabel("Detections"); + } + + get motionTab(): Locator { + return this.page.getByRole("radio", { name: "Motion" }); + } + + get camerasFilterTrigger(): Locator { + return this.page.getByRole("button", { name: /cameras/i }).first(); + } + + get calendarTrigger(): Locator { + return this.page.getByRole("button", { name: /24 hours|calendar|date/i }); + } + + get showReviewedToggle(): Locator { + return this.page.getByRole("button", { name: /reviewed/i }); + } + + get reviewItems(): Locator { + return this.page.locator(".review-item"); + } + + /** The filter popover content (desktop) or drawer (mobile). */ + get filterOverlay(): Locator { + return this.page + .locator( + '[data-radix-popper-content-wrapper], [role="dialog"], [data-vaul-drawer]', + ) + .first(); + } +} diff --git a/web/e2e/playwright.config.ts b/web/e2e/playwright.config.ts new file mode 100644 index 0000000000..4b21258f12 --- /dev/null +++ b/web/e2e/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from "@playwright/test"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(__dirname, ".."); + +const DESKTOP_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; +const MOBILE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + +export default defineConfig({ + testDir: "./specs", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 4, + reporter: process.env.CI ? [["json"], ["html"]] : [["html"]], + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL: "http://localhost:4173", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + webServer: { + command: "npx vite preview --port 4173", + port: 4173, + cwd: webRoot, + reuseExistingServer: !process.env.CI, + }, + + projects: [ + { + name: "desktop", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1920, height: 1080 }, + userAgent: DESKTOP_UA, + }, + }, + { + name: "mobile", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 390, height: 844 }, + userAgent: MOBILE_UA, + isMobile: true, + hasTouch: true, + }, + }, + ], +}); diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs new file mode 100644 index 0000000000..e4046869df --- /dev/null +++ b/web/e2e/scripts/lint-specs.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Lint script for e2e specs. Bans lenient test patterns and requires + * a @mobile-tagged test in every spec under specs/ (excluding _meta/). + * + * Banned patterns: + * - page.waitForTimeout( — use expect().toPass() or waitFor instead + * - if (await ... .isVisible()) — assertions must be unconditional + * - if ((await ... .count()) > 0) — same as above + * - expect(... .length).toBeGreaterThan(0) on textContent results + * + * Escape hatch: append `// e2e-lint-allow` on any line to silence the + * check for that line. Use sparingly and explain why in a comment above. + * + * @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must + * contain at least one test title or describe with the substring "@mobile". + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SPECS_DIR = resolve(__dirname, "..", "specs"); +const META_PREFIX = resolve(SPECS_DIR, "_meta"); + +const BANNED_PATTERNS = [ + { + name: "page.waitForTimeout", + regex: /\bwaitForTimeout\s*\(/, + advice: + "Use expect.poll(), expect(...).toPass(), or waitFor() with a real condition.", + }, + { + name: "conditional isVisible() assertion", + regex: /\bif\s*\(\s*await\s+[^)]*\.isVisible\s*\(/, + advice: + "Assertions must be unconditional. Use expect(...).toBeVisible() instead.", + }, + { + name: "conditional count() assertion", + regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/, + advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).", + }, + { + name: "vacuous textContent length assertion", + regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/, + advice: "Assert specific content, not that some text exists.", + }, +]; + +function walk(dir) { + const entries = readdirSync(dir); + const out = []; + for (const entry of entries) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + out.push(...walk(full)); + } else if (entry.endsWith(".spec.ts")) { + out.push(full); + } + } + return out; +} + +function lintFile(file) { + if (file.includes("/specs/settings/")) return []; + + const errors = []; + const text = readFileSync(file, "utf8"); + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.includes("e2e-lint-allow")) continue; + for (const pat of BANNED_PATTERNS) { + if (pat.regex.test(line)) { + errors.push({ + file, + line: i + 1, + col: 1, + rule: pat.name, + message: `${pat.name}: ${pat.advice}`, + source: line.trim(), + }); + } + } + } + + // @mobile rule: skip _meta + const isMeta = file.startsWith(META_PREFIX); + if (!isMeta) { + if (!/@mobile\b/.test(text)) { + errors.push({ + file, + line: 1, + col: 1, + rule: "missing @mobile test", + message: + 'Spec must contain at least one test or describe tagged with "@mobile".', + source: "", + }); + } + } + + return errors; +} + +function main() { + const files = walk(SPECS_DIR); + const allErrors = []; + for (const f of files) { + allErrors.push(...lintFile(f)); + } + + if (allErrors.length === 0) { + console.log(`e2e:lint: ${files.length} spec files OK`); + process.exit(0); + } + + for (const err of allErrors) { + const rel = relative(process.cwd(), err.file); + console.error(`${rel}:${err.line}:${err.col} ${err.rule}`); + console.error(` ${err.message}`); + if (err.source) console.error(` > ${err.source}`); + } + console.error( + `\ne2e:lint: ${allErrors.length} error${allErrors.length === 1 ? "" : "s"} in ${files.length} files`, + ); + process.exit(1); +} + +main(); diff --git a/web/e2e/specs/_meta/error-collector.spec.ts b/web/e2e/specs/_meta/error-collector.spec.ts new file mode 100644 index 0000000000..7a888d4b22 --- /dev/null +++ b/web/e2e/specs/_meta/error-collector.spec.ts @@ -0,0 +1,112 @@ +/** + * Self-tests for the error collector fixture itself. + * + * These guard against future regressions in the safety net. Each test + * deliberately triggers (or avoids triggering) an error to verify the + * collector behaves correctly. Tests that expect to fail use the + * `expectedErrors` fixture parameter to allowlist their own errors. + */ + +import { test, expect } from "../../fixtures/frigate-test"; + +// test.use applies to a whole describe block in Playwright, so each test +// that needs a custom allowlist gets its own describe. + +test.describe("Error Collector — clean @meta", () => { + test("clean page passes", async ({ frigateApp }) => { + await frigateApp.goto("/"); + // No errors triggered. The fixture teardown should not throw. + }); +}); + +test.describe("Error Collector — unallowlisted console error fails @meta", () => { + test("console.error fails the test when not allowlisted", async ({ + page, + frigateApp, + }) => { + test.skip( + process.env.E2E_STRICT_ERRORS !== "1", + "Requires E2E_STRICT_ERRORS=1 to assert failure", + ); + test.fail(); // We expect the fixture teardown to throw + await frigateApp.goto("/"); + await page.evaluate(() => { + // eslint-disable-next-line no-console + console.error("UNEXPECTED_DELIBERATE_TEST_ERROR_xyz123"); + }); + }); +}); + +test.describe("Error Collector — allowlisted console error passes @meta", () => { + test.use({ expectedErrors: [/ALLOWED_DELIBERATE_TEST_ERROR_xyz123/] }); + + test("console.error is silenced when allowlisted via expectedErrors", async ({ + page, + frigateApp, + }) => { + await frigateApp.goto("/"); + await page.evaluate(() => { + // eslint-disable-next-line no-console + console.error("ALLOWED_DELIBERATE_TEST_ERROR_xyz123"); + }); + }); +}); + +test.describe("Error Collector — uncaught pageerror fails @meta", () => { + test("uncaught pageerror fails the test", async ({ page, frigateApp }) => { + test.skip( + process.env.E2E_STRICT_ERRORS !== "1", + "Requires E2E_STRICT_ERRORS=1 to assert failure", + ); + test.fail(); + await frigateApp.goto("/"); + await page.evaluate(() => { + setTimeout(() => { + throw new Error("UNCAUGHT_DELIBERATE_TEST_ERROR_xyz789"); + }, 0); + }); + // Wait a frame to let the throw propagate before fixture teardown. + // The marker below silences the e2e:lint banned-pattern check on this line. + await page.waitForTimeout(100); // e2e-lint-allow: deliberate; need to await async throw + }); +}); + +test.describe("Error Collector — 5xx fails @meta", () => { + test("same-origin 5xx response fails the test", async ({ + page, + frigateApp, + }) => { + test.skip( + process.env.E2E_STRICT_ERRORS !== "1", + "Requires E2E_STRICT_ERRORS=1 to assert failure", + ); + test.fail(); + await page.route("**/api/version", (route) => + route.fulfill({ status: 500, body: "boom" }), + ); + await frigateApp.goto("/"); + await page.evaluate(() => fetch("/api/version").catch(() => {})); + // Give the response listener a microtask to fire + await expect.poll(async () => true).toBe(true); + }); +}); + +test.describe("Error Collector — allowlisted 5xx passes @meta", () => { + // Use a single alternation regex so test.use() receives a 1-element array. + // Playwright's isFixtureTuple() treats any [value, object] pair as a fixture + // tuple, so a 2-element array whose second item is a RegExp would be + // misinterpreted as [defaultValue, options]. Both the request collector + // error ("500 … /api/version") and the browser console error + // ("Failed to load resource … 500") are matched by the alternation below. + test.use({ + expectedErrors: [/500.*\/api\/version|Failed to load resource.*500/], + }); + + test("allowlisted 5xx passes", async ({ page, frigateApp }) => { + await page.route("**/api/version", (route) => + route.fulfill({ status: 500, body: "boom" }), + ); + await frigateApp.goto("/"); + await page.evaluate(() => fetch("/api/version").catch(() => {})); + }); +}); diff --git a/web/e2e/specs/_meta/mock-overrides.spec.ts b/web/e2e/specs/_meta/mock-overrides.spec.ts new file mode 100644 index 0000000000..f3c1ae3df7 --- /dev/null +++ b/web/e2e/specs/_meta/mock-overrides.spec.ts @@ -0,0 +1,73 @@ +/** + * Self-tests for the mock override helpers. Verifies each helper + * intercepts the matched URL and returns the expected payload/status. + */ + +import { test, expect } from "../../fixtures/frigate-test"; +import { mockEmpty, mockError, mockDelay } from "../../helpers/mock-overrides"; + +test.describe("Mock Overrides — empty @meta", () => { + test("mockEmpty returns []", async ({ page, frigateApp }) => { + await mockEmpty(page, "**/api/__meta_test__"); + await frigateApp.goto("/"); + const result = await page.evaluate(async () => { + const r = await fetch("/api/__meta_test__"); + return { status: r.status, body: await r.json() }; + }); + expect(result.status).toBe(200); + expect(result.body).toEqual([]); + }); +}); + +test.describe("Mock Overrides — error default @meta", () => { + // Match both the collected request error and the browser's console echo. + // Using a single alternation regex avoids Playwright's isFixtureTuple + // collision with multi-element RegExp arrays. + test.use({ + expectedErrors: [/500.*__meta_test__|Failed to load resource.*500/], + }); + + test("mockError returns 500 by default", async ({ page, frigateApp }) => { + await mockError(page, "**/api/__meta_test__"); + await frigateApp.goto("/"); + const status = await page.evaluate(async () => { + const r = await fetch("/api/__meta_test__"); + return r.status; + }); + expect(status).toBe(500); + }); +}); + +test.describe("Mock Overrides — error custom status @meta", () => { + // The browser emits a "Failed to load resource" console.error for 404s, + // which the error collector catches even though 404 is not a 5xx. + test.use({ + expectedErrors: [/Failed to load resource.*404|404.*__meta_test_404__/], + }); + + test("mockError accepts a custom status", async ({ page, frigateApp }) => { + await mockError(page, "**/api/__meta_test_404__", 404); + await frigateApp.goto("/"); + const status = await page.evaluate(async () => { + const r = await fetch("/api/__meta_test_404__"); + return r.status; + }); + expect(status).toBe(404); + }); +}); + +test.describe("Mock Overrides — delay @meta", () => { + test("mockDelay delays response by the requested ms", async ({ + page, + frigateApp, + }) => { + await mockDelay(page, "**/api/__meta_test_delay__", 300, ["delayed"]); + await frigateApp.goto("/"); + const elapsed = await page.evaluate(async () => { + const start = performance.now(); + await fetch("/api/__meta_test_delay__"); + return performance.now() - start; + }); + expect(elapsed).toBeGreaterThanOrEqual(250); + }); +}); diff --git a/web/e2e/specs/auth.spec.ts b/web/e2e/specs/auth.spec.ts new file mode 100644 index 0000000000..f0326a5d02 --- /dev/null +++ b/web/e2e/specs/auth.spec.ts @@ -0,0 +1,110 @@ +/** + * Auth and role tests -- HIGH tier. + * + * Admin access to /system, /config, /logs; viewer access denied + * markers (via i18n heading, not a data-testid we don't own); + * viewer nav restrictions; all-routes smoke. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { viewerProfile } from "../fixtures/mock-data/profile"; + +test.describe("Auth — admin access @high", () => { + test("admin /system renders general tab", async ({ frigateApp }) => { + await frigateApp.goto("/system"); + await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("admin /config renders Monaco editor", async ({ frigateApp }) => { + await frigateApp.goto("/config"); + await expect( + frigateApp.page + .locator(".monaco-editor, [data-keybinding-context]") + .first(), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("admin /logs renders frigate tab", async ({ frigateApp }) => { + await frigateApp.goto("/logs"); + await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe("Auth — viewer restrictions @high", () => { + for (const path of ["/system", "/config", "/logs"]) { + test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto(path); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toBeVisible({ timeout: 10_000 }); + }); + } + + test("viewer sees cameras on /", async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/"); + await expect( + frigateApp.page.locator("[data-camera='front_door']"), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("viewer sees severity tabs on /review", async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/review"); + await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("viewer can access all non-admin routes without AccessDenied", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + const routes = ["/", "/review", "/explore", "/export", "/settings"]; + for (const route of routes) { + await frigateApp.page.goto(route); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toHaveCount(0); + } + }); +}); + +test.describe("Auth — viewer nav restrictions (desktop) @high", () => { + test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop"); + + test("viewer sidebar hides admin routes", async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/"); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + for (const href of ["/system", "/config", "/logs"]) { + await expect( + frigateApp.page.locator(`aside a[href='${href}']`), + ).toHaveCount(0); + } + }); +}); + +test.describe("Auth — all routes smoke @high @mobile", () => { + test("every common route renders #pageRoot", async ({ frigateApp }) => { + for (const route of ["/", "/review", "/explore", "/export", "/settings"]) { + await frigateApp.goto(route); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + } + }); +}); diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts new file mode 100644 index 0000000000..cab49828ab --- /dev/null +++ b/web/e2e/specs/chat.spec.ts @@ -0,0 +1,358 @@ +/** + * Chat page tests -- MEDIUM tier. + * + * Starting state, NDJSON streaming contract (not SSE), assistant + * bubble grows as chunks arrive, error path, and mobile viewport. + */ + +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; + +/** + * Install a window.fetch override on the page so that POSTs to + * chat/completion resolve with a real ReadableStream that emits the + * given chunks over time. This is the only way to validate + * chunk-by-chunk rendering through Playwright — page.route() does not + * support streaming responses. + * + * Must be called BEFORE frigateApp.goto(). The override also exposes + * `__chatRequests` on window so tests can assert the outgoing body. + */ +async function installChatStreamOverride( + app: FrigateApp, + chunks: Array>, + opts: { chunkDelayMs?: number; status?: number } = {}, +) { + const { chunkDelayMs = 40, status = 200 } = opts; + await app.page.addInitScript( + ({ chunks, chunkDelayMs, status }) => { + (window as unknown as { __chatRequests: unknown[] }).__chatRequests = []; + const origFetch = window.fetch; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes("chat/completion")) { + const body = + init?.body instanceof String || typeof init?.body === "string" + ? JSON.parse(init!.body as string) + : null; + ( + window as unknown as { __chatRequests: unknown[] } + ).__chatRequests.push({ url, body }); + if (status !== 200) { + return new Response(JSON.stringify({ error: "boom" }), { + status, + }); + } + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + await new Promise((r) => setTimeout(r, chunkDelayMs)); + controller.enqueue( + encoder.encode(JSON.stringify(chunk) + "\n"), + ); + } + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + } + return origFetch.call(window, input as RequestInfo, init); + }; + }, + { chunks, chunkDelayMs, status }, + ); +} + +test.describe("Chat — starting state @medium", () => { + test("empty message list renders ChatStartingState with title and input", async ({ + frigateApp, + }) => { + await frigateApp.goto("/chat"); + await expect( + frigateApp.page.getByRole("heading", { level: 1 }), + ).toBeVisible({ timeout: 10_000 }); + await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible(); + // Four quick-reply buttons from starting_requests.* + const quickReplies = frigateApp.page.locator( + "button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')", + ); + await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — streaming @medium", () => { + test("submission POSTs to chat/completion with stream: true", async ({ + frigateApp, + }) => { + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: "Hel" }, + { type: "content", delta: "lo" }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello chat" }, + { role: "assistant", content: "Hello" }, + ], + }, + { type: "done" }, + ]); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("hello chat"); + await input.press("Enter"); + + await expect + .poll( + async () => + frigateApp.page.evaluate( + () => + (window as unknown as { __chatRequests: unknown[] }) + .__chatRequests?.length ?? 0, + ), + { timeout: 5_000 }, + ) + .toBeGreaterThan(0); + + const request = await frigateApp.page.evaluate( + () => + ( + window as unknown as { + __chatRequests: Array<{ + url: string; + body: { stream: boolean; messages: Array<{ content: string }> }; + }>; + } + ).__chatRequests[0], + ); + expect(request.body.stream).toBe(true); + expect( + request.body.messages[request.body.messages.length - 1].content, + ).toBe("hello chat"); + }); + + test("NDJSON content chunks accumulate in the assistant bubble", async ({ + frigateApp, + }) => { + await installChatStreamOverride( + frigateApp, + [ + { type: "content", delta: "Hel" }, + { type: "content", delta: "lo, " }, + { type: "content", delta: "world!" }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "greet me" }, + { role: "assistant", content: "Hello, world!" }, + ], + }, + { type: "done" }, + ], + { chunkDelayMs: 50 }, + ); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("greet me"); + await input.press("Enter"); + + await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("tool calls in the chain render a ToolCallsGroup", async ({ + frigateApp, + }) => { + const toolTurn = [ + { role: "system", content: "sys" }, + { role: "user", content: "find people" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "search_objects", + arguments: '{"label":"person"}', + }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "[]" }, + ]; + await installChatStreamOverride(frigateApp, [ + { type: "messages", messages: toolTurn }, + { type: "content", delta: "Searching for people." }, + { + type: "messages", + messages: [ + ...toolTurn, + { role: "assistant", content: "Searching for people." }, + ], + }, + { type: "done" }, + ]); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("find people"); + await input.press("Enter"); + + // ToolCallsGroup normalizes "search_objects" → "Search Objects" via + // normalizeName(). Match the rendered display label instead. + await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.getByText(/searching for people/i), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — stop @medium", () => { + test("Stop button aborts an in-flight stream and freezes the partial message", async ({ + frigateApp, + }) => { + // A long chunk sequence with big delays gives us time to hit Stop. + await installChatStreamOverride( + frigateApp, + [ + { type: "content", delta: "First chunk. " }, + { type: "content", delta: "Second chunk. " }, + { type: "content", delta: "Third chunk. " }, + ], + { chunkDelayMs: 300 }, + ); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("slow response please"); + await input.press("Enter"); + + // Wait for the first chunk to render + await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({ + timeout: 10_000, + }); + + // The Stop button is a destructive rounded button shown while isLoading. + // It contains only an FaStop SVG icon (no visible text). Find it by the + // destructive variant class or fall back to aria-label. + const stopBtn = frigateApp.page + .locator("button.bg-destructive, button[class*='destructive']") + .first(); + await stopBtn.click({ timeout: 3_000 }).catch(async () => { + await frigateApp.page + .getByRole("button", { name: /stop|cancel/i }) + .first() + .click(); + }); + + // Third chunk should never appear. + await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0); + }); +}); + +test.describe("Chat — error @medium", () => { + test("non-OK response renders an error banner", async ({ frigateApp }) => { + await installChatStreamOverride(frigateApp, [], { status: 500 }); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("trigger error"); + await input.press("Enter"); + // The error banner is a role="alert" paragraph; target by role so we + // don't collide with the user-message bubble that contains "trigger + // error" (which would match /error/ in strict mode). + await expect( + frigateApp.page.getByRole("alert").filter({ + hasText: /boom|something went wrong/i, + }), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — attachment chip @medium", () => { + test("attaching an event renders a ChatAttachmentChip", async ({ + frigateApp, + }) => { + // The chat starts with an empty message list (ChatStartingState). + // After sending a message, ChatEntry with the paperclip button appears. + // We use the stream override so the first message completes quickly. + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: "Done." }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "Done." }, + ], + }, + { type: "done" }, + ]); + await frigateApp.goto("/chat"); + + // Send a first message to transition out of ChatStartingState so the + // full ChatEntry (with the paperclip) is visible. + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("hello"); + await input.press("Enter"); + // Wait for the assistant response to complete so isLoading becomes false + // and the paperclip button is re-enabled. + await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({ + timeout: 10_000, + }); + + // The paperclip button has aria-label from t("attachment_picker_placeholder") + // = "Attach an event". + const paperclip = frigateApp.page + .getByRole("button", { name: /attach an event/i }) + .first(); + await expect(paperclip).toBeVisible({ timeout: 5_000 }); + await paperclip.click(); + + // The popover shows a paste input with placeholder "Or paste event ID". + const idInput = frigateApp.page + .locator('input[placeholder*="event" i], input[aria-label*="attach" i]') + .first(); + await expect(idInput).toBeVisible({ timeout: 3_000 }); + await idInput.fill("test-event-1"); + await frigateApp.page + .getByRole("button", { name: /^attach$/i }) + .first() + .click(); + + // The ChatAttachmentChip renders in the composer area. It shows an + // activity indicator while loading event data (event_ids API not mocked), + // so assert on the chip container being present in the composer. + await expect( + frigateApp.page.locator( + "[class*='inline-flex'][class*='rounded-lg'][class*='border']", + ), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("chat input is focusable at mobile viewport", async ({ frigateApp }) => { + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.focus(); + await expect(input).toBeFocused(); + }); +}); diff --git a/web/e2e/specs/classification.spec.ts b/web/e2e/specs/classification.spec.ts new file mode 100644 index 0000000000..83a33a815d --- /dev/null +++ b/web/e2e/specs/classification.spec.ts @@ -0,0 +1,228 @@ +/** + * Classification page tests -- MEDIUM tier. + * + * Model list driven by config.classification.custom + per-model + * dataset fetches. Admin-only access. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { viewerProfile } from "../fixtures/mock-data/profile"; + +const CUSTOM_MODELS = { + object_classifier: { + name: "object_classifier", + object_config: { objects: ["person"], classification_type: "sub_label" }, + }, + state_classifier: { + name: "state_classifier", + state_config: { cameras: { front_door: { crop: [0, 0, 1, 1] } } }, + }, +}; + +async function installDatasetRoute( + app: { page: import("@playwright/test").Page }, + name: string, + body: Record = { categories: {} }, +) { + await app.page.route( + new RegExp(`/api/classification/${name}/dataset`), + (route) => route.fulfill({ json: body }), + ); +} + +async function installTrainRoute( + app: { page: import("@playwright/test").Page }, + name: string, +) { + await app.page.route( + new RegExp(`/api/classification/${name}/train`), + (route) => route.fulfill({ json: [] }), + ); +} + +test.describe("Classification — model list @medium", () => { + test("custom models render by name", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("empty custom map renders without crash", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: {} } }, + }); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("toggling to states view switches the rendered card set", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await frigateApp.goto("/classification"); + // Objects is default — object_classifier visible, state_classifier hidden. + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); + await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0); + + // Click the "states" toggle. Radix ToggleGroup type="single" uses role="radio". + const statesToggle = frigateApp.page + .getByRole("radio", { name: /state/i }) + .first(); + await expect(statesToggle).toBeVisible({ timeout: 5_000 }); + await statesToggle.click(); + + await expect(frigateApp.page.getByText("state_classifier")).toBeVisible({ + timeout: 5_000, + }); + await expect(frigateApp.page.getByText("object_classifier")).toHaveCount(0); + }); +}); + +test.describe("Classification — model detail navigation @medium", () => { + test("clicking a model card opens ModelTrainingView", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await installTrainRoute(frigateApp, "object_classifier"); + await frigateApp.goto("/classification"); + + const objectCard = frigateApp.page.getByText("object_classifier").first(); + await expect(objectCard).toBeVisible({ timeout: 10_000 }); + await objectCard.click(); + + // ModelTrainingView renders a Back button (aria-label "Back"). + // useOverlayState stores the selected model in window.history.state + // (not the URL), so we verify the state transition via the DOM. + await expect( + frigateApp.page.getByRole("button", { name: /back/i }), + ).toBeVisible({ timeout: 5_000 }); + + // The model grid is no longer shown; state_classifier card is gone. + await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0); + }); +}); + +test.describe("Classification — delete model (desktop) @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Delete action menu is desktop-focused", + ); + + test("deleting a model fires DELETE + PUT /config/set", async ({ + frigateApp, + }) => { + let deleteCalled = false; + let configSetCalled = false; + + // installDefaults must run first because Playwright matches routes in + // LIFO order — routes registered after installDefaults take precedence + // over the generic catch-all registered inside it. + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + + // Register spy routes after installDefaults so they win over the catch-all. + await frigateApp.page.route( + /\/api\/classification\/object_classifier$/, + async (route) => { + if (route.request().method() === "DELETE") { + deleteCalled = true; + await route.fulfill({ json: { success: true } }); + return; + } + return route.fallback(); + }, + ); + await frigateApp.page.route("**/api/config/set", async (route) => { + if (route.request().method() === "PUT") configSetCalled = true; + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); + + // The card-level actions menu (FiMoreVertical three-dot icon) is a + // DropdownMenuTrigger with asChild on a BlurredIconButton div. + // Radix forwards aria-haspopup="menu" to the child element. + // Scope the selector to the model card grid to avoid hitting the + // settings sidebar trigger. + const cardGrid = frigateApp.page.locator(".grid.auto-rows-max"); + await expect(cardGrid).toBeVisible({ timeout: 5_000 }); + const trigger = cardGrid.locator('[aria-haspopup="menu"]').first(); + await expect(trigger).toBeVisible({ timeout: 5_000 }); + await trigger.click(); + const deleteItem = frigateApp.page + .getByRole("menuitem", { name: /delete/i }) + .first(); + await expect(deleteItem).toBeVisible({ timeout: 5_000 }); + await deleteItem.click(); + + // Confirm the AlertDialog. + const alert = frigateApp.page.getByRole("alertdialog"); + await expect(alert).toBeVisible({ timeout: 5_000 }); + await alert + .getByRole("button", { name: /delete|confirm/i }) + .first() + .click(); + + await expect.poll(() => deleteCalled, { timeout: 5_000 }).toBe(true); + await expect.poll(() => configSetCalled, { timeout: 5_000 }).toBe(true); + }); +}); + +test.describe("Classification — admin only @medium", () => { + test("viewer navigating to /classification is redirected to access-denied", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/classification"); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect(frigateApp.page).toHaveURL(/\/unauthorized/, { + timeout: 10_000, + }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Classification — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("page renders at mobile viewport", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/web/e2e/specs/clone-camera.spec.ts b/web/e2e/specs/clone-camera.spec.ts new file mode 100644 index 0000000000..1c75e71a3d --- /dev/null +++ b/web/e2e/specs/clone-camera.spec.ts @@ -0,0 +1,181 @@ +/** + * Camera clone dialog E2E tests. + * + * Covers the design invariants that don't depend on per-camera resolution + * differences in the mock fixture: + * 1. Dialog opens from the "Clone settings" button below Add/Delete. + * 2. A source camera must be chosen inside the dialog before cloning. + * 3. "Stream URLs and roles" is forced on and disabled for new-camera target. + * 4. Cloning to a new camera issues a single add PUT and shows a restart prompt. + * 5. The existing-camera target selects multiple destinations via a switch + * popover (with an "All cameras" toggle and source exclusion); the closed + * trigger summarizes the selection by name or as "All cameras". + * + * The spatial-mismatch warning path is exercised in unit-level review and via + * manual QA — the shared mock fixture ships every camera at 1280×720. The + * existing-camera PUT fan-out is likewise not asserted here: the mock cameras + * are identical apart from stream URLs (which existing-camera clones never + * copy) and the schema mock is empty, so a clone onto them produces no diff + * and no PUT. That path is covered by unit-level review and manual QA. + */ + +import { test, expect } from "../fixtures/frigate-test"; + +async function openCloneDialog(frigateApp: { + page: import("@playwright/test").Page; +}) { + await frigateApp.page + .getByRole("button", { name: /^Clone settings$/i }) + .click(); + await expect(frigateApp.page.getByRole("dialog")).toBeVisible(); +} + +async function selectSource( + frigateApp: { page: import("@playwright/test").Page }, + source: string, +) { + await frigateApp.page.getByRole("dialog").getByRole("combobox").click(); + await frigateApp.page + .getByRole("option", { name: source, exact: true }) + .click(); +} + +test.describe("Camera clone dialog @medium @mobile", () => { + test.beforeEach(async ({ frigateApp }) => { + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + }); + + test("opens the dialog from the Clone settings button", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + + await expect( + frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i), + ).toBeVisible(); + + // The Clone button is disabled until a source (and target) is chosen. + await expect( + frigateApp.page.getByRole("button", { name: /^Clone$/i }), + ).toBeDisabled(); + }); + + test("forces Stream URLs and roles on for new-camera target", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + // The "New camera" radio is selected by default; the Streams group renders + // the ffmpeg_live checkbox as forced-checked and disabled. + const streamsLabel = frigateApp.page + .locator("label") + .filter({ hasText: /Stream URLs and roles/i }); + await expect(streamsLabel).toBeVisible(); + + const streamsCheckbox = streamsLabel.getByRole("checkbox"); + await expect(streamsCheckbox).toBeChecked(); + await expect(streamsCheckbox).toBeDisabled(); + }); + + test("issues a single add PUT and shows restart toast for new-camera target", async ({ + frigateApp, + }) => { + const requests: { body: unknown }[] = []; + + await frigateApp.page.route("**/api/config/set", async (route) => { + const body = route.request().postDataJSON(); + requests.push({ body }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, require_restart: false }), + }); + }); + + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + const nameInput = frigateApp.page.getByPlaceholder( + /e\.g\., back_door or Back Door/i, + ); + await nameInput.fill("clone_target_one"); + + // With a source picked and a valid name, changeCount > 0 enables Clone. + await expect( + frigateApp.page.getByRole("button", { name: /^Clone$/i }), + ).toBeEnabled({ timeout: 5_000 }); + + await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click(); + + // New-camera clones bundle into a single atomic add PUT (avoids + // per-section validation ordering issues). + await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1); + + const firstBody = requests[0].body as { + requires_restart?: number; + update_topic?: string; + }; + expect(firstBody.update_topic).toMatch( + /config\/cameras\/clone_target_one\/add/, + ); + expect(firstBody.requires_restart).toBe(1); + + // The toast offers a Restart action because new-camera always needs restart. + // .first() avoids strict-mode rejection when both the toast action and the + // RestartDialog trigger render concurrently. + await expect( + frigateApp.page.getByRole("button", { name: /Restart/i }).first(), + ).toBeVisible({ timeout: 8_000 }); + }); + + test("selects multiple existing destination cameras via a switch popover", async ({ + frigateApp, + }) => { + await openCloneDialog(frigateApp); + await selectSource(frigateApp, "Front Door"); + + await frigateApp.page + .getByRole("radio", { name: /Existing cameras/i }) + .click(); + + const dialog = frigateApp.page.getByRole("dialog"); + + // The destination trigger starts with the empty-selection placeholder. + await dialog + .getByRole("button", { name: /Select at least one camera/i }) + .click(); + + // The chosen source is excluded from the destination switch list. + await expect( + dialog.getByRole("switch", { name: /Backyard/i }), + ).toBeVisible(); + await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible(); + await expect( + dialog.getByRole("switch", { name: /^Front Door$/i }), + ).toHaveCount(0); + + // Selecting a single camera summarizes by name once the popover closes. + await dialog.getByRole("switch", { name: /Backyard/i }).click(); + await frigateApp.page.keyboard.press("Escape"); + await expect( + dialog.getByRole("button", { name: /^Backyard$/i }), + ).toBeVisible(); + + // Reopen and select everything; the trigger collapses to "All cameras". + await dialog.getByRole("button", { name: /^Backyard$/i }).click(); + await dialog.getByRole("switch", { name: /^All cameras$/i }).click(); + await frigateApp.page.keyboard.press("Escape"); + await expect( + dialog.getByRole("button", { name: /^All cameras$/i }), + ).toBeVisible(); + }); +}); diff --git a/web/e2e/specs/config-editor.spec.ts b/web/e2e/specs/config-editor.spec.ts new file mode 100644 index 0000000000..2b51a93636 --- /dev/null +++ b/web/e2e/specs/config-editor.spec.ts @@ -0,0 +1,276 @@ +/** + * Config Editor tests -- MEDIUM tier. + * + * Monaco load + value, Save (config/save?save_option=saveonly), + * Save error path, Save and Restart (WS frame via useRestart), + * Copy (clipboard), schema markers. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames"; +import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; +import { + getMonacoVisibleText, + replaceMonacoValue, + waitForErrorMarker, +} from "../helpers/monaco"; + +const SAMPLE_CONFIG = + "mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n"; + +async function installSaveRoute( + app: { page: import("@playwright/test").Page }, + status: number, + body: Record, +): Promise<{ + capturedUrl: () => string | null; + capturedBody: () => string | null; +}> { + let lastUrl: string | null = null; + let lastBody: string | null = null; + await app.page.route("**/api/config/save**", async (route) => { + lastUrl = route.request().url(); + lastBody = route.request().postData(); + await route.fulfill({ status, json: body }); + }); + return { + capturedUrl: () => lastUrl, + capturedBody: () => lastBody, + }; +} + +test.describe("Config Editor — Monaco @medium", () => { + test("editor loads with mocked configRaw content", async ({ frigateApp }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + // Assert via DOM-rendered visible text (Monaco virtualizes — works + // for short configs which covers our mocked content). + await expect + .poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 }) + .toContain("front_door"); + }); +}); + +test.describe("Config Editor — Save @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Save button copy is desktop-visible (hidden md:block)", + ); + + test("clicking Save Only POSTs config/save?save_option=saveonly", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + const capture = await installSaveRoute(frigateApp, 200, { + message: "Config saved", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Save Only").click(); + await expect + .poll(() => capture.capturedUrl(), { timeout: 5_000 }) + .toMatch(/config\/save\?save_option=saveonly/); + // Body is the raw YAML as text/plain + await expect + .poll(() => capture.capturedBody(), { timeout: 5_000 }) + .toContain("front_door"); + }); + + test("Save error shows the server message in the error area", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 400, { + message: "Invalid field `cameras.front_door`", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Save Only").click(); + await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe("Config Editor — Save and Restart @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Save and Restart button copy is desktop-visible", + ); + + test("Save and Restart opens dialog; confirm sends WS restart frame", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 200, { message: "Saved" }); + await installWsFrameCapture(frigateApp.page); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Save & Restart").click(); + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + await dialog.getByRole("button", { name: /restart/i }).click(); + await waitForWsFrame( + frigateApp.page, + (frame) => frame.includes('"restart"') || frame.includes("restart"), + { message: "useRestart should send a WS frame on the restart topic" }, + ); + }); + + test("cancelling the restart dialog leaves body interactive", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 200, { message: "Saved" }); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Save & Restart").click(); + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.getByRole("button", { name: /cancel/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + await expect( + frigateApp.page.locator(".monaco-editor").first(), + ).toBeVisible(); + }); +}); + +test.describe("Config Editor — Copy @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Copy button copy is desktop-visible", + ); + + test("Copy places the editor value in the clipboard", async ({ + frigateApp, + context, + }) => { + await grantClipboardPermissions(context); + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Copy Config").click(); + await expect + .poll(() => readClipboard(frigateApp.page), { timeout: 5_000 }) + .toContain("front_door"); + }); +}); + +test.describe("Config Editor — schema markers @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Schema validation assumes focused desktop editing", + ); + + test("invalid YAML renders at least one error marker in the DOM", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + // Replace editor contents with clearly invalid YAML via keyboard. + await replaceMonacoValue( + frigateApp.page, + "this is not: [yaml: and has {unbalanced", + ); + // Monaco debounces marker evaluation; the .squiggly-error decoration + // appears asynchronously in the .view-overlays layer. + await waitForErrorMarker(frigateApp.page); + }); +}); + +test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Keyboard save shortcut is desktop-only", + ); + + test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + const capture = await installSaveRoute(frigateApp, 200, { + message: "Saved", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + // Focus the editor so Monaco's keybinding receives the shortcut. + await frigateApp.page.locator(".monaco-editor").first().click(); + await frigateApp.page.keyboard.press("ControlOrMeta+s"); + + await expect + .poll(() => capture.capturedUrl(), { timeout: 5_000 }) + .toMatch(/config\/save\?save_option=saveonly/); + }); +}); + +test.describe("Config Editor — Safe Mode auto-validation @medium", () => { + test("safe-mode config auto-posts on mount and shows the inline error", async ({ + frigateApp, + }) => { + // Thread safe_mode: true through the config override, then stub + // config/save to return a validation error. The page's + // initialValidationRef effect runs on mount and POSTs + // config/save?save_option=saveonly with the raw config; the 400 + // surfaces through setError. + // installDefaults must come first so our specific route wins (LIFO). + await frigateApp.installDefaults({ + config: { safe_mode: true } as unknown as Record, + configRaw: "cameras:\n front_door:\n ffmpeg: {}\n", + }); + let autoSaveCalled = false; + await frigateApp.page.route("**/api/config/save**", async (route) => { + autoSaveCalled = true; + await route.fulfill({ + status: 400, + json: { message: "safe-mode validation failure" }, + }); + }); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true); + await expect( + frigateApp.page.getByText(/safe-mode validation failure/i), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Config Editor — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("editor renders at narrow viewport", async ({ frigateApp }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + }); +}); diff --git a/web/e2e/specs/explore.spec.ts b/web/e2e/specs/explore.spec.ts new file mode 100644 index 0000000000..442a6d8d88 --- /dev/null +++ b/web/e2e/specs/explore.spec.ts @@ -0,0 +1,333 @@ +/** + * Explore page tests -- HIGH tier. + * + * Search input, Enter submission, camera filter popover (desktop), + * event grid rendering with mocked events, mobile filter drawer. + * + * DEVIATION NOTES (from original plan): + * + * 1. Search input: InputWithTags is only rendered when + * config.semantic_search.enabled is true. Tests that exercise the search + * input override the config accordingly, using model:"genai" (not in the + * JINA_EMBEDDING_MODELS list) so the page skips local model-state checks + * and renders without waiting for model-download WS messages. + * + * 2. Filter buttons (Cameras, Labels, More Filters): SearchFilterGroup is + * only rendered when hasExistingSearch is true. Tests navigate with a URL + * param (?labels=person) to surface the filter bar. + * + * 3. Cameras button: accessible name is "Cameras Filter" (aria-label), not + * "All Cameras" (inner text). Use getByLabel("Cameras Filter"). + * + * 4. Labels: button accessible name is "Labels" (aria-label). With + * ?labels=person, the text shows "Person" rather than "All Labels". + * Use getByLabel("Labels"). + * + * 5. Sub-labels / Zones: These live inside the "More Filters" dialog + * (SearchFilterDialog), not as standalone top-level buttons. The Zones + * test opens "More Filters" and asserts zone content from config. + * + * 6. similarity_search_id URL param: This param does not exist in the app. + * The correct entrypoint for similarity search is + * ?search_type=similarity&event_id=. The test uses this URL and + * polls for the resulting API request. + */ + +import { test, expect } from "../fixtures/frigate-test"; + +// Semantic search config override used by multiple tests. Using model: +// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which +// skips local model-state checks and lets the page render without waiting for +// individual model download WS messages. The WS mocker returns a completed +// reindexState so !reindexState is false and the loading gate clears. +const SEMANTIC_SEARCH_CONFIG = { + semantic_search: { enabled: true, model: "genai" }, +} as const; + +// --------------------------------------------------------------------------- +// Search input (semantic_search must be enabled) +// --------------------------------------------------------------------------- + +test.describe("Explore — search @high", () => { + test("search input accepts text and clears", async ({ frigateApp }) => { + // Enable semantic search so InputWithTags renders. + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill("person"); + await expect(searchInput).toHaveValue("person"); + await searchInput.fill(""); + await expect(searchInput).toHaveValue(""); + }); + + test("Enter submission does not crash the page", async ({ frigateApp }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill("car in driveway"); + await searchInput.press("Enter"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Filter bar — desktop only +// Filter buttons appear once hasExistingSearch is true (URL params present). +// --------------------------------------------------------------------------- + +test.describe("Explore — filters (desktop) @high", () => { + test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers"); + + test("Cameras popover lists configured cameras", async ({ frigateApp }) => { + // Navigate with a labels filter param so the filter bar renders. + await frigateApp.goto("/explore?labels=person"); + // CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to + // match against the accessible name (not the inner "All Cameras" text). + const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first(); + await expect(camerasBtn).toBeVisible({ timeout: 10_000 }); + await camerasBtn.click(); + // DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper. + const popover = frigateApp.page.locator( + "[data-radix-popper-content-wrapper]", + ); + await expect(popover.first()).toBeVisible({ timeout: 3_000 }); + await expect(frigateApp.page.getByText("Front Door")).toBeVisible(); + }); + + test("Labels filter lists labels from config", async ({ frigateApp }) => { + // Navigate with an existing search so the filter bar renders. + await frigateApp.goto("/explore?labels=person"); + // GeneralFilterButton has aria-label="Labels". With ?labels=person the + // button text shows "Person" (the selected label), but the aria-label + // remains "Labels". + const labelsBtn = frigateApp.page.getByLabel("Labels").first(); + await expect(labelsBtn).toBeVisible({ timeout: 10_000 }); + await labelsBtn.click(); + // PlatformAwareDialog renders on desktop as a dropdown/popover overlay. + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + // "person" is already selected (it's in the URL); assert it appears in + // the overlay content. + await expect(overlay.first().getByText(/person/i)).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); + }); + + test("Sub-labels filter renders inside More Filters dialog", async ({ + frigateApp, + }) => { + // Sub-labels live inside SearchFilterDialog ("More Filters" button). + // With sub_labels mocked as [], the section still renders its heading. + await frigateApp.page.route("**/api/sub_labels**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.goto("/explore?labels=person"); + const moreBtn = frigateApp.page.getByLabel("More Filters").first(); + await expect(moreBtn).toBeVisible({ timeout: 10_000 }); + await moreBtn.click(); + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + // "Sub Labels" section heading always renders inside the dialog. + await expect( + frigateApp.page.getByText(/sub.?label/i).first(), + ).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); + }); + + test("Zones filter lists configured zones inside More Filters dialog", async ({ + frigateApp, + }) => { + // Override config to guarantee a known zone on front_door. + await frigateApp.installDefaults({ + config: { + cameras: { + front_door: { + zones: { + front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" }, + }, + }, + }, + }, + }); + await frigateApp.goto("/explore?labels=person"); + const moreBtn = frigateApp.page.getByLabel("More Filters").first(); + await expect(moreBtn).toBeVisible({ timeout: 10_000 }); + await moreBtn.click(); + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); + }); +}); + +// --------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------- + +test.describe("Explore — content @high", () => { + test("page renders with mock events", async ({ frigateApp }) => { + await frigateApp.goto("/explore"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.locator("#pageRoot button").first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("empty events renders without crash", async ({ frigateApp }) => { + await frigateApp.installDefaults({ events: [] }); + await frigateApp.goto("/explore"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("search fires a /api/events request with the query", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + const eventsRequests: string[] = []; + frigateApp.page.on("request", (req) => { + const url = req.url(); + if (/\/api\/events/.test(url)) eventsRequests.push(url); + }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + + const before = eventsRequests.length; + await searchInput.fill("person in driveway"); + await searchInput.press("Enter"); + await expect + .poll(() => eventsRequests.length > before, { timeout: 5_000 }) + .toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Similarity search URL param +// --------------------------------------------------------------------------- + +test.describe("Explore — similarity search (desktop) @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Similarity trigger is hover-based; desktop-focused", + ); + + test("URL similarity search params fetch events", async ({ frigateApp }) => { + const eventsRequests: string[] = []; + frigateApp.page.on("request", (req) => { + const url = req.url(); + if (/\/api\/events/.test(url)) eventsRequests.push(url); + }); + // The app uses search_type=similarity&event_id= (not + // similarity_search_id). This exercises the same similarity search code + // path as clicking "Find Similar" on a thumbnail. + // Use a valid event-id format (timestamp.fractional-alphanumeric). + await frigateApp.goto( + "/explore?search_type=similarity&event_id=1712412000.000000-abc123", + ); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + // Poll to allow any pending SWR fetch to complete and be captured. + await expect + .poll(() => eventsRequests.length, { timeout: 5_000 }) + .toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Mobile +// --------------------------------------------------------------------------- + +test.describe("Explore — mobile @high @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("search input is focusable at mobile viewport", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.focus(); + await expect(searchInput).toBeFocused(); + }); +}); + +// --------------------------------------------------------------------------- +// Frigate+ submission — desktop only +// The detail dialog's previous/next arrows only render on desktop. +// --------------------------------------------------------------------------- + +test.describe("Explore — Frigate+ submission (desktop) @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Detail dialog navigation arrows are desktop-only", + ); + + test("in-flight submission does not mark the next tracked object as submitted", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ config: { plus: { enabled: true } } }); + const page = frigateApp.page; + + // Hold the submission open so it is still in flight while the user moves + // on to the next tracked object. + let releaseSubmission: () => void = () => {}; + const submissionHeld = new Promise((resolve) => { + releaseSubmission = resolve; + }); + let submissions = 0; + await page.route("**/api/events/*/plus", async (route) => { + submissions += 1; + await submissionHeld; + await route.fulfill({ json: { success: true } }); + }); + + await frigateApp.goto("/explore?labels=person"); + + const firstResult = page.locator("[data-start]").first(); + await expect(firstResult).toBeVisible({ timeout: 10_000 }); + await firstResult.click(); + + // The label being confirmed is rendered in a tag inside the + // "Is this object a