Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub f3b7c642e8 Update cuda-python requirement in /docker/tensorrt
Updates the requirements on [cuda-python](https://github.com/NVIDIA/cuda-python) to permit the latest version.
- [Release notes](https://github.com/NVIDIA/cuda-python/releases)
- [Commits](https://github.com/NVIDIA/cuda-python/compare/v12.6.0...v13.3.0)

---
updated-dependencies:
- dependency-name: cuda-python
  dependency-version: 13.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 13:36:16 +00:00
BanandGitHub 88f944fe81 feat: add Traditional Chinese (zh-Hant) language option (#23322)
The zh-Hant translations are synced from Weblate (98% complete) but the
locale was never registered in the language selector, so users could not
select it. Register zh-Hant in supportedLanguageKeys, add its display
label, and map it to the zh-TW date-fns locale.
2026-05-27 08:04:41 -05:00
Josh HawkinsandGitHub 39a3667f39 add motion review docs (#23307)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-05-25 07:04:45 -06:00
Josh HawkinsandGitHub 2ed70bd693 Profiles fixes (#23306)
* add prop to disable id field

* disable id field when editing profile mask/zone

also, disable if the zone name already exists in required_zones or the base config is being edited and the id already exists on a profile

* add backend validation to reject profile-omly masks/zones

* add tests

* update docs

* tweak
2026-05-25 07:04:00 -06:00
Josh HawkinsandGitHub 90248ef243 remove camera name badge (#23305) 2026-05-25 07:02:57 -06:00
Josh HawkinsandGitHub 7e0e0635b8 UI tweaks (#23304)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* restructure camera enable/disable pane

* remove obsolete camera edit form

* change terminology to off/on instead of disabled/enabled

* docs

* move menu options and add current camera name badge

* docs

* tweaks
2026-05-24 14:59:56 -06:00
ec44398b1c Miscellaneous fixes (#23295)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* filter motion review by allowed cameras

* filter alertCameras by allowed cameras so the recent alerts query for restricted roles doesn't reference cameras they can't access

* skip data streams in chapter exports to avoid ffmpeg segfault

* formatting

* restrict debug replay UI entry points to admin users

* Adjust default iGPU name when it can't be found

* Fix when model tries to request an invalid camera

* Improve prompt

* add collapsible main nav items in settings

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-24 06:48:52 -06:00
Josh HawkinsandGitHub d556ff8df2 Tweaks (#23292)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add review padding to explore debug replay api calls

* add semantic search model size widget

disables model_size select with n/a text when an embeddings genai provider is selected

* regenerate zone contours and per-zone filter masks on detect resolution change

* treat null as a clear sentinel in buildOverrides so nullable field edits don't snap back

* extract replay config sheet to new component

* add validation and messages for detect settings
2026-05-22 14:41:07 -05:00
Josh HawkinsandGitHub 3a09d01bbe Debug replay resolution (#23287)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* unlink shm frames when camera is removed

* drop stale shm cache refs when cached segment is too small for requested shape

* skip new-object frame cache write when current_frame is unavailable

* add tests

* use setdefault when adding a new camera

Multiple subscribers in the same process each unpickle the ZMQ payload independently and would otherwise write divergent Python objects to the shared cameras dict — leaving long-lived references (e.g. CameraState.camera_config) pointing at a copy that subsequent in-place mutations like apply_section_update can never reach. setdefault collapses everyone onto the first writer's object so attribute mutations propagate to every consumer in this process.

* rebuild ffmpeg commands on detect update

Rebuild the cached ffmpeg cmd so the next process spawn picks up new resolution/fps. Running cameras keep their existing cmd (ffmpeg_cmds is only read at process startup); replay cameras are recycled by CameraMaintainer to pick up the rebuilt cmd

* drop stale shm cache refs when cached segment size doesn't match requested shape

The cached SharedMemoryFrameManager reference can point at a segment whose
size no longer matches the requested shape — the segment was unlinked and
recreated at a different size in a camera add/remove cycle. This catches
both a resolution increase (cached too small) and a decrease (cached too
large, pointing at an orphaned inode whose stale bytes would otherwise be
misinterpreted at the new shape, producing distorted/miscolored YUV frames).

After reopening, if the OS-level segment still doesn't match the requested
shape we're in a transient mid-recreate state — either the maintainer
hasn't allocated the new segment yet (size too small) or we opened a
pre-recycle segment (size too big). Either way, skip the frame and don't
cache the mismatched ref.

* recycle replay camera on detect update

* discard tracked-object state when detect resolution changes mid-session

When detect resolution changes mid-session every tracked object we hold
was localized against the old pixel grid. Their boxes no longer
correspond to anything in the new frame, and the `end` callback that
fires when their IDs disappear from the new detect process's detections
publishes those stale boxes to consumers (LPR, snapshot crop) that slice
the new frame and crash on empty arrays. Drop the tracked-object state
on a shape change so no stale boxes ever cross the CameraState boundary.

Belt-and-suspenders: also drop any incoming batch whose boxes exceed the
current detect resolution. These are in-flight queue entries from the
pre-recycle detect process that beat the new detect process to the
queue; processing them would re-introduce stale-resolution tracked
objects we just dropped above. The per-camera detect process clamps
legitimate boxes to detect.width-1 / detect.height-1, so any coord
beyond that is unambiguously stale.

* rebuild motion and object filter masks on detect resolution change

Apply the detect update first so frame_shape reflects the new resolution
before we rebuild dependents.

Motion's rasterized_mask is sized to frame_shape at construction. When
detect resolution changes we must rebuild RuntimeMotionConfig so the
mask matches the new frame size; otherwise consumers like the LPR
processor and motion detector hit a shape mismatch when they index
frames with the stale mask.

Same story for per-object filter masks — rebuild RuntimeFilterConfig at
the new frame_shape so the merged global+per-object masks they hold
match what they'll be indexed against.

* republish motion and objects on in-memory detect resize

A detect resolution change also invalidates the rasterized masks on
motion and per-object filters. apply_section_update has rebuilt them at
the new frame_shape; publish them too so other processes replace their
old values.

* add test

* frontend

* add refresh topic for camera maintainer recycle action

The maintainer's recycle branch is doing an action (recycle the camera)
in response to a section-level signal. Introduce a
CameraConfigUpdateEnum.refresh case as an explicit action signal — the
maintainer subscribes to refresh instead of detect, parallel with add
and remove. Publishers fire refresh alongside detect when a recycle is
needed; section-level subscribers keep their existing topic.

Since no main-process subscriber listens for detect anymore, the
refresh handler calls recreate_ffmpeg_cmds() explicitly so the shared
CameraConfig's ffmpeg_cmds is rebuilt before the new subprocesses
spawn.

* factor stale-resolution state drop into a CameraState method
2026-05-22 08:39:52 -06:00
0bdf5002a0 Miscellaneous fixes (#23279)
* use monotonic clock for detector inference duration to prevent negative values from wall clock steps

* add ability to set camera's webui_url from camera management pane

* Gemini send thought signature

* Update docs

* copy face and lpr configs from source camera to replay camera

* add guard

* improve dummy camera docs

* remove version number

* fix stale field message after reverting a conditional form field

Routes field-level conditional messages through a dedicated React Context instead of merging them into uiSchema. RJSF's Form keeps state.uiSchema sticky across renders during processPendingChange (formData is updated, uiSchema is not), so a previously injected ui:messages array stays attached to a field even after the triggering condition flips back to false. Context propagation re-runs FieldTemplate directly on every provider value change, sidestepping that staleness.

* add semantic search field message to note that model_size is irrelevant when embeddings provider is selected

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-22 07:52:01 -06:00
Nicolas MowenandGitHub a4a592b4e6 Cleanup and fix mypy (#23283)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-05-21 14:38:38 -06:00
Nicolas MowenandGitHub 66a2417229 Support Dynamic Thinking Models (#23281)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
* Add ability to toggle thinking

* Disable thinking for descriptions automatically

* mypy

* Cleanup
2026-05-21 12:54:23 -05:00
Josh HawkinsandGitHub 555ef89800 Debug replay fixes (#23276)
* filter replay camera from camera selectors

* add face rec and lpr to replay configuration sheet

* add missing config topic subscriptions in embeddings maintainer

* pop replay camera from config object when stopping
2026-05-21 08:12:53 -06:00
Nicolas MowenandGitHub 01c82d6921 Improve language around prompt restrictions (#23274)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-05-20 20:38:00 -05:00
Josh HawkinsandGitHub 68e8afd35c Improve credential redaction handling (#23265)
* redact credentials in config endpoint with sentinel

* backend test

* frontend

* apply widget for credential fields

* i18n
2026-05-20 15:59:01 -06:00
Josh HawkinsandGitHub 5ef8b9b924 Debug replay fixes (#23270)
* ensure motion masks from source camera are copied to replay

* stop polling debug_replay/status after live_ready

* use vod for constructing replay clips
2026-05-20 16:37:02 -05:00
Sean KellyandGitHub a576ad5218 Refactor move_preview_frames function (#23264)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
Refactor move_preview_frames to simplify logic and improve error handling.
2026-05-20 10:52:47 -06:00
8ea46e7c6c Miscellaneous fixes (#23258)
* render orphaned filter entries as collapsibles instead of the Key/Value editor

* Symlink for various AI files

* change replay confg dialog to platform aware sheet

* change agents title

* fix test

* tweak collapsible

* remove camera ui section in settings

no point to having it anymore with profiles and camera management settings

* fix admin response cache leak to non-admin users via nginx proxy_cache

* add model fetcher endpoint for genai config ui

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-20 08:36:49 -06:00
03f4f76b72 Translated using Weblate (Norwegian Bokmål)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (127 of 127 strings)

Update translation files

Updated by "Squash Git commits" add-on in Weblate.

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (59 of 59 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (471 of 471 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (792 of 792 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (1122 of 1122 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: OverTheHillsAndFarAway <prosjektx@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/nb_NO/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
6ffb9f2c9e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1162 of 1162 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1150 of 1150 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.8% (1128 of 1141 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1122 of 1122 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (792 of 792 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (471 of 471 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (127 of 127 strings)

Co-authored-by: Edward Zhang <hsrzq@126.com>
Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hans/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
b470258d95 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (64 of 64 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (59 of 59 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1150 of 1150 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (473 of 473 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jamie HUANG <114514020@live.asia.edu.tw>
Co-authored-by: fascinate722 <fascinate722@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/zh_Hant/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
fac11286f5 Translated using Weblate (Urdu)
Currently translated at 4.0% (1 of 25 strings)

Translated using Weblate (Urdu)

Currently translated at 4.5% (1 of 22 strings)

Translated using Weblate (Urdu)

Currently translated at 0.1% (1 of 794 strings)

Translated using Weblate (Urdu)

Currently translated at 0.2% (1 of 473 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Muhammad Arsalan Siddiqui <mailofarsalan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ur/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ur/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/ur/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ur/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
2026-05-19 15:22:50 -05:00
50f7f11f0b Translated using Weblate (French)
Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (French)

Currently translated at 46.6% (21 of 45 strings)

Translated using Weblate (French)

Currently translated at 30.0% (12 of 40 strings)

Co-authored-by: Erwan Cogoluenhes <erwan.cogo@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Le Buzzy <bwinster2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/fr/
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-replay
2026-05-19 15:22:50 -05:00
f96127c264 Translated using Weblate (Spanish)
Currently translated at 100.0% (53 of 53 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1171 of 1171 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1150 of 1150 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1137 of 1137 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (59 of 59 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (64 of 64 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (40 of 40 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (471 of 471 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1122 of 1122 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (792 of 792 strings)

Translated using Weblate (Spanish)

Currently translated at 52.5% (31 of 59 strings)

Translated using Weblate (Spanish)

Currently translated at 99.4% (174 of 175 strings)

Translated using Weblate (Spanish)

Currently translated at 23.3% (110 of 471 strings)

Translated using Weblate (Spanish)

Currently translated at 68.8% (31 of 45 strings)

Translated using Weblate (Spanish)

Currently translated at 21.8% (173 of 792 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Spanish)

Currently translated at 62.3% (63 of 101 strings)

Translated using Weblate (Spanish)

Currently translated at 40.6% (35 of 86 strings)

Translated using Weblate (Spanish)

Currently translated at 80.0% (32 of 40 strings)

Translated using Weblate (Spanish)

Currently translated at 67.6% (759 of 1122 strings)

Translated using Weblate (Spanish)

Currently translated at 70.3% (45 of 64 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jjavin <javiernovoa@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/es/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
2ae415be6b Translated using Weblate (Nepali)
Currently translated at 29.7% (14 of 47 strings)

Translated using Weblate (Nepali)

Currently translated at 53.8% (14 of 26 strings)

Translated using Weblate (Nepali)

Currently translated at 16.2% (14 of 86 strings)

Translated using Weblate (Nepali)

Currently translated at 14.0% (14 of 100 strings)

Translated using Weblate (Nepali)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Nepali)

Currently translated at 23.7% (14 of 59 strings)

Translated using Weblate (Nepali)

Currently translated at 56.0% (14 of 25 strings)

Translated using Weblate (Nepali)

Currently translated at 1.1% (13 of 1122 strings)

Translated using Weblate (Nepali)

Currently translated at 32.5% (13 of 40 strings)

Translated using Weblate (Nepali)

Currently translated at 63.6% (14 of 22 strings)

Translated using Weblate (Nepali)

Currently translated at 11.6% (15 of 129 strings)

Translated using Weblate (Nepali)

Currently translated at 18.9% (14 of 74 strings)

Translated using Weblate (Nepali)

Currently translated at 24.1% (14 of 58 strings)

Translated using Weblate (Nepali)

Currently translated at 5.9% (14 of 237 strings)

Translated using Weblate (Nepali)

Currently translated at 3.1% (15 of 471 strings)

Translated using Weblate (Nepali)

Currently translated at 28.5% (14 of 49 strings)

Translated using Weblate (Nepali)

Currently translated at 11.0% (14 of 127 strings)

Translated using Weblate (Nepali)

Currently translated at 2.2% (18 of 792 strings)

Translated using Weblate (Nepali)

Currently translated at 9.6% (14 of 145 strings)

Translated using Weblate (Nepali)

Currently translated at 3.7% (19 of 501 strings)

Translated using Weblate (Nepali)

Currently translated at 20.3% (13 of 64 strings)

Translated using Weblate (Nepali)

Currently translated at 13.8% (14 of 101 strings)

Translated using Weblate (Nepali)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Nepali)

Currently translated at 31.1% (14 of 45 strings)

Translated using Weblate (Nepali)

Currently translated at 8.0% (14 of 175 strings)

Translated using Weblate (Nepali)

Currently translated at 100.0% (6 of 6 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: bijaydewan <bijaydewan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-configeditor/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-recording/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ne/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ne/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-configeditor
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-recording
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
59faa4e088 Translated using Weblate (Dutch)
Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Dutch)

Currently translated at 83.0% (49 of 59 strings)

Translated using Weblate (Dutch)

Currently translated at 83.9% (397 of 473 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (1150 of 1150 strings)

Translated using Weblate (Dutch)

Currently translated at 15.2% (72 of 473 strings)

Translated using Weblate (Dutch)

Currently translated at 30.0% (15 of 50 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Dutch)

Currently translated at 10.2% (81 of 794 strings)

Translated using Weblate (Dutch)

Currently translated at 59.3% (35 of 59 strings)

Translated using Weblate (Dutch)

Currently translated at 35.0% (14 of 40 strings)

Translated using Weblate (Dutch)

Currently translated at 23.7% (14 of 59 strings)

Translated using Weblate (Dutch)

Currently translated at 24.4% (11 of 45 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Dutch)

Currently translated at 63.4% (712 of 1122 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Dutch)

Currently translated at 11.8% (7 of 59 strings)

Translated using Weblate (Dutch)

Currently translated at 20.0% (8 of 40 strings)

Translated using Weblate (Dutch)

Currently translated at 8.8% (4 of 45 strings)

Translated using Weblate (Dutch)

Currently translated at 10.1% (80 of 792 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Dutch)

Currently translated at 93.7% (121 of 129 strings)

Co-authored-by: Bart Smeding <bartsmeding@gmail.com>
Co-authored-by: Björn Vanneste <info@nidhhoggr.net>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hosted Weblate user 151476 <marijndekker3@gmail.com>
Co-authored-by: bb61523 <brambini@gmail.com>
Co-authored-by: soosterwaal <sebastiaan@bg-engineering.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nl/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
3df7c22f4d Translated using Weblate (Italian)
Currently translated at 27.7% (220 of 794 strings)

Translated using Weblate (Italian)

Currently translated at 24.9% (118 of 473 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (64 of 64 strings)

Translated using Weblate (Italian)

Currently translated at 24.8% (197 of 794 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Italian)

Currently translated at 20.0% (95 of 473 strings)

Translated using Weblate (Italian)

Currently translated at 77.3% (882 of 1141 strings)

Co-authored-by: Gringo <ita.translations@tiscali.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/it/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
f4cbbe806d Translated using Weblate (Polish)
Currently translated at 91.9% (218 of 237 strings)

Translated using Weblate (Polish)

Currently translated at 63.5% (731 of 1150 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J P <jpoloczek24@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/pl/
Translation: Frigate NVR/common
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
cfb1420660 Translated using Weblate (Portuguese)
Currently translated at 100.0% (2 of 2 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-input/pt/
Translation: Frigate NVR/components-input
2026-05-19 15:22:50 -05:00
161f56b5d4 Translated using Weblate (Catalan)
Currently translated at 100.0% (53 of 53 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1171 of 1171 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1162 of 1162 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1151 of 1151 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1150 of 1150 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1137 of 1137 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1122 of 1122 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (127 of 127 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Gerard Ricart Castells <gerard.ricart@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
5ddf8bc1b0 Translated using Weblate (Romanian)
Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1129 of 1129 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (127 of 127 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
dc2c48f6d7 Translated using Weblate (Russian)
Currently translated at 92.0% (23 of 25 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (22 of 22 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Max Slotov <max@slotov.dev>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ru/
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
2026-05-19 15:22:50 -05:00
6e5d55ff64 Translated using Weblate (Estonian)
Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (127 of 127 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/et/
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
2026-05-19 15:22:50 -05:00
d439b09f90 Translated using Weblate (German)
Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (German)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (German)

Currently translated at 100.0% (1141 of 1141 strings)

Translated using Weblate (German)

Currently translated at 100.0% (237 of 237 strings)

Translated using Weblate (German)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (German)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (German)

Currently translated at 100.0% (50 of 50 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sebastian Sie <sebastian.neuplanitz@googlemail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
2026-05-19 15:22:50 -05:00
3f7768a48f Translated using Weblate (Thai)
Currently translated at 31.8% (7 of 22 strings)

Translated using Weblate (Thai)

Currently translated at 22.8% (40 of 175 strings)

Translated using Weblate (Thai)

Currently translated at 15.2% (9 of 59 strings)

Translated using Weblate (Thai)

Currently translated at 15.5% (7 of 45 strings)

Translated using Weblate (Thai)

Currently translated at 0.5% (4 of 794 strings)

Translated using Weblate (Thai)

Currently translated at 16.0% (4 of 25 strings)

Translated using Weblate (Thai)

Currently translated at 16.0% (8 of 50 strings)

Translated using Weblate (Thai)

Currently translated at 13.5% (8 of 59 strings)

Translated using Weblate (Thai)

Currently translated at 37.5% (24 of 64 strings)

Translated using Weblate (Thai)

Currently translated at 21.7% (38 of 175 strings)

Translated using Weblate (Thai)

Currently translated at 12.0% (3 of 25 strings)

Translated using Weblate (Thai)

Currently translated at 11.6% (10 of 86 strings)

Translated using Weblate (Thai)

Currently translated at 14.0% (7 of 50 strings)

Translated using Weblate (Thai)

Currently translated at 13.3% (6 of 45 strings)

Translated using Weblate (Thai)

Currently translated at 7.8% (90 of 1141 strings)

Translated using Weblate (Thai)

Currently translated at 0.3% (3 of 794 strings)

Translated using Weblate (Thai)

Currently translated at 4.6% (6 of 129 strings)

Translated using Weblate (Thai)

Currently translated at 1.2% (6 of 473 strings)

Translated using Weblate (Thai)

Currently translated at 77.6% (184 of 237 strings)

Translated using Weblate (Thai)

Currently translated at 9.6% (14 of 145 strings)

Translated using Weblate (Thai)

Currently translated at 27.2% (6 of 22 strings)

Translated using Weblate (Thai)

Currently translated at 8.0% (2 of 25 strings)

Translated using Weblate (Thai)

Currently translated at 11.8% (7 of 59 strings)

Translated using Weblate (Thai)

Currently translated at 8.8% (4 of 45 strings)

Translated using Weblate (Thai)

Currently translated at 10.4% (9 of 86 strings)

Translated using Weblate (Thai)

Currently translated at 16.0% (16 of 100 strings)

Translated using Weblate (Thai)

Currently translated at 21.1% (37 of 175 strings)

Translated using Weblate (Thai)

Currently translated at 15.0% (6 of 40 strings)

Translated using Weblate (Thai)

Currently translated at 45.0% (27 of 60 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ton Zabretooth <zabretooth@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/th/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-19 15:22:50 -05:00
Josh HawkinsandGitHub 7881bea60f Filter outbound websocket broadcasts by per-recipient camera access (#23256)
* filter outbound ws broadcasts by per-recipient camera access

* fan out config updates to comms

* tests

* mypy

* allow viewers to use jobstate

* update agent instructions

* remove vitest
2026-05-19 14:51:16 -05:00
Nicolas MowenandGitHub b0b00fe1d0 GenAI Refactor (#23253)
* Ensure runtime options are passed

* Add attribute info to prompt when configured

* Move GenAI plugins to dedicated directory

* Migrate prompts to dedicated folder

* Move chat prompts to prompts

* Implement reasoning traces in the UI

* Cleanup

* Make azure a subclass of openai

* Implement reasoning for other providers

* mypy

* Cleanup
2026-05-19 13:03:57 -05:00
Josh HawkinsandGitHub b1de5e2290 Add attributes to UI filters list (#23250)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* preserve user-set min_score on attribute filters instead of bumping any 0.5 value

use model_fields_set to distinguish "user explicitly set min_score" from "Pydantic applied the generic FilterConfig default of 0.5"

* add config test for attributes

* fix attributes frontend type

* add expanded hidden field context

* extend schema modification

* special case for attributes

* i18n for attributes

* handle dedicated lpr mode

* strip unrendered FilterConfig fields from attribute filter form data to fix validation errors
2026-05-19 08:31:50 -06:00
Josh HawkinsandGitHub 4fdc107987 Improve go2rtc pane in Settings (#23251)
* improve layout and handling of multiple ffmpeg args in go2rtc pane

* add e2e tests

* fix spacing
2026-05-19 08:30:04 -06:00
GuoQing LiuandGitHub a83809de54 fix: fix chat request params miss runtime_options (#23247)
* fix: fix chat request params miss runtime_options

* fix: mypy
2026-05-19 06:29:28 -06:00
43d97acd21 Miscellaneous fixes (#23238)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* start audio transcription post processor when enabled on any camera

* Fetch embed key whenever an error occurs in case the llama server was restarted

* mypy

* add tooltips for colored dots in settings menu

* add ability to reorder cameras from management pane

* add ability to reorder birdseye

* add reordering save text to camera management view

* Include NPU in latency performance hint

* Implement turbo for NPU on object detection

* hide order fields

* drop auto-derived field paths from camera value when unset globally

* use correct field type for export hwaccel args

* add debug replay to detail actions menu

* clarify debug replay in docs

* guard get_current_frame_time against missing camera state

* Implement debug reply from export

* Refactor debug replay to use sources for dynamic playback

* Mypy

* fix debug export replay source timestamp handling

* skip replay cameras in stats immediately

* broadcast debug replay state over ws and buffer pre-OPEN sends

- push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling
- fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load

* fix debug replay test hang

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-18 22:52:40 -05:00
Josh HawkinsandGitHub d968f00500 Settings UI fixes (#23237)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* detector UI fixes

- derive detector and model from memo rather than using two drain useeffects
- sanitize save payload through sanitizeSectionData to prevent yaml validation issues

* increase display duration for restart required toasts

* mimic logic in detector section for save all button

also, increase toast duration for restart required toasts

* fixes and tweaks

- use section hidden fields for sanitization instead of duplicating code
- use parent hooks so save all, pending data, and the status dots work correctly
2026-05-18 13:22:54 -06:00
305 changed files with 22598 additions and 4186 deletions
-401
View File
@@ -1,401 +0,0 @@
# GitHub Copilot Instructions for Frigate NVR
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
## Project Overview
Frigate NVR is a realtime object detection system for IP cameras that uses:
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
- **Frontend**: React with TypeScript, Vite, TailwindCSS
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
- **Focus**: Minimal resource usage with maximum performance
## Code Review Guidelines
When reviewing code, do NOT comment on:
- Missing imports - Static analysis tooling catches these
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
- Minor style inconsistencies already enforced by linters
## Python Backend Standards
### Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use modern Python features:
- Pattern matching
- Type hints (comprehensive typing preferred)
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Async/await patterns
### Code Quality Standards
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **Language**: American English for all code, comments, and documentation
### Logging Standards
- **Logger Pattern**: Use module-level logger
```python
import logging
logger = logging.getLogger(__name__)
```
- **Format Guidelines**:
- No periods at end of log messages
- No sensitive data (keys, tokens, passwords)
- Use lazy logging: `logger.debug("Message with %s", variable)`
- **Log Levels**:
- `debug`: Development and troubleshooting information
- `info`: Important runtime events (startup, shutdown, state changes)
- `warning`: Recoverable issues that should be addressed
- `error`: Errors that affect functionality but don't crash the app
- `exception`: Use in except blocks to include traceback
### Error Handling
- **Exception Types**: Choose most specific exception available
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/except
- Avoid bare exceptions except in background tasks
Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
result = processed
except DeviceError:
logger.error("Failed to get data")
```
Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
logger.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
result = processed
```
### Async Programming
- **External I/O**: All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
- Avoid awaiting in loops - use `asyncio.gather()` instead
- No blocking calls in async functions
- Use `asyncio.create_task()` for background operations
- **Thread Safety**: Use proper synchronization for shared state
### Documentation Standards
- **Module Docstrings**: Concise descriptions at top of files
```python
"""Utilities for motion detection and analysis."""
```
- **Function Docstrings**: Required for public functions and methods
```python
async def process_frame(frame: ndarray, config: Config) -> Detection:
"""Process a video frame for object detection.
Args:
frame: The video frame as numpy array
config: Detection configuration
Returns:
Detection results with bounding boxes
"""
```
- **Comment Style**:
- Explain the "why" not just the "what"
- Keep lines under 88 characters when possible
- Use clear, descriptive comments
### File Organization
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
- **Configuration**: `frigate/config/` - Configuration parsing and validation
- **Detectors**: `frigate/detectors/` - Object detection backends
- **Events**: `frigate/events/` - Event management and storage
- **Utilities**: `frigate/util/` - Shared utility functions
## Frontend (React/TypeScript) Standards
### Internationalization (i18n)
- **CRITICAL**: Never write user-facing strings directly in components
- **Always use react-i18next**: Import and use the `t()` function
```tsx
import { useTranslation } from "react-i18next";
function MyComponent() {
const { t } = useTranslation(["views/live"]);
return <div>{t("camera_not_found")}</div>;
}
```
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
### Code Quality
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
- **Formatting**: Prettier with Tailwind CSS plugin
- **Type Safety**: TypeScript strict mode enabled
- **Testing**: Vitest for unit tests
### Component Patterns
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
- **Styling**: TailwindCSS with `cn()` utility for class merging
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
- **Data Fetching**: Custom hooks with proper loading and error states
### ESLint Rules
Key rules enforced:
- `react-hooks/rules-of-hooks`: error
- `react-hooks/exhaustive-deps`: error
- `no-console`: error (use proper logging or remove)
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
- Unused variables must be prefixed with `_`
- Comma dangles required for multiline objects/arrays
### File Organization
- **Pages**: `web/src/pages/` - Route components
- **Views**: `web/src/views/` - Complex view components
- **Components**: `web/src/components/` - Reusable components
- **Hooks**: `web/src/hooks/` - Custom React hooks
- **API**: `web/src/api/` - API client functions
- **Types**: `web/src/types/` - TypeScript type definitions
## Testing Requirements
### Backend Testing
- **Framework**: Python unittest
- **Run Command**: `python3 -u -m unittest`
- **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names
```python
class TestMotionDetection(unittest.TestCase):
def test_detects_motion_above_threshold(self):
# Test implementation
```
### Test Best Practices
- Always have a way to test your work and confirm your changes
- Write tests for bug fixes to prevent regressions
- Test edge cases and error conditions
- Mock external dependencies (cameras, APIs, hardware)
- Use fixtures for test data
## Development Commands
### Python Backend
```bash
# Run all tests
python3 -u -m unittest
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
# Check formatting (Ruff)
ruff format --check frigate/
# Apply formatting
ruff format frigate/
# Run linter
ruff check frigate/
```
### Frontend (from web/ directory)
```bash
# Start dev server (AI agents should never run this directly unless asked)
npm run dev
# Build for production
npm run build
# Run linter
npm run lint
# Fix linting issues
npm run lint:fix
# Format code
npm run prettier:write
```
### Docker Development
AI agents should never run these commands directly unless instructed.
```bash
# Build local image
make local
# Build debug image
make debug
```
## Common Patterns
### API Endpoint Pattern
```python
from fastapi import APIRouter, Request
from frigate.api.defs.tags import Tags
router = APIRouter(tags=[Tags.Events])
@router.get("/events")
async def get_events(request: Request, limit: int = 100):
"""Retrieve events from the database."""
# Implementation
```
### Configuration Access
```python
# Access Frigate configuration
config: FrigateConfig = request.app.frigate_config
camera_config = config.cameras["front_door"]
```
### Database Queries
```python
from frigate.models import Event
# Use Peewee ORM for database access
events = (
Event.select()
.where(Event.camera == camera_name)
.order_by(Event.start_time.desc())
.limit(limit)
)
```
## Common Anti-Patterns to Avoid
### ❌ Avoid These
```python
# Blocking operations in async functions
data = requests.get(url) # ❌ Use async HTTP client
time.sleep(5) # ❌ Use asyncio.sleep()
# Hardcoded strings in React components
<div>Camera not found</div> # ❌ Use t("camera_not_found")
# Missing error handling
data = await api.get_data() # ❌ No exception handling
# Bare exceptions in regular code
try:
value = await sensor.read()
except Exception: # ❌ Too broad
logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
```
### ✅ Use These Instead
```python
# Async operations
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
await asyncio.sleep(5) # ✅ Non-blocking
# Translatable strings in React
const { t } = useTranslation();
<div>{t("camera_not_found")}</div> # ✅ Translatable
# Proper error handling
try:
data = await api.get_data()
except ApiException as err:
logger.error("API error: %s", err)
raise
# Specific exceptions
try:
value = await sensor.read()
except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
```
## Project-Specific Conventions
### Configuration Files
- Main config: `config/config.yml`
### Directory Structure
- Backend code: `frigate/`
- Frontend code: `web/`
- Docker files: `docker/`
- Documentation: `docs/`
- Database migrations: `migrations/`
### Code Style Conformance
Always conform new and refactored code to the existing coding style in the project:
- Follow established patterns in similar files
- Match indentation and formatting of surrounding code
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
- Maintain the same level of verbosity in comments and docstrings
## Additional Resources
- Documentation: https://docs.frigate.video
- Main Repository: https://github.com/blakeblackshear/frigate
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+439
View File
@@ -0,0 +1,439 @@
# Agent Instructions for Frigate NVR
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
## Project Overview
Frigate NVR is a realtime object detection system for IP cameras that uses:
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
- **Frontend**: React with TypeScript, Vite, TailwindCSS
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
- **Focus**: Minimal resource usage with maximum performance
## Code Review Guidelines
When reviewing code, do NOT comment on:
- Missing imports - Static analysis tooling catches these
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
- Minor style inconsistencies already enforced by linters
## Python Backend Standards
### Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use modern Python features:
- Pattern matching
- Type hints (comprehensive typing preferred)
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Async/await patterns
### Code Quality Standards
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **Language**: American English for all code, comments, and documentation
### Logging Standards
- **Logger Pattern**: Use module-level logger
```python
import logging
logger = logging.getLogger(__name__)
```
- **Format Guidelines**:
- No periods at end of log messages
- No sensitive data (keys, tokens, passwords)
- Use lazy logging: `logger.debug("Message with %s", variable)`
- **Log Levels**:
- `debug`: Development and troubleshooting information
- `info`: Important runtime events (startup, shutdown, state changes)
- `warning`: Recoverable issues that should be addressed
- `error`: Errors that affect functionality but don't crash the app
- `exception`: Use in except blocks to include traceback
### Error Handling
- **Exception Types**: Choose most specific exception available
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/except
- Avoid bare exceptions except in background tasks
Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
result = processed
except DeviceError:
logger.error("Failed to get data")
```
Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
logger.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
result = processed
```
### Async Programming
- **External I/O**: All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
- Avoid awaiting in loops - use `asyncio.gather()` instead
- No blocking calls in async functions
- Use `asyncio.create_task()` for background operations
- **Thread Safety**: Use proper synchronization for shared state
### Documentation Standards
- **Module Docstrings**: Concise descriptions at top of files
```python
"""Utilities for motion detection and analysis."""
```
- **Function Docstrings**: Required for public functions and methods
```python
async def process_frame(frame: ndarray, config: Config) -> Detection:
"""Process a video frame for object detection.
Args:
frame: The video frame as numpy array
config: Detection configuration
Returns:
Detection results with bounding boxes
"""
```
- **Comment Style**:
- Explain the "why" not just the "what"
- Keep lines under 88 characters when possible
- Use clear, descriptive comments
### File Organization
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
- **Configuration**: `frigate/config/` - Configuration parsing and validation
- **Detectors**: `frigate/detectors/` - Object detection backends
- **Events**: `frigate/events/` - Event management and storage
- **Utilities**: `frigate/util/` - Shared utility functions
## Frontend (React/TypeScript) Standards
### Internationalization (i18n)
- **CRITICAL**: Never write user-facing strings directly in components
- **Always use react-i18next**: Import and use the `t()` function
```tsx
import { useTranslation } from "react-i18next";
function MyComponent() {
const { t } = useTranslation(["views/live"]);
return <div>{t("camera_not_found")}</div>;
}
```
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
### Code Quality
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
- **Formatting**: Prettier with Tailwind CSS plugin
- **Type Safety**: TypeScript strict mode enabled
### Component Patterns
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
- **Styling**: TailwindCSS with `cn()` utility for class merging
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
- **Data Fetching**: Custom hooks with proper loading and error states
### ESLint Rules
Key rules enforced:
- `react-hooks/rules-of-hooks`: error
- `react-hooks/exhaustive-deps`: error
- `no-console`: error (use proper logging or remove)
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
- Unused variables must be prefixed with `_`
- Comma dangles required for multiline objects/arrays
### File Organization
- **Pages**: `web/src/pages/` - Route components
- **Views**: `web/src/views/` - Complex view components
- **Components**: `web/src/components/` - Reusable components
- **Hooks**: `web/src/hooks/` - Custom React hooks
- **API**: `web/src/api/` - API client functions
- **Types**: `web/src/types/` - TypeScript type definitions
## Testing Requirements
### Backend Testing
- **Framework**: Python unittest
- **Run Command**: `python3 -u -m unittest`
- **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names
```python
class TestMotionDetection(unittest.TestCase):
def test_detects_motion_above_threshold(self):
# Test implementation
```
### Test Best Practices
- Always have a way to test your work and confirm your changes
- Write tests for bug fixes to prevent regressions
- Test edge cases and error conditions
- Mock external dependencies (cameras, APIs, hardware)
- Use fixtures for test data
## Development Commands
### Python Backend
```bash
# Run all tests
python3 -u -m unittest
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
# Check formatting (Ruff)
ruff format --check frigate/
# Apply formatting
ruff format frigate/
# Run linter
ruff check frigate/
# Type check
python3 -u -m mypy --config-file frigate/mypy.ini frigate
```
### Frontend (from web/ directory)
```bash
# Start dev server (AI agents should never run this directly unless asked)
npm run dev
# Build for production
npm run build
# Run linter
npm run lint
# Fix linting issues
npm run lint:fix
# Format code
npm run prettier:write
# E2E: first-time setup
npm install
npx playwright install chromium
# E2E: build the app and run all tests
npm run e2e:build && npm run e2e
# E2E: interactive UI for debugging
npm run e2e:ui
# E2E: run a specific spec
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
# E2E: filter by name, or run only desktop/mobile
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
npx playwright test --config e2e/playwright.config.ts --project=desktop
# E2E: regenerate mock data after backend model changes (from repo root)
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
# Regenerate config translations from Pydantic models — outputs to
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
# JSON files by hand; change the Pydantic field title/description and
# re-run this script. (from repo root)
python3 generate_config_translations.py
# Extract i18n keys from source into the locale files after adding
# new t() calls. Use the :ci variant to verify the locale files are
# in sync with source (fails if extraction would change anything).
npm run i18n:extract
npm run i18n:extract:ci
```
### Docker Development
AI agents should never run these commands directly unless instructed.
```bash
# Build local image
make local
# Build debug image
make debug
```
## Common Patterns
### API Endpoint Pattern
```python
from fastapi import APIRouter, Request
from frigate.api.defs.tags import Tags
router = APIRouter(tags=[Tags.Events])
@router.get("/events")
async def get_events(request: Request, limit: int = 100):
"""Retrieve events from the database."""
# Implementation
```
### Configuration Access
```python
# Access Frigate configuration
config: FrigateConfig = request.app.frigate_config
camera_config = config.cameras["front_door"]
```
### Database Queries
```python
from frigate.models import Event
# Use Peewee ORM for database access
events = (
Event.select()
.where(Event.camera == camera_name)
.order_by(Event.start_time.desc())
.limit(limit)
)
```
## Common Anti-Patterns to Avoid
### ❌ Avoid These
```python
# Blocking operations in async functions
data = requests.get(url) # ❌ Use async HTTP client
time.sleep(5) # ❌ Use asyncio.sleep()
# Hardcoded strings in React components
<div>Camera not found</div> # ❌ Use t("camera_not_found")
# Missing error handling
data = await api.get_data() # ❌ No exception handling
# Bare exceptions in regular code
try:
value = await sensor.read()
except Exception: # ❌ Too broad
logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
```
### ✅ Use These Instead
```python
# Async operations
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
await asyncio.sleep(5) # ✅ Non-blocking
# Translatable strings in React
const { t } = useTranslation();
<div>{t("camera_not_found")}</div> # ✅ Translatable
# Proper error handling
try:
data = await api.get_data()
except ApiException as err:
logger.error("API error: %s", err)
raise
# Specific exceptions
try:
value = await sensor.read()
except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
```
## WebSocket Broadcasts
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
## Project-Specific Conventions
### Configuration Files
- Main config: `config/config.yml`
### Directory Structure
- Backend code: `frigate/`
- Frontend code: `web/`
- Docker files: `docker/`
- Documentation: `docs/`
- Database migrations: `migrations/`
### Code Style Conformance
Always conform new and refactored code to the existing coding style in the project:
- Follow established patterns in similar files
- Match indentation and formatting of surrounding code
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
- Maintain the same level of verbosity in comments and docstrings
## Additional Resources
- Documentation: https://docs.frigate.video
- Main Repository: https://github.com/blakeblackshear/frigate
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
@@ -252,6 +252,7 @@ http {
include proxy.conf;
proxy_cache api_cache;
proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
proxy_cache_lock on;
proxy_cache_use_stale updating;
proxy_cache_valid 200 5s;
+1 -1
View File
@@ -1,2 +1,2 @@
cuda-python == 12.6.*; platform_machine == 'aarch64'
cuda-python == 13.3.*; platform_machine == 'aarch64'
numpy == 1.26.*; platform_machine == 'aarch64'
+1 -1
View File
@@ -67,7 +67,7 @@ Additional cameras are simply added under the camera configuration section.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Management" /> and use the add camera button to configure each additional camera.
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the add camera button to configure each additional camera.
</TabItem>
<TabItem value="yaml">
+5 -6
View File
@@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
### Model Types: Instruct vs Thinking
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid.
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
Frigate manages reasoning per task automatically:
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation).
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
**Recommendation:**
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use.
You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions.
### llama.cpp
+3 -3
View File
@@ -113,7 +113,7 @@ Here are some common starter configuration examples. These can be configured thr
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>
@@ -192,7 +192,7 @@ cameras:
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>
@@ -270,7 +270,7 @@ cameras:
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
8. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>
+26 -7
View File
@@ -257,19 +257,38 @@ cameras:
</TabItem>
</ConfigTabs>
### Disabling cameras
### Camera state
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
:::note
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate.
#### Turning a camera on or off
:::
Turning a camera off is temporary and does not require a restart. The available controls are:
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
- The power button in the single-camera Live view header
- The right-click context menu on a camera tile on the Live dashboard
- The Camera management settings pane (status set to **Off**)
- The mobile settings drawer on the single-camera Live view (admin users only)
- The [MQTT topic](/integrations/mqtt#frigatecamera_nameenabledset) `frigate/<camera_name>/enabled/set` with payload `ON` or `OFF`
- The Home Assistant integration via the [`camera.turn_on` / `camera.turn_off` actions](/integrations/home-assistant#camera-api)
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
#### Disabling a camera
Disabling a camera saves the change to your configuration file. Navigate to **Settings → Global configuration → Camera management** and set the camera's status to **Disabled**. Runtime processing stops immediately; the change persists across restarts.
Re-enabling a disabled camera requires a restart of Frigate so that the ffmpeg processes and other camera-scoped resources can be initialized. The UI will prompt you to restart when you switch a disabled camera back to On.
#### Restream behavior
For both Off and Disabled cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
#### Choosing Off versus Disabled
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
### Live player error messages
@@ -197,3 +197,7 @@ This option is handy when you want to prevent large transient changes from trigg
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
:::
## Reviewing Detected Motion
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](review.md#reviewing-motion) on the Review page.
+32 -6
View File
@@ -33,10 +33,10 @@ The easiest way to define profiles is to use the Frigate UI. Profiles can also b
<ConfigTabs>
<TabItem value="ui">
1. **Create a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
1. **Create a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button to clear overrides.
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
4. **Delete a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to <NavPath path="Settings > Global configuration > Profiles" />, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
4. **Delete a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
</TabItem>
<TabItem value="yaml">
@@ -126,7 +126,9 @@ Only the fields you explicitly set in a profile override are applied. All other
## Activating Profiles
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
## Example: Home / Away Setup
@@ -135,10 +137,10 @@ A common use case is having different detection and notification settings based
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
1. Navigate to <NavPath path="Settings > Global configuration > Profiles" /> and create two profiles: **Home** and **Away**.
2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications.
3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
4. Activate the desired profile from <NavPath path="Settings > Global configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
</TabItem>
<TabItem value="yaml">
@@ -207,3 +209,27 @@ In this example:
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
- **No profile active**: All cameras use their base configuration values.
## FAQ
### Can I define a zone or mask in a profile but not have it in the base config?
No. Profiles are pure overrides. Every zone and mask defined under a profile must reference an entry that already exists on the base camera config. Configurations that introduce profile-only zones or masks are rejected at startup.
If you want a zone or mask to be active only under a specific profile, define it on the base config with `enabled: false`, then enable it in that profile's overrides.
### How do I revert a profile zone or mask override back to the base configuration?
Delete the override. In the Frigate UI, edit the profile and use the "Revert override" action (the trash can icon) on the zone or mask. The base entry is left untouched, and once the override is removed the profile inherits the base values for that zone or mask.
### Can multiple profiles be active at the same time?
No. Only one profile can be active at a time. Activating a new profile automatically deactivates the current one.
### What happens to my profile overrides if I delete a zone or mask from the base?
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
### Why are some settings missing when I configure a profile override?
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
+2 -2
View File
@@ -840,8 +840,8 @@ cameras:
# Required: name of the camera
back:
# Optional: Enable/Disable the camera (default: shown below).
# If disabled: config is used but no live stream and no capture etc.
# Events/Recordings are still viewable.
# When False, ffmpeg is not started and the camera is hidden from the UI
# (except Camera Management). Re-enabling requires a Frigate restart.
enabled: True
# Optional: camera type used for some Frigate features (default: shown below)
# Options are "generic" and "lpr"
+41 -1
View File
@@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts
## Alerts and Detections
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
:::note
@@ -130,3 +130,43 @@ By default a review item will be created if any `review -> alerts -> labels` and
Because zones don't apply to audio, audio labels will always be marked as a detection by default.
:::
## Reviewing Motion
The Review page also can show periods of motion that didn't produce a tracked object, and provides a way to search past recordings for motion in a specific region. These tools complement the alerts and detections workflow above — see [Tuning Motion Detection](motion_detection.md) for how the underlying motion detector is configured.
### Motion Previews
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
The pane provides a few controls:
- **Speed** — speeds up or slows down all of the preview clips at once.
- **Dim** — controls how strongly non-motion areas are darkened by the heatmap overlay. Higher values increase motion area visibility.
- **Filter** — opens a 16×16 grid overlaid on a snapshot of the camera. Select one or more cells to only show clips with motion in those regions. This is helpful for filtering out motion in areas like a busy street while keeping motion in your driveway.
Clicking a preview clip seeks the recording player to that timestamp so you can review the full footage.
### Motion Search
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
2. Draw a polygon on the camera frame to define the region of interest.
3. Adjust the search parameters if needed:
| Field | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
| **Maximum Results** | Maximum number of matching timestamps to return. |
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
+1 -1
View File
@@ -144,7 +144,7 @@ At this point you should be able to start Frigate and a basic config will be cre
### Step 2: Add a camera
Click the **Add Camera** button in <NavPath path="Settings > Camera configuration > Management" /> to use the camera setup wizard to get your first camera added into Frigate.
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate.
### Step 3: Configure hardware acceleration (recommended)
+8 -2
View File
@@ -195,7 +195,7 @@ For clips to be castable to media devices, audio is required and may need to be
## Camera API
To disable a camera dynamically
To turn a camera off (pauses Frigate's processing of the stream; does not persist across Frigate restarts; see [Camera state](/configuration/live#camera-state)):
```
action: camera.turn_off
@@ -204,7 +204,7 @@ target:
entity_id: camera.back_deck_cam # your Frigate camera entity ID
```
To enable a camera that has been disabled dynamically
To turn a camera back on:
```
action: camera.turn_on
@@ -213,6 +213,12 @@ target:
entity_id: camera.back_deck_cam # your Frigate camera entity ID
```
:::note
These actions toggle Frigate's runtime On/Off state. To permanently disable a camera, set its status to **Disabled** in **Settings → Camera Management** in the Frigate UI.
:::
## Notification API
Many people do not want to expose Frigate to the web, so the integration creates some public API endpoints that can be used for notifications.
+3 -3
View File
@@ -306,7 +306,7 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
- `online`: Stream is running and being processed
- `offline`: Stream is offline and is being restarted
- `disabled`: Camera is currently disabled
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
### `frigate/<camera_name>/<object_name>`
@@ -368,11 +368,11 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
### `frigate/<camera_name>/enabled/set`
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
### `frigate/<camera_name>/enabled/state`
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
Topic with current runtime state of processing for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/detect/set`
@@ -37,6 +37,8 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
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.
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
@@ -54,6 +56,7 @@ Only one replay session can be active at a time. If a session is already running
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
+74
View File
@@ -2058,6 +2058,47 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/genai/models:
get:
tags:
- App
summary: List available GenAI models
description: Returns available models for each configured GenAI provider.
operationId: genai_models_genai_models_get
responses:
"200":
description: Successful Response
content:
application/json:
schema: {}
/genai/probe:
post:
tags:
- App
summary: Probe a GenAI provider without saving config
description: >-
Builds a transient client from the request body and returns its
available models. Used to validate provider credentials in the UI
before saving the configuration. Requires admin role.
operationId: genai_probe_genai_probe_post
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/GenAIProbeBody"
responses:
"200":
description: Successful Response
content:
application/json:
schema: {}
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/vainfo:
get:
tags:
@@ -7031,6 +7072,39 @@ components:
"john_doe": ["face1.webp", "face2.jpg"],
"jane_smith": ["face3.png"]
}
GenAIProbeBody:
properties:
provider:
type: string
enum:
- openai
- azure_openai
- gemini
- ollama
- llamacpp
title: Provider
description: GenAI provider to probe
api_key:
anyOf:
- type: string
- type: "null"
title: API Key
description: API key for the provider (when applicable)
base_url:
anyOf:
- type: string
- type: "null"
title: Base URL
description: Base URL for self-hosted or compatible providers
provider_options:
type: object
title: Provider Options
description: Additional provider-specific options
default: {}
type: object
required:
- provider
title: GenAIProbeBody
GenerateObjectExamplesBody:
properties:
model_name:
+162 -13
View File
@@ -34,15 +34,18 @@ from frigate.api.auth import (
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
GenAIProbeBody,
MediaSyncBody,
)
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
get_current_media_sync_job,
get_media_sync_job_by_id,
@@ -59,7 +62,11 @@ from frigate.util.builtin import (
process_config_query_string,
update_yaml_file_bulk,
)
from frigate.util.config import apply_section_update, find_config_file
from frigate.util.config import (
apply_section_update,
find_config_file,
redact_credential,
)
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@@ -75,6 +82,14 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app])
# Short timeout for the /genai/probe path. The probe is interactive — fail
# fast on hung providers rather than holding an API worker thread.
_PROBE_TIMEOUT_SECONDS = 10
# Outer cap that returns control to the caller even if the underlying sync
# HTTP call ignores its timeout. The sync work continues in the background
# thread; only the response is bounded.
_PROBE_OUTER_TIMEOUT_SECONDS = 15
@router.get(
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
@@ -170,6 +185,95 @@ def genai_models(request: Request):
return JSONResponse(content=request.app.genai_manager.list_models())
@router.post(
"/genai/probe",
dependencies=[Depends(require_role(["admin"]))],
summary="Probe a GenAI provider without saving config",
description=(
"Builds a transient client from the request body and returns its "
"available models. Used to validate provider credentials in the UI "
"before saving the configuration."
),
)
async def genai_probe(body: GenAIProbeBody):
load_providers()
provider_cls = PROVIDERS.get(body.provider)
if not provider_cls:
return JSONResponse(
status_code=400,
content={"success": False, "message": "Unknown provider"},
)
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
# provider_options; other plugins use GenAIClient.timeout passed below.
# Don't inject timeout for Gemini — its HttpOptions interprets the value
# in milliseconds and would clash with the plugin's own default.
probe_provider_options: dict[str, Any] = dict(body.provider_options or {})
if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai):
probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS)
try:
transient_cfg = GenAIConfig(
provider=body.provider,
api_key=body.api_key,
base_url=body.base_url,
provider_options=probe_provider_options,
# model is required by the schema but irrelevant for listing.
model="probe",
roles=[],
)
except ValidationError:
logger.exception("GenAI probe: invalid configuration")
return JSONResponse(
status_code=400,
content={"success": False, "message": "Invalid provider configuration"},
)
try:
client = provider_cls(
transient_cfg,
timeout=_PROBE_TIMEOUT_SECONDS,
validate_model=False,
)
except Exception:
logger.exception("GenAI probe: failed to construct client")
return JSONResponse(
content={
"success": False,
"message": "Failed to connect to provider",
},
)
try:
models = await asyncio.wait_for(
asyncio.to_thread(client.list_models),
timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
return JSONResponse(
content={"success": False, "message": "Probe timed out"},
)
except Exception:
logger.exception("GenAI probe: list_models failed")
return JSONResponse(
content={"success": False, "message": "Provider returned no models"},
)
if not models:
return JSONResponse(
content={
"success": False,
"message": (
"No models returned. Check the API key, base URL, and "
"that the provider is reachable."
),
},
)
return JSONResponse(content={"success": True, "models": models})
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
def config(request: Request):
config_obj: FrigateConfig = request.app.frigate_config
@@ -185,26 +289,24 @@ def config(request: Request):
if request.headers.get("remote-role") != "admin":
config.pop("environment_vars", None)
# remove mqtt credentials
config["mqtt"].pop("password", None)
config["mqtt"].pop("user", None)
# redact mqtt credentials
redact_credential(config["mqtt"], "password")
# remove the proxy secret
config["proxy"].pop("auth_secret", None)
# redact proxy secret
redact_credential(config["proxy"], "auth_secret")
# remove genai api keys
for genai_name, genai_cfg in config.get("genai", {}).items():
# redact genai api keys
for _genai_name, genai_cfg in config.get("genai", {}).items():
if isinstance(genai_cfg, dict):
genai_cfg.pop("api_key", None)
redact_credential(genai_cfg, "api_key")
for camera_name, camera in request.app.frigate_config.cameras.items():
camera_dict = config["cameras"][camera_name]
# remove onvif credentials
# redact onvif credentials
onvif_dict = camera_dict.get("onvif", {})
if onvif_dict:
onvif_dict.pop("user", None)
onvif_dict.pop("password", None)
redact_credential(onvif_dict, "password")
# clean paths
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
@@ -581,6 +683,10 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop any field whose value is still the redaction sentinel
updates = {
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(
@@ -644,6 +750,40 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
settings,
)
# detect resize also republishes motion + objects so other
# processes pick up the rebuilt masks, and fires refresh so
# the camera maintainer recycles the camera process to pick
# up the new ffmpeg cmd / SHM sizing
if field == "detect":
cam_cfg = config.cameras.get(camera)
if cam_cfg is not None:
if cam_cfg.motion is not None:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.motion, camera
),
cam_cfg.motion,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.objects, camera
),
cam_cfg.objects,
)
if cam_cfg.zones:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.zones, camera
),
cam_cfg.zones,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.refresh, camera
),
cam_cfg,
)
return JSONResponse(
content={"success": True, "message": "Config applied in-memory"},
status_code=200,
@@ -691,6 +831,13 @@ def config_set(request: Request, body: AppConfigSetBody):
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential
# placeholders don't clobber the saved YAML value.
updates = {
k: v
for k, v in updates.items()
if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(
@@ -774,6 +921,8 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
for comm in request.app.dispatcher.comms:
comm.config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
+71 -454
View File
@@ -35,9 +35,13 @@ from frigate.api.defs.response.chat_response import (
ToolCall,
)
from frigate.api.defs.tags import Tags
from frigate.api.event import events
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.config import FrigateConfig
from frigate.config.ui import UnitSystemEnum
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
get_tool_definitions,
)
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.vlm_watch import (
get_vlm_watch_job,
@@ -68,390 +72,6 @@ class VLMMonitorRequest(BaseModel):
zones: List[str] = []
def get_tool_definitions(
semantic_search_enabled: bool = False,
) -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
Returns a list of tool definitions that can be used with OpenAI-compatible
function calling APIs. When semantic search is enabled, the search_objects
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included.
"""
search_objects_properties: Dict[str, Any] = {
"camera": {
"type": "string",
"description": "Camera name to filter by (optional).",
},
"label": {
"type": "string",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
},
"sub_label": {
"type": "string",
"description": (
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
),
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "List of zone names to filter by.",
},
"limit": {
"type": "integer",
"description": "Maximum number of objects to return (default: 25).",
"default": 25,
},
}
if semantic_search_enabled:
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
),
}
search_objects_description = (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
"type": "function",
"function": {
"name": "search_objects",
"description": search_objects_description,
"parameters": {
"type": "object",
"properties": search_objects_properties,
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
@router.get(
"/chat/tools",
dependencies=[Depends(allow_any_authenticated())],
@@ -460,10 +80,13 @@ def get_tool_definitions(
)
def get_tools(request: Request) -> JSONResponse:
"""Get list of available tools for LLM function calling."""
semantic_search_enabled = bool(
getattr(request.app.frigate_config.semantic_search, "enabled", False)
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
return JSONResponse(content={"tools": tools})
@@ -554,11 +177,14 @@ async def _execute_search_objects(
elif zones is None:
zones = "all"
attribute = arguments.get("attribute")
# Build query parameters compatible with EventsQueryParams
query_params = EventsQueryParams(
cameras=arguments.get("camera", "all"),
labels=arguments.get("label", "all"),
sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
attributes=attribute if attribute else "all",
zones=zones,
zone=zones,
after=after,
@@ -626,6 +252,7 @@ async def _execute_search_objects_semantic(
label = arguments.get("label")
sub_label = arguments.get("sub_label")
attribute = arguments.get("attribute")
zones = arguments.get("zones")
if isinstance(zones, list) and zones:
@@ -668,6 +295,10 @@ async def _execute_search_objects_semantic(
if sub_label:
# case-insensitive match to mirror events() behavior
clauses.append(fn.LOWER(Event.sub_label.cast("text")) == sub_label.lower())
if attribute:
attribute_clause = _build_attribute_filter_clause(attribute)
if attribute_clause is not None:
clauses.append(attribute_clause)
if zones:
zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones]
clauses.append(reduce(operator.or_, zone_clauses))
@@ -916,9 +547,21 @@ async def _execute_get_live_context(
camera: str,
allowed_cameras: List[str],
) -> Dict[str, Any]:
# Reject wildcards explicitly so models retry with a real camera name
# instead of silently fanning out across every camera.
if camera in ("*", "all"):
return {
"error": (
"get_live_context requires a single camera name; wildcards "
"are not supported. Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
if camera not in allowed_cameras:
return {
"error": f"Camera '{camera}' not found or access denied",
"available_cameras": allowed_cameras,
}
if camera not in request.app.frigate_config.cameras:
@@ -1090,7 +733,14 @@ async def _execute_tool_internal(
"Arguments: %s",
json.dumps(arguments),
)
return {"error": "Camera parameter is required"}
return {
"error": (
"get_live_context requires a single camera name; "
"wildcards and empty values are not supported. "
"Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
return await _execute_get_live_context(request, camera, allowed_cameras)
elif tool_name == "start_camera_watch":
return await _execute_start_camera_watch(request, arguments)
@@ -1481,72 +1131,19 @@ async def chat_completion(
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
conversation = []
current_datetime = datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
current_time_str = current_datetime.strftime("%I:%M:%S %p")
cameras_info = []
has_speed_zone = False
for camera_id in allowed_cameras:
if camera_id not in config.cameras:
continue
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
if has_speed_zone:
speed_unit = (
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
semantic_search_section = ""
if semantic_search_enabled:
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{cameras_section}{speed_units_section}"""
system_prompt = build_chat_system_prompt(
config=config,
allowed_cameras=allowed_cameras,
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
conversation.append(
{
@@ -1595,6 +1192,7 @@ When a user refers to a specific object they have seen or describe with identify
messages=conversation,
tools=tools if tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
):
if await request.is_disconnected():
logger.debug("Client disconnected, stopping chat stream")
@@ -1607,6 +1205,13 @@ When a user refers to a specific object they have seen or describe with identify
)
+ b"\n"
)
elif kind == "reasoning_delta":
yield (
json.dumps({"type": "reasoning", "delta": value}).encode(
"utf-8"
)
+ b"\n"
)
elif kind == "stats":
yield (
json.dumps({"type": "stats", **value}).encode("utf-8")
@@ -1682,6 +1287,7 @@ When a user refers to a specific object they have seen or describe with identify
messages=conversation,
tools=tools if tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
)
if response.get("finish_reason") == "error":
@@ -1707,6 +1313,7 @@ When a user refers to a specific object they have seen or describe with identify
final_content = response.get("content") or ""
if body.stream:
final_reasoning = response.get("reasoning")
async def stream_body() -> Any:
if tool_calls:
@@ -1721,6 +1328,15 @@ When a user refers to a specific object they have seen or describe with identify
).encode("utf-8")
+ b"\n"
)
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
yield (
json.dumps(
{"type": "reasoning", "delta": final_reasoning}
).encode("utf-8")
+ b"\n"
)
# Stream content in word-sized chunks for smooth UX
for part in chunk_content(final_content):
yield (
@@ -1741,6 +1357,7 @@ When a user refers to a specific object they have seen or describe with identify
message=ChatMessageResponse(
role="assistant",
content=final_content,
reasoning=response.get("reasoning"),
tool_calls=None,
),
finish_reason=response.get("finish_reason", "stop"),
+104 -4
View File
@@ -6,11 +6,18 @@ 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 start_debug_replay_job
from frigate.jobs.debug_replay import (
ExportDebugReplaySource,
RecordingDebugReplaySource,
start_debug_replay_job,
)
from frigate.models import Export
from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
@@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel):
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."""
@@ -73,13 +86,100 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager
internal_port = request.app.frigate_config.networking.listen.internal
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
source = RecordingDebugReplaySource(
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
internal_port=internal_port,
)
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
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.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,
+9
View File
@@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from frigate.config import GenAIProviderEnum
class AppConfigSetBody(BaseModel):
requires_restart: int = 1
@@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
skip_save: bool = False
class GenAIProbeBody(BaseModel):
provider: GenAIProviderEnum
api_key: Optional[str] = None
base_url: Optional[str] = None
provider_options: Dict[str, Any] = Field(default_factory=dict)
class AppPutPasswordBody(BaseModel):
password: str
old_password: Optional[str] = None
+7
View File
@@ -36,3 +36,10 @@ class ChatCompletionRequest(BaseModel):
default=False,
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
)
enable_thinking: Optional[bool] = Field(
default=None,
description=(
"Per-request thinking toggle. None means use the provider default. "
"Ignored by providers that do not expose a per-request thinking switch."
),
)
@@ -20,6 +20,10 @@ class ChatMessageResponse(BaseModel):
content: Optional[str] = Field(
default=None, description="Message content (None if tool calls present)"
)
reasoning: Optional[str] = Field(
default=None,
description="Separated reasoning/thinking trace if the model emitted one",
)
tool_calls: Optional[list[ToolCallInvocation]] = Field(
default=None, description="Tool calls if LLM wants to call tools"
)
+1 -1
View File
@@ -398,7 +398,7 @@ class _StreamingZipBuffer:
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(datetime.datetime.timestamp(export.date))}"
base = f"{export.camera}_{int(export.date)}"
candidate = f"{base}.mp4"
counter = 1
+54
View File
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
@@ -50,6 +51,7 @@ class CameraMaintainer(threading.Thread):
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.refresh,
],
)
self.shm_count = self.__calculate_shm_frame_count()
@@ -202,6 +204,25 @@ class CameraMaintainer(threading.Thread):
capture_process.terminate()
capture_process.join()
def __unlink_camera_frame_slots(self, camera: str) -> None:
"""Drop the camera's per-frame YUV SHM segments from this
process's frame_manager and unlink them at the OS level.
Safe to call after the camera's capture/processor subprocesses
have been joined — they no longer hold mappings, so unlink frees
the segments immediately. Other long-lived processes that opened
these slots will continue using their existing mappings until
they call frame_manager.get with a shape that no longer fits
(the get path drops and reopens stale refs).
"""
prefix = f"{camera}_frame"
names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)]
for name in names:
try:
self.frame_manager.delete(name)
except Exception as exc:
logger.debug("Could not unlink SHM %s: %s", name, exc)
def __stop_camera_process(self, camera: str) -> None:
camera_process = self.camera_processes.get(camera)
if camera_process is not None:
@@ -253,12 +274,45 @@ class CameraMaintainer(threading.Thread):
for camera in updated_cameras:
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
self.__unlink_camera_frame_slots(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.camera_stop_events.pop(camera, None)
self.region_grids.pop(camera, None)
self.camera_metrics.pop(camera, None)
self.ptz_metrics.pop(camera, None)
elif update_type == CameraConfigUpdateEnum.refresh.name:
# Recycle replay cameras so detect width/height/fps
# propagate through ffmpeg args, SHM sizing, and the
# region grid. Regular cameras detect change still
# requires a full restart.
for camera in updated_cameras:
if not camera.startswith(REPLAY_CAMERA_PREFIX):
continue
new_config = self.update_subscriber.camera_configs.get(camera)
if new_config is None:
# remove arrived in the same batch
continue
if (
camera not in self.camera_processes
and camera not in self.capture_processes
):
continue
# rebuild ffmpeg cmds on the shared config so the
# new subprocesses spawn with current args
new_config.recreate_ffmpeg_cmds()
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
self.__unlink_camera_frame_slots(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.__start_camera_processor(camera, new_config, runtime=True)
self.__start_camera_capture(camera, new_config, runtime=True)
# ensure the capture processes are done
for camera in self.capture_processes.keys():
+52 -8
View File
@@ -45,6 +45,7 @@ class CameraState:
self.frame_cache: dict[float, dict[str, Any]] = {}
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv
self.current_frame_lock = threading.Lock()
self.current_frame_time = 0.0
self.motion_boxes: list[tuple[int, int, int, int]] = []
@@ -303,6 +304,42 @@ class CameraState:
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback)
def _discard_stale_resolution_state(
self, current_detections: dict[str, dict[str, Any]]
) -> bool:
"""Drop tracked state when the camera's detect resolution has
changed, and signal the caller to skip this batch if it contains
out-of-bounds boxes from the pre-recycle detect process.
Returns True when the batch should be skipped entirely.
"""
# detect resolution changed — drop tracked state so old-grid
# boxes don't leak through end-callbacks
current_shape = self.camera_config.frame_shape_yuv
if current_shape != self._last_frame_shape:
logger.debug(
f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state"
)
with self.current_frame_lock:
self.tracked_objects.clear()
self.motion_boxes = []
self.regions = []
self._last_frame_shape = current_shape
# drop in-flight batches from the pre-recycle detect process
# whose boxes exceed the current detect resolution
detect = self.camera_config.detect
if detect.width is not None and detect.height is not None:
for obj in current_detections.values():
box = obj.get("box")
if box and (box[2] > detect.width or box[3] > detect.height):
logger.debug(
f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})"
)
return True
return False
def update(
self,
frame_name: str,
@@ -311,6 +348,9 @@ class CameraState:
motion_boxes: list[tuple[int, int, int, int]],
regions: list[tuple[int, int, int, int]],
) -> None:
if self._discard_stale_resolution_state(current_detections):
return
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
)
@@ -332,14 +372,18 @@ class CameraState:
current_detections[id],
)
# add initial frame to frame cache
logger.debug(
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
)
self.frame_cache[frame_time] = {
"frame": np.copy(current_frame), # type: ignore[arg-type]
"object_id": id,
}
# Skip caching when the frame buffer isn't readable — e.g.
# frame_manager.get returned None because the SHM segment was
# unlinked or hasn't been recreated yet during a camera
# add/remove cycle.
if current_frame is not None:
logger.debug(
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
)
self.frame_cache[frame_time] = {
"frame": np.copy(current_frame),
"object_id": id,
}
# save initial thumbnail data and best object
thumbnail_data = {
+356 -6
View File
@@ -34,6 +34,8 @@ from frigate.const import (
UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT,
)
from frigate.models import User
from frigate.output.ws_auth import ws_has_camera_access
logger = logging.getLogger(__name__)
@@ -66,6 +68,7 @@ _WS_VIEWER_TOPICS = frozenset(
"audioTranscriptionState",
"birdseyeLayout",
"embeddingsReindexProgress",
"jobState",
}
)
@@ -102,6 +105,321 @@ def _check_ws_authorization(
return topic in _WS_VIEWER_TOPICS
# ---- Outbound filtering ---------------------------------------------------
#
# Every WebSocket broadcast is classified into one of a small set of scopes,
# then materialized per recipient. Connections with restricted roles only see
# data for cameras they are authorized to access; admin and full-access roles
# behave as today.
# Topics that are safe to broadcast to every authenticated client.
_WS_GLOBAL_OUTBOUND_TOPICS = frozenset(
{
"model_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"profile/state",
"notifications/state",
"notification_test",
}
)
# Topics that restricted roles must never receive. Birdseye composites span
# all cameras, so the existing JSMPEG policy already restricts birdseye access
# to unrestricted roles; the layout broadcast follows the same rule.
_WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
{
"birdseye_layout",
}
)
# Topics whose payload (parsed as JSON) names a single owning camera at the
# given key path. Used to scope events, reviews, triggers, etc.
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
"events": ("after", "camera"),
"reviews": ("after", "camera"),
"tracked_object_update": ("camera",),
"triggers": ("camera",),
"camera_monitoring": ("camera",),
}
# Topics whose payload is a dict keyed by camera name; filter keys per
# recipient.
_WS_RESHAPE_BY_CAMERA_KEY_TOPICS = frozenset(
{
"camera_activity",
"audio_detections",
}
)
# Topics whose payload is a dict keyed by job_type, where each entry may
# contain a "camera" or "source_camera" field, or a nested ``results.jobs``
# list of per-camera sub-jobs (export broadcasts).
_WS_RESHAPE_JOB_STATE_TOPICS = frozenset(
{
"job_state",
}
)
# Topics whose payload mixes global aggregates with a ``cameras`` sub-dict
# keyed by camera name. Aggregates and detector data stay; per-camera entries
# are filtered.
_WS_RESHAPE_STATS_TOPICS = frozenset(
{
"stats",
}
)
def _collect_zone_names(config: FrigateConfig) -> set[str]:
"""Return the set of all zone names defined across cameras."""
names: set[str] = set()
for camera in config.cameras.values():
zones = getattr(camera, "zones", None) or {}
names.update(zones.keys())
return names
def _parse_json_payload(payload: Any) -> Any:
"""Return payload parsed as JSON if it is a string, else as-is."""
if isinstance(payload, str):
try:
return json.loads(payload)
except (ValueError, TypeError):
return None
return payload
def _scope_job_entry_to_allowed(entry: Any, allowed: set[str]) -> dict[str, Any] | None:
"""Filter a single job_state entry to the recipient's allowed cameras.
Returns the (possibly reshaped) entry, or None to drop it. Four shapes
are handled:
* Top-level ``camera`` or ``source_camera`` (motion_search, vlm_watch,
export sub-job dicts): drop the entry if not allowed.
* Nested ``results.jobs`` list of per-camera sub-jobs (the aggregated
export broadcast): filter the list; drop the entry if nothing remains.
* Nested ``results.camera`` or ``results.source_camera`` (debug_replay,
which puts replay-specific fields inside ``results``): drop the entry
if not allowed.
* No camera anywhere (e.g. ``media_sync``): treat as global and keep.
"""
if not isinstance(entry, dict):
return None
cam = entry.get("camera") or entry.get("source_camera")
if cam is None:
results = entry.get("results")
if isinstance(results, dict):
sub_jobs = results.get("jobs")
if isinstance(sub_jobs, list):
filtered_jobs = [
j
for j in sub_jobs
if isinstance(j, dict)
and (j.get("camera") or j.get("source_camera")) in allowed
]
if not filtered_jobs:
return None
reshaped = dict(entry)
reshaped["results"] = dict(results)
reshaped["results"]["jobs"] = filtered_jobs
return reshaped
cam = results.get("camera") or results.get("source_camera")
if cam is not None:
return entry if cam in allowed else None
return entry
def _extract_payload_camera(payload: Any, path: tuple[str, ...]) -> str | None:
"""Walk the dotted path through a (possibly JSON-encoded) payload."""
cur = _parse_json_payload(payload)
for key in path:
if not isinstance(cur, dict):
return None
cur = cur.get(key)
return cur if isinstance(cur, str) else None
def _classify_outbound(
topic: str, all_cameras: set[str], all_zones: set[str]
) -> tuple[str, Any]:
"""Classify an outbound topic into (kind, extra).
kind values:
- "global" : send to every authenticated client
- "drop" : send to nobody (fail-closed for unknowns)
- "unrestricted_only" : send only to admin/full-access roles
- "camera" : extra is the owning camera name
- "payload_camera" : extra is the JSON key path to the camera name
- "reshape_by_camera_key"
- "reshape_job_state"
- "reshape_stats"
"""
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
return ("global", None)
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
return ("unrestricted_only", None)
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
return ("reshape_by_camera_key", None)
if topic in _WS_RESHAPE_JOB_STATE_TOPICS:
return ("reshape_job_state", None)
if topic in _WS_RESHAPE_STATS_TOPICS:
return ("reshape_stats", None)
if topic in _WS_PAYLOAD_CAMERA_TOPICS:
return ("payload_camera", _WS_PAYLOAD_CAMERA_TOPICS[topic])
# Topic-prefix based: first segment names the owning camera or zone.
first = topic.split("/", 1)[0]
if first in all_cameras:
return ("camera", first)
if first in all_zones:
# Zone aggregates span cameras; restricted users see nothing here.
return ("unrestricted_only", None)
return ("drop", None)
def _ws_role_header(ws: Any) -> str | None:
"""Return the HTTP_REMOTE_ROLE header value, if any."""
environ = getattr(ws, "environ", None)
if not environ:
return None
value = environ.get("HTTP_REMOTE_ROLE")
return value if isinstance(value, str) else None
def _ws_valid_roles(ws: Any, config: FrigateConfig) -> list[str]:
"""Return the list of recognized roles for this connection."""
header = _ws_role_header(ws)
if not header:
return []
roles = [r.strip() for r in header.split(config.proxy.separator) if r.strip()]
return [r for r in roles if r in config.auth.roles]
def _ws_is_unrestricted(ws: Any, config: FrigateConfig) -> bool:
"""True when the connection has unrestricted camera access.
Mirrors the policy in ``frigate.output.ws_auth``: admin or any role with
an empty allow-list grants full access.
"""
roles = _ws_valid_roles(ws, config)
if not roles:
return False
roles_dict = config.auth.roles
return any(r == "admin" or not roles_dict.get(r) for r in roles)
def _ws_allowed_cameras(ws: Any, config: FrigateConfig) -> set[str]:
"""Return the union of cameras this connection may access across its roles."""
roles = _ws_valid_roles(ws, config)
if not roles:
return set()
all_cameras = set(config.cameras.keys())
allowed: set[str] = set()
for role in roles:
if role == "admin" or not config.auth.roles.get(role):
return all_cameras
allowed.update(User.get_allowed_cameras(role, config.auth.roles, all_cameras))
return allowed
def _wrap_envelope(topic: str, inner_payload: Any) -> str:
"""Re-serialize a (topic, payload) message after payload reshaping.
Frigate's wire format keeps payloads as JSON-encoded strings inside the
outer envelope, mirroring what producers send today.
"""
return json.dumps({"topic": topic, "payload": json.dumps(inner_payload)})
def _materialize_for_ws(
ws: Any,
topic: str,
full_message: str,
scope: tuple[str, Any],
parsed_payload: Any,
config: FrigateConfig,
) -> str | None:
"""Return the JSON string to deliver to ``ws``, or None to skip it."""
kind, extra = scope
has_role = _ws_role_header(ws) is not None
if kind == "drop":
return None
if kind == "global":
# Globals still require an authenticated connection. Missing role
# falls back to viewer semantics (matching the inbound rule).
return full_message
# Beyond globals, an authenticated role header is required (fail-closed).
if not has_role:
return None
if kind == "unrestricted_only":
return full_message if _ws_is_unrestricted(ws, config) else None
if kind == "camera":
return full_message if ws_has_camera_access(ws, extra, config) else None
if kind == "payload_camera":
camera = _extract_payload_camera(parsed_payload, extra)
if camera is None:
return None
return full_message if ws_has_camera_access(ws, camera, config) else None
if kind == "reshape_by_camera_key":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
filtered = {cam: data for cam, data in parsed_payload.items() if cam in allowed}
if not filtered:
return None
return _wrap_envelope(topic, filtered)
if kind == "reshape_job_state":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
filtered_jobs: dict[str, Any] = {}
for job_type, job_payload in parsed_payload.items():
scoped = _scope_job_entry_to_allowed(job_payload, allowed)
if scoped is not None:
filtered_jobs[job_type] = scoped
if not filtered_jobs:
return None
return _wrap_envelope(topic, filtered_jobs)
if kind == "reshape_stats":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
cameras_block = parsed_payload.get("cameras")
if isinstance(cameras_block, dict):
filtered_cameras = {
name: data for name, data in cameras_block.items() if name in allowed
}
reshaped = dict(parsed_payload)
reshaped["cameras"] = filtered_cameras
return _wrap_envelope(topic, reshaped)
return full_message
return None
class WebSocket(WebSocket_): # type: ignore[misc]
def unhandled_error(self, error: Any) -> None:
"""
@@ -183,6 +501,10 @@ class WebSocketClient(Communicator):
self.websocket_thread.start()
def publish(self, topic: str, payload: Any, _: bool = False) -> None:
if self.websocket_server is None:
logger.debug("Skipping message, websocket not connected yet")
return
try:
ws_message = json.dumps(
{
@@ -195,14 +517,42 @@ class WebSocketClient(Communicator):
logger.debug(f"payload for {topic} wasn't text. Skipping...")
return
if self.websocket_server is None:
logger.debug("Skipping message, websocket not connected yet")
all_cameras = set(self.config.cameras.keys())
all_zones = _collect_zone_names(self.config)
scope = _classify_outbound(topic, all_cameras, all_zones)
if scope[0] == "drop":
return
try:
self.websocket_server.manager.broadcast(ws_message)
except ConnectionResetError:
pass
# Pre-parse payload once for topics that need to read its contents.
parsed_payload: Any = None
if scope[0] in (
"payload_camera",
"reshape_by_camera_key",
"reshape_job_state",
"reshape_stats",
):
parsed_payload = _parse_json_payload(payload)
if parsed_payload is None:
# malformed payload — fail closed
return
manager = self.websocket_server.manager
with manager.lock:
websockets = list(manager.websockets.values())
for ws in websockets:
if getattr(ws, "terminated", False):
continue
message = _materialize_for_ws(
ws, topic, ws_message, scope, parsed_payload, self.config
)
if message is None:
continue
try:
ws.send(message)
except (ConnectionResetError, BrokenPipeError, ValueError):
pass
def stop(self) -> None:
if self.websocket_server is not None:
+1 -1
View File
@@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel):
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
)
model: str = Field(
default="gpt-4o",
default="",
title="Model",
description="The model to use from the provider for generating descriptions or summaries.",
)
+3 -2
View File
@@ -26,6 +26,7 @@ class CameraConfigUpdateEnum(str, Enum):
object_genai = "object_genai"
onvif = "onvif"
record = "record"
refresh = "refresh" # signals the camera maintainer to recycle the camera process
remove = "remove" # for removing a camera
review = "review"
review_genai = "review_genai"
@@ -84,8 +85,8 @@ class CameraConfigUpdateSubscriber:
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
) -> None:
if update_type == CameraConfigUpdateEnum.add:
self.config.cameras[camera] = updated_config
self.camera_configs[camera] = updated_config
shared = self.config.cameras.setdefault(camera, updated_config)
self.camera_configs[camera] = shared
return
elif update_type == CameraConfigUpdateEnum.remove:
self.config.cameras.pop(camera, None)
+46 -3
View File
@@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
)
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
for profile_name, profile in camera_config.profiles.items():
if profile.zones:
for zone_name in profile.zones:
if zone_name not in camera_config.zones:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"zone '{zone_name}' that does not exist on the base config"
)
if profile.motion and profile.motion.mask:
for mask_name in profile.motion.mask:
if mask_name not in camera_config.motion.mask:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"motion mask '{mask_name}' that does not exist on the base config"
)
if profile.objects:
for mask_name in profile.objects.mask or {}:
if mask_name not in (camera_config.objects.mask or {}):
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' that does not exist on the base config"
)
for label, filter_config in (profile.objects.filters or {}).items():
base_filter = (camera_config.objects.filters or {}).get(label)
profile_filter_masks = (
filter_config.mask if filter_config else None
) or {}
base_filter_masks = (base_filter.mask if base_filter else None) or {}
for mask_name in profile_filter_masks:
if mask_name not in base_filter_masks:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' for '{label}' that does not exist "
f"on the base config"
)
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
"""Verify that required_zones are specified when autotracking is enabled."""
if (
@@ -629,10 +670,11 @@ class FrigateConfig(FrigateBaseModel):
# 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":
@@ -951,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
verify_required_zones_exist(camera_config)
verify_profile_overrides_match_base(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)
verify_objects_track(camera_config, labelmap_objects)
+2
View File
@@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
# Attribute & Object constants
DEFAULT_ATTRIBUTE_LABEL_MAP = {
+47 -4
View File
@@ -9,6 +9,7 @@ import logging
import os
import shutil
import threading
import time
from ruamel.yaml import YAML
@@ -25,7 +26,15 @@ from frigate.const import (
REPLAY_DIR,
THUMB_DIR,
)
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
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
@@ -49,6 +58,7 @@ class DebugReplayManager:
self.clip_path: str | None = None
self.start_ts: float | None = None
self.end_ts: float | None = None
self._job_state_publisher = JobStatePublisher()
@property
def active(self) -> bool:
@@ -150,6 +160,7 @@ class DebugReplayManager:
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).
@@ -158,11 +169,27 @@ class DebugReplayManager:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
frigate_config.cameras[replay_name],
)
frigate_config.cameras.pop(replay_name, None)
if replay_name is not None:
self._cleanup_db(replay_name)
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)
@@ -211,6 +238,10 @@ class DebugReplayManager:
zone_dump.setdefault("coordinates", zone_config.coordinates)
zones_dict[zone_name] = zone_dump
# Extract LPR and face recognition configs
lpr_dict = source_config.lpr.model_dump()
face_recognition_dict = source_config.face_recognition.model_dump()
# Extract motion config (exclude runtime fields)
motion_dict = {}
if source_config.motion is not None:
@@ -219,11 +250,23 @@ class DebugReplayManager:
"frame_shape",
"raw_mask",
"mask",
"improved_contrast_enabled",
"enabled_in_config",
"rasterized_mask",
}
)
if source_config.motion.mask:
motion_dict["mask"] = {
mask_id: (
mask_cfg.model_dump(
exclude={"raw_coordinates", "enabled_in_config"}
)
if mask_cfg is not None
else None
)
for mask_id, mask_cfg in source_config.motion.mask.items()
}
return {
"enabled": True,
"ffmpeg": {
@@ -248,8 +291,8 @@ class DebugReplayManager:
},
"birdseye": {"enabled": False},
"audio": {"enabled": False},
"lpr": {"enabled": False},
"face_recognition": {"enabled": False},
"lpr": lpr_dict,
"face_recognition": face_recognition_dict,
}
def _cleanup_db(self, camera_name: str) -> None:
+14 -1
View File
@@ -282,6 +282,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
@@ -310,9 +317,15 @@ 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"})
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
self.compiled_model = self.ov_core.compile_model(
model=model_path, device_name=device
+8 -1
View File
@@ -98,10 +98,17 @@ class EmbeddingMaintainer(threading.Thread):
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.detect,
CameraConfigUpdateEnum.face_recognition,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.lpr,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.object_genai,
CameraConfigUpdateEnum.review,
CameraConfigUpdateEnum.review_genai,
CameraConfigUpdateEnum.semantic_search,
CameraConfigUpdateEnum.zones,
],
)
self.enrichment_config_subscriber = ConfigSubscriber("config/")
@@ -232,7 +239,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()
):
+5 -2
View File
@@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess):
threading.current_thread().name = "process:audio_manager"
if self.config.audio_transcription.enabled:
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",
@@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
if (
self.config.audio_transcription.enabled
self.camera_config.audio_transcription.enabled
and self.audio_transcription_model_runner is not None
):
# init the transcription processor for this camera
+94 -155
View File
@@ -1,21 +1,25 @@
"""Generative AI module for Frigate."""
import datetime
import importlib
import json
import logging
import os
import re
from typing import Any, Callable, Optional
from typing import Any, AsyncGenerator, Callable, Optional
import numpy as np
from playhouse.shortcuts import model_to_dict
from pydantic import ValidationError
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.const import CLIPS_DIR
from frigate.data_processing.post.types import ReviewMetadata
from frigate.genai.manager import GenAIClientManager
from frigate.genai.prompts import (
build_object_description_prompt,
build_review_description_prompt,
build_review_description_response_format,
build_review_summary_prompt,
)
from frigate.models import Event
logger = logging.getLogger(__name__)
@@ -46,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
class GenAIClient:
"""Generative AI client for Frigate."""
def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None:
def __init__(
self,
genai_config: GenAIConfig,
timeout: int = 120,
validate_model: bool = True,
) -> None:
self.genai_config: GenAIConfig = genai_config
self.timeout = timeout
self.validate_model = validate_model
self.provider = self._init_provider()
def generate_review_description(
@@ -61,75 +71,14 @@ class GenAIClient:
activity_context_prompt: str,
) -> ReviewMetadata | None:
"""Generate a description for the review item activity."""
context_prompt = build_review_description_prompt(
review_data,
thumbnails,
concerns,
preferred_language,
activity_context_prompt,
)
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
- {concern_list}"""
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
context_prompt = f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
## Response Field Guidelines
Respond with a JSON object matching the provided schema. Field-specific guidance:
- `observations`: Include the very start of the activity for example, a vehicle entering the frame or pulling into the driveway even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `` separator in "Objects in Scene"), always use their name do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
- `shortSummary`: Briefly summarize the primary activity across the observations.
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
## Sequence Details
- Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activitythey simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
logger.debug(
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
)
@@ -143,25 +92,7 @@ Each line represents a detection state, not necessarily unique individuals. The
) as f:
f.write(context_prompt)
# Build JSON schema for structured output from ReviewMetadata model
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
response_format = {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
response_format = build_review_description_response_format(concerns)
response = self._send(context_prompt, thumbnails, response_format)
@@ -240,61 +171,9 @@ Each line represents a detection state, not necessarily unique individuals. The
debug_save: bool,
) -> str | None:
"""Generate a summary of review item descriptions over a period of time."""
time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
timeline_summary_prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: Normal, Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
timeline_summary_prompt += "\n\nEvents:\n"
for event in events:
timeline_summary_prompt += f"\n{event}\n"
if preferred_language:
timeline_summary_prompt += f"\nProvide your answer in {preferred_language}"
timeline_summary_prompt = build_review_summary_prompt(
start_ts, end_ts, events, preferred_language
)
if debug_save:
with open(
@@ -326,10 +205,7 @@ Guidelines:
) -> Optional[str]:
"""Generate a description for the frame."""
try:
prompt = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
).format(**model_to_dict(event))
prompt = build_object_description_prompt(camera_config, event)
except KeyError as e:
logger.error(f"Invalid key in GenAI prompt: {e}")
return None
@@ -346,8 +222,15 @@ Guidelines:
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
enable_thinking: bool = False,
) -> Optional[str]:
"""Submit a request to the provider."""
"""Submit a request to the provider.
``enable_thinking`` is honored only by providers that report
``supports_toggleable_thinking``. Description-style callers leave it
at the default (off) since synthesis tasks don't benefit from
reasoning traces.
"""
return None
@property
@@ -359,6 +242,11 @@ Guidelines:
"""
return True
@property
def supports_toggleable_thinking(self) -> bool:
"""Whether the configured model exposes a per-request thinking toggle."""
return False
def list_models(self) -> list[str]:
"""Return the list of model names available from this provider.
@@ -402,6 +290,7 @@ Guidelines:
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""
Send chat messages to LLM with optional tool definitions.
@@ -425,11 +314,17 @@ Guidelines:
- 'none': Model must not call tools
- 'required': Model must call at least one tool
- Or a dict specifying a specific tool to call
**kwargs: Additional provider-specific parameters.
enable_thinking: Per-request thinking toggle. None means use the
provider default. Ignored by providers without a per-request
toggle (see `supports_toggleable_thinking`).
Returns:
Dictionary with:
- 'content': Optional[str] - The text response from the LLM, None if tool calls
- 'reasoning': Optional[str] - The separated reasoning/thinking trace
if the model emitted one (e.g. via OpenAI-compatible
`reasoning_content`). None when the model does not surface a
trace or the provider does not parse it.
- 'tool_calls': Optional[List[Dict]] - List of tool calls if LLM wants to call tools.
Each tool call dict has:
- 'id': str - Unique identifier for this tool call
@@ -441,6 +336,14 @@ Guidelines:
- 'length': Hit token limit
- 'error': An error occurred
Streaming counterpart `chat_with_tools_stream` yields
``(kind, value)`` tuples where ``kind`` is one of:
- 'content_delta': value is a string fragment of the answer
- 'reasoning_delta': value is a string fragment of the reasoning
trace (emitted before content for thinking models)
- 'stats': value is a usage stats dict
- 'message': value is the final dict shape described above
Raises:
NotImplementedError: If the provider doesn't implement this method.
"""
@@ -451,14 +354,50 @@ Guidelines:
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> AsyncGenerator[tuple[str, Any], None]:
"""Streaming counterpart to `chat_with_tools`.
Yields ``(kind, value)`` tuples where ``kind`` is one of:
- 'content_delta': value is a string fragment of the answer
- 'reasoning_delta': value is a string fragment of the reasoning
trace (emitted before content for thinking models)
- 'stats': value is a usage stats dict
- 'message': value is the final dict shape described in
`chat_with_tools`
Argument semantics including ``enable_thinking`` match
`chat_with_tools`. Providers that don't support streaming should
override this and yield an error 'message' event.
"""
logger.warning(
f"{self.__class__.__name__} does not support chat_with_tools_stream. "
"This method should be overridden by the provider implementation."
)
yield (
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
)
def load_providers() -> None:
package_dir = os.path.dirname(__file__)
for filename in os.listdir(package_dir):
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
for filename in os.listdir(plugins_dir):
if filename.endswith(".py") and filename != "__init__.py":
module_name = f"frigate.genai.{filename[:-3]}"
module_name = f"frigate.genai.plugins.{filename[:-3]}"
importlib.import_module(module_name)
-315
View File
@@ -1,315 +0,0 @@
"""Azure OpenAI Provider for Frigate AI."""
import base64
import json
import logging
from typing import Any, AsyncGenerator, Optional
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.openai import _stats_from_openai_usage
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
provider: AzureOpenAI
def _init_provider(self) -> AzureOpenAI | None:
"""Initialize the client."""
try:
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
if not api_version:
logger.warning("Azure OpenAI url is missing API version.")
return None
except Exception as e:
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
return None
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def _send(
self,
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
) -> Optional[str]:
"""Submit a request to Azure OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
try:
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if response_format:
request_params["response_format"] = response_format
result = self.provider.chat.completions.create(**request_params)
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return None
if len(result.choices) > 0:
return str(result.choices[0].message.content.strip())
return None
def list_models(self) -> list[str]:
"""Return available model IDs from Azure OpenAI."""
try:
return sorted(m.id for m in self.provider.models.list().data)
except Exception as e:
logger.warning("Failed to list Azure OpenAI models: %s", e)
return []
def get_context_size(self) -> int:
"""Get the context window size for Azure OpenAI."""
return 128000
def chat_with_tools(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> dict[str, Any]:
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
or not hasattr(result, "choices")
or len(result.choices) == 0
):
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
tool_calls = None
if message.tool_calls:
tool_calls = []
for tool_call in message.tool_calls:
try:
arguments = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(
f"Failed to parse tool call arguments: {e}, "
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
)
arguments = {}
tool_calls.append(
{
"id": tool_call.id if hasattr(tool_call, "id") else "",
"name": tool_call.function.name
if hasattr(tool_call.function, "name")
else "",
"arguments": arguments,
}
)
finish_reason = "error"
if hasattr(choice, "finish_reason") and choice.finish_reason:
finish_reason = choice.finish_reason
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
return {
"content": content,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for Azure OpenAI models.
"""
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
# Use streaming API
content_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
if not chunk or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message
full_content = "".join(content_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
if tool_calls_by_index:
tool_calls_list = []
for tc in tool_calls_by_index.values():
try:
# Parse accumulated arguments as JSON
parsed_args = json.loads(tc["arguments"])
except (json.JSONDecodeError, Exception):
parsed_args = tc["arguments"]
tool_calls_list.append(
{
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
}
)
finish_reason = "tool_calls"
if usage_stats is not None:
yield ("stats", usage_stats)
yield (
"message",
{
"content": full_content,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
)
except Exception as e:
logger.warning("Azure OpenAI streaming returned an error: %s", str(e))
yield (
"message",
{
"content": None,
"tool_calls": None,
"finish_reason": "error",
},
)
+12 -7
View File
@@ -6,7 +6,7 @@ no chat feature is active) are never initialized.
"""
import logging
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Any, Optional
from frigate.config import FrigateConfig
from frigate.config.camera.genai import GenAIConfig, GenAIRoleEnum
@@ -108,11 +108,16 @@ class GenAIClientManager:
name = self._role_map.get(GenAIRoleEnum.embeddings)
return self._get_client(name) if name else None
def list_models(self) -> dict[str, list[str]]:
"""Return available models keyed by config entry name."""
result: dict[str, list[str]] = {}
for name in self._configs:
def list_models(self) -> dict[str, dict[str, Any]]:
"""Return per-entry model lists and capabilities, keyed by config entry name."""
result: dict[str, dict[str, Any]] = {}
for name, genai_cfg in self._configs.items():
client = self._get_client(name)
if client:
result[name] = client.list_models()
if not client:
continue
result[name] = {
"models": client.list_models(),
"roles": [r.value for r in genai_cfg.roles],
"supports_toggleable_thinking": client.supports_toggleable_thinking,
}
return result
+1
View File
@@ -0,0 +1 @@
"""GenAI provider plugins."""
+53
View File
@@ -0,0 +1,53 @@
"""Azure OpenAI Provider for Frigate AI.
Azure OpenAI exposes the same chat completions API as OpenAI once the
client is constructed, so this provider inherits all transport, streaming,
reasoning, and tool-calling logic from :class:`OpenAIClient` and only
overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
malformed; :class:`GenAIClientManager` catches the exception and
disables the provider.
- Context size: Azure does not expose a per-model ``max_model_len`` field
reliably, so we keep the historical 128K default rather than the
model-name heuristic used by OpenAI.
"""
import logging
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider
from frigate.genai.plugins.openai import OpenAIClient
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.")
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K."""
return 128000
@@ -1,5 +1,7 @@
"""Gemini Provider for Frigate AI."""
import base64
import binascii
import json
import logging
from typing import Any, AsyncGenerator, Optional
@@ -14,6 +16,27 @@ from frigate.genai import GenAIClient, register_genai_provider
logger = logging.getLogger(__name__)
def _decode_thought_signature(value: Any) -> Optional[bytes]:
"""Decode a base64-encoded thought_signature carried across conversation turns."""
if not value:
return None
if isinstance(value, bytes):
return value
if isinstance(value, str):
try:
return base64.b64decode(value)
except (binascii.Error, ValueError):
return None
return None
def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]:
"""Encode bytes thought_signature as base64 so it survives JSON-friendly transport."""
if not signature:
return None
return base64.b64encode(signature).decode("ascii")
def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]:
"""Build a stats dict from a Gemini usage_metadata object."""
prompt_tokens = getattr(usage, "prompt_token_count", None)
@@ -62,6 +85,7 @@ class GeminiClient(GenAIClient):
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
enable_thinking: bool = False,
) -> Optional[str]:
"""Submit a request to Gemini."""
contents = [prompt] + [
@@ -119,11 +143,14 @@ class GeminiClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""
Send chat messages to Gemini with optional tool definitions.
Implements function calling/tool usage for Gemini models.
Implements function calling/tool usage for Gemini models. Thinking is
configured at the model level for Gemini, so ``enable_thinking`` is
accepted for interface parity and ignored.
"""
try:
# Convert messages to Gemini format
@@ -165,11 +192,17 @@ class GeminiClient(GenAIClient):
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
)
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
@@ -248,6 +281,13 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models (Gemini 2.5+) to include their
# reasoning trace as separate `thought` parts so we can surface
# it on the reasoning channel. Older models ignore this field.
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
@@ -262,19 +302,24 @@ class GeminiClient(GenAIClient):
if not response or not response.candidates:
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
candidate = response.candidates[0]
content = None
reasoning_parts: list[str] = []
tool_calls = None
# Extract content and tool calls from response
# Extract content, reasoning, and tool calls from response
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
content = part.text.strip()
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
else:
content = part.text.strip()
elif part.function_call:
# Handle function call
if tool_calls is None:
@@ -294,9 +339,14 @@ class GeminiClient(GenAIClient):
"id": part.function_call.name or "",
"name": part.function_call.name or "",
"arguments": arguments,
"thought_signature": _encode_thought_signature(
getattr(part, "thought_signature", None)
),
}
)
reasoning = "".join(reasoning_parts).strip() or None
# Determine finish reason
finish_reason = "error"
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
@@ -322,6 +372,7 @@ class GeminiClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -330,6 +381,7 @@ class GeminiClient(GenAIClient):
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -339,6 +391,7 @@ class GeminiClient(GenAIClient):
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -348,11 +401,14 @@ class GeminiClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for Gemini models.
``enable_thinking`` is accepted for interface parity; Gemini configures
thinking at the model level, so it is ignored here.
"""
try:
# Convert messages to Gemini format
@@ -394,11 +450,17 @@ class GeminiClient(GenAIClient):
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
)
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
@@ -477,12 +539,19 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models to include their reasoning trace
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
# Use streaming API
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
@@ -519,12 +588,16 @@ class GeminiClient(GenAIClient):
]:
finish_reason = "error"
# Extract content and tool calls from chunk
# Extract content, reasoning, and tool calls from chunk
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
content_parts.append(part.text)
yield ("content_delta", part.text)
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
yield ("reasoning_delta", part.text)
else:
content_parts.append(part.text)
yield ("content_delta", part.text)
elif part.function_call:
# Handle function call
try:
@@ -553,6 +626,7 @@ class GeminiClient(GenAIClient):
"id": tool_call_id,
"name": tool_call_name,
"arguments": "",
"thought_signature": None,
}
# Accumulate arguments
@@ -563,8 +637,16 @@ class GeminiClient(GenAIClient):
else str(arguments)
)
# Capture latest thought_signature for this call
chunk_sig = getattr(part, "thought_signature", None)
if chunk_sig:
tool_calls_by_index[found_index][
"thought_signature"
] = chunk_sig
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@@ -582,6 +664,9 @@ class GeminiClient(GenAIClient):
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
"thought_signature": _encode_thought_signature(
tc.get("thought_signature")
),
}
)
finish_reason = "tool_calls"
@@ -593,6 +678,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -604,6 +690,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -616,6 +703,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -4,7 +4,7 @@ import base64
import io
import json
import logging
from typing import Any, AsyncGenerator, Optional
from typing import Any, AsyncGenerator, Optional, cast
import httpx
import numpy as np
@@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1]
def _fetch_llama_props(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 = requests.get(
f"{base_url}/props",
params={"model": model},
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except Exception:
response = requests.get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
@@ -99,6 +122,7 @@ class LlamaCppClient(GenAIClient):
_supports_vision: bool
_supports_audio: bool
_supports_tools: bool
_supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None
_media_marker: str
@@ -112,6 +136,7 @@ class LlamaCppClient(GenAIClient):
self._supports_vision = False
self._supports_audio = False
self._supports_tools = False
self._supports_reasoning = False
self._image_token_cache = {}
self._text_baseline_tokens = None
self._media_marker = "<__media__>"
@@ -127,6 +152,10 @@ class LlamaCppClient(GenAIClient):
else:
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
if not self.validate_model:
# Probe path
return base_url
configured_model = self.genai_config.model
info = self._get_model_info(base_url, configured_model)
@@ -137,15 +166,17 @@ class LlamaCppClient(GenAIClient):
self._supports_vision = info["supports_vision"]
self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"]
self._supports_reasoning = info["supports_reasoning"]
self._media_marker = info["media_marker"]
logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
configured_model,
self._context_size or "unknown",
self._supports_vision,
self._supports_audio,
self._supports_tools,
self._supports_reasoning,
)
return base_url
@@ -173,6 +204,7 @@ class LlamaCppClient(GenAIClient):
"supports_vision": False,
"supports_audio": False,
"supports_tools": False,
"supports_reasoning": False,
"media_marker": "<__media__>",
}
@@ -239,21 +271,7 @@ class LlamaCppClient(GenAIClient):
info["supports_tools"] = True
try:
try:
response = requests.get(
f"{base_url}/props",
params={"model": configured_model},
timeout=10,
)
response.raise_for_status()
props = response.json()
except Exception:
response = requests.get(
f"{base_url}/upstream/{configured_model}/props",
timeout=10,
)
response.raise_for_status()
props = response.json()
props = _fetch_llama_props(base_url, configured_model)
if info["context_size"] is None:
default_settings = props.get("default_generation_settings", {})
@@ -266,10 +284,17 @@ class LlamaCppClient(GenAIClient):
info["supports_vision"] = bool(modalities.get("vision", False))
info["supports_audio"] = bool(modalities.get("audio", False))
chat_caps = props.get("chat_template_caps") or {}
if not info["supports_tools"]:
chat_caps = props.get("chat_template_caps", {})
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
# llama.cpp does not advertise per-template reasoning support, so
# detect it by looking for the `enable_thinking` toggle variable
# in the Jinja chat template itself.
chat_template = props.get("chat_template") or ""
info["supports_reasoning"] = "enable_thinking" in chat_template
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
info["media_marker"] = media_marker
@@ -287,6 +312,7 @@ class LlamaCppClient(GenAIClient):
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
enable_thinking: bool = False,
) -> Optional[str]:
"""Submit a request to llama.cpp server."""
if self.provider is None:
@@ -314,7 +340,7 @@ class LlamaCppClient(GenAIClient):
)
# Build request payload with llama.cpp native options
payload = {
payload: dict[str, Any] = {
"model": self.genai_config.model,
"messages": [
{
@@ -328,6 +354,9 @@ class LlamaCppClient(GenAIClient):
if response_format:
payload["response_format"] = response_format
if self.supports_toggleable_thinking:
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
@@ -364,6 +393,10 @@ class LlamaCppClient(GenAIClient):
"""Whether the loaded model supports tool/function calling."""
return self._supports_tools
@property
def supports_toggleable_thinking(self) -> bool:
return self._supports_reasoning
def list_models(self) -> list[str]:
"""Return available model IDs from the llama.cpp server."""
base_url = self.provider or (
@@ -491,6 +524,7 @@ class LlamaCppClient(GenAIClient):
tools: Optional[list[dict[str, Any]]],
tool_choice: Optional[str],
stream: bool = False,
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""Build request payload for chat completions (sync or stream)."""
openai_tool_choice = None
@@ -506,31 +540,47 @@ class LlamaCppClient(GenAIClient):
"messages": messages,
"model": self.genai_config.model,
}
if stream:
payload["stream"] = True
payload["stream_options"] = {"include_usage": True}
payload["timings_per_token"] = True
if tools:
payload["tools"] = tools
if openai_tool_choice is not None:
payload["tool_choice"] = openai_tool_choice
if enable_thinking is not None and self._supports_reasoning:
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
provider_opts = {
k: v for k, v in self.provider_options.items() if k != "context_size"
}
payload.update(provider_opts)
payload.update(self.genai_config.runtime_options)
return payload
def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]:
"""Parse OpenAI-style choice into {content, tool_calls, finish_reason}."""
"""Parse OpenAI-style choice into {content, reasoning, tool_calls, finish_reason}.
llama.cpp's `--reasoning-format` puts the trace in
`message.reasoning_content` (preferred) or `message.thinking`; both
keys are accepted so different builds work without configuration.
"""
message = choice.get("message", {})
content = message.get("content")
content = content.strip() if content else None
reasoning = message.get("reasoning_content") or message.get("thinking")
reasoning = reasoning.strip() if reasoning else None
tool_calls = parse_tool_calls_from_message(message)
finish_reason = choice.get("finish_reason") or (
"tool_calls" if tool_calls else "stop" if content else "error"
)
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -559,6 +609,31 @@ class LlamaCppClient(GenAIClient):
)
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 = _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,
@@ -583,30 +658,46 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768
content = []
for text in texts:
content.append({"prompt_string": text})
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 = base64.b64encode(to_encode).decode("utf-8")
# prompt_string must contain the server's media marker placeholder.
# The marker is randomized per server startup (read from /props).
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded], # type: ignore[dict-item]
}
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 requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
)
try:
response = requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": content},
timeout=self.timeout,
)
response.raise_for_status()
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
@@ -669,6 +760,7 @@ class LlamaCppClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""
Send chat messages to llama.cpp server with optional tool definitions.
@@ -686,7 +778,13 @@ class LlamaCppClient(GenAIClient):
"finish_reason": "error",
}
try:
payload = self._build_payload(messages, tools, tool_choice, stream=False)
payload = self._build_payload(
messages,
tools,
tool_choice,
stream=False,
enable_thinking=enable_thinking,
)
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
@@ -734,6 +832,7 @@ class LlamaCppClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> AsyncGenerator[tuple[str, Any], None]:
"""Stream chat with tools via OpenAI-compatible streaming API."""
if self.provider is None:
@@ -750,8 +849,15 @@ class LlamaCppClient(GenAIClient):
)
return
try:
payload = self._build_payload(messages, tools, tool_choice, stream=True)
payload = self._build_payload(
messages,
tools,
tool_choice,
stream=True,
enable_thinking=enable_thinking,
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
@@ -781,6 +887,15 @@ class LlamaCppClient(GenAIClient):
delta = choices[0].get("delta", {})
if choices[0].get("finish_reason"):
finish_reason = choices[0]["finish_reason"]
# llama.cpp emits separated thinking under
# reasoning_content (preferred) or thinking before any
# content tokens arrive
reasoning_delta = delta.get("reasoning_content") or delta.get(
"thinking"
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
if delta.get("content"):
content_parts.append(delta["content"])
yield ("content_delta", delta["content"])
@@ -806,6 +921,7 @@ class LlamaCppClient(GenAIClient):
)
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index)
if tool_calls_list:
finish_reason = "tool_calls"
@@ -813,6 +929,7 @@ class LlamaCppClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -98,6 +98,22 @@ class OllamaClient(GenAIClient):
provider: ApiClient | None
provider_options: dict[str, Any]
_supports_thinking_cache: Optional[bool] = None
@property
def supports_toggleable_thinking(self) -> bool:
if self._supports_thinking_cache is not None:
return self._supports_thinking_cache
if self.provider is None:
return False
try:
response = self.provider.show(self.genai_config.model)
capabilities = response.get("capabilities") or []
self._supports_thinking_cache = "thinking" in capabilities
except Exception as e:
logger.debug("Failed to query Ollama model capabilities: %s", e)
self._supports_thinking_cache = False
return self._supports_thinking_cache
def _auth_headers(self) -> dict | None:
if self.genai_config.api_key:
@@ -118,6 +134,9 @@ class OllamaClient(GenAIClient):
timeout=self.timeout,
headers=self._auth_headers(),
)
if not self.validate_model:
# Probe path
return client
# ensure the model is available locally
response = client.show(self.genai_config.model)
if response.get("error"):
@@ -175,6 +194,7 @@ class OllamaClient(GenAIClient):
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
enable_thinking: bool = False,
) -> Optional[str]:
"""Submit a request to Ollama"""
if self.provider is None:
@@ -191,6 +211,8 @@ class OllamaClient(GenAIClient):
schema = response_format.get("json_schema", {}).get("schema")
if schema:
ollama_options["format"] = self._clean_schema_for_ollama(schema)
if self.supports_toggleable_thinking:
ollama_options["think"] = enable_thinking
logger.debug(
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
"has_format=%s, options=%s",
@@ -271,6 +293,7 @@ class OllamaClient(GenAIClient):
tools: Optional[list[dict[str, Any]]],
tool_choice: Optional[str],
stream: bool = False,
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""Build request_messages and params for chat (sync or stream)."""
request_messages = []
@@ -309,11 +332,14 @@ class OllamaClient(GenAIClient):
"model": self.genai_config.model,
"messages": request_messages,
**self.provider_options,
**self.genai_config.runtime_options,
}
if stream:
request_params["stream"] = True
if tools:
request_params["tools"] = tools
if enable_thinking is not None and self.supports_toggleable_thinking:
request_params["think"] = enable_thinking
return request_params
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
@@ -336,6 +362,9 @@ class OllamaClient(GenAIClient):
response.get("done"),
)
content = message.get("content", "").strip() if message.get("content") else None
reasoning = (
message.get("thinking", "").strip() if message.get("thinking") else None
)
tool_calls = parse_tool_calls_from_message(message)
finish_reason = "error"
if response.get("done"):
@@ -348,6 +377,7 @@ class OllamaClient(GenAIClient):
finish_reason = "stop"
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -357,6 +387,7 @@ class OllamaClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
if self.provider is None:
logger.warning(
@@ -369,7 +400,11 @@ class OllamaClient(GenAIClient):
}
try:
request_params = self._build_request_params(
messages, tools, tool_choice, stream=False
messages,
tools,
tool_choice,
stream=False,
enable_thinking=enable_thinking,
)
response = self.provider.chat(**request_params)
return self._message_from_response(response)
@@ -393,6 +428,7 @@ class OllamaClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> AsyncGenerator[tuple[str, Any], None]:
"""Stream chat with tools; yields content deltas then final message.
@@ -422,7 +458,11 @@ class OllamaClient(GenAIClient):
"Ollama: tools provided, using non-streaming call for tool support"
)
request_params = self._build_request_params(
messages, tools, tool_choice, stream=False
messages,
tools,
tool_choice,
stream=False,
enable_thinking=enable_thinking,
)
async_client = OllamaAsyncClient(
host=self.genai_config.base_url,
@@ -431,6 +471,9 @@ class OllamaClient(GenAIClient):
)
response = await async_client.chat(**request_params)
result = self._message_from_response(response)
reasoning = result.get("reasoning")
if reasoning:
yield ("reasoning_delta", reasoning)
content = result.get("content")
if content:
yield ("content_delta", content)
@@ -441,7 +484,11 @@ class OllamaClient(GenAIClient):
return
request_params = self._build_request_params(
messages, tools, tool_choice, stream=True
messages,
tools,
tool_choice,
stream=True,
enable_thinking=enable_thinking,
)
async_client = OllamaAsyncClient(
host=self.genai_config.base_url,
@@ -449,6 +496,7 @@ class OllamaClient(GenAIClient):
headers=self._auth_headers(),
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
final_message: dict[str, Any] | None = None
final_chunk: Any = None
stream = await async_client.chat(**request_params)
@@ -456,6 +504,10 @@ class OllamaClient(GenAIClient):
if not chunk or "message" not in chunk:
continue
msg = chunk.get("message", {})
reasoning_delta = msg.get("thinking") or ""
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
delta = msg.get("content") or ""
if delta:
content_parts.append(delta)
@@ -463,8 +515,10 @@ class OllamaClient(GenAIClient):
if chunk.get("done"):
final_chunk = chunk
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
final_message = {
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": None,
"finish_reason": "stop",
}
@@ -481,6 +535,7 @@ class OllamaClient(GenAIClient):
"message",
{
"content": "".join(content_parts).strip() or None,
"reasoning": "".join(reasoning_parts).strip() or None,
"tool_calls": None,
"finish_reason": "stop",
},
@@ -38,7 +38,11 @@ class OpenAIClient(GenAIClient):
context_size: Optional[int] = None
def _init_provider(self) -> OpenAI:
"""Initialize the client."""
"""Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
"""
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
provider_opts = {
@@ -57,6 +61,7 @@ class OpenAIClient(GenAIClient):
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
enable_thinking: bool = False,
) -> Optional[str]:
"""Submit a request to OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
@@ -183,11 +188,14 @@ class OpenAIClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> dict[str, Any]:
"""
Send chat messages to OpenAI with optional tool definitions.
Implements function calling/tool usage for OpenAI models.
Implements function calling/tool usage for OpenAI models. The OpenAI
chat completions API does not expose a per-request thinking toggle,
so ``enable_thinking`` is accepted for interface parity and ignored.
"""
try:
openai_tool_choice = None
@@ -203,6 +211,7 @@ class OpenAIClient(GenAIClient):
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if tools:
@@ -219,7 +228,7 @@ class OpenAIClient(GenAIClient):
}
request_params.update(provider_opts)
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
result = self.provider.chat.completions.create(**request_params)
if (
result is None
@@ -235,6 +244,10 @@ class OpenAIClient(GenAIClient):
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
tool_calls = None
if message.tool_calls:
@@ -269,6 +282,7 @@ class OpenAIClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -277,6 +291,7 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI request timed out: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -284,6 +299,7 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI returned an error: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -293,11 +309,15 @@ class OpenAIClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
enable_thinking: Optional[bool] = None,
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for OpenAI models.
The OpenAI chat completions API does not expose a per-request thinking
toggle, so ``enable_thinking`` is accepted for interface parity and
ignored.
"""
try:
openai_tool_choice = None
@@ -315,6 +335,7 @@ class OpenAIClient(GenAIClient):
"timeout": self.timeout,
"stream": True,
"stream_options": {"include_usage": True},
**self.genai_config.runtime_options,
}
if tools:
@@ -333,11 +354,12 @@ class OpenAIClient(GenAIClient):
# Use streaming API
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
stream = self.provider.chat.completions.create(**request_params)
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
@@ -354,6 +376,15 @@ class OpenAIClient(GenAIClient):
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
@@ -382,6 +413,7 @@ class OpenAIClient(GenAIClient):
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@@ -410,6 +442,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -421,6 +454,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -431,6 +465,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
+744
View File
@@ -0,0 +1,744 @@
"""Prompt and response-format builders for GenAI features.
Centralizes the per-feature prompt framing and structured-output schema
shaping so provider clients in :mod:`frigate.genai.plugins` only handle
transport.
"""
import datetime
from typing import Any, Dict, List, Optional
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.config.ui import UnitSystemEnum
from frigate.data_processing.post.types import ReviewMetadata
from frigate.models import Event
def build_review_description_prompt(
review_data: dict[str, Any],
thumbnails: list[bytes],
concerns: list[str],
preferred_language: str | None,
activity_context_prompt: str,
) -> str:
"""Build the prompt for review activity description generation."""
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return (
"\n- `other_concerns` (list of strings): Include a list of any of "
"the following concerns that are occurring:\n"
f" - {concern_list}"
)
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
return f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **Treat "Objects in Scene" as the list of tracked subjects to describe.** Do not introduce additional people or vehicles that are not present in this list. You may freely reference other items, surfaces, and environmental details visible in the frames when describing what the listed subjects are doing.
- **Describe the most likely activity from visible cues across the sequence** the subject's path, what they are carrying, and what they interact with. Avoid asserting completed outcomes you do not observe; describe in-progress actions rather than results.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo 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 activitythey simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
def build_review_description_response_format(concerns: list[str]) -> dict[str, Any]:
"""Build the structured-output JSON schema for review descriptions.
Strips the `time` field (populated server-side) and drops
`other_concerns` when no concerns are configured.
"""
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
return {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
def build_review_summary_prompt(
start_ts: float,
end_ts: float,
events: list[dict[str, Any]],
preferred_language: str | None,
) -> str:
"""Build the prompt for a multi-event review summary."""
time_range = (
f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')}"
f" to "
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
)
prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: Normal, Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
prompt += "\n\nEvents:\n"
for event in events:
prompt += f"\n{event}\n"
if preferred_language:
prompt += f"\nProvide your answer in {preferred_language}"
return prompt
def build_object_description_prompt(
camera_config: CameraConfig,
event: Event,
) -> str:
"""Build the prompt for a per-object description.
Pulls the per-label override from `objects.genai.object_prompts`, falling
back to the camera default, and interpolates event fields.
Raises:
KeyError: if the user-defined prompt template references an unknown
event field.
"""
template = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
)
return template.format(**model_to_dict(event))
def get_attribute_classifications(config: FrigateConfig) -> List[Dict[str, Any]]:
"""Return enabled custom classification models of `attribute` type.
Each entry: {"name": <model name>, "objects": [<object label>, ...]}.
These models attach attribute metadata to events on the listed object
types, which can later be filtered via the search_objects `attribute`
field.
"""
result: List[Dict[str, Any]] = []
for model_key, model_config in config.classification.custom.items():
if not model_config.enabled or model_config.object_config is None:
continue
if (
model_config.object_config.classification_type
!= ObjectClassificationType.attribute
):
continue
result.append(
{
"name": model_config.name or model_key,
"objects": list(model_config.object_config.objects or []),
}
)
return result
def get_tool_definitions(
semantic_search_enabled: bool = False,
attribute_classifications: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
Returns a list of tool definitions that can be used with OpenAI-compatible
function calling APIs. When semantic search is enabled, the search_objects
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included. When attribute classification models are configured, an
`attribute` parameter is exposed for filtering by their labels.
"""
search_objects_properties: Dict[str, Any] = {
"camera": {
"type": "string",
"description": "Camera name to filter by (optional).",
},
"label": {
"type": "string",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
},
"sub_label": {
"type": "string",
"description": (
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
),
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "List of zone names to filter by.",
},
"limit": {
"type": "integer",
"description": "Maximum number of objects to return (default: 25).",
"default": 25,
},
}
if attribute_classifications:
model_outline = "; ".join(
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
for m in attribute_classifications
)
search_objects_properties["attribute"] = {
"type": "string",
"description": (
"Filter by a classification attribute label produced by a "
"configured attribute classification model. Use this INSTEAD "
"of semantic_query when the user's request matches one of "
"these classifications. Configured models: "
f"{model_outline}. "
"Set the value to the attribute label that matches the user's "
"phrasing (case-sensitive)."
),
}
if semantic_search_enabled:
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
),
}
search_objects_description = (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
"type": "function",
"function": {
"name": "search_objects",
"description": search_objects_description,
"parameters": {
"type": "object",
"properties": search_objects_properties,
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a single camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera. "
"Operates on one camera at a time; call the tool again for each additional camera. "
"Wildcards and empty values are not accepted."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": (
"Exact name of a single camera to get live context for. "
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
),
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
def build_chat_system_prompt(
config: FrigateConfig,
allowed_cameras: List[str],
semantic_search_enabled: bool,
attribute_classifications: List[Dict[str, Any]],
) -> str:
"""Build the system prompt for the chat completion endpoint.
Composes the static framing with conditional sections describing the
available cameras, speed units, semantic-search routing guidance, and
configured attribute classifications.
"""
current_datetime = datetime.datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
current_time_str = current_datetime.strftime("%I:%M:%S %p")
cameras_info: List[str] = []
has_speed_zone = False
for camera_id in allowed_cameras:
if camera_id not in config.cameras:
continue
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
if has_speed_zone:
speed_unit = (
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
semantic_search_section = ""
if semantic_search_enabled:
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
attribute_classification_section = ""
if attribute_classifications:
model_lines = "\n".join(
f"- {m['name']}: applies to {', '.join(m['objects']) or 'any object'}"
for m in attribute_classifications
)
attribute_classification_section = (
"\n\nAttribute classification models are configured for the following object types:\n"
f"{model_lines}\n"
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
)
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
+8
View File
@@ -69,6 +69,14 @@ def build_assistant_message_for_conversation(
"name": tc["name"],
"arguments": json.dumps(tc.get("arguments") or {}),
},
# Gemini-only: opaque signature that must be echoed back on
# the same functionCall part in the next turn. Other providers
# do not set or read this.
**(
{"thought_signature": tc["thought_signature"]}
if tc.get("thought_signature")
else {}
),
}
for tc in tool_calls_raw
]
+146 -39
View File
@@ -1,4 +1,4 @@
"""Debug replay startup job: ffmpeg concat + camera config publish.
"""Debug replay startup job: ffmpeg remux + camera config publish.
The runner orchestrates the async portion of starting a debug replay
session. The DebugReplayManager (in frigate.debug_replay) owns session
@@ -12,6 +12,7 @@ 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
@@ -23,7 +24,7 @@ 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 Recordings
from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
@@ -114,6 +115,130 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
return cast(ModelSelect, query)
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 ValueError(
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.
@@ -126,6 +251,7 @@ class DebugReplayJobRunner(threading.Thread):
def __init__(
self,
job: DebugReplayJob,
source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
@@ -133,6 +259,7 @@ class DebugReplayJobRunner(threading.Thread):
) -> 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
@@ -183,7 +310,6 @@ class DebugReplayJobRunner(threading.Thread):
def run(self) -> None:
replay_name = self.job.replay_camera_name
os.makedirs(REPLAY_DIR, exist_ok=True)
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
self.job.status = JobStatusTypesEnum.running
@@ -192,23 +318,13 @@ class DebugReplayJobRunner(threading.Thread):
self._broadcast(force=True)
try:
recordings = query_recordings(
self.job.source_camera, self.job.start_ts, self.job.end_ts
)
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
ffmpeg_cmd = [
self.frigate_config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
*input_args,
"-c",
"copy",
"-movflags",
@@ -285,7 +401,7 @@ class DebugReplayJobRunner(threading.Thread):
self.replay_manager.clear_session()
_remove_silent(clip_path)
finally:
_remove_silent(concat_file)
self.source.cleanup(REPLAY_DIR)
_set_active_runner(None)
def _finalize_cancelled(self, clip_path: str) -> None:
@@ -309,52 +425,43 @@ def _remove_silent(path: str) -> None:
def start_debug_replay_job(
*,
source_camera: str,
start_ts: float,
end_ts: float,
source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
) -> str:
"""Validate, create job, start runner. Returns the job id.
Raises ValueError for bad params (camera missing, time range
invalid, no recordings) and RuntimeError if a session is already
active.
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_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source_camera}' not found")
if source.source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source.source_camera}' not found")
if end_ts <= start_ts:
raise ValueError("End time must be after start time")
source.validate()
recordings = query_recordings(source_camera, start_ts, end_ts)
if not recordings.count():
raise ValueError(
f"No recordings found for camera '{source_camera}' in the specified time range"
)
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
replay_manager.mark_starting(
source_camera=source_camera,
source_camera=source.source_camera,
replay_camera_name=replay_name,
start_ts=start_ts,
end_ts=end_ts,
start_ts=source.start_ts,
end_ts=source.end_ts,
)
job = DebugReplayJob(
source_camera=source_camera,
source_camera=source.source_camera,
replay_camera_name=replay_name,
start_ts=start_ts,
end_ts=end_ts,
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,
+2 -1
View File
@@ -167,8 +167,9 @@ class DetectorRunner(FrigateProcess):
# detect and send the output
self.start_time.value = datetime.datetime.now().timestamp()
mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame)
duration = datetime.datetime.now().timestamp() - self.start_time.value
duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs:
+23 -13
View File
@@ -342,20 +342,30 @@ def move_preview_frames(loc: str) -> None:
preview_holdover = os.path.join(CLIPS_DIR, "preview_restart_cache")
preview_cache = os.path.join(CACHE_DIR, "preview_frames")
if loc == "clips":
src = preview_cache
dst = preview_holdover
elif loc == "cache":
src = preview_holdover
dst = preview_cache
else:
return
try:
if loc == "clips":
shutil.move(preview_cache, preview_holdover)
elif loc == "cache":
if not os.path.exists(preview_holdover):
return
if not os.path.exists(src):
return
if not os.access(preview_holdover, os.R_OK | os.W_OK):
logger.error(
"Insufficient permissions on preview restart cache at %s",
preview_holdover,
)
return
shutil.move(src, dst)
shutil.move(preview_holdover, preview_cache)
except PermissionError:
logger.error(
"Insufficient permissions while moving preview restart cache from %s to %s",
src,
dst,
)
except shutil.Error:
logger.error("Failed to restore preview cache.")
logger.error(
"Failed to move preview restart cache from %s to %s",
src,
dst,
)
+2
View File
@@ -1331,6 +1331,8 @@ class PtzAutoTracker:
return self.tracked_object[camera]["region"]
def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras:
return
camera_config = self.config.cameras[camera]
if camera_config.onvif.autotracking.enabled:
+3 -1
View File
@@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
else:
chapters_path = self._build_chapter_metadata_file(recordings)
chapter_args = (
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
if chapters_path
else ""
)
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
@@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
# 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=kwargs["source_camera"],
source_camera=source.source_camera,
replay_camera_name="_replay_front",
start_ts=kwargs["start_ts"],
end_ts=kwargs["end_ts"],
start_ts=source.start_ts,
end_ts=source.end_ts,
)
return "job-1234"
+110 -1
View File
@@ -1,5 +1,9 @@
from unittest.mock import Mock
from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -71,3 +75,108 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
####################################################################################################################
################################### Credential redaction sentinel ################################################
####################################################################################################################
def test_config_response_redacts_mqtt_password_with_sentinel(self):
self.minimal_config["mqtt"]["user"] = "mqttuser"
self.minimal_config["mqtt"]["password"] = "supersecret"
app = super().create_app()
with AuthTestClient(app) as client:
response = client.get("/config")
assert response.status_code == 200
mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
####################################################################################################################
################################### POST /genai/probe Endpoint ##################################################
####################################################################################################################
def test_genai_probe_requires_admin(self):
app = super().create_app()
with AuthTestClient(app) as client:
response = client.post(
"/genai/probe",
json={"provider": "openai"},
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
assert response.status_code == 403
def test_genai_probe_returns_models_from_transient_client(self):
class FakeClient(GenAIClient):
def list_models(self):
return ["fake-model-a", "fake-model-b"]
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: FakeClient},
),
):
response = client.post(
"/genai/probe",
json={
"provider": "openai",
"api_key": "sk-test",
"base_url": "https://example.invalid",
},
)
assert response.status_code == 200
assert response.json() == {
"success": True,
"models": ["fake-model-a", "fake-model-b"],
}
def test_genai_probe_empty_list_is_treated_as_failure(self):
# The plugin's list_models() returns [] on connection failure rather
# than raising. The endpoint should surface that as success=false so
# the UI can show a meaningful error.
class EmptyClient(GenAIClient):
def list_models(self):
return []
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: EmptyClient},
),
):
response = client.post(
"/genai/probe",
json={"provider": "openai"},
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is False
assert "message" in payload
def test_genai_probe_handles_provider_failure(self):
class FailingClient(GenAIClient):
def list_models(self):
raise RuntimeError("provider unreachable")
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: FailingClient},
),
):
response = client.post(
"/genai/probe",
json={"provider": "openai"},
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is False
assert "message" in payload
+79
View File
@@ -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()
+55
View File
@@ -1673,5 +1673,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)
+8
View File
@@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase):
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
+55 -27
View File
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
from frigate.debug_replay import DebugReplayManager
from frigate.jobs.debug_replay import (
DebugReplayJob,
RecordingDebugReplaySource,
cancel_debug_replay_job,
get_active_runner,
start_debug_replay_job,
@@ -99,9 +100,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_unknown_camera(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="missing",
start_ts=100.0,
end_ts=200.0,
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,
@@ -110,9 +114,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_invalid_time_range(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="front",
start_ts=200.0,
end_ts=100.0,
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,
@@ -124,9 +131,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -154,9 +164,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
job_id = start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -191,9 +204,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -201,9 +217,12 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -269,9 +288,12 @@ class TestRunnerHappyPath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -340,9 +362,12 @@ class TestRunnerFailurePath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
@@ -418,9 +443,12 @@ class TestRunnerCancellation(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
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,
+1 -1
View File
@@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
id=export_id,
camera=camera,
name=f"export-{export_id}",
date=datetime.datetime.now(),
date=int(datetime.datetime.now().timestamp()),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,
+135
View File
@@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
with self.assertRaises(ValidationError):
FrigateConfig(**config_data)
def test_profile_zone_without_base_rejected(self):
"""Profile defining a zone not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"profiles": {
"armed": {
"zones": {
"phantom": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom", str(ctx.exception))
def test_profile_motion_mask_without_base_rejected(self):
"""Profile defining a motion mask not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"motion": {
"mask": {
"base_mask": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"motion": {
"mask": {
"phantom_mask": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom_mask", str(ctx.exception))
def test_profile_overrides_matching_base_accepted(self):
"""Profile overrides that reference existing base zones/masks parse cleanly."""
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"zones": {
"front_yard": {
"coordinates": "0,0,50,0,50,50,0,50",
"inertia": 5,
},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,75,0,75,75,0,75",
},
},
},
},
},
},
},
}
config = FrigateConfig(**config_data)
assert "armed" in config.cameras["front"].profiles
class TestProfileInConfig(unittest.TestCase):
"""Test that profiles parse correctly in FrigateConfig."""
@@ -0,0 +1,156 @@
"""Tests for SharedMemoryFrameManager cache invalidation.
Covers the case where a SHM segment is unlinked and recreated at a
different size across a camera add/remove cycle while a long-lived
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment.
"""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.image import SharedMemoryFrameManager
def _fake_shm(size: int) -> SimpleNamespace:
"""A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)
class TestSharedMemoryFrameManagerGet(unittest.TestCase):
def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
"""A cached ref to an older smaller segment must be dropped and the
current (correctly sized) segment reopened. Without this, np.ndarray
would raise "buffer is too small for requested array" when the
in-memory cache pointed at an old SHM after a same-name resize."""
manager = SharedMemoryFrameManager()
small = _fake_shm(size=100)
current = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = small
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
self.assertIs(manager.shm_store["cam_frame0"], current)
def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
"""Symmetric to the smaller-cache case: when detect resolution drops,
the SHM is unlinked and recreated at a smaller size. A cached ref to
the old, larger segment still satisfies any size check but points at
an orphaned inode whose stale bytes get reinterpreted at the new
shape producing miscolored, distorted YUV frames downstream. Drop
the cache so we reopen by name and bind to the current segment."""
manager = SharedMemoryFrameManager()
old_large = _fake_shm(size=10_000)
current = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = old_large
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
self.assertIs(manager.shm_store["cam_frame0"], current)
def test_get_keeps_cached_segment_when_size_matches(self) -> None:
"""Don't pay the reopen cost when the cached ref is the right size."""
manager = SharedMemoryFrameManager()
cached = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = cached
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
arr = manager.get("cam_frame0", (50, 50))
untracked_shm_cls.assert_not_called()
self.assertIsNotNone(arr)
self.assertIs(manager.shm_store["cam_frame0"], cached)
def test_get_opens_fresh_when_no_cache_entry(self) -> None:
manager = SharedMemoryFrameManager()
fresh = _fake_shm(size=2_500)
with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertIs(manager.shm_store["cam_frame0"], fresh)
def test_get_returns_none_when_segment_missing(self) -> None:
manager = SharedMemoryFrameManager()
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=FileNotFoundError,
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNone(arr)
def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
"""Race during a same-name SHM recreate: cache is stale, we reopen
by name, but the maintainer hasn't allocated the new segment yet —
the reopened ref is also too small. Skip the frame (return None)
rather than crash on np.ndarray."""
manager = SharedMemoryFrameManager()
small_cached = _fake_shm(size=100)
still_small_after_reopen = _fake_shm(size=100)
manager.shm_store["cam_frame0"] = small_cached
with patch(
"frigate.util.image.UntrackedSharedMemory",
return_value=still_small_after_reopen,
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNone(arr)
# Don't cache the too-small reopened ref — next call will re-open
# once the maintainer has finished recreating the segment.
self.assertNotIn("cam_frame0", manager.shm_store)
def test_get_handles_n_dimensional_shape(self) -> None:
"""np.prod must be used (not raw multiplication) for tuple shapes."""
manager = SharedMemoryFrameManager()
# YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
big_enough = _fake_shm(size=3_110_400)
manager.shm_store["cam_frame0"] = big_enough
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
arr = manager.get("cam_frame0", (1620, 1920))
untracked_shm_cls.assert_not_called()
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (1620, 1920))
class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
"""End-to-end-style: simulates the full unlink-and-recreate cycle."""
def test_segment_grows_then_get_succeeds(self) -> None:
manager = SharedMemoryFrameManager()
# Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
small = _fake_shm(size=115_200)
manager.shm_store["cam_frame0"] = small
arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
self.assertEqual(arr_small.shape, (360, 320))
# Phase 2: restart at 1920x1080 — new SHM segment, larger size.
large = _fake_shm(size=3_110_400)
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
arr_large = manager.get("cam_frame0", (1620, 1920))
self.assertIsNotNone(arr_large)
self.assertEqual(arr_large.shape, (1620, 1920))
if __name__ == "__main__":
unittest.main()
+806
View File
@@ -0,0 +1,806 @@
"""Tests for outbound WebSocket broadcast filtering."""
import json
import threading
import unittest
from types import SimpleNamespace
from typing import Any
from frigate.comms.ws import (
WebSocketClient,
_classify_outbound,
_collect_zone_names,
_extract_payload_camera,
_materialize_for_ws,
_ws_allowed_cameras,
_ws_is_unrestricted,
)
from frigate.config import FrigateConfig
def _build_config(
*,
extra_roles: dict[str, list[str]] | None = None,
extra_cameras: dict[str, dict[str, Any]] | None = None,
extra_zones: dict[str, dict[str, dict[str, Any]]] | None = None,
) -> FrigateConfig:
"""Construct a FrigateConfig used by the outbound filter tests.
The default fixture has three cameras: front_door, back_door, garage.
Restricted role "house_only" sees front_door + back_door but not garage.
"""
cameras: dict[str, dict[str, Any]] = {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"garage": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.3:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
}
if extra_cameras:
cameras.update(extra_cameras)
if extra_zones:
for cam_name, zones in extra_zones.items():
cameras[cam_name]["zones"] = zones
roles = {"house_only": ["front_door", "back_door"]}
if extra_roles:
roles.update(extra_roles)
return FrigateConfig(
mqtt={"host": "mqtt"},
auth={"roles": roles},
cameras=cameras,
)
def _ws(role: str | None) -> Any:
"""Build a fake ws4py-style websocket exposing ``environ``."""
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
return SimpleNamespace(environ=environ, terminated=False, sent=[])
class TestClassifyOutbound(unittest.TestCase):
"""The pure classifier — bucket every topic into a scope."""
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.all_cameras = set(self.config.cameras.keys())
self.all_zones = _collect_zone_names(self.config)
def _classify(self, topic: str) -> tuple[str, Any]:
return _classify_outbound(topic, self.all_cameras, self.all_zones)
# --- Global allowlist ---
def test_model_state_is_global(self):
self.assertEqual(self._classify("model_state"), ("global", None))
def test_profile_state_is_global(self):
self.assertEqual(self._classify("profile/state"), ("global", None))
def test_bare_notifications_state_is_global(self):
"""The 2-segment ``notifications/state`` is global; the 3-segment
``<camera>/notifications/state`` is camera-scoped (see below)."""
self.assertEqual(self._classify("notifications/state"), ("global", None))
def test_notification_test_is_global(self):
self.assertEqual(self._classify("notification_test"), ("global", None))
# --- Unrestricted-only ---
def test_birdseye_layout_is_unrestricted_only(self):
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
# --- Camera-prefixed ---
def test_camera_state_topic_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/detect/state"), ("camera", "front_door")
)
def test_camera_motion_topic_resolves_to_camera(self):
self.assertEqual(self._classify("back_door/motion"), ("camera", "back_door"))
def test_camera_per_notification_topic_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/notifications/state"),
("camera", "front_door"),
)
def test_camera_label_counter_resolves_to_camera(self):
self.assertEqual(self._classify("front_door/person"), ("camera", "front_door"))
def test_camera_object_mask_state_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/object_mask/zone_1/state"),
("camera", "front_door"),
)
# --- Zone-prefixed ---
def test_zone_aggregate_topic_is_unrestricted_only(self):
self.assertEqual(self._classify("driveway/person"), ("unrestricted_only", None))
def test_zone_all_topic_is_unrestricted_only(self):
self.assertEqual(self._classify("driveway/all"), ("unrestricted_only", None))
# --- Payload-camera ---
def test_events_topic_marks_payload_camera_path(self):
self.assertEqual(
self._classify("events"), ("payload_camera", ("after", "camera"))
)
def test_reviews_topic_marks_payload_camera_path(self):
self.assertEqual(
self._classify("reviews"), ("payload_camera", ("after", "camera"))
)
def test_triggers_topic_marks_payload_camera_path(self):
self.assertEqual(self._classify("triggers"), ("payload_camera", ("camera",)))
def test_tracked_object_update_marks_payload_camera_path(self):
self.assertEqual(
self._classify("tracked_object_update"), ("payload_camera", ("camera",))
)
# --- Reshape ---
def test_camera_activity_is_reshape_by_camera_key(self):
self.assertEqual(
self._classify("camera_activity"), ("reshape_by_camera_key", None)
)
def test_audio_detections_is_reshape_by_camera_key(self):
self.assertEqual(
self._classify("audio_detections"), ("reshape_by_camera_key", None)
)
def test_job_state_is_reshape_job_state(self):
self.assertEqual(self._classify("job_state"), ("reshape_job_state", None))
def test_stats_is_reshape_stats(self):
self.assertEqual(self._classify("stats"), ("reshape_stats", None))
# --- Fail-closed ---
def test_unknown_topic_is_dropped(self):
self.assertEqual(self._classify("some_random_topic"), ("drop", None))
def test_unknown_camera_prefix_is_dropped(self):
self.assertEqual(self._classify("ghost_camera/detect/state"), ("drop", None))
class TestCollectZoneNames(unittest.TestCase):
def test_zones_from_all_cameras(self):
config = _build_config(
extra_zones={
"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}},
"back_door": {"yard": {"coordinates": "0,0,1,0,1,1,0,1"}},
}
)
self.assertEqual(_collect_zone_names(config), {"driveway", "yard"})
def test_no_zones_returns_empty(self):
self.assertEqual(_collect_zone_names(_build_config()), set())
class TestExtractPayloadCamera(unittest.TestCase):
def test_extract_from_dict_path(self):
payload = {"after": {"camera": "front_door"}}
self.assertEqual(
_extract_payload_camera(payload, ("after", "camera")), "front_door"
)
def test_extract_from_json_string(self):
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertEqual(
_extract_payload_camera(payload, ("after", "camera")), "front_door"
)
def test_extract_single_segment_path(self):
self.assertEqual(
_extract_payload_camera({"camera": "garage"}, ("camera",)), "garage"
)
def test_missing_key_returns_none(self):
self.assertIsNone(_extract_payload_camera({}, ("after", "camera")))
def test_malformed_json_returns_none(self):
self.assertIsNone(_extract_payload_camera("not-json", ("camera",)))
def test_non_string_camera_returns_none(self):
self.assertIsNone(_extract_payload_camera({"camera": 42}, ("camera",)))
class TestWsRoleHelpers(unittest.TestCase):
def setUp(self):
self.config = _build_config()
def test_admin_is_unrestricted(self):
self.assertTrue(_ws_is_unrestricted(_ws("admin"), self.config))
def test_viewer_is_unrestricted(self):
self.assertTrue(_ws_is_unrestricted(_ws("viewer"), self.config))
def test_restricted_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws("house_only"), self.config))
def test_missing_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws(None), self.config))
def test_unknown_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws("ghost"), self.config))
def test_admin_allowed_cameras_is_all(self):
self.assertEqual(
_ws_allowed_cameras(_ws("admin"), self.config),
{"front_door", "back_door", "garage"},
)
def test_restricted_role_allowed_cameras_is_subset(self):
self.assertEqual(
_ws_allowed_cameras(_ws("house_only"), self.config),
{"front_door", "back_door"},
)
def test_missing_role_allowed_cameras_is_empty(self):
self.assertEqual(_ws_allowed_cameras(_ws(None), self.config), set())
def test_multi_role_union_grants_widest(self):
self.assertEqual(
_ws_allowed_cameras(_ws("house_only,admin"), self.config),
{"front_door", "back_door", "garage"},
)
class TestMaterializeForWs(unittest.TestCase):
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.all_cameras = set(self.config.cameras.keys())
self.all_zones = _collect_zone_names(self.config)
def _materialize(self, ws: Any, topic: str, payload: Any) -> str | None:
scope = _classify_outbound(topic, self.all_cameras, self.all_zones)
from frigate.comms.ws import _parse_json_payload
parsed = (
_parse_json_payload(payload)
if scope[0]
in (
"payload_camera",
"reshape_by_camera_key",
"reshape_job_state",
"reshape_stats",
)
else None
)
full = json.dumps({"topic": topic, "payload": payload})
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
# --- Globals: every authenticated client sees them ---
def test_globals_reach_admin(self):
self.assertIsNotNone(self._materialize(_ws("admin"), "model_state", "{}"))
def test_globals_reach_restricted(self):
self.assertIsNotNone(self._materialize(_ws("house_only"), "model_state", "{}"))
def test_globals_reach_no_role(self):
"""A missing role header still gets globals (matches viewer-default
for inbound)."""
self.assertIsNotNone(self._materialize(_ws(None), "model_state", "{}"))
# --- Unknown topic dropped for everyone ---
def test_unknown_topic_dropped_for_admin(self):
self.assertIsNone(self._materialize(_ws("admin"), "rogue_topic", "{}"))
# --- Non-global topics require a role (fail-closed) ---
def test_no_role_blocked_from_camera_topic(self):
self.assertIsNone(self._materialize(_ws(None), "front_door/detect/state", "ON"))
def test_no_role_blocked_from_events(self):
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertIsNone(self._materialize(_ws(None), "events", payload))
# --- Camera-prefixed ---
def test_restricted_role_sees_allowed_camera(self):
self.assertIsNotNone(
self._materialize(_ws("house_only"), "front_door/detect/state", "ON")
)
def test_restricted_role_blocked_from_unallowed_camera(self):
self.assertIsNone(
self._materialize(_ws("house_only"), "garage/detect/state", "ON")
)
def test_admin_sees_all_camera_topics(self):
self.assertIsNotNone(
self._materialize(_ws("admin"), "garage/detect/state", "ON")
)
# --- Unrestricted-only (zones, birdseye_layout) ---
def test_zone_aggregate_blocked_for_restricted(self):
self.assertIsNone(self._materialize(_ws("house_only"), "driveway/person", 3))
def test_zone_aggregate_visible_to_admin(self):
self.assertIsNotNone(self._materialize(_ws("admin"), "driveway/person", 3))
def test_birdseye_layout_blocked_for_restricted(self):
payload = json.dumps(
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
)
self.assertIsNone(
self._materialize(_ws("house_only"), "birdseye_layout", payload)
)
def test_birdseye_layout_visible_to_admin(self):
payload = json.dumps(
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
)
self.assertIsNotNone(
self._materialize(_ws("admin"), "birdseye_layout", payload)
)
# --- Payload-camera ---
def test_events_filtered_by_payload_camera(self):
payload = json.dumps({"after": {"camera": "garage"}})
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertIsNotNone(self._materialize(_ws("house_only"), "events", payload))
def test_events_with_missing_camera_dropped(self):
payload = json.dumps({"after": {}})
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
def test_triggers_filtered_by_payload_camera(self):
payload = json.dumps({"name": "t1", "camera": "garage"})
self.assertIsNone(self._materialize(_ws("house_only"), "triggers", payload))
# --- Reshape: dict keyed by camera ---
def test_camera_activity_filtered_to_allowed_keys(self):
payload = json.dumps(
{
"front_door": {"objects": 1},
"back_door": {"objects": 0},
"garage": {"objects": 2},
}
)
message = self._materialize(_ws("house_only"), "camera_activity", payload)
self.assertIsNotNone(message)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner.keys()), {"front_door", "back_door"})
self.assertNotIn("garage", inner)
def test_camera_activity_unchanged_for_admin(self):
payload = json.dumps({"front_door": {}, "back_door": {}, "garage": {}})
message = self._materialize(_ws("admin"), "camera_activity", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_camera_activity_with_no_allowed_returns_none(self):
payload = json.dumps({"garage": {"objects": 2}})
self.assertIsNone(
self._materialize(_ws("house_only"), "camera_activity", payload)
)
def test_audio_detections_filtered_to_allowed_keys(self):
payload = json.dumps({"front_door": {"bark": {}}, "garage": {"speech": {}}})
message = self._materialize(_ws("house_only"), "audio_detections", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner.keys()), {"front_door"})
# --- Reshape: job_state ---
def test_job_state_admin_sees_full_payload(self):
payload = json.dumps(
{
"motion_search": {"job_type": "motion_search", "camera": "garage"},
"media_sync": {"job_type": "media_sync"},
}
)
message = self._materialize(_ws("admin"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_restricted_keeps_allowed_camera_jobs(self):
"""Top-level camera field on a job entry: drop if not allowed."""
payload = json.dumps(
{
"motion_search": {"job_type": "motion_search", "camera": "front_door"},
"vlm_watch": {"job_type": "vlm_watch", "camera": "garage"},
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("motion_search", inner)
self.assertNotIn("vlm_watch", inner)
def test_job_state_export_results_jobs_filtered_per_recipient(self):
"""The aggregated export broadcast nests per-camera sub-jobs under
``results.jobs``. Restricted users must only see allowed entries."""
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "front_door", "id": "a"},
{"job_type": "export", "camera": "garage", "id": "b"},
{"job_type": "export", "camera": "back_door", "id": "c"},
]
},
}
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("export", inner)
kept_cameras = [j["camera"] for j in inner["export"]["results"]["jobs"]]
self.assertEqual(kept_cameras, ["front_door", "back_door"])
# Sibling fields like ``status`` must survive reshaping.
self.assertEqual(inner["export"]["status"], "running")
def test_job_state_export_entry_dropped_when_no_jobs_allowed(self):
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "garage", "id": "b"},
]
},
}
}
)
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
# --- Reshape: stats ---
def _stats_payload(self) -> str:
return json.dumps(
{
"cameras": {
"front_door": {"camera_fps": 5.0, "pid": 1234},
"back_door": {"camera_fps": 5.0, "pid": 1235},
"garage": {"camera_fps": 5.0, "pid": 1236},
},
"detectors": {"cpu": {"detection_start": 0.0, "inference_speed": 10}},
"service": {"uptime": 12345, "version": "0.16.0"},
"camera_fps": 15.0,
"detection_fps": 6.0,
}
)
def test_stats_admin_sees_full_payload(self):
message = self._materialize(_ws("admin"), "stats", self._stats_payload())
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], self._stats_payload())
def test_stats_restricted_filters_camera_keys_but_keeps_aggregates(self):
message = self._materialize(_ws("house_only"), "stats", self._stats_payload())
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner["cameras"].keys()), {"front_door", "back_door"})
self.assertNotIn("garage", inner["cameras"])
# Aggregates, detectors, and service block must survive.
self.assertEqual(inner["camera_fps"], 15.0)
self.assertEqual(inner["detection_fps"], 6.0)
self.assertIn("detectors", inner)
self.assertIn("service", inner)
def test_stats_restricted_with_no_allowed_cameras_still_sends_aggregates(self):
"""A restricted role whose allow-list contains only nonexistent cameras
still gets the global aggregates and service block."""
config = _build_config(extra_roles={"empty_role": ["nonexistent"]})
from frigate.comms.ws import _parse_json_payload
payload = self._stats_payload()
all_cameras = set(config.cameras.keys())
scope = _classify_outbound("stats", all_cameras, _collect_zone_names(config))
full = json.dumps({"topic": "stats", "payload": payload})
message = _materialize_for_ws(
_ws("empty_role"),
"stats",
full,
scope,
_parse_json_payload(payload),
config,
)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(inner["cameras"], {})
self.assertEqual(inner["camera_fps"], 15.0)
self.assertIn("service", inner)
def test_stats_without_cameras_key_passes_through(self):
"""A malformed stats payload missing the cameras sub-dict shouldn't
break delivery for restricted users fall back to the full message."""
payload = json.dumps({"detectors": {}, "service": {}, "detection_fps": 0.0})
message = self._materialize(_ws("house_only"), "stats", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_export_entry_unchanged_for_admin(self):
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "garage", "id": "b"},
]
},
}
}
)
message = self._materialize(_ws("admin"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_restricted_keeps_global_jobs(self):
"""media_sync has no camera field; restricted users still see it."""
payload = json.dumps(
{"media_sync": {"job_type": "media_sync", "status": "running"}}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("media_sync", inner)
def test_job_state_debug_replay_nested_source_camera_filtered(self):
"""debug_replay puts ``source_camera`` inside ``results`` (see
jobs/debug_replay.py:to_dict). Restricted users must not receive
entries whose nested source camera is unauthorized."""
payload = json.dumps(
{
"debug_replay": {
"id": "bd6dc99d-a7d",
"job_type": "debug_replay",
"status": "running",
"start_time": 1.0,
"end_time": None,
"error_message": None,
"results": {
"current_step": "preparing_clip",
"progress_percent": 0.0,
"source_camera": "garage",
"replay_camera_name": "_replay_garage",
"start_ts": 0.0,
"end_ts": 1.0,
},
}
}
)
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
def test_job_state_debug_replay_nested_source_camera_allowed(self):
payload = json.dumps(
{
"debug_replay": {
"id": "bd6dc99d-a7d",
"job_type": "debug_replay",
"status": "running",
"results": {
"source_camera": "front_door",
"replay_camera_name": "_replay_front_door",
},
}
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("debug_replay", inner)
self.assertEqual(
inner["debug_replay"]["results"]["source_camera"], "front_door"
)
class _FakeManager:
"""Minimal ws4py manager: holds clients and exposes a lock."""
def __init__(self, clients: list[Any]) -> None:
self.lock = threading.Lock()
self.websockets = {id(c): c for c in clients}
class _FakeServer:
def __init__(self, manager: _FakeManager) -> None:
self.manager = manager
class _CapturingWs(SimpleNamespace):
"""Fake ws4py client that records what was sent."""
def __init__(self, role: str | None) -> None:
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
super().__init__(environ=environ, terminated=False)
self.sent: list[str] = []
def send(self, message: str) -> None: # noqa: D401 - matches ws4py API
self.sent.append(message)
class TestPublishEndToEnd(unittest.TestCase):
"""Drive WebSocketClient.publish() against fake clients with different roles."""
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.admin = _CapturingWs("admin")
self.restricted = _CapturingWs("house_only")
self.anon = _CapturingWs(None)
self.client = WebSocketClient(self.config)
self.client.websocket_server = _FakeServer(
_FakeManager([self.admin, self.restricted, self.anon])
)
def _payloads(self, ws: _CapturingWs) -> list[Any]:
return [json.loads(m)["payload"] for m in ws.sent]
def test_global_topic_reaches_everyone(self):
self.client.publish("model_state", "{}")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 1)
self.assertEqual(len(self.anon.sent), 1)
def test_camera_topic_filters_restricted_recipient(self):
self.client.publish("garage/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
self.assertEqual(len(self.anon.sent), 0)
def test_camera_topic_allows_restricted_recipient_for_allowed_camera(self):
self.client.publish("front_door/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 1)
self.assertEqual(len(self.anon.sent), 0)
def test_events_payload_filtered(self):
self.client.publish("events", json.dumps({"after": {"camera": "garage"}}))
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
def test_camera_activity_reshaped_per_recipient(self):
self.client.publish(
"camera_activity",
json.dumps(
{
"front_door": {"objects": 1},
"back_door": {"objects": 0},
"garage": {"objects": 2},
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(set(admin_inner.keys()), {"front_door", "back_door", "garage"})
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(set(restricted_inner.keys()), {"front_door", "back_door"})
self.assertEqual(len(self.anon.sent), 0)
def test_birdseye_layout_blocked_for_restricted_and_anon(self):
self.client.publish(
"birdseye_layout",
json.dumps({"front_door": {"x": 0, "y": 0, "width": 1, "height": 1}}),
)
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
self.assertEqual(len(self.anon.sent), 0)
def test_zone_aggregate_blocked_for_restricted(self):
self.client.publish("driveway/person", 2)
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
def test_stats_reshaped_per_recipient(self):
self.client.publish(
"stats",
json.dumps(
{
"cameras": {
"front_door": {"camera_fps": 5.0},
"garage": {"camera_fps": 5.0},
},
"service": {"uptime": 1},
"camera_fps": 10.0,
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(set(admin_inner["cameras"].keys()), {"front_door", "garage"})
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(set(restricted_inner["cameras"].keys()), {"front_door"})
self.assertEqual(restricted_inner["camera_fps"], 10.0)
self.assertIn("service", restricted_inner)
# Stats requires a role; anonymous gets nothing.
self.assertEqual(len(self.anon.sent), 0)
def test_export_job_state_filters_results_jobs_per_recipient(self):
self.client.publish(
"job_state",
json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"camera": "front_door", "id": "a"},
{"camera": "garage", "id": "b"},
]
},
}
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(
[j["camera"] for j in admin_inner["export"]["results"]["jobs"]],
["front_door", "garage"],
)
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(
[j["camera"] for j in restricted_inner["export"]["results"]["jobs"]],
["front_door"],
)
def test_unknown_topic_dropped_for_everyone(self):
self.client.publish("some_rogue_topic", "data")
self.assertEqual(self.admin.sent, [])
self.assertEqual(self.restricted.sent, [])
self.assertEqual(self.anon.sent, [])
def test_terminated_client_is_skipped(self):
self.restricted.terminated = True
self.client.publish("front_door/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -357,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(
+55 -1
View File
@@ -8,7 +8,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
@@ -18,6 +18,21 @@ CURRENT_CONFIG_VERSION = "0.18-0"
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
def redact_credential(obj: dict[str, Any], key: str) -> None:
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.
Used when shaping the /config response so saved credentials never leave
the server. The frontend recognizes REDACTED_CREDENTIAL_SENTINEL, renders
the field as empty with a "saved — leave blank to keep" placeholder, and
/config/set strips it from any incoming payload so the YAML value is
preserved when the user doesn't touch the field.
"""
if obj.get(key):
obj[key] = REDACTED_CREDENTIAL_SENTINEL
else:
obj.pop(key, None)
def find_config_file() -> str:
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
@@ -773,6 +788,45 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
)
camera_config.objects = new_objects
elif section == "detect":
# apply detect first so frame_shape reflects the new resolution
# before we rebuild mask-dependent runtime configs below
merged = deep_merge(current.model_dump(), update, override=True)
camera_config.detect = current.__class__.model_validate(merged)
new_frame_shape = camera_config.frame_shape
# rebuild motion's rasterized_mask at the new frame_shape
if camera_config.motion is not None:
camera_config.motion = RuntimeMotionConfig(
frame_shape=new_frame_shape,
**camera_config.motion.model_dump(exclude_unset=True),
)
# rebuild per-object filter masks at the new frame_shape
for obj_name, filt in camera_config.objects.filters.items():
merged_mask = dict(filt.mask)
if camera_config.objects.mask:
for gid, gmask in camera_config.objects.mask.items():
merged_mask[f"global_{gid}"] = gmask
camera_config.objects.filters[obj_name] = RuntimeFilterConfig(
frame_shape=new_frame_shape,
mask=merged_mask,
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
)
# Regenerate zone contours and per-zone filter masks at the new
# frame_shape so zone outlines and membership stay relative
for zone in camera_config.zones.values():
if zone.filters:
for zone_obj_name, zone_filter in zone.filters.items():
zone.filters[zone_obj_name] = RuntimeFilterConfig(
frame_shape=new_frame_shape,
**zone_filter.model_dump(exclude_unset=True),
)
zone.generate_contour(new_frame_shape)
else:
merged = deep_merge(current.model_dump(), update, override=True)
setattr(camera_config, section, current.__class__.model_validate(merged))
+18 -3
View File
@@ -1089,10 +1089,25 @@ class SharedMemoryFrameManager(FrameManager):
def get(self, name: str, shape) -> Optional[np.ndarray]:
try:
if name in self.shm_store:
shm = self.shm_store[name]
else:
required = int(np.prod(shape))
shm = self.shm_store.get(name)
if shm is not None and shm.size != required:
# stale cached ref from a same-name recreate — drop and reopen
try:
shm.close()
except Exception:
pass
self.shm_store.pop(name, None)
shm = None
if shm is None:
shm = UntrackedSharedMemory(name=name)
if shm.size != required:
# mid-recreate: OS segment doesn't match shape yet; skip
try:
shm.close()
except Exception:
pass
return None
self.shm_store[name] = shm
return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf)
except FileNotFoundError:
+1 -1
View File
@@ -478,7 +478,7 @@ def get_intel_gpu_stats(
overall_pct = min(100.0, compute_pct + dec_pct)
entry: dict[str, Any] = {
"name": names.get(pdev) or f"Intel GPU {pdev}",
"name": names.get(pdev) or "Intel iGPU",
"vendor": "intel",
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",
+58
View File
@@ -364,6 +364,64 @@ def main():
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.<attr>.<field>` lookups to
# `filters_attribute.<field>` when `<attr>` 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
+8 -2
View File
@@ -129,8 +129,14 @@ test.describe("Replay — active session @medium", () => {
);
await actionGroup.first().click();
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
// On mobile PlatformAwareSheet renders a MobilePage (full-screen panel)
// instead of a Radix Dialog, so assert the panel title heading is visible.
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /^Configuration$/i,
}),
).toBeVisible({ timeout: 5_000 });
});
test("Objects tab renders with the camera_activity objects list", async ({
@@ -0,0 +1,235 @@
/**
* go2rtc streams settings page tests -- MEDIUM tier.
*
* Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown
* fragments like #timeout=10 must remain visible and editable when the
* stream is using compatibility mode.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const STREAM_NAME = "dome_sub";
const FFMPEG_URL_WITH_TIMEOUT =
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10";
async function installRawPathsRoute(page: Page, streamUrl: string) {
let lastSavedConfig: unknown = null;
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: {},
go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return {
capturedConfig: () => lastSavedConfig,
};
}
async function expandStream(page: Page, streamName: string) {
// Each StreamCard renders the stream name as an h4 next to a rename
// button, with the chevron toggle as the last button in the header row.
// Scope to the header row (h4's grandparent) and click that last button.
const headerRow = page
.locator(`h4:text-is("${streamName}")`)
.locator("xpath=../..");
await headerRow.getByRole("button").last().click();
}
test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => {
test("preserves unknown fragments like #timeout= in the URL input", async ({
frigateApp,
}) => {
await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expect(
frigateApp.page.getByRole("heading", { name: STREAM_NAME }),
).toBeVisible();
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await expect(urlInput).toBeVisible();
// Focus the input so credential masking is bypassed and the raw value
// is rendered — this matches how a user would inspect the URL before
// editing it.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10",
);
});
test("lets the user add an extra fragment in compat mode", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
FFMPEG_URL_WITH_TIMEOUT,
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await urlInput.focus();
await urlInput.fill(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
await urlInput.blur();
// Reopen and re-focus to assert the new value round-tripped through
// parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
// Save and verify the persisted URL includes both extras after the
// recognized video/audio directives.
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0",
],
},
},
},
});
});
test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
// Idiomatic go2rtc fallback: copy if source has the codec, else transcode
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
// Two pre-populated audio rows — one per #audio= fragment.
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2);
await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText(
"Copy",
);
await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText(
"Transcode to Opus",
);
// Add a third audio codec via the LuPlus next to the "Audio" label.
await audioRowsContainer
.getByRole("button", { name: "Add audio codec" })
.click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3);
// Change the newly-added entry to AAC.
await audioRowsContainer.getByRole("combobox").nth(2).click();
await frigateApp.page
.getByRole("option", { name: "Transcode to AAC" })
.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac",
],
},
},
},
});
});
test("LuX is only shown on fallback rows and removes only that codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
const removeButtons = audioRowsContainer.getByRole("button", {
name: "Remove codec",
});
// Primary (audio=copy) row is permanent and has no X; only the audio=opus
// fallback exposes a remove button.
await expect(removeButtons).toHaveCount(1);
await removeButtons.first().click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1);
await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
],
},
},
},
});
});
test("picking Exclude on the primary row drops the #video= fragment entirely", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const videoLabel = frigateApp.page.locator(`label:text-is("Video")`);
const videoRowsContainer = videoLabel.locator("xpath=../..");
await videoRowsContainer.getByRole("combobox").first().click();
await frigateApp.page.getByRole("option", { name: "Exclude" }).click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy",
],
},
},
},
});
});
});
+1 -1
View File
@@ -138,7 +138,7 @@
"plucked_string_instrument": "Instrument de corda pinçada",
"guitar": "Guitarra",
"electric_guitar": "Guitarra elèctrica",
"bass_guitar": "Baix",
"bass_guitar": "Guitarra baixa",
"acoustic_guitar": "Guitarra acústica",
"steel_guitar": "Guitarra steel",
"tapping": "Tapping",
+2 -1
View File
@@ -49,7 +49,8 @@
"gl": "Galego (Gallec)",
"id": "Bahasa Indonesia (Indonesi)",
"ur": "اردو (Urdú)",
"hr": "Hrvatski (croat)"
"hr": "Hrvatski (croat)",
"bs": "Bosanski (Bosni)"
},
"system": "Sistema",
"systemMetrics": "Mètriques del sistema",
+5 -1
View File
@@ -33,7 +33,11 @@
},
"filters": {
"label": "Filtres d'àudio",
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
"threshold": {
"label": "Confiança mínima de l'àudio",
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
}
},
"enabled_in_config": {
"label": "Estat d'àudio original",
+41 -2
View File
@@ -258,6 +258,41 @@
},
"raw_mask": {
"label": "Màscara en brut"
},
"filters_attribute": {
"label": "Filtres d'atribut",
"description": "Filtres aplicats als atributs detectats per reduir falsos positius (àrea, relació, confiança).",
"min_area": {
"label": "Àrea mínima de l'atribut",
"description": "Es requereix una àrea de caixa contenidora mínima (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
},
"max_area": {
"label": "Àrea màxima de l'atribut",
"description": "Es permet l'àrea màxima del contenidor (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
},
"min_ratio": {
"label": "Relació mínima d'aspecte",
"description": "Relació mínima d'amplada/alçada requerida per a la casella contenidora a qualificar."
},
"max_ratio": {
"label": "Relació màxima d'aspecte",
"description": "Es permet la relació màxima d'amplada/alçada per a la casella contenidora a qualificar."
},
"threshold": {
"label": "Llindar de confiança",
"description": "Es requereix un llindar de confiança mitjà per a la detecció perquè l'atribut es consideri un veritable positiu."
},
"min_score": {
"label": "Confiança mínima",
"description": "Es requereix una confiança mínima de detecció d'un sol fotograma per a associar aquest atribut amb el seu objecte pare."
},
"mask": {
"label": "Màscara de filtre",
"description": "Coordenades de polígon que defineixen on s'aplica aquest filtre dins del marc."
},
"raw_mask": {
"label": "Màscara en brut"
}
}
},
"record": {
@@ -1987,7 +2022,11 @@
},
"filters": {
"label": "Filtres d'àudio",
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
"threshold": {
"label": "Confiança mínima de l'àudio",
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
}
},
"enabled_in_config": {
"label": "Estat d'àudio original",
@@ -2207,7 +2246,7 @@
},
"match_distance": {
"label": "Distància de la coincidència",
"description": "Nombre de desajustos de caràcters permesos quan es comparen les plaques detectades amb les plaques conegudes."
"description": "Nombre de discrepàncies de caràcters permesos en comparar les plaques detectades amb les plaques conegudes."
},
"known_plates": {
"label": "Matricules conegudes",
+6 -1
View File
@@ -121,5 +121,10 @@
"royal_mail": "Royal Mail",
"school_bus": "Bus escolar",
"skunk": "Mofeta",
"kangaroo": "Cangur"
"kangaroo": "Cangur",
"baby": "Nadó",
"baby_stroller": "Cotxet",
"rickshaw": "Ricksaw",
"Rodent": "Rosegador",
"rodent": "Rosegador"
}
+23
View File
@@ -42,5 +42,28 @@
"show_camera_status": "Quin és l'estat actual de les meves càmeres?",
"recap": "Què va passar mentre jo era fora?",
"watch_camera": "Vigila la porta d'entrada i fes-me saber si algú apareix"
},
"new_chat": "Xat nou",
"settings": {
"title": "Configuració del xat",
"show_stats": {
"title": "Mostra les estadístiques",
"desc": "Mostra la velocitat de generació i la mida del context per a les respostes del xat.",
"while_generating": "En generar",
"always": "Sempre"
},
"auto_scroll": {
"title": "Desplaçament automàtic",
"desc": "Segueix els missatges nous a mesura que arriben."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Raonant…",
"show": "Mostra el raonament",
"hide": "Amaga el raonament"
}
}
+5 -1
View File
@@ -14,7 +14,11 @@
"empty": "No hi ha intents recents de reconeixement de rostres",
"title": "Reconeixements recents",
"aria": "Selecciona els reconeixements recents",
"titleShort": "Recent"
"titleShort": "Recent",
"emptyNoLibrary": {
"title": "Puja una cara",
"description": "Heu d'afegir com a mínim una cara a la biblioteca perquè el reconeixement de la cara funcioni."
}
},
"description": {
"addFace": "Afegiu una col·lecció nova a la biblioteca de cares pujant la vostra primera imatge.",
+158 -15
View File
@@ -15,7 +15,8 @@
"globalConfig": "Configuració global - Frigate",
"cameraConfig": "Configuració de la càmera - Frigate",
"maintenance": "Manteniment - Frigate",
"profiles": "Perfils - Frigate"
"profiles": "Perfils - Frigate",
"detectorsAndModel": "Detectors i model - Frigate"
},
"menu": {
"ui": "Interfície d'usuari",
@@ -90,7 +91,8 @@
"regionGrid": "Quadrícula de la regió",
"uiSettings": "Paràmetres de la IU",
"profiles": "Perfils",
"systemGo2rtcStreams": "go2rtc streams"
"systemGo2rtcStreams": "go2rtc streams",
"systemDetectorsAndModel": "Detectors i model"
},
"dialog": {
"unsavedChanges": {
@@ -526,7 +528,7 @@
},
"title": "Afinador de detecció de moviment",
"toast": {
"success": "Els ajustos de la detecció de moviment s'han desat."
"success": "S'han desat els paràmetres del moviment."
},
"unsavedChanges": "Canvis no desats en l'ajust de moviment {{camera}}"
},
@@ -724,7 +726,7 @@
"trainDate": "Data d'entrenament",
"title": "Informació del model",
"supportedDetectors": "Detectors compatibles",
"availableModels": "Models disponibles",
"availableModels": "Models Frigate+ disponibles",
"cameras": "Càmeres",
"plusModelType": {
"userModel": "Afinat",
@@ -733,7 +735,15 @@
"loadingAvailableModels": "Carregant models disponibles…",
"loading": "Carregant informació del model…",
"error": "No s'ha pogut carregar la informació del model",
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector."
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector.",
"noModelLoaded": "Actualment no s'ha carregat cap model Frigate+.",
"selectModel": "Selecciona un model",
"noModelsAvailable": "No hi ha models disponibles",
"filter": {
"ariaLabel": "Filtra els models per tipus",
"baseModels": "Models de base",
"fineTunedModels": "Models ajustats"
}
},
"apiKey": {
"plusLink": "Llegeix més sobre Frigate+",
@@ -755,7 +765,8 @@
"currentModel": "Model actual",
"otherModels": "Altres models",
"configuration": "Configuració"
}
},
"changeInDetectorsAndModel": "Canviar model"
},
"enrichments": {
"semanticSearch": {
@@ -1295,7 +1306,7 @@
"title": "Habilita / Inhabilita les càmeres",
"desc": "Inhabilita temporalment una càmera fins que es reiniciï la fragata. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
"enableLabel": "Càmeres habilitades",
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no inhabilita els restreams go2rtc.</em><br /><br />Drag el handle per reordenar les càmeres tal com apareixen a la interfície d'usuari. L'ordre de les càmeres habilitades es reflectirà en tota la interfície d'usuari, incloent el tauler en viu i els desplegables de selecció de càmeres.",
"disableLabel": "Càmeres inhabilitades",
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.",
@@ -1304,7 +1315,10 @@
"title": "Edita el nom de la pantalla",
"description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.",
"rename": "Canvia el nom"
}
},
"reorderHandle": "Arrossega per reordenar",
"saving": "S'està desant…",
"saved": "Desat"
},
"cameraConfig": {
"add": "Afegeix una càmera",
@@ -1362,7 +1376,8 @@
"dedicatedLpr": "LPR dedicat",
"saveSuccess": "Tipus de càmera actualitzat per {{cameraName}}. Reinicia la fragata per aplicar els canvis.",
"normal": "Normal"
}
},
"description": "Afegiu, editeu i suprimiu les càmeres, controleu quines càmeres estan habilitades, i configureu les superposicions per perfil i tipus de càmera. Per a configurar fluxos, detecció, moviment i altres paràmetres específics de la càmera, trieu la secció específica a Configuració de la càmera."
},
"cameraReview": {
"object_descriptions": {
@@ -1661,7 +1676,9 @@
"options": {
"embeddings": "Incrustació",
"vision": "Visió",
"tools": "Eines"
"tools": "Eines",
"descriptions": "Descripcions",
"chat": "Xat"
}
},
"semanticSearchModel": {
@@ -1718,7 +1735,10 @@
"saveAllPartial_many": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
"saveAllPartial_other": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
"saveAllFailure": "Ha fallat en desar totes les seccions.",
"applied": "La configuració s'ha aplicat correctament"
"applied": "La configuració s'ha aplicat correctament",
"saveAllSuccessRestartRequired_one": "S'ha desat la secció {{count}} correctament. Reinicia la fragata per aplicar els canvis.",
"saveAllSuccessRestartRequired_many": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis.",
"saveAllSuccessRestartRequired_other": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis."
},
"unsavedChanges": "Teniu canvis sense desar",
"confirmReset": "Confirma el restabliment",
@@ -1743,7 +1763,15 @@
"othersField_many": "{{count}} altres",
"othersField_other": "{{count}} altres",
"profilePrefix": "Perfil {{profile}}: {{fields}}"
}
},
"overriddenGlobalHeading_one": "Aquesta càmera substitueix el camp {{count}} de la configuració global:",
"overriddenGlobalHeading_many": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
"overriddenGlobalHeading_other": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
"overriddenGlobalNoDeltas": "Aquesta càmera anul·la la configuració global, però no hi ha valors de camp diferents.",
"overriddenBaseConfigHeading_one": "El perfil {{profile}} substitueix el camp {{count}} de la configuració base:",
"overriddenBaseConfigHeading_many": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
"overriddenBaseConfigHeading_other": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
"overriddenBaseConfigNoDeltas": "El perfil {{profile}} substitueix aquesta secció, però no hi ha valors de camp diferents de la configuració base."
},
"profiles": {
"title": "Perfils",
@@ -1827,8 +1855,17 @@
"audioMp3": "Transcodifica a MP3",
"audioExclude": "Exclou",
"hardwareNone": "Sense acceleració de hardware",
"hardwareAuto": "Acceleració de hardware automàtica"
}
"hardwareAuto": "Automàtic (recomanat)",
"addVideoCodec": "Afegeix un còdec de vídeo",
"addAudioCodec": "Afegeix un còdec d'àudio",
"removeCodec": "Elimina el còdec",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox"
},
"streamNumber": "Flux {{index}}"
},
"timestampPosition": {
"tl": "A dalt a l'esquerra",
@@ -1838,7 +1875,14 @@
},
"onvif": {
"profileAuto": "Automàtic",
"profileLoading": "S'estan carregant perfils..."
"profileLoading": "S'estan carregant perfils...",
"autotracking": {
"zooming": {
"disabled": "Desactivat",
"absolute": "Absolut",
"relative": "Relatiu"
}
}
},
"configMessages": {
"review": {
@@ -1886,5 +1930,104 @@
"semanticSearch": {
"jinav2SmallModelSize": "La mida 'petita' amb el model Jina V2 té un alt cost de RAM i d'inferència. Es recomana el model 'gran' amb una GPU discreta."
}
},
"modelSize": {
"large": "Gran",
"small": "Petit"
},
"birdseye": {
"trackingMode": {
"objects": "Objectes",
"motion": "Moviment",
"continuous": "Continu"
},
"cameraOrder": {
"label": "Ordre de la càmera",
"description": "Arrossega les càmeres per establir el seu ordre en la disposició Birdseye.",
"reorderHandle": "Arrossega per reordenar",
"saving": "S'està desant…",
"saved": "Desat"
}
},
"snapshot": {
"retainMode": {
"all": "Tots",
"motion": "Moviment",
"active_objects": "Objectes Actius"
}
},
"ui": {
"timeFormat": {
"browser": "Visor",
"12hour": "12 hores",
"24hour": "24 hores"
},
"TimeOrDateStyle": {
"full": "Complet",
"long": "Llarg",
"medium": "Mitjà",
"short": "Curt"
},
"unitSystem": {
"metric": "Métric",
"imperial": "Imperial"
}
},
"review": {
"imageSource": {
"recordings": "Gravacions",
"previews": "Previsualitzacions"
}
},
"logger": {
"logLevel": {
"debug": "Depurar",
"info": "Informació",
"warning": "Avís",
"error": "Error",
"critical": "Crític"
}
},
"retainMode": {
"all": "Tots",
"motion": "Moviment",
"active_objects": "Objectes actius"
},
"previewQuality": {
"very_high": "Molt alta",
"high": "Alta",
"medium": "Mitja",
"low": "Baix",
"very_low": "Molt baix"
},
"detectorsAndModel": {
"restartRequired": "Reinici requerit (canvi en detector o model)",
"title": "Detectors i model",
"description": "Configuri el detector final que corre la detecció d'objectes i el model que usa. Els canvis es gravaràn junts i així el detector i el model estan sincronitzats.",
"cardTitles": {
"detector": "Detector Hardware",
"model": "Model de detecció"
},
"tabs": {
"plus": "Frigate+",
"custom": "Model personalitzat"
},
"mismatch": {
"warning": "El model actual de Frigate+ \"{{model}}\" requereix el detector {{required}}. Selecciona un model compatible a baix o canvía e model personalitzat abans de gravar."
},
"plusModel": {
"requiresDetector": "Requereix: {{detector}}",
"noModelSelected": "Selecciona un model Frigate+"
},
"toast": {
"saveSuccess": "Configuració de detectors i model guardats. Reinicia Frigate per aplicar els canvis.",
"saveError": "Fallo en gravar la configuració de detector i model"
},
"unsavedChanges": "Canvis de detector i model no gravats"
},
"menuDot": {
"overrideGlobal": "Aquesta secció substitueix la configuració global",
"overrideProfile": "Aquesta secció està substituïda pel perfil {{profile}}",
"unsaved": "Aquesta secció té canvis sense desar"
}
}
+2 -1
View File
@@ -192,7 +192,8 @@
"bg": "Български (bulgarisch)",
"gl": "Galego (Galicisch)",
"id": "Bahasa Indonesia (Indonesisch)",
"hr": "Hrvatski (Kroatisch)"
"hr": "Hrvatski (Kroatisch)",
"bs": "Bosnisch"
},
"appearance": "Erscheinung",
"theme": {
+5 -1
View File
@@ -25,7 +25,11 @@
},
"filters": {
"label": "Audiofilter",
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
"threshold": {
"label": "Mindestvertrauensgrad für Audio",
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
}
},
"max_not_heard": {
"label": "Ende Timeout",
+5 -1
View File
@@ -23,7 +23,11 @@
},
"filters": {
"label": "Audiofilter",
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
"threshold": {
"label": "Mindestvertrauensgrad für Audio",
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
}
},
"max_not_heard": {
"label": "Ende Timeout",
+5 -1
View File
@@ -121,5 +121,9 @@
"royal_mail": "Royal-Mail",
"school_bus": "Schulbus",
"skunk": "Stinktier",
"kangaroo": "Känguruh"
"kangaroo": "Känguruh",
"baby": "Baby",
"baby_stroller": "Kinderwagen",
"rickshaw": "Rikscha",
"rodent": "Nagetier"
}
+18
View File
@@ -42,5 +42,23 @@
"show_camera_status": "Wie ist der aktuelle Status meiner Kameras?",
"recap": "Was ist passiert, während ich weg war?",
"watch_camera": "Pass auf die Haustür auf und sag mir Bescheid, wenn jemand kommt"
},
"new_chat": "Neuer Chat",
"settings": {
"title": "Chat Einstellung",
"show_stats": {
"title": "Statistiken anzeigen",
"desc": "Generierungsrate und Kontextgröße für Chat-Antworten anzeigen.",
"while_generating": "Während der Erstellung",
"always": "Immer"
},
"auto_scroll": {
"title": "Auto scrollen",
"desc": "Verfolgen Sie neue Nachrichten, sobald sie eintreffen."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
}
}
+5 -1
View File
@@ -48,7 +48,11 @@
"title": "Neueste Erkennungen",
"aria": "Wähle aktuelle Erkennungen",
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung",
"titleShort": "frisch"
"titleShort": "frisch",
"emptyNoLibrary": {
"title": "Gesicht hinzufügen",
"description": "Sie müssen mindestens ein Gesicht zur Bibliothek hinzufügen, damit die Gesichtserkennung funktioniert."
}
},
"deleteFaceLibrary": {
"title": "Lösche Name",
+84 -5
View File
@@ -803,7 +803,15 @@
"availableModels": "Verfügbare Modelle",
"loadingAvailableModels": "Lade verfügbare Modelle…",
"baseModel": "Basis Model",
"title": "Model Informationen"
"title": "Model Informationen",
"noModelLoaded": "Derzeit ist kein „Frigate+“-Modell geladen.",
"selectModel": "Wählen Sie ein Modell aus",
"noModelsAvailable": "Keine Modelle verfügbar",
"filter": {
"ariaLabel": "Modelle nach Typ filtern",
"baseModels": "Basismodelle",
"fineTunedModels": "Optimierte Modelle"
}
},
"toast": {
"error": "Speichern der Konfigurationsänderungen fehlgeschlagen: {{errorMessage}}",
@@ -1415,7 +1423,8 @@
"normal": "Normal",
"dedicatedLpr": "Spezielles LPR-System",
"saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen."
}
},
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, legen Sie fest, welche Kameras aktiviert sind, und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus."
},
"cameraReview": {
"title": "Kamera-Einstellungen überprüfen",
@@ -1489,7 +1498,13 @@
"othersField_one": "{{count}} andere",
"othersField_other": "{{count}} weitere",
"profilePrefix": "{{profile}} Profile: {{fields}}"
}
},
"overriddenGlobalHeading_one": "Diese Kamera überschreibt das Feld {{count}} aus der globalen Konfiguration:",
"overriddenGlobalHeading_other": "Diese Kamera überschreibt alle Felder {{count}} aus der globalen Konfiguration:",
"overriddenGlobalNoDeltas": "Diese Kamera überschreibt die globale Konfiguration, es gibt jedoch keine Abweichungen bei den Feldwerten.",
"overriddenBaseConfigHeading_one": "Das Profil {{profile}} überschreibt das Feld {{count}} aus der Basiskonfiguration:",
"overriddenBaseConfigHeading_other": "Das Profil {{profile}} überschreibt di Felder {{count}} aus der Basiskonfiguration:",
"overriddenBaseConfigNoDeltas": "Das Profil {{profile}} überschreibt diesen Abschnitt, jedoch weichen keine Feldwerte von der Basiskonfiguration ab."
},
"timestampPosition": {
"tl": "Oben links",
@@ -1726,7 +1741,9 @@
"options": {
"embeddings": "Einbetten",
"vision": "Vision",
"tools": "Werkzeuge"
"tools": "Werkzeuge",
"descriptions": "Beschreibung",
"chat": "Chat"
}
},
"semanticSearchModel": {
@@ -1884,7 +1901,14 @@
},
"onvif": {
"profileAuto": "Auto",
"profileLoading": "Profile werden geladen..."
"profileLoading": "Profile werden geladen...",
"autotracking": {
"zooming": {
"disabled": "deaktiviert",
"absolute": "Absolut",
"relative": "Verwandter"
}
}
},
"configMessages": {
"review": {
@@ -1932,5 +1956,60 @@
"semanticSearch": {
"jinav2SmallModelSize": "Die „kleine“ Variante des Jina V2-Modells verursacht hohe RAM- und Inferenzkosten. Es wird das „große“ Modell mit einer dedizierten GPU empfohlen."
}
},
"birdseye": {
"trackingMode": {
"objects": "Objekte",
"motion": "Bewegung",
"continuous": "Fortlaufend"
}
},
"retainMode": {
"all": "Alle",
"motion": "Bewegung",
"active_objects": "Aktive Objekte"
},
"previewQuality": {
"very_high": "sehr hoch",
"high": "hoch",
"medium": "Mittel",
"low": "niedrig",
"very_low": "sehr niedrig"
},
"ui": {
"timeFormat": {
"browser": "Browser",
"12hour": "12 Stunden",
"24hour": "24 Stunden"
},
"TimeOrDateStyle": {
"full": "vollständig",
"long": "lang",
"medium": "mittel",
"short": "kurz"
},
"unitSystem": {
"metric": "Metrik",
"imperial": "Imperial"
}
},
"review": {
"imageSource": {
"recordings": "Aufnahmen",
"previews": "Vorschau"
}
},
"logger": {
"logLevel": {
"debug": "Debug",
"info": "Info",
"warning": "Warnung",
"error": "Fehler",
"critical": "Kritisch"
}
},
"modelSize": {
"small": "klein",
"large": "groß"
}
}
+5 -1
View File
@@ -177,6 +177,7 @@
"en": "English (English)",
"es": "Español (Spanish)",
"zhCN": "简体中文 (Simplified Chinese)",
"zhHant": "繁體中文 (Traditional Chinese)",
"hi": "हिन्दी (Hindi)",
"fr": "Français (French)",
"ar": "العربية (Arabic)",
@@ -316,5 +317,8 @@
"pixels": "{{area}}px"
},
"no_items": "No items",
"validation_errors": "Validation Errors"
"validation_errors": "Validation Errors",
"credentialField": {
"savedPlaceholder": "Saved — leave blank to keep current"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"title": "Stream Offline",
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
},
"cameraDisabled": "Camera is disabled",
"cameraOff": "Camera is off",
"stats": {
"streamType": {
"title": "Stream Type:",
+1 -1
View File
@@ -950,4 +950,4 @@
"label": "Original camera state",
"description": "Keep track of original state of camera."
}
}
}
+36 -1
View File
@@ -921,6 +921,41 @@
"label": "Original GenAI state",
"description": "Indicates whether GenAI was enabled in the original static config."
}
},
"filters_attribute": {
"label": "Attribute filters",
"description": "Filters applied to detected attributes to reduce false positives (area, ratio, confidence).",
"min_area": {
"label": "Minimum attribute area",
"description": "Minimum bounding box area (pixels or percentage) required for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"max_area": {
"label": "Maximum attribute area",
"description": "Maximum bounding box area (pixels or percentage) allowed for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"min_ratio": {
"label": "Minimum aspect ratio",
"description": "Minimum width/height ratio required for the bounding box to qualify."
},
"max_ratio": {
"label": "Maximum aspect ratio",
"description": "Maximum width/height ratio allowed for the bounding box to qualify."
},
"threshold": {
"label": "Confidence threshold",
"description": "Average detection confidence threshold required for the attribute to be considered a true positive."
},
"min_score": {
"label": "Minimum confidence",
"description": "Minimum single-frame detection confidence required to associate this attribute with its parent object."
},
"mask": {
"label": "Filter mask",
"description": "Polygon coordinates defining where this filter applies within the frame."
},
"raw_mask": {
"label": "Raw Mask"
}
}
},
"record": {
@@ -1597,4 +1632,4 @@
"description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication."
}
}
}
}
@@ -28,5 +28,8 @@
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
}
},
"detect": {
"dimensionMustBeEven": "Must be an even number."
}
}
+8
View File
@@ -60,5 +60,13 @@
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Reasoning…",
"show": "Show reasoning",
"hide": "Hide reasoning"
},
"thinking": {
"toggle": "Toggle thinking"
}
}
+1 -1
View File
@@ -222,7 +222,7 @@
"label": "Hide object path"
},
"debugReplay": {
"label": "Debug replay",
"label": "Debug Replay",
"aria": "View this tracked object in the debug replay view"
},
"more": {
+3 -3
View File
@@ -57,8 +57,8 @@
"presets": "PTZ camera presets"
},
"camera": {
"enable": "Enable Camera",
"disable": "Disable Camera"
"turnOn": "Turn Camera On",
"turnOff": "Turn Camera Off"
},
"muteCameras": {
"enable": "Mute All Cameras",
@@ -153,7 +153,7 @@
},
"cameraSettings": {
"title": "{{camera}} Settings",
"cameraEnabled": "Camera Enabled",
"camera": "Camera",
"objectDetection": "Object Detection",
"recording": "Recording",
"snapshots": "Snapshots",
+66 -21
View File
@@ -40,6 +40,11 @@
"profilePrefix": "{{profile}} profile: {{fields}}"
}
},
"menuDot": {
"overrideGlobal": "This section overrides the global configuration",
"overrideProfile": "This section is overridden by the {{profile}} profile",
"unsaved": "This section has unsaved changes"
},
"menu": {
"general": "General",
"globalConfig": "Global configuration",
@@ -98,7 +103,7 @@
"cameraUi": "Camera UI",
"cameraTimestampStyle": "Timestamp style",
"cameraMqtt": "Camera MQTT",
"cameraManagement": "Management",
"cameraManagement": "Camera management",
"cameraReview": "Review",
"masksAndZones": "Masks / Zones",
"motionTuner": "Motion tuner",
@@ -452,7 +457,7 @@
},
"cameraManagement": {
"title": "Manage Cameras",
"description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"addCamera": "Add New Camera",
"deleteCamera": "Delete Camera",
"deleteCameraDialog": {
@@ -470,17 +475,29 @@
"selectCamera": "Select a Camera",
"backToSettings": "Back to Camera Settings",
"streams": {
"title": "Enable / Disable Cameras",
"enableLabel": "Enabled cameras",
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
"disableLabel": "Disabled cameras",
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"rename": "Rename"
"title": "Camera State and Details",
"label": "Camera state",
"description": "Set the operating state for each camera.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.",
"disabledSubheading": "Disabled in configuration",
"status": {
"on": "On",
"off": "Off",
"disabled": "Disabled"
},
"enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.",
"disableSuccess": "Disabled {{cameraName}} and saved to configuration.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved",
"details": {
"edit": "Edit camera details",
"title": "Edit Camera Details",
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
"friendlyNameLabel": "Display Name",
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"webuiUrlLabel": "Camera Web UI URL",
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
}
},
"cameraConfig": {
@@ -515,10 +532,10 @@
"profiles": {
"title": "Profile Camera Overrides",
"selectLabel": "Select profile",
"description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.",
"description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.",
"inherit": "Inherit",
"enabled": "Enabled",
"disabled": "Disabled"
"on": "On",
"off": "Off"
},
"cameraType": {
"title": "Camera Type",
@@ -1531,6 +1548,9 @@
"builtIn": "Built-in Models",
"genaiProviders": "GenAI Providers"
},
"semanticSearchModelSize": {
"notApplicable": "Not applicable for GenAI providers"
},
"review": {
"title": "Review Settings"
},
@@ -1549,9 +1569,14 @@
"searchPlaceholder": "Search...",
"addCustomLabel": "Add custom label...",
"genaiModel": {
"placeholder": "Select model…",
"search": "Search models…",
"noModels": "No models available"
"placeholder": "Select or enter a model…",
"search": "Search or enter a model…",
"noModels": "No models available",
"available": "Available models",
"useCustom": "Use \"{{value}}\"",
"refresh": "Refresh models",
"probeFailed": "Failed to probe models",
"fetchedModels": "Successfully fetched model list"
}
},
"globalConfig": {
@@ -1583,6 +1608,8 @@
"resetError": "Failed to reset settings",
"saveAllSuccess_one": "Saved {{count}} section successfully.",
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
"saveAllSuccessRestartRequired_one": "Saved {{count}} section successfully. Restart Frigate to apply your changes.",
"saveAllSuccessRestartRequired_other": "All {{count}} sections saved successfully. Restart Frigate to apply your changes.",
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
"saveAllFailure": "Failed to save all sections."
@@ -1639,6 +1666,7 @@
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamNumber": "Stream {{index}}",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
@@ -1672,7 +1700,15 @@
"audioMp3": "Transcode to MP3",
"audioExclude": "Exclude",
"hardwareNone": "No hardware acceleration",
"hardwareAuto": "Automatic hardware acceleration"
"hardwareAuto": "Automatic (recommended)",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox",
"addVideoCodec": "Add video codec",
"addAudioCodec": "Add audio codec",
"removeCodec": "Remove codec"
}
},
"birdseye": {
@@ -1680,6 +1716,13 @@
"objects": "Objects",
"motion": "Motion",
"continuous": "Continuous"
},
"cameraOrder": {
"label": "Camera order",
"description": "Drag cameras to set their order in the Birdseye layout.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved"
}
},
"retainMode": {
@@ -1756,7 +1799,9 @@
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image."
},
"objects": {
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
+24 -5
View File
@@ -154,7 +154,8 @@
"gl": "Galego (Gallego)",
"id": "Bahasa Indonesia (Indonesio)",
"ur": "اردو (Urdu)",
"hr": "Hrvatski (Croata)"
"hr": "Hrvatski (Croata)",
"bs": "Bosanski (Bosnio)"
},
"appearance": "Apariencia",
"darkMode": {
@@ -196,7 +197,10 @@
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
"faceLibrary": "Biblioteca de rostros",
"classification": "Clasificación",
"profiles": "Perfiles"
"profiles": "Perfiles",
"actions": "Acciones",
"features": "Funciones",
"chat": "Chat"
},
"unit": {
"speed": {
@@ -252,7 +256,19 @@
"saving": "Guardando…",
"exitFullscreen": "Salir de pantalla completa",
"on": "ENCENDIDO",
"continue": "Continuar"
"continue": "Continuar",
"add": "Añadir",
"applying": "Aplicando…",
"undo": "Deshacer",
"copiedToClipboard": "Copiado al portapapeles",
"modified": "Modificado",
"overridden": "Sobrescrito",
"resetToGlobal": "Restablecer a global",
"resetToDefault": "Restablecer valores predeterminados",
"saveAll": "Guardar todo",
"savingAll": "Guardando todo…",
"undoAll": "Deshacer todo",
"retry": "Reintentar"
},
"toast": {
"save": {
@@ -260,7 +276,8 @@
"noMessage": "No se pudieron guardar los cambios de configuración",
"title": "No se pudieron guardar los cambios de configuración: {{errorMessage}}"
},
"title": "Guardar"
"title": "Guardar",
"success": "Cambios de configuración guardados correctamente."
},
"copyUrlToClipboard": "URL copiada al portapapeles."
},
@@ -314,5 +331,7 @@
"field": {
"optional": "Opcional",
"internalID": "La ID interna que usa Frigate en la configuración y en la base de datos"
}
},
"no_items": "No hay elementos",
"validation_errors": "Errores de validación"
}
+70 -4
View File
@@ -71,16 +71,77 @@
"endTimeMustAfterStartTime": "La hora de finalización debe ser posterior a la hora de inicio"
},
"success": "Exportación iniciada con éxito. Ver el archivo en la página exportaciones.",
"view": "Ver"
"view": "Ver",
"queued": "Exportación en cola. Consulta el progreso en la página de exportaciones.",
"batchSuccess_one": "Se inició 1 exportación. Abriendo el caso ahora.",
"batchSuccess_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"batchSuccess_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"batchPartial": "Se iniciaron {{successful}} de {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
"batchFailed": "No se pudieron iniciar {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
"batchQueuedSuccess_one": "1 exportación en cola. Abriendo el caso ahora.",
"batchQueuedSuccess_many": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
"batchQueuedSuccess_other": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
"batchQueuedPartial": "{{successful}} de {{total}} exportaciones en cola. Cámaras fallidas: {{failedCameras}}",
"batchQueueFailed": "No se pudieron poner en cola {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}"
},
"fromTimeline": {
"saveExport": "Guardar exportación",
"previewExport": "Vista previa de la exportación"
"previewExport": "Vista previa de la exportación",
"queueingExport": "Poniendo exportación en cola...",
"useThisRange": "Usar este intervalo"
},
"selectOrExport": "Seleccionar o exportar",
"case": {
"label": "Caso",
"newCaseDescriptionPlaceholder": "Descripción de caso"
"newCaseDescriptionPlaceholder": "Descripción de caso",
"newCaseOption": "Crear nuevo caso",
"newCaseNamePlaceholder": "Nombre del nuevo caso",
"nonAdminHelp": "Se creará un nuevo caso para estas exportaciones.",
"placeholder": "Selecciona un caso"
},
"queueing": "Poniendo la exportación en cola…",
"tabs": {
"export": "Cámara única",
"multiCamera": "Multicámara"
},
"multiCamera": {
"timeRange": "Intervalo de tiempo",
"selectFromTimeline": "Seleccionar desde la línea de tiempo",
"cameraSelection": "Cámaras",
"cameraSelectionHelp": "Las cámaras con objetos detectados en este intervalo de tiempo están preseleccionadas",
"checkingActivity": "Comprobando actividad de las cámaras...",
"noCameras": "No hay cámaras disponibles",
"detectionCount_one": "1 objeto detectado",
"detectionCount_many": "{{count}} objetos detectados",
"detectionCount_other": "{{count}} objetos detectados",
"nameLabel": "Nombre de la exportación",
"namePlaceholder": "Nombre base opcional para estas exportaciones",
"queueingButton": "Poniendo exportaciones en cola...",
"exportButton_one": "Exportar 1 cámara",
"exportButton_many": "Exportar {{count}} cámaras",
"exportButton_other": "Exportar {{count}} cámaras"
},
"multi": {
"title_one": "Exportar 1 revisión",
"title_many": "Exportar {{count}} revisiones",
"title_other": "Exportar {{count}} revisiones",
"description": "Exportar cada revisión seleccionada. Todas las exportaciones se agruparán en un único caso.",
"descriptionNoCase": "Exportar cada revisión seleccionada.",
"caseNamePlaceholder": "Exportación de revisión - {{date}}",
"exportButton_one": "Exportar 1 revisión",
"exportButton_many": "Exportar {{count}} revisiones",
"exportButton_other": "Exportar {{count}} revisiones",
"exportingButton": "Exportando...",
"toast": {
"started_one": "Se inició 1 exportación. Abriendo el caso ahora.",
"started_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"started_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"startedNoCase_one": "Se inició 1 exportación.",
"startedNoCase_many": "Se iniciaron {{count}} exportaciones.",
"startedNoCase_other": "Se iniciaron {{count}} exportaciones.",
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
}
}
},
"streaming": {
@@ -130,7 +191,12 @@
"markAsUnreviewed": "Marcar como no revisado"
},
"shareTimestamp": {
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara."
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.",
"label": "Compartir marca de tiempo",
"title": "Compartir marca de tiempo",
"custom": "Marca de tiempo personalizada",
"button": "Compartir URL de la marca de tiempo",
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
}
},
"imagePicker": {
+719 -55
View File
@@ -8,7 +8,7 @@
"description": "Habilitado"
},
"audio": {
"label": "Eventos de audio",
"label": "Detección de audio",
"description": "Configuración para la detección de eventos basada en audio para esta cámara.",
"enabled": {
"label": "Habilitar la detección de audio",
@@ -28,14 +28,19 @@
},
"filters": {
"label": "Filtros de audio",
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos."
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos.",
"threshold": {
"label": "Confianza mínima de audio",
"description": "Umbral mínimo de confianza para que se cuente el evento de audio."
}
},
"enabled_in_config": {
"description": "Indica si la detección de audio estaba habilitada originalmente en el archivo de configuración estática.",
"label": "Estado original del audio"
},
"num_threads": {
"label": "Hilos de detección"
"label": "Hilos de detección",
"description": "Número de hilos que se utilizarán para el procesamiento de la detección de audio."
}
},
"friendly_name": {
@@ -50,29 +55,79 @@
},
"autotracking": {
"zoom_factor": {
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75."
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75.",
"label": "Factor de zoom"
},
"calibrate_on_startup": {
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración."
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración.",
"label": "Calibrar al iniciar"
},
"description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.",
"zooming": {
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)."
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos).",
"label": "Modo de zoom"
},
"return_preset": {
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento."
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento.",
"label": "Preajuste de retorno"
},
"timeout": {
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida."
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida.",
"label": "Tiempo de espera de retorno"
},
"label": "Seguimiento automático",
"enabled": {
"label": "Habilitar seguimiento automático",
"description": "Habilita o deshabilita el seguimiento automático con cámara PTZ de objetos detectados."
},
"track": {
"label": "Objetos rastreados",
"description": "Lista de tipos de objetos que deben activar el seguimiento automático."
},
"required_zones": {
"label": "Zonas requeridas",
"description": "Los objetos deben entrar en una de estas zonas antes de que comience el seguimiento automático."
},
"movement_weights": {
"label": "Pesos de movimiento",
"description": "Valores de calibración generados automáticamente por la calibración de la cámara. No los modifiques manualmente."
},
"enabled_in_config": {
"label": "Estado original de autoseguimiento",
"description": "Campo interno para rastrear si el seguimiento automático estaba habilitado en la configuración."
}
},
"tls_insecure": {
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)."
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras).",
"label": "Deshabilitar verificación TLS"
},
"label": "ONVIF",
"description": "Ajustes de conexión ONVIF y seguimiento automático PTZ para esta cámara.",
"host": {
"label": "Host ONVIF",
"description": "Host (y esquema opcional) para el servicio ONVIF de esta cámara."
},
"port": {
"label": "Puerto ONVIF",
"description": "Número de puerto del servicio ONVIF."
},
"user": {
"label": "Nombre de usuario ONVIF",
"description": "Nombre de usuario para la autenticación ONVIF; algunos dispositivos requieren un usuario administrador para ONVIF."
},
"password": {
"label": "Contraseña ONVIF",
"description": "Contraseña para la autenticación ONVIF."
},
"ignore_time_mismatch": {
"label": "Ignorar discrepancia horaria",
"description": "Ignora las diferencias de sincronización horaria entre la cámara y el servidor Frigate para la comunicación ONVIF."
}
},
"zones": {
"distances": {
"label": "Distancias reales"
"label": "Distancias reales",
"description": "Distancias reales opcionales para cada lado del cuadrilátero de la zona, usadas para cálculos de velocidad o distancia. Debe tener exactamente 4 valores si se establece."
},
"coordinates": {
"description": "Coordenadas del polígono que definen el área de la zona. Puede ser una cadena separada por comas o una lista de cadenas de coordenadas. Las coordenadas deben ser relativas (0-1) o absolutas (heredadas).",
@@ -106,23 +161,41 @@
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede expresarse en píxeles (entero) o como porcentaje (decimal entre 0,000001 y 0,99).",
"label": "Área máxima del objeto"
},
"description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona."
"description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona.",
"label": "Filtros de zona",
"min_area": {
"label": "Área mínima de objeto",
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
}
},
"objects": {
"description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos."
"description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos.",
"label": "Objetos activadores"
},
"description": "Las zonas le permiten definir un área específica del fotograma, de modo que pueda determinar si un objeto se encuentra o no dentro de un área determinada.",
"speed_threshold": {
"description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad."
"description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad.",
"label": "Velocidad mínima"
},
"friendly_name": {
"description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona."
"description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona.",
"label": "Nombre de zona"
},
"inertia": {
"description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias."
"description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias.",
"label": "Fotogramas de inercia"
},
"loitering_time": {
"description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo."
"description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo.",
"label": "Segundos de permanencia"
},
"label": "Zonas",
"enabled": {
"label": "Habilitado",
"description": "Habilita o deshabilita esta zona. Las zonas deshabilitadas se ignoran en tiempo de ejecución."
},
"enabled_in_config": {
"label": "Mantiene el registro del estado original de la zona."
}
},
"objects": {
@@ -142,148 +215,739 @@
},
"send_triggers": {
"after_significant_updates": {
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado."
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado.",
"label": "Activador temprano de GenAI"
},
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)."
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.).",
"label": "Activadores de GenAI",
"tracked_object_end": {
"label": "Enviar al finalizar",
"description": "Envía una solicitud a GenAI cuando finaliza el objeto rastreado."
}
},
"required_zones": {
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI."
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI.",
"label": "Zonas requeridas"
},
"prompt": {
"label": "Prompt de descripción",
"description": "Plantilla de prompt predeterminada usada al generar descripciones con GenAI."
},
"object_prompts": {
"label": "Prompts de objetos",
"description": "Prompts por objeto para personalizar las salidas de GenAI para etiquetas concretas."
},
"objects": {
"label": "Objetos de GenAI",
"description": "Lista de etiquetas de objetos que se enviarán a GenAI de forma predeterminada."
},
"debug_save_thumbnails": {
"label": "Guardar miniaturas",
"description": "Guarda las miniaturas enviadas a GenAI para depuración y revisión."
},
"enabled_in_config": {
"label": "Estado original de GenAI",
"description": "Indica si GenAI estaba habilitado en la configuración estática original."
}
},
"label": "Objetos",
"description": "Valores predeterminados de seguimiento de objetos, incluidas las etiquetas que se rastrean y los filtros por objeto.",
"track": {
"label": "Objetos a rastrear",
"description": "Lista de etiquetas de objetos a rastrear para esta cámara."
},
"filters": {
"label": "Filtros de objetos",
"description": "Filtros aplicados a los objetos detectados para reducir falsos positivos (área, relación, confianza).",
"min_area": {
"label": "Área mínima de objeto",
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
},
"max_area": {
"label": "Área máxima de objeto",
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
},
"min_ratio": {
"label": "Relación de aspecto mínima",
"description": "Relación mínima anchura/altura necesaria para que el cuadro delimitador sea válido."
},
"max_ratio": {
"label": "Relación de aspecto máxima",
"description": "Relación máxima anchura/altura permitida para que el cuadro delimitador sea válido."
},
"threshold": {
"label": "Umbral de confianza",
"description": "Umbral medio de confianza de detección necesario para que el objeto se considere un positivo verdadero."
},
"min_score": {
"label": "Confianza mínima",
"description": "Confianza mínima de detección en un único fotograma necesaria para que el objeto se contabilice."
},
"mask": {
"label": "Máscara de filtro",
"description": "Coordenadas del polígono que definen dónde se aplica este filtro dentro del fotograma."
},
"raw_mask": {
"label": "Máscara sin procesar"
}
},
"mask": {
"label": "Máscara de objeto",
"description": "Polígono de máscara usado para evitar la detección de objetos en áreas especificadas."
}
},
"mqtt": {
"label": "MQTT",
"required_zones": {
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT."
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT.",
"label": "Zonas requeridas"
},
"description": "Ajustes de publicación de imágenes MQTT.",
"enabled": {
"label": "Enviar imagen",
"description": "Habilita la publicación de instantáneas de objetos en temas MQTT para esta cámara."
},
"timestamp": {
"label": "Añadir marca de tiempo",
"description": "Superpone una marca de tiempo en las imágenes publicadas en MQTT."
},
"bounding_box": {
"label": "Añadir cuadro delimitador",
"description": "Dibuja cuadros delimitadores en las imágenes publicadas mediante MQTT."
},
"crop": {
"label": "Recortar imagen",
"description": "Recorta las imágenes publicadas en MQTT al cuadro delimitador del objeto detectado."
},
"height": {
"label": "Altura de imagen",
"description": "Altura (píxeles) a la que redimensionar las imágenes publicadas mediante MQTT."
},
"quality": {
"label": "Calidad JPEG",
"description": "Calidad JPEG de las imágenes publicadas en MQTT (0-100)."
}
},
"notifications": {
"email": {
"label": "Email de notificacion"
"label": "Email de notificacion",
"description": "Dirección de correo electrónico usada para notificaciones push o requerida por ciertos proveedores de notificaciones."
},
"label": "Notificaciones",
"description": "Ajustes para habilitar y controlar las notificaciones de esta cámara.",
"enabled": {
"label": "Habilitar notificaciones",
"description": "Habilita o deshabilita las notificaciones para esta cámara."
},
"cooldown": {
"label": "Periodo de enfriamiento",
"description": "Periodo de enfriamiento (segundos) entre notificaciones para evitar saturar a los destinatarios."
},
"enabled_in_config": {
"label": "Estado original de notificaciones",
"description": "Indica si las notificaciones estaban habilitadas en la configuración estática original."
}
},
"audio_transcription": {
"description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.",
"enabled": {
"label": "Habilitar transcripción"
"label": "Habilitar transcripción",
"description": "Activar o desactivar la transcripción de eventos de audio activados manualmente."
},
"label": "Transcripción de audio",
"enabled_in_config": {
"label": "Estado original de la transcripción"
},
"live_enabled": {
"label": "Transcripción en directo",
"description": "Activar la transcripción en directo del audio a medida que se recibe."
}
},
"motion": {
"skip_motion_threshold": {
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función."
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función.",
"label": "Omitir umbral de movimiento"
},
"lightning_threshold": {
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento."
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento.",
"label": "Umbral de iluminación"
},
"threshold": {
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)."
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255).",
"label": "Umbral de movimiento"
},
"label": "Detección de movimiento",
"description": "Ajustes predeterminados de detección de movimiento para esta cámara.",
"enabled": {
"label": "Habilitar detección de movimiento",
"description": "Habilita o deshabilita la detección de movimiento para esta cámara."
},
"improve_contrast": {
"label": "Mejorar contraste",
"description": "Aplica una mejora de contraste a los fotogramas antes del análisis de movimiento para ayudar a la detección."
},
"contour_area": {
"label": "Área de contorno",
"description": "Área mínima de contorno en píxeles necesaria para que se cuente un contorno de movimiento."
},
"delta_alpha": {
"label": "Delta alfa",
"description": "Factor de mezcla alfa usado en la diferencia entre fotogramas para calcular el movimiento."
},
"frame_alpha": {
"label": "Alfa del fotograma",
"description": "Valor alfa usado al mezclar fotogramas para el preprocesamiento de movimiento."
},
"frame_height": {
"label": "Altura del fotograma",
"description": "Altura en píxeles a la que escalar los fotogramas al calcular el movimiento."
},
"mask": {
"label": "Coordenadas de máscara",
"description": "Coordenadas x,y ordenadas que definen el polígono de máscara de movimiento usado para incluir/excluir áreas."
},
"mqtt_off_delay": {
"label": "Retraso de apagado MQTT",
"description": "Segundos a esperar tras el último movimiento antes de publicar un estado MQTT 'off'."
},
"enabled_in_config": {
"label": "Estado de movimiento original",
"description": "Indica si la detección de movimiento estaba habilitada en la configuración estática original."
},
"raw_mask": {
"label": "Máscara sin procesar"
}
},
"lpr": {
"enhancement": {
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución."
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución.",
"label": "Nivel de mejora"
},
"expire_time": {
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)."
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas).",
"label": "Segundos hasta caducar"
},
"label": "Reconocimiento de matrículas",
"description": "Ajustes de reconocimiento de matrículas, incluidos umbrales de detección, formato y matrículas conocidas.",
"enabled": {
"label": "Habilitar LPR",
"description": "Habilita o deshabilita LPR en esta cámara."
},
"min_area": {
"label": "Área mínima de matrícula",
"description": "Área mínima de matrícula (píxeles) necesaria para intentar el reconocimiento."
}
},
"detect": {
"fps": {
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)."
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez).",
"label": "FPS de detección"
},
"min_initialized": {
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2."
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2.",
"label": "Fotogramas mínimos de inicialización"
},
"height": {
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión.",
"label": "Altura de detección"
},
"width": {
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión.",
"label": "Anchura de detección"
},
"stationary": {
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo."
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo.",
"label": "Configuración de objetos estacionarios",
"interval": {
"label": "Intervalo estacionario",
"description": "Frecuencia (en fotogramas) con la que se ejecuta una comprobación de detección para confirmar un objeto estacionario."
},
"threshold": {
"label": "Umbral estacionario",
"description": "Número de fotogramas sin cambio de posición necesarios para marcar un objeto como estacionario."
},
"max_frames": {
"label": "Fotogramas máximos",
"description": "Limita durante cuánto tiempo se rastrean los objetos estacionarios antes de descartarlos.",
"default": {
"label": "Fotogramas máximos predeterminados",
"description": "Número máximo predeterminado de fotogramas para rastrear un objeto estacionario antes de detenerse."
},
"objects": {
"label": "Fotogramas máximos por objeto",
"description": "Sobrescrituras por objeto para el número máximo de fotogramas en los que rastrear objetos estacionarios."
}
},
"classifier": {
"label": "Habilitar clasificador visual",
"description": "Usa un clasificador visual para detectar objetos realmente estacionarios incluso cuando los cuadros delimitadores oscilan."
}
},
"label": "Detección de objetos",
"description": "Ajustes del rol de detección/detect usado para ejecutar la detección de objetos e inicializar los rastreadores.",
"enabled": {
"label": "Habilitar detección de objetos",
"description": "Habilita o deshabilita la detección de objetos para esta cámara."
},
"max_disappeared": {
"label": "Fotogramas máximos desaparecido",
"description": "Número de fotogramas sin detección antes de que un objeto rastreado se considere desaparecido."
},
"annotation_offset": {
"label": "Desplazamiento de anotaciones",
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
}
},
"record": {
"motion": {
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones.",
"label": "Retención de movimiento",
"days": {
"label": "Días de retención",
"description": "Días durante los que conservar las grabaciones."
}
},
"continuous": {
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones.",
"label": "Retención continua",
"days": {
"label": "Días de retención",
"description": "Días durante los que conservar las grabaciones."
}
},
"detections": {
"pre_capture": {
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación.",
"label": "Segundos de captura previa"
},
"post_capture": {
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
"description": "Número de segundos después del evento de detección que se incluirán en la grabación.",
"label": "Segundos de captura posterior"
},
"label": "Retención de detección",
"description": "Ajustes de retención de grabaciones para eventos de detección, incluidas las duraciones de captura previa/posterior.",
"retain": {
"label": "Retención de eventos",
"description": "Ajustes de retención para grabaciones de eventos de detección.",
"days": {
"label": "Días de retención",
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
}
}
},
"alerts": {
"pre_capture": {
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación.",
"label": "Segundos de captura previa"
},
"post_capture": {
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
"description": "Número de segundos después del evento de detección que se incluirán en la grabación.",
"label": "Segundos de captura posterior"
},
"label": "Retención de alertas",
"description": "Ajustes de retención de grabaciones para eventos de alerta, incluidas las duraciones de captura previa/posterior.",
"retain": {
"label": "Retención de eventos",
"description": "Ajustes de retención para grabaciones de eventos de detección.",
"days": {
"label": "Días de retención",
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
}
}
},
"label": "Grabación",
"description": "Ajustes de grabación y retención para esta cámara.",
"enabled": {
"label": "Habilitar grabación",
"description": "Habilita o deshabilita la grabación para esta cámara."
},
"expire_interval": {
"label": "Intervalo de limpieza de grabaciones",
"description": "Minutos entre pasadas de limpieza que eliminan segmentos de grabación caducados."
},
"export": {
"label": "Configuración de exportación",
"description": "Ajustes usados al exportar grabaciones, como timelapse y aceleración por hardware.",
"hwaccel_args": {
"label": "Argumentos hwaccel de exportación",
"description": "Argumentos de aceleración por hardware que se usarán en operaciones de exportación/transcodificación."
},
"max_concurrent": {
"label": "Exportaciones simultáneas máximas",
"description": "Número máximo de trabajos de exportación que se procesarán al mismo tiempo."
}
},
"preview": {
"label": "Configuración de vista previa",
"description": "Ajustes que controlan la calidad de las vistas previas de grabaciones mostradas en la interfaz.",
"quality": {
"label": "Calidad de vista previa",
"description": "Nivel de calidad de vista previa (very_low, low, medium, high, very_high)."
}
},
"enabled_in_config": {
"label": "Estado de grabación original",
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
}
},
"ui": {
"dashboard": {
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz."
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz.",
"label": "Mostrar en la interfaz"
},
"label": "Interfaz de cámara",
"description": "Orden de visualización y visibilidad de esta cámara en la interfaz. El orden afecta al panel predeterminado. Para un control más granular, usa grupos de cámaras.",
"order": {
"label": "Orden en la interfaz",
"description": "Orden numérico usado para ordenar la cámara en la interfaz (panel predeterminado y listas); los números más altos aparecen más tarde."
}
},
"live": {
"height": {
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección."
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección.",
"label": "Altura en directo"
},
"description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo."
"description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo.",
"label": "Reproducción en directo",
"streams": {
"label": "Nombres de flujos en directo",
"description": "Asignación de nombres de flujos configurados a nombres de restream/go2rtc usados para la reproducción en directo."
},
"quality": {
"label": "Calidad en directo",
"description": "Calidad de codificación para el flujo jsmpeg (1 la más alta, 31 la más baja)."
}
},
"review": {
"description": "Configuraciones que controlan las alertas, las detecciones y los resúmenes de revisión de GenAI utilizados por la interfaz de usuario y el almacenamiento de esta cámara.",
"alerts": {
"required_zones": {
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona."
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona.",
"label": "Zonas requeridas"
},
"labels": {
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)."
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person).",
"label": "Etiquetas de alerta"
},
"label": "Configuración de alertas",
"description": "Ajustes sobre qué objetos rastreados generan alertas y cómo se conservan las alertas.",
"enabled": {
"label": "Habilitar alertas",
"description": "Habilita o deshabilita la generación de alertas para esta cámara."
},
"enabled_in_config": {
"label": "Estado original de alertas",
"description": "Rastrea si las alertas estaban habilitadas originalmente en la configuración estática."
},
"cutoff_time": {
"label": "Tiempo de corte de alertas",
"description": "Segundos que se esperarán tras dejar de haber actividad causante de alerta antes de cortar una alerta."
}
},
"detections": {
"required_zones": {
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona."
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona.",
"label": "Zonas requeridas"
},
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones."
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones.",
"label": "Configuración de detecciones",
"enabled": {
"label": "Habilitar detecciones",
"description": "Habilita o deshabilita los eventos de detección para esta cámara."
},
"labels": {
"label": "Etiquetas de detección",
"description": "Lista de etiquetas de objetos que cuentan como eventos de detección."
},
"cutoff_time": {
"label": "Tiempo de corte de detecciones",
"description": "Segundos que se esperarán tras dejar de haber actividad causante de detección antes de cortar una detección."
},
"enabled_in_config": {
"label": "Estado original de detecciones",
"description": "Rastrea si las detecciones estaban habilitadas originalmente en la configuración estática."
}
},
"genai": {
"image_source": {
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens."
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens.",
"label": "Origen de imagen de revisión"
},
"additional_concerns": {
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara."
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara.",
"label": "Consideraciones adicionales"
},
"activity_context_prompt": {
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI."
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI.",
"label": "Prompt de contexto de actividad"
},
"description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.",
"debug_save_thumbnails": {
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión."
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión.",
"label": "Guardar miniaturas"
},
"label": "Configuración de GenAI",
"enabled": {
"label": "Habilitar descripciones de GenAI",
"description": "Habilita o deshabilita las descripciones y resúmenes generados por GenAI para los elementos de revisión."
},
"alerts": {
"label": "Habilitar GenAI para alertas",
"description": "Usa GenAI para generar descripciones de elementos de alerta."
},
"detections": {
"label": "Habilitar GenAI para detecciones",
"description": "Usa GenAI para generar descripciones de elementos de detección."
},
"enabled_in_config": {
"label": "Estado original de GenAI",
"description": "Rastrea si la revisión de GenAI estaba habilitada originalmente en la configuración estática."
},
"preferred_language": {
"label": "Idioma preferido",
"description": "Idioma preferido que se solicitará al proveedor de GenAI para las respuestas generadas."
}
}
},
"label": "Revisión"
},
"birdseye": {
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista."
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista.",
"label": "Vista general",
"enabled": {
"label": "Habilitar Birdseye",
"description": "Habilita o deshabilita la función de vista Birdseye."
},
"mode": {
"label": "Modo de seguimiento",
"description": "Modo para incluir cámaras en Birdseye: 'objects', 'motion' o 'continuous'."
},
"order": {
"label": "Posición",
"description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye."
}
},
"ffmpeg": {
"retry_interval": {
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10."
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10.",
"label": "Tiempo de reintento de FFmpeg"
},
"path": {
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")."
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\").",
"label": "Ruta de FFmpeg"
},
"output_args": {
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación."
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación.",
"label": "Argumentos de salida",
"detect": {
"label": "Argumentos de salida de detección",
"description": "Argumentos de salida predeterminados para los flujos con rol de detección."
},
"record": {
"label": "Argumentos de salida de grabación",
"description": "Argumentos de salida predeterminados para los flujos con rol de grabación."
}
},
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol."
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol.",
"label": "FFmpeg",
"global_args": {
"label": "Argumentos globales de FFmpeg",
"description": "Argumentos globales pasados a los procesos de FFmpeg."
},
"hwaccel_args": {
"label": "Argumentos de aceleración por hardware",
"description": "Argumentos de aceleración por hardware para FFmpeg. Se recomiendan preajustes específicos del proveedor."
},
"input_args": {
"label": "Argumentos de entrada",
"description": "Argumentos de entrada aplicados a los flujos de entrada de FFmpeg."
},
"apple_compatibility": {
"label": "Compatibilidad con Apple",
"description": "Habilita el etiquetado HEVC para mejorar la compatibilidad con reproductores de Apple al grabar H.265."
},
"gpu": {
"label": "Índice de GPU",
"description": "Índice de GPU predeterminado usado para la aceleración por hardware si está disponible."
},
"inputs": {
"label": "Entradas de cámara",
"description": "Lista de definiciones de flujos de entrada (rutas y roles) para esta cámara.",
"path": {
"label": "Ruta de entrada",
"description": "URL o ruta del flujo de entrada de la cámara."
},
"roles": {
"label": "Roles de entrada",
"description": "Roles para este flujo de entrada."
},
"global_args": {
"label": "Argumentos globales de FFmpeg",
"description": "Argumentos globales de FFmpeg para este flujo de entrada."
},
"hwaccel_args": {
"label": "Argumentos de aceleración por hardware",
"description": "Argumentos de aceleración por hardware para este flujo de entrada."
},
"input_args": {
"label": "Argumentos de entrada",
"description": "Argumentos de entrada específicos para este flujo."
}
}
},
"face_recognition": {
"label": "Reconocimiento facial",
"description": "Ajustes de detección y reconocimiento facial para esta cámara.",
"enabled": {
"label": "Habilitar reconocimiento facial",
"description": "Habilita o deshabilita el reconocimiento facial."
},
"min_area": {
"label": "Área mínima de rostro",
"description": "Área mínima (píxeles) del cuadro de un rostro detectado necesaria para intentar el reconocimiento."
}
},
"semantic_search": {
"label": "Búsqueda semántica",
"description": "Ajustes de búsqueda semántica, que crea y consulta embeddings de objetos para encontrar elementos similares.",
"triggers": {
"label": "Activadores",
"description": "Acciones y criterios de coincidencia para activadores de búsqueda semántica específicos de la cámara.",
"friendly_name": {
"label": "Nombre descriptivo",
"description": "Nombre descriptivo opcional mostrado en la interfaz para este activador."
},
"enabled": {
"label": "Habilitar este activador",
"description": "Habilita o deshabilita este activador de búsqueda semántica."
},
"type": {
"label": "Tipo de activador",
"description": "Tipo de activador: 'thumbnail' (coincidir con imagen) o 'description' (coincidir con texto)."
},
"data": {
"label": "Contenido del activador",
"description": "Frase de texto o ID de miniatura que se comparará con objetos rastreados."
},
"threshold": {
"label": "Umbral del activador",
"description": "Puntuación mínima de similitud (0-1) necesaria para activar este activador."
},
"actions": {
"label": "Acciones del activador",
"description": "Lista de acciones que se ejecutarán cuando el activador coincida (notification, sub_label, attribute)."
}
}
},
"snapshots": {
"label": "Instantáneas",
"description": "Ajustes de instantáneas generadas por la API de objetos rastreados para esta cámara.",
"enabled": {
"label": "Habilitar instantáneas",
"description": "Habilita o deshabilita el guardado de instantáneas para esta cámara."
},
"timestamp": {
"label": "Superposición de marca de tiempo",
"description": "Superpone una marca de tiempo en las instantáneas de la API."
},
"bounding_box": {
"label": "Superposición de cuadro delimitador",
"description": "Dibuja cuadros delimitadores para los objetos rastreados en las instantáneas de la API."
},
"crop": {
"label": "Recortar instantánea",
"description": "Recorta las instantáneas de la API al cuadro delimitador del objeto detectado."
},
"required_zones": {
"label": "Zonas requeridas",
"description": "Zonas en las que debe entrar un objeto para que se guarde una instantánea."
},
"height": {
"label": "Altura de instantánea",
"description": "Altura (píxeles) a la que redimensionar las instantáneas de la API; déjalo vacío para conservar el tamaño original."
},
"retain": {
"label": "Retención de instantáneas",
"description": "Ajustes de retención de instantáneas, incluidos días predeterminados y sobrescrituras por objeto.",
"default": {
"label": "Retención predeterminada",
"description": "Número predeterminado de días durante los que conservar instantáneas."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
},
"objects": {
"label": "Retención por objeto",
"description": "Sobrescrituras por objeto para los días de retención de instantáneas."
}
},
"quality": {
"label": "Calidad de instantánea",
"description": "Calidad de codificación de las instantáneas guardadas (0-100)."
}
},
"timestamp_style": {
"label": "Estilo de marca de tiempo",
"description": "Opciones de estilo para marcas de tiempo integradas aplicadas a grabaciones e instantáneas.",
"position": {
"label": "Posición de marca de tiempo",
"description": "Posición de la marca de tiempo en la imagen (tl/tr/bl/br)."
},
"format": {
"label": "Formato de marca de tiempo",
"description": "Cadena de formato de fecha y hora usada para las marcas de tiempo (códigos de formato datetime de Python)."
},
"color": {
"label": "Color de marca de tiempo",
"description": "Valores de color RGB para el texto de la marca de tiempo (todos los valores 0-255).",
"red": {
"label": "Rojo",
"description": "Componente rojo (0-255) para el color de la marca de tiempo."
},
"green": {
"label": "Verde",
"description": "Componente verde (0-255) para el color de la marca de tiempo."
},
"blue": {
"label": "Azul",
"description": "Componente azul (0-255) para el color de la marca de tiempo."
}
},
"thickness": {
"label": "Grosor de marca de tiempo",
"description": "Grosor de línea del texto de la marca de tiempo."
},
"effect": {
"label": "Efecto de marca de tiempo",
"description": "Efecto visual para el texto de la marca de tiempo (none, solid, shadow)."
}
},
"best_image_timeout": {
"label": "Tiempo de espera de mejor imagen",
"description": "Tiempo que se esperará la imagen con la puntuación de confianza más alta."
},
"type": {
"label": "Tipo de cámara",
"description": "Tipo de cámara"
},
"webui_url": {
"label": "URL de la cámara",
"description": "URL para visitar la cámara directamente desde la página del sistema"
},
"profiles": {
"label": "Perfiles",
"description": "Perfiles de configuración con nombre y sobrescrituras parciales que pueden activarse en tiempo de ejecución."
},
"enabled_in_config": {
"label": "Estado original de cámara",
"description": "Mantiene el registro del estado original de la cámara."
}
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -116,5 +116,15 @@
"animal": "Animal",
"postnord": "PostNord",
"usps": "USPS",
"gls": "GLS"
"gls": "GLS",
"canada_post": "Canada Post",
"royal_mail": "Royal Mail",
"school_bus": "Autobús escolar",
"skunk": "Mofeta",
"kangaroo": "Canguro",
"baby": "Bebé",
"baby_stroller": "Cochecito de bebé",
"rickshaw": "Rickshaw",
"Rodent": "Roedor",
"rodent": "Roedor"
}
+69 -1
View File
@@ -1 +1,69 @@
{}
{
"documentTitle": "Chat - Frigate",
"title": "Frigate Chat",
"subtitle": "Tu asistente de IA para la gestión de cámaras y análisis",
"placeholder": "Pregunta cualquier cosa...",
"error": "Algo salió mal. Por favor, inténtalo de nuevo.",
"processing": "Procesando...",
"toolsUsed": "Usado: {{tools}}",
"showTools": "Mostrar herramientas ({{count}})",
"hideTools": "Ocultar herramientas",
"call": "Llamar",
"result": "Resultado",
"arguments": "Argumentos:",
"response": "Respuesta:",
"attachment_chip_label": "{{label}} en {{camera}}",
"attachment_chip_remove": "Eliminar adjunto",
"open_in_explore": "Abrir en Explorar",
"attach_event_aria": "Adjuntar evento {{eventId}}",
"attachment_picker_paste_label": "O pega el ID del evento",
"attachment_picker_attach": "Adjuntar",
"attachment_picker_placeholder": "Adjuntar un evento",
"quick_reply_find_similar": "Buscar avistamientos similares",
"quick_reply_tell_me_more": "Cuéntame más sobre esto",
"quick_reply_when_else": "¿Cuándo más se vio?",
"quick_reply_find_similar_text": "Buscar avistamientos similares a este.",
"quick_reply_tell_me_more_text": "Cuéntame más sobre este.",
"quick_reply_when_else_text": "¿Cuándo más se vio esto?",
"anchor": "Referencia",
"similarity_score": "Similitud",
"no_similar_objects_found": "No se encontraron objetos similares.",
"semantic_search_required": "La búsqueda semántica debe estar activada para encontrar objetos similares.",
"send": "Enviar",
"suggested_requests": "Prueba preguntando:",
"starting_requests": {
"show_recent_events": "Mostrar eventos recientes",
"show_camera_status": "Mostrar estado de la cámara",
"recap": "¿Qué ha pasado mientras estaba fuera?",
"watch_camera": "Vigilar una cámara en busca de actividad"
},
"starting_requests_prompts": {
"show_recent_events": "Muéstrame los eventos recientes de la última hora",
"show_camera_status": "¿Cuál es el estado actual de mis cámaras?",
"recap": "¿Qué ha pasado mientras estaba fuera?",
"watch_camera": "Vigila la puerta principal y avísame si aparece alguien"
},
"new_chat": "Nuevo chat",
"settings": {
"title": "Ajustes del chat",
"show_stats": {
"title": "Mostrar estadísticas",
"desc": "Mostrar la velocidad de generación y el tamaño del contexto en las respuestas del chat.",
"while_generating": "Durante la generación",
"always": "Siempre"
},
"auto_scroll": {
"title": "Desplazamiento automático",
"desc": "Seguir los mensajes nuevos a medida que llegan."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Razonando…",
"show": "Mostrar razonamiento",
"hide": "Ocultar razonamiento"
}
}

Some files were not shown because too many files have changed in this diff Show More