Compare commits

..
Author SHA1 Message Date
Josh Hawkins df4e4860b5 wake the mqtt worker when a publish is queued
publish() only queued the message, and the worker was blocked waiting on the broker socket for up to a second, so on a quiet connection messages went out up to 1s late. A socketpair registered in the worker's selector now interrupts the wait when a publish is queued or stop() is called.
2026-10-06 08:30:16 -05:00
RonnieandGitHub 6b1e084fdc Fix MQTT network loop with high socket file descriptors (#24573)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
2026-10-06 07:38:40 -05:00
Josh HawkinsandGitHub 9ab52c2be6 Clean up onvif and autotracking (#24559)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* simplify onvif and autotracking code

Removes about 230 lines from the ONVIF controller and autotracker without changing how PTZ moves are calculated. `OnvifController` no longer keeps its own `camera_configs` copy of the camera config, the cached `GetStatus`, `GetServiceCapabilities`, and `AbsoluteMove` request objects are gone in favor of plain dicts at the call site, and `PtzAutoTrackerThread` is merged into `PtzAutoTracker`. Repeated blocks in the autotracker (waiting for the motor to stop, disabling autotracking on a failed setup step) are now single helpers.

* use camera config for autotracking enabled state

Camera processes read autotracking state from a shared `autotracker_enabled` value that the dispatcher and autotracker had to keep in sync with the config by hand. Camera processes now subscribe to the `autotracking` and `onvif` config updates and read `onvif.autotracking.enabled` directly, so the shared value and the autotracker's mirroring method are gone. `_disable` now publishes its change so the camera process hears about it. Also removes `tracking_active`, which was set and cleared but never read.

* compute max target box from live zoom factor

* fix autotracking debug overlay max target box lookup

* make max target box a function of zoom factor
2026-10-05 11:25:24 -06:00
Josh HawkinsandGitHub dab0d85830 Add overflow menu to system tabs on mobile (#24543)
* add overflow menu to system tabs on mobile

* add default icon, observe measure div, fix tests
2026-10-05 11:24:19 -06:00
Josh HawkinsandGitHub 9b2839f4fb Fix go2rtc missing from process stats after a restart (#24546)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* re-resolve go2rtc pid on every stats snapshot

* add snapshot test for go2rtc pid after restart
2026-10-02 15:35:04 -06:00
Josh HawkinsandGitHub c06bf97b5e retry and report Frigate+ connection failures at startup (#24545)
A Frigate+ model that wasn't cached yet needed api.frigate.video at startup, and when it couldn't be reached (a network that comes up late, a DNS blip) the requests ConnectionError wasn't a validation error, so Frigate crashed with a traceback before it could start. PlusApi requests now go through a session that retries connection failures for about 30 seconds, and a connection failure that outlasts that is raised as a ValueError so it shows up as a clear config validation error instead.
2026-10-02 15:34:26 -06:00
Josh HawkinsandGitHub f3723698cd Refactor camera status caching (#24544)
* refactor camera status caching

* fix tests
2026-10-02 15:33:58 -06:00
Josh HawkinsandGitHub 38b87feece support env var substitution for notification email (#24517)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
`notifications.email` is now an `EnvString`, so it can come from `secrets.yaml`, a Docker secret, or a container env var. `/api/config` returns the resolved value, so the email is now redacted for non-admin users, including each camera's inherited copy and the profile `base_config` copy.
2026-10-02 06:58:29 -06:00
Blake Blackshear 9b02a077e9 Merge remote-tracking branch 'origin/master' into dev 2026-10-02 06:39:23 -05:00
Nicolas MowenandGitHub d1cb8395ea Reserve other volumes (#24538)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-01 10:07:15 -06:00
Nicolas MowenandGitHub 3d0d12366f Improve build cleanup step to reduce failures (#24536) 2026-10-01 09:43:22 -06:00
Josh HawkinsandGitHub 321e78e65b Fix record status stuck offline after ffmpeg restart (#24522)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix record status never returning online after a record ffmpeg restart

The record ffmpeg restart paths sent `offline` directly, so the cached record status still read `online` and the recovery was never published. Detect and record also shared one resend timestamp, and detect's resend always ran first, so record's periodic resend never fired either. Record's offline now goes through the cache and each status has its own timestamp.

* don't publish record online when the record process has exited
2026-10-01 08:56:27 -06:00
Josh HawkinsandGitHub 17a8efa09c Fix camera name collision bypassing admin check (#24516)
* don't let camera names waive the admin check on non-camera routes

The global admin guard skipped the admin check for any request whose first path segment matched a configured camera name, without looking at which route actually handled it. A camera named `faces`, `lpr`, `audio`, or `classification` let viewers reach the face, LPR, audio transcription, and classification endpoints that rely only on the global guard. The exemption now also requires the matched route to be a `/{camera_name}` route, so camera routes behave exactly as before.

* add test
2026-10-01 08:56:04 -06:00
Nicolas MowenandGitHub 0936f4b4ef Fix detection not correctly being started after an alert ends (#24534)
* Fix detection not correctly being started after an alert ends

* Improve coverage

* Fix incorrect behavior

* Handle unsent thumbs

* Add more tests
2026-10-01 09:51:10 -05:00
007hacky007andGitHub 160d213025 Add frigate-abr to third party extensions (#24526) 2026-10-01 07:36:39 -06:00
Josh HawkinsandGitHub 24a236a152 Miscellaneous fixes (#24528)
* revert disable save buttons when there are no changes in config editor

* pass migrated config to each step in the config migration chain

* use 150 as the default max when typing a min speed in the search filter

* fix preview export outpoint to be relative to the start of the preview file

* don't crash on invalid trusted proxy entries or non-ip forwarded hops

* translate the camera count badge in the roles table

* run every batch through the lpr recognition model

* log rejected motion and notification mqtt payloads

* log invalid addresses in x-forwarded-for

* add test

* remove unused autotracked_object_region

* remove unreachable autotracker setup call in camera maintenance

* apply onvif retry limit when initialization fails

* fix reindex progress overcounting when there are fewer events than a batch

* match the register device button aria label to its text

* reset the add profile form on cancel

* ignore case when filtering search suggestions

* tweak comment
2026-10-01 07:19:31 -06:00
Nicolas MowenandGitHub b749b0bbc5 Apply data check to all cases when validating a segment (#24515)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-30 08:29:45 -05:00
Josh HawkinsandGitHub e37041c879 delete timeline entries when expiring events without clips (#24510)
Events without a clip were deleted once their snapshot expired, but their timeline rows were only removed when clip retention expired, so they were orphaned indefinitely. The timeline cache also held entries forever for events that ended without ever being saved.
2026-09-30 06:02:45 -06:00
Mitchell CurrieandGitHub 61e50a366f Detail XDNA2 community detector (#24480)
* Update hardware.md

Fix stray qualification

* docs: add XDNA2 detector configuration

* docs: add XDNA2 ZMQ model config
2026-09-28 22:49:37 -06:00
Josh HawkinsandGitHub 4e196516dd Tweaks (#24484)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
* fix long press on mobile in face and classification

the listener was on the image only, so long pressing on any overlaid div/text area would cause iOS to select the text instead of adding the blue outline

* improve navigation to and from explore

when viewing a tracked object in explore from a classification card, triggers, or the detail stream, explore would open and show a single tracked object. on mobile (noted especially on iOS with frigate in HA), there is no obvious way to navigate back, so add a back button in its usual spot.

also, when going back to the classification view from explore, it may not be obvious which thumbnail you were last viewing, so add a temporary blue outline around the card like review and explore already does

* test tweaks
2026-09-27 07:45:32 -06:00
Josh HawkinsandGitHub 3941355051 add note to mqtt docs to use ID rather than friendly_name (#24441) 2026-09-24 06:31:40 -06:00
lin-xianmingandGitHub 1a278630da Fix ffmpeg default record preset in reference config (#24451)
Default was changed in b733355
2026-09-23 17:33:53 -06:00
Josh HawkinsandGitHub 9d0d8a99bb use resolved camera config in object processor to avoid race on replay stop (#24450)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
2026-09-23 15:23:20 -06:00
Nicolas MowenandGitHub bbc412763d Update keywords used in docs to match UI (#24436) 2026-09-21 18:45:08 -05:00
Josh HawkinsandGitHub ac9ac50df5 back off restarts when a recording stream goes stale (#24420)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
The watchdog loop runs every second and the record staleness check restarted ffmpeg on every pass, so once a camera's segments went stale it got one restart per second and never had time to finish a 10 second segment. The restart is now gated on `can_restart` like the detect paths and grants 90 seconds of grace afterward. Backport of https://github.com/blakeblackshear/frigate/pull/24072, already in 0.19.
2026-09-20 12:44:47 -06:00
Josh HawkinsandGitHub 93aa6c4174 Add version/release link to docs site (#24410)
* add version/release link to docs

* link to full releases page
2026-09-20 07:40:09 -06:00
Josh HawkinsandGitHub 26e6adee88 Fix semantic search reindex (#24407)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix semantic search reindex

sqlite-vec added the `_info` shadow table in 0.1.6 and drops it unconditionally when a vec0 table is destroyed, so `DROP TABLE` on a table written by 0.17 failed with "SQL logic error" once 0.18 moved to 0.1.9. `SqliteQueueDatabase` queues non-SELECT statements and stores the exception on the cursor it returns, and nothing read those cursors, so the failed drop and every write after it went unreported while reindex still logged "Embedded N thumbnails". `drop_embeddings_tables()` now recreates the missing `_info` stub before dropping, and writes go through `execute_write()`, which waits on the cursor so failures raise. `INSERT OR REPLACE` is gone too, since vec0 implements neither REPLACE nor UPSERT and it always failed on an id already in the table, including under the 0.1.3 build 0.17 shipped.

* use lock

* show reindex failure in status bar
2026-09-19 08:10:36 -06:00
Nicolas MowenandGitHub de416b7ae7 Remove invalid hardware acceleration step in recording troubleshooting (#24395)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Remove the invalid suggestion in docs
2026-09-17 13:28:51 -05:00
Josh HawkinsandGitHub 06967fec91 Fix explore paging for non-date sorts (#24392)
* fix explore paging for non-date sorts

Explore paged every sort by passing the last row's `start_time` as a `before` or `after` cursor, which only works when rows are ordered by `start_time`. For score, speed, and relevance sorts, each page dropped every match newer than that row and repeated older rows from earlier pages, so infinite scroll stopped after a few pages. `/events` and `/events/search` now accept `offset`, and Explore pages non-date sorts by offset. Date sorts keep the cursor because `useSWRInfinite` only revalidates the first page, and cursor keys for later pages follow it while offset keys don't. Score and speed sorts on `/events` break ties on `id` so offset pages stay stable.

* order search ties by id and reject negative offsets

`/events/search` sorted in Python over a query with no `ORDER BY`, so tied scores, speeds, or distances kept whatever order SQLite returned, which isn't guaranteed to match across page requests. The query is now ordered by id and the stable sorts keep that order for ties. `offset` also accepted negative values, which sliced from the end of the search results.
2026-09-17 11:14:20 -06:00
Nicolas MowenandGitHub d69107de33 Handle sub labeled objects to still show up in review filter (#24391) 2026-09-17 11:54:54 -05:00
Nicolas MowenandGitHub 04480a18b6 Revert QSV ffmpeg framerate filter (#24384)
* Update version

* Move back to QSV framerate filter

* Use standard fps filter instead
2026-09-17 11:17:45 -05:00
Josh HawkinsandGitHub b02aea03cd add field messages for recording and notifications (#24272)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
recording and notifications require enabled_in_config to be true at startup to build the correct ffmpeg commands and start the notifications worker
2026-09-13 11:53:44 -06:00
Josh HawkinsandGitHub 51171319a4 Clarify profile docs (#24267)
* Recording must always be enabled in the config to be toggled later by a profile

* add faq
2026-09-13 06:41:33 -06:00
Blake BlackshearandGitHub b1b725b80a Merge pull request #24249 from blakeblackshear/dev
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
0.18.0 Release
2026-09-12 08:09:11 -05:00
Blake BlackshearandGitHub 50a2b6729e update labels/faq (#23759) 2026-07-18 11:19:12 -06:00
104 changed files with 3788 additions and 1604 deletions
+5
View File
@@ -17,9 +17,14 @@ runs:
shell: bash
# This creates a virtual volume at /var/lib/docker to maximize the size
# As of 2/14/2024, this results in 97G for docker images
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
- name: Maximize build space
uses: easimon/maximize-build-space@master
with:
root-reserve-mb: 8192
temp-reserve-mb: 4096
remove-dotnet: 'true'
remove-android: 'true'
remove-haskell: 'true'
+32
View File
@@ -824,6 +824,38 @@ cpu:
models:
- devices:
- cpu:3
xdna2:
title: AMD XDNA2
models:
- key: yolov9
label: YOLOv9
recommended: true
download: |-
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
ui: |-
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `320` |
| **Object detection model input height** | `320` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nchw` |
| **Model Input D Type** | `float` |
| **Object Detection Model Type** | `yolo-generic` |
yaml: |-
models:
- devices:
- zmq:tcp://xdna:5555
model_type: yolo-generic
width: 320
height: 320
input_tensor: nchw
input_dtype: float
path: /config/models/yolov9-c-320.onnx
labelmap_path: /labelmap/coco-80.txt
memryx:
title: MemryX
models:
@@ -294,9 +294,9 @@ ffmpeg:
# Optional: output args for detect streams (default: shown below)
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
# Optional: output args for record streams (default: shown below)
record: preset-record-generic
record: preset-record-generic-audio-aac
# Optional: output args for sub stream record streams (default: the record output args above)
# record_sub: preset-record-generic
# record_sub: preset-record-generic-audio-aac
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
@@ -571,6 +571,8 @@ notifications:
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
email: "admin@example.com"
# Optional: Cooldown time for notifications in seconds (default: shown below)
cooldown: 0
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
| Field | Description |
| ---------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Threshold** | Set to `0.7` |
| Field | Description |
| --------------------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
@@ -30,6 +30,7 @@ Frigate supports multiple different detectors that work on different types of ha
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
**Apple Silicon**
@@ -567,6 +568,28 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
# Community Supported Detectors
## AMD XDNA2
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
detector interface.
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
are not yet qualified; XDNA1 is unsupported.
Follow the frigate-xdna setup instructions to prepare and start the sidecar
before starting Frigate.
### Configuration {#configuration-xdna2}
Using the detector config below will connect Frigate to the sidecar:
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
The example assumes Frigate and the sidecar share a Docker network where the
sidecar is named `xdna`.
## MemryX MX3
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
+10 -10
View File
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
| Field | Description |
| --------------------------------------- | ---------------------------------------------------------------- |
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
| Field | Description |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
| Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
| Field | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
+8 -8
View File
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
| Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
| Field | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
+7 -3
View File
@@ -191,14 +191,12 @@ cameras:
detect:
enabled: false
record:
enabled: false
enabled: true
profiles:
away:
enabled: true
detect:
enabled: true
record:
enabled: true
home:
enabled: false
```
@@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
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.
### Why can't a profile enable recording when it's disabled in the base config?
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
### Can I schedule profiles to be enabled or disabled at certain times?
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
+2 -2
View File
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
3. In the **Create Trigger** wizard:
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
- Select the **Type** (`Thumbnail` or `Description`).
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
+4 -4
View File
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Under the **Zones** section, click the plus icon to add a new zone.
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
5. Press **Save** when finished.
</TabItem>
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `sidewalk`).
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
- Under **Objects**, add the relevant object types (e.g., `person`)
</TabItem>
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone with distances configured.
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
- The unit is kph or mph, depending on the **Unit system** setting
</TabItem>
+2 -2
View File
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
## Min Score
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
## Model
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
## Threshold
The median score an object must reach to be considered a true positive.
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
## Top Score
+29
View File
@@ -75,6 +75,9 @@ Frigate supports multiple different detectors that work on different types of ha
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
- Runs best on discrete AMD GPUs
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
**Apple Silicon**
@@ -338,6 +341,32 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
| ---------------- | ----------------------------------- |
| yolov9-tiny | ~ 4 ms |
### AMD Ryzen AI / XDNA2
Frigate supports AMD XDNA2 NPUs through the community-maintained
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
once on the target system and cached for subsequent use.
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
devices are not yet qualified; XDNA1 is unsupported.
Measured YOLOv9 detector latency on Strix Halo:
| Model | 320 | 640 |
| ----- | ---: | ---: |
| YOLOv9-T | ~7.4 ms | unsupported |
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
**YOLOv9-C at 320 is the recommended quality/performance balance.**
C at 640 is also usable where the lower throughput is acceptable.
Setup, model preparation, and compatibility details are available
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
+6
View File
@@ -11,6 +11,12 @@ MQTT requires a network connection to your broker. This is typically local, but
:::
:::note
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
:::
## General Frigate Topics
### `frigate/available`
@@ -27,6 +27,10 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
## [Frigate Notify](https://github.com/0x2142/frigate-notify)
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
+7 -1
View File
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
### Can I use Frigate+ models commercially?
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
### Why can't I submit images to Frigate+?
+13 -13
View File
@@ -63,20 +63,20 @@ Frigate+ models generally have much higher scores than the default model provide
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
| Object | Min Score | Threshold |
| ----------------- | --------- | --------- |
| **dog** | .7 | .9 |
| **cat** | .65 | .8 |
| **face** | .7 | |
| **package** | .65 | .9 |
| **license_plate** | .6 | |
| **amazon** | .75 | |
| **ups** | .75 | |
| **fedex** | .75 | |
| **person** | .65 | .85 |
| **car** | .65 | .85 |
| Object | Minimum confidence | Confidence threshold |
| ----------------- | ------------------ | -------------------- |
| **dog** | .7 | .9 |
| **cat** | .65 | .8 |
| **face** | .7 | |
| **package** | .65 | .9 |
| **license_plate** | .6 | |
| **amazon** | .75 | |
| **ups** | .75 | |
| **fedex** | .75 | |
| **person** | .65 | .85 |
| **car** | .65 | .85 |
</TabItem>
<TabItem value="yaml">
+9 -6
View File
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
- **People**: `person`, `face`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
- **People**: `person`, `face`, `baby`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
- **Other**: `sports_ball`, `drone`, `lawnmower`
Candidate labels are not available for automatic suggestions.
+2 -10
View File
@@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
#### Step 6: Verify hardware acceleration configuration
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
#### Step 7: Verify go2rtc stream configuration
#### Step 6: Verify go2rtc stream configuration
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
#### Step 8: Check system resources
#### Step 7: Check system resources
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
+30 -22
View File
@@ -3,6 +3,9 @@ import * as path from "node:path";
import type { Config, PluginConfig } from "@docusaurus/types";
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
// Bump when a new stable release ships
const STABLE_VERSION = "0.18";
const config: Config = {
title: "Frigate",
tagline: "NVR With Realtime Object Detection for IP Cameras",
@@ -23,17 +26,17 @@ const config: Config = {
mermaid: true,
},
i18n: {
defaultLocale: 'en',
locales: ['en'],
defaultLocale: "en",
locales: ["en"],
localeConfigs: {
en: {
label: 'English',
}
label: "English",
},
},
},
themeConfig: {
announcementBar: {
id: 'frigate_plus',
id: "frigate_plus",
content: `
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
Get more relevant and accurate detections with Frigate+ models.
@@ -45,8 +48,8 @@ const config: Config = {
50% { transform: scale(1.1); }
}
</style>`,
backgroundColor: '#005f73',
textColor: '#e0fbfc',
backgroundColor: "#005f73",
textColor: "#e0fbfc",
isCloseable: false,
},
docs: {
@@ -83,15 +86,15 @@ const config: Config = {
},
},
prism: {
magicComments:[
magicComments: [
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
className: "theme-code-block-highlighted-line",
line: "highlight-next-line",
block: { start: "highlight-start", end: "highlight-end" },
},
{
className: 'code-block-error-line',
line: 'highlight-error-line',
className: "code-block-error-line",
line: "highlight-error-line",
},
],
additionalLanguages: ["bash", "json"],
@@ -131,6 +134,11 @@ const config: Config = {
srcDark: "img/branding/logo-dark.svg",
},
items: [
{
href: "https://github.com/blakeblackshear/frigate/releases",
label: `${STABLE_VERSION}`,
position: "left",
},
{
to: "/",
activeBasePath: "docs",
@@ -148,19 +156,19 @@ const config: Config = {
position: "right",
},
{
type: 'localeDropdown',
position: 'right',
type: "localeDropdown",
position: "right",
dropdownItemsAfter: [
{
label: '简体中文(社区翻译)',
href: 'https://docs.frigate-cn.video',
}
]
label: "简体中文(社区翻译)",
href: "https://docs.frigate-cn.video",
},
],
},
{
href: 'https://github.com/blakeblackshear/frigate',
label: 'GitHub',
position: 'right',
href: "https://github.com/blakeblackshear/frigate",
label: "GitHub",
position: "right",
},
],
},
+20
View File
@@ -4493,6 +4493,16 @@ paths:
- type: 'null'
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: after
in: query
required: false
@@ -4798,6 +4808,16 @@ paths:
- type: 'null'
default: 50
title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: cameras
in: query
required: false
+14 -3
View File
@@ -311,9 +311,12 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True
)
# remove environment_vars for non-admin users
if request.headers.get("remote-role") != "admin":
is_admin = request.headers.get("remote-role") == "admin"
# hide environment_vars and the notification email from non-admin users
if not is_admin:
config.pop("environment_vars", None)
redact_credential(config["notifications"], "email")
# redact mqtt credentials
redact_credential(config["mqtt"], "password")
@@ -370,7 +373,15 @@ def config(request: Request):
camera_name
)
if base_sections:
camera_dict["base_config"] = base_sections
# copy so redaction below can't alter the profile manager's cache
camera_dict["base_config"] = copy.deepcopy(base_sections)
# cameras inherit the global notification email
if not is_admin:
redact_credential(camera_dict["notifications"], "email")
redact_credential(
camera_dict.get("base_config", {}).get("notifications", {}), "email"
)
# remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
+24 -24
View File
@@ -130,23 +130,18 @@ def require_admin_by_default():
if path.startswith(EXEMPT_PREFIXES):
return
# Dynamic camera path exemption:
# Any path whose first segment matches a configured camera name should
# bypass the global admin requirement. These endpoints enforce access
# via route-level dependencies (e.g. require_camera_access) to ensure
# per-camera authorization. This allows non-admin authenticated users
# (e.g. viewer role) to access camera-specific resources without
# needing admin privileges.
try:
if path.startswith("/"):
first_segment = path.split("/", 2)[1]
if (
first_segment
and first_segment in request.app.frigate_config.cameras
):
return
except Exception:
pass
# Camera routes enforce per-camera access via route-level dependencies
# (e.g. require_camera_access). Match on the route template, not the raw
# path, so a camera named like another namespace (e.g. "faces") can't
# waive the admin check for that namespace's routes.
route = request.scope.get("route")
if (
route is not None
and route.path.startswith("/{camera_name}")
and request.path_params.get("camera_name")
in request.app.frigate_config.cameras
):
return
# For all other paths, require admin role
# Internal port requests have admin role set automatically
@@ -324,11 +319,17 @@ def get_remote_addr(request: Request):
network = ipaddress.ip_network(proxy)
except ValueError:
logger.warning(f"Unable to parse trusted network: {proxy}")
continue
trusted_proxies.append(network)
# return the first remote address that is not trusted
for addr in route:
ip = ipaddress.ip_address(addr.strip())
try:
ip = ipaddress.ip_address(addr.strip())
except ValueError:
logger.debug("Invalid address in X-Forwarded-For header")
return direct_addr or "127.0.0.1"
logger.debug(f"Checking {ip} (v{ip.version})")
trusted = False
for trusted_proxy in trusted_proxies:
@@ -473,12 +474,11 @@ def create_encoded_jwt(user, role, expiration, secret):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# SameSite is intentionally left unset (browsers default to Lax). Setting
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
# iframes, breaking embedded views such as the Home Assistant Frigate card.
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
# cross-origin pages cannot set without a CORS preflight that Frigate never
# grants (see check_csrf in api/fastapi_app.py).
# Starlette sets SameSite=Lax by default. The cookie is still sent to
# same-site iframes (e.g. Home Assistant on the same host or domain), but
# not to cross-site ones. CSRF is also mitigated by requiring a custom
# X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
# preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
response.set_cookie(
key=cookie_name,
value=encoded_jwt,
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
zone: str | None = "all"
zones: str | None = "all"
limit: int | None = 100
offset: int | None = Field(0, ge=0)
after: float | None = None
before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
deprecated=True,
)
limit: int | None = 50
offset: int | None = Field(0, ge=0)
cameras: str | None = "all"
labels: str | None = "all"
sub_labels: str | None = "all"
+11 -2
View File
@@ -129,6 +129,7 @@ def events(
zones = zone
limit = params.limit
offset = params.offset
after = params.after
before = params.before
time_range = params.time_range
@@ -361,11 +362,15 @@ def events(
else:
order_by = Event.start_time.desc()
# offset paging needs a stable order when scores or speeds tie
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
events = (
Event.select(*selected_columns)
.where(reduce(operator.and_, clauses))
.order_by(order_by)
.order_by(order_by, *tiebreaker)
.limit(limit)
.offset(offset)
.dicts()
.iterator()
)
@@ -534,6 +539,7 @@ def events_search(
search_type = params.search_type
include_thumbnails = params.include_thumbnails
limit = params.limit
offset = params.offset
sort = params.sort
# Filters
@@ -840,6 +846,9 @@ def events_search(
if search_results:
events_query = events_query.where(Event.id << list(search_results.keys()))
# sorts below are stable, so this orders ties for offset paging
events_query = events_query.order_by(Event.id)
# Fetch events and process them in a single pass
processed_events = []
for event in events_query.dicts():
@@ -897,7 +906,7 @@ def events_search(
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned
processed_events = processed_events[:limit]
processed_events = processed_events[offset:][:limit]
return JSONResponse(content=processed_events)
+20 -11
View File
@@ -44,6 +44,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review])
def get_label_clause(label: str, include_audio: bool = True):
"""Build a clause matching a label within a review segment's data.
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
so that variant is matched as well.
"""
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
)
if include_audio:
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
return clause
@router.get(
"/review",
response_model=list[ReviewSegmentResponse],
@@ -93,10 +109,7 @@ async def review(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clauses.append(get_label_clause(label))
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
@@ -239,10 +252,7 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clauses.append(get_label_clause(label))
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
# use matching so segments with multiple zones
@@ -340,9 +350,8 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
)
label_clauses.append(get_label_clause(label, include_audio=False))
clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data
+3 -7
View File
@@ -77,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size
from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports
@@ -166,11 +166,7 @@ class FrigateApp:
# create camera_metrics
for camera_name in self.config.cameras.keys():
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[camera_name] = PTZMetrics(
autotracker_enabled=self.config.cameras[
camera_name
].onvif.autotracking.enabled
)
self.ptz_metrics[camera_name] = PTZMetrics()
def init_queues(self) -> None:
# Queue for cameras to push tracked objects to
@@ -443,7 +439,7 @@ class FrigateApp:
)
def start_ptz_autotracker(self) -> None:
self.ptz_autotracker_thread = PtzAutoTrackerThread(
self.ptz_autotracker_thread = PtzAutoTracker(
self.config,
self.onvif_controller,
self.ptz_metrics,
+1 -7
View File
@@ -43,8 +43,6 @@ class CameraMetrics:
class PTZMetrics:
autotracker_enabled: Synchronized
start_time: Synchronized
stop_time: Synchronized
frame_time: Synchronized
@@ -52,13 +50,10 @@ class PTZMetrics:
max_zoom: Synchronized
min_zoom: Synchronized
tracking_active: Event
motor_stopped: Event
reset: Event
def __init__(self, *, autotracker_enabled: bool):
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
def __init__(self) -> None:
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
@@ -66,7 +61,6 @@ class PTZMetrics:
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.tracking_active = mp.Event()
self.motor_stopped = mp.Event()
self.reset = mp.Event()
+6 -4
View File
@@ -104,12 +104,13 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys():
if camera not in self.config.cameras:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue
# handle cameras that were added dynamically
if camera not in self.camera_all_object_counts:
self.__init_camera(self.config.cameras[camera])
self.__init_camera(camera_config)
new_objects = new_activity[camera].get("objects", [])
all_objects.extend(new_objects)
@@ -234,12 +235,13 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp()
for camera in new_activity.keys():
if camera not in self.config.cameras:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue
# handle cameras that were added dynamically
if camera not in self.current_audio_detections:
self.__init_camera(self.config.cameras[camera])
self.__init_camera(camera_config)
new_detections = new_activity[camera].get("detections", [])
if self.compare_audio_activity(camera, new_detections, now):
+1 -3
View File
@@ -120,9 +120,7 @@ class CameraMaintainer(threading.Thread):
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(
autotracker_enabled=config.onvif.autotracking.enabled
)
self.ptz_metrics[name] = PTZMetrics()
self.region_grids[name] = get_camera_regions_grid(
name,
config.detect,
+9 -13
View File
@@ -16,7 +16,7 @@ from frigate.config import (
ZoomingModeEnum,
)
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
from frigate.track.tracked_object import TrackedObject
from frigate.util.image import (
SharedMemoryFrameManager,
@@ -35,7 +35,7 @@ class CameraState:
name: str,
config: FrigateConfig,
frame_manager: SharedMemoryFrameManager,
ptz_autotracker_thread: PtzAutoTrackerThread,
ptz_autotracker_thread: PtzAutoTracker,
) -> None:
self.name = name
self.config = config
@@ -115,17 +115,13 @@ class CameraState:
# draw thicker box around ptz autotracked object
if (
self.camera_config.onvif.autotracking.enabled
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
self.name
)
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
]
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
and self.ptz_autotracker_thread.tracked_object[self.name]
is not None
and obj["id"]
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
self.name
].obj_data["id"] # type: ignore[attr-defined]
].obj_data["id"]
and obj["frame_time"] == frame_time
):
thickness = 5
@@ -138,9 +134,9 @@ class CameraState:
and self.camera_config.detect.width is not None
and self.camera_config.detect.height is not None
):
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
self.name
]["max_target_box"] # type: ignore[index]
max_target_box = calculate_max_target_box(
self.camera_config.onvif.autotracking.zoom_factor
)
side_length = max_target_box * (
max(
self.camera_config.detect.width,
+11 -7
View File
@@ -759,15 +759,13 @@ class Dispatcher:
"Autotracking must be enabled in the config to be turned on via MQTT."
)
return
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
if not ptz_autotracker_settings.enabled:
logger.info(f"Turning on ptz autotracker for {camera_name}")
self.ptz_metrics[camera_name].autotracker_enabled.value = True
self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = True
elif payload == "OFF":
if self.ptz_metrics[camera_name].autotracker_enabled.value:
if ptz_autotracker_settings.enabled:
logger.info(f"Turning off ptz autotracker for {camera_name}")
self.ptz_metrics[camera_name].autotracker_enabled.value = False
self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = False
@@ -782,7 +780,9 @@ class Dispatcher:
try:
payload = int(payload)
except ValueError:
f"Received unsupported value for motion contour area: {payload}"
logger.warning(
f"Received unsupported value for motion contour area: {payload}"
)
return
motion_settings = self.config.cameras[camera_name].motion
@@ -799,7 +799,9 @@ class Dispatcher:
try:
payload = int(payload)
except ValueError:
f"Received unsupported value for motion threshold: {payload}"
logger.warning(
f"Received unsupported value for motion threshold: {payload}"
)
return
motion_settings = self.config.cameras[camera_name].motion
@@ -814,7 +816,9 @@ class Dispatcher:
def _on_global_notification_command(self, payload: str) -> None:
"""Callback for global notification topic."""
if payload != "ON" and payload != "OFF":
f"Received unsupported value for all notification: {payload}"
logger.warning(
f"Received unsupported value for all notification: {payload}"
)
return
notification_settings = self.config.notifications
+58 -3
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import logging
import queue
import selectors
import socket
import threading
import time
from collections.abc import Callable
@@ -56,6 +58,11 @@ class MqttClient(Communicator):
self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0
# lets other threads interrupt the worker's socket wait
self._wake_recv, self._wake_send = socket.socketpair()
self._wake_recv.setblocking(False)
self._wake_send.setblocking(False)
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver
@@ -85,6 +92,7 @@ class MqttClient(Communicator):
return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self._wake_worker()
def stop(self) -> None:
if self._worker is None:
@@ -100,9 +108,11 @@ class MqttClient(Communicator):
publish_done,
)
)
self._wake_worker()
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set()
self._wake_worker()
if self.client is not None:
try:
@@ -358,7 +368,7 @@ class MqttClient(Communicator):
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
while not message_info.is_published() and time.monotonic() < deadline:
if (
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
!= mqtt.MQTT_ERR_SUCCESS
):
break
@@ -368,6 +378,51 @@ class MqttClient(Communicator):
exc_info=True,
)
def _wake_worker(self) -> None:
try:
self._wake_send.send(b"\0")
except BlockingIOError:
# the buffer is full, so a wake is already pending
pass
def _loop_client(self, timeout: float) -> int:
"""Drive Paho without select()'s limit on socket file descriptors."""
assert self.client is not None
client = self.client
sock = client.socket()
if sock is None:
return mqtt.MQTT_ERR_NO_CONN
events = selectors.EVENT_READ
if client.want_write():
events |= selectors.EVENT_WRITE
# TLS can have decrypted bytes buffered even when the socket is not ready.
pending = hasattr(sock, "pending") and sock.pending() > 0
with selectors.DefaultSelector() as selector:
selector.register(sock, events)
selector.register(self._wake_recv, selectors.EVENT_READ)
ready = {
key.fileobj: mask
for key, mask in selector.select(0.0 if pending else timeout)
}
if self._wake_recv in ready:
try:
self._wake_recv.recv(4096)
except BlockingIOError:
pass
ready_events = ready.get(sock, 0)
if pending or ready_events & selectors.EVENT_READ:
result = client.loop_read()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
return result
if ready_events & selectors.EVENT_WRITE:
result = client.loop_write()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
return result
return client.loop_misc()
def _mqtt_loop_worker(self) -> None:
# The worker owns all socket I/O so reconnect, subscribe, and publish
# ordering stays serialized in one place.
@@ -384,7 +439,7 @@ class MqttClient(Communicator):
assert self.client is not None
try:
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT loop error: %s", err)
self._schedule_reconnect()
@@ -617,7 +672,7 @@ class MqttClient(Communicator):
return
try:
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT publish wait failed: %s", err)
self._schedule_reconnect()
+2 -1
View File
@@ -1,6 +1,7 @@
from pydantic import Field
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"]
@@ -11,7 +12,7 @@ class NotificationConfig(FrigateBaseModel):
title="Enable notifications",
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
)
email: str | None = Field(
email: EnvString | None = Field(
default=None,
title="Notification email",
description="Email address used for push notifications or required by certain notification providers.",
@@ -176,6 +176,7 @@ class LicensePlateProcessingMixin:
"""
input_shape = [3, 48, 320]
num_images = len(images)
outputs: list[np.ndarray] = []
for index in range(0, num_images, self.batch_size):
input_h, input_w = input_shape[1], input_shape[2]
@@ -195,11 +196,11 @@ class LicensePlateProcessingMixin:
norm_image = norm_image[np.newaxis, :]
norm_images.append(norm_image)
try:
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}")
return [], []
try:
outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}")
return [], []
return self.ctc_decoder(outputs)
+76 -15
View File
@@ -1,8 +1,10 @@
import logging
import sqlite3
import threading
from typing import Any
import regex
from peewee import DatabaseError
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
self.load_vec_extension: bool = load_vec_extension
# no extension necessary, sqlite will load correctly for each platform
self.sqlite_vec_path = "/usr/local/lib/vec0"
self.upsert_lock = threading.Lock()
super().__init__(*args, **kwargs)
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp)
def execute_write(self, sql: str, params: Any = None) -> None:
"""Run a write and wait for it, so that failures are raised here.
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
stores any exception on the cursor it returns, so callers that ignore
that cursor never learn the write failed.
"""
self.execute_sql(sql, params).fetchall()
def _table_exists(self, table: str) -> bool:
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
return cursor.fetchone() is not None
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
return
# the embeddings tables are only created once semantic search has run
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
if not self._table_exists(table):
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
# callers treat cleanup as best effort, so log rather than propagate
try:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
except DatabaseError:
logger.exception("Failed to delete embeddings from %s", table)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def delete_embeddings_description(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_descriptions", event_ids)
def _restore_vec_info_table(self, table: str) -> None:
"""Recreate the _info shadow table a legacy vec0 table is missing.
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
table is destroyed, so tables written by Frigate 0.17 and earlier fail
to drop. An empty stub is enough, and leaving it unseeded keeps the
table reading as pre-0.1.10 if the drop does not follow.
"""
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
return
logger.debug("Restoring the %s_info shadow table before dropping", table)
self.execute_write(
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
)
def drop_embeddings_tables(self) -> None:
self.execute_sql("""
DROP TABLE vec_descriptions;
""")
self.execute_sql("""
DROP TABLE vec_thumbnails;
""")
for table in ("vec_descriptions", "vec_thumbnails"):
self._restore_vec_info_table(table)
self.execute_write(f"DROP TABLE IF EXISTS {table}")
def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings"""
self.execute_sql("""
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine
);
""")
self.execute_sql("""
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine
);
""")
def upsert_embeddings(
self, table: str, column: str, embeddings: dict[str, bytes]
) -> None:
"""Write embeddings for the given event ids, replacing any that exist.
vec0 implements neither REPLACE nor UPSERT, so rows that are already
there have to be deleted first.
"""
if not embeddings:
return
event_ids = list(embeddings.keys())
ids = ",".join(["?" for _ in event_ids])
params: list[Any] = []
for event_id in event_ids:
params.extend((event_id, embeddings[event_id]))
values = ", ".join(["(?, ?)"] * len(event_ids))
# reindexing and live embedding run on separate threads, and each write
# is queued separately, so the delete and the insert have to be held
# together or an interleaved pair fails on the vec0 primary key
with self.upsert_lock:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
self.execute_write(
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
)
+16 -3
View File
@@ -186,15 +186,28 @@ class ModelConfig(BaseModel):
# download the model if it doesn't exist
if not os.path.isfile(self.path):
download_url = plus_api.get_model_download_url(model_id)
r = requests.get(download_url)
try:
download_url = plus_api.get_model_download_url(model_id)
r = requests.get(download_url)
except requests.exceptions.ConnectionError as e:
raise ValueError(
f"Unable to connect to Frigate+ to download model {model_id}"
) from e
with open(self.path, "wb") as f:
f.write(r.content)
# download the model info if it doesn't exist
if not os.path.isfile(model_info_path):
try:
model_info = plus_api.get_model_info(model_id)
except requests.exceptions.ConnectionError as e:
raise ValueError(
f"Unable to connect to Frigate+ to download model info for {model_id}"
) from e
with open(model_info_path, "w") as f:
json.dump(plus_api.get_model_info(model_id), f)
json.dump(model_info, f)
model_info = load_plus_model_info(model_id)
+43 -40
View File
@@ -6,9 +6,10 @@ import logging
import os
import threading
import time
from typing import Any
import numpy as np
from peewee import DoesNotExist, IntegrityError
from peewee import DatabaseError, DoesNotExist, IntegrityError
from PIL import Image
from playhouse.shortcuts import model_to_dict
@@ -207,12 +208,10 @@ class Embeddings:
embedding = self.vision_embedding([thumbnail])[0]
if upsert:
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
self.db.upsert_embeddings(
"vec_thumbnails",
"thumbnail_embedding",
{event_id: serialize(embedding)},
)
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -251,19 +250,12 @@ class Embeddings:
embeddings = self.vision_embedding(valid_thumbs)
if upsert:
items = []
items = {}
for i in range(len(valid_ids)):
items.append(valid_ids[i])
items.append(serialize(embeddings[i]))
items[valid_ids[i]] = serialize(embeddings[i])
self.image_eps.update()
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
items,
)
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
duration = datetime.datetime.now().timestamp() - start
self.image_inference_speed.update(duration / len(valid_ids))
@@ -277,12 +269,10 @@ class Embeddings:
embedding = self.text_embedding([description])[0]
if upsert:
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
self.db.upsert_embeddings(
"vec_descriptions",
"description_embedding",
{event_id: serialize(embedding)},
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -302,19 +292,14 @@ class Embeddings:
if upsert:
ids = list(event_descriptions.keys())
items = []
items = {}
for i in range(len(ids)):
items.append(ids[i])
items.append(serialize(embeddings[i]))
items[ids[i]] = serialize(embeddings[i])
self.text_eps.update()
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", items
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -322,6 +307,17 @@ class Embeddings:
return embeddings
def reindex(self) -> None:
"""Rebuild every tracked object embedding from scratch."""
totals: dict[str, Any] = {"status": "indexing"}
try:
self._reindex(totals)
except DatabaseError:
logger.exception("Unable to reindex tracked object embeddings")
totals["status"] = "failed"
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
def _reindex(self, totals: dict[str, Any]) -> None:
logger.info("Indexing tracked object embeddings...")
self.db.drop_embeddings_tables()
@@ -346,17 +342,24 @@ class Embeddings:
batch_size = 32
current_page = 1
totals = {
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1 if total_events < batch_size else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
totals.update(
{
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1
if total_events < batch_size
else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
)
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
# a single batch sends no progress, so the first message shows it nearly done
totals["processed_objects"] = 0
events = (
Event.select()
.order_by(Event.start_time.desc())
+1
View File
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
chunk = ids_to_delete[i : i + CHUNK_SIZE]
logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute()
Timeline.delete().where(Timeline.source_id << chunk).execute()
# embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events
+2 -2
View File
@@ -126,8 +126,8 @@ PRESETS_HW_ACCEL_SCALE = {
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
"preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder
+4 -3
View File
@@ -19,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector):
config: RuntimeMotionConfig,
fps: int,
ptz_metrics: PTZMetrics | None = None,
autotracking_enabled: bool = False,
name: str = "improved",
blur_radius: int = 1,
interpolation: int = cv2.INTER_NEAREST,
@@ -45,6 +46,7 @@ class ImprovedMotionDetector(MotionDetector):
self.contrast_values[:, 1:2] = 255
self.contrast_values_index = 0
self.ptz_metrics = ptz_metrics
self.autotracking_enabled = autotracking_enabled
self.last_stop_time: float | None = None
def is_calibrating(self) -> bool:
@@ -59,8 +61,7 @@ class ImprovedMotionDetector(MotionDetector):
# if ptz motor is moving from autotracking, quickly return
# a single box that is 80% of the frame
if self.ptz_metrics is not None and (
self.ptz_metrics.autotracker_enabled.value
and not self.ptz_metrics.motor_stopped.is_set()
self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
):
return [
(
@@ -162,7 +163,7 @@ class ImprovedMotionDetector(MotionDetector):
# if so, reassign the average to the current frame so we begin with a new baseline
if self.ptz_metrics is not None and (
# ensure we only do this for cameras with autotracking enabled
self.ptz_metrics.autotracker_enabled.value
self.autotracking_enabled
and self.ptz_metrics.motor_stopped.is_set()
and (
self.last_stop_time is None
+15 -4
View File
@@ -9,7 +9,9 @@ from typing import Any
import cv2
import requests
from numpy import ndarray
from requests.adapters import HTTPAdapter
from requests.models import Response
from urllib3.util.retry import Retry
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
@@ -101,6 +103,13 @@ class PlusApi:
self._is_active: bool = self.key is not None
self._token_data: dict = {}
# Retry connection failures so a network that comes up late at startup
# doesn't fail the Frigate+ model download
self._session = requests.Session()
self._session.mount(
self.host, HTTPAdapter(max_retries=Retry(connect=5, backoff_factor=1))
)
def _refresh_token_if_needed(self) -> None:
if (
self._token_data.get("expires") is None
@@ -111,7 +120,9 @@ class PlusApi:
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
)
parts = self.key.split(":")
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
r = self._session.get(
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
)
if not r.ok:
raise Exception(f"Unable to refresh API token: {r.text}")
self._token_data = r.json()
@@ -121,19 +132,19 @@ class PlusApi:
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
def _get(self, path: str) -> Response:
return requests.get(
return self._session.get(
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
)
def _post(self, path: str, data: dict) -> Response:
return requests.post(
return self._session.post(
f"{self.host}/v1/{path}",
headers=self._get_authorization_header(),
json=data,
)
def _put(self, path: str, data: dict) -> Response:
return requests.put(
return self._session.put(
f"{self.host}/v1/{path}",
headers=self._get_authorization_header(),
json=data,
+176 -326
View File
@@ -23,6 +23,7 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
CameraConfigUpdateTopic,
)
from frigate.const import (
AUTOTRACKING_MAX_AREA_RATIO,
@@ -42,6 +43,11 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
logger = logging.getLogger(__name__)
def calculate_max_target_box(zoom_factor: float) -> float:
"""Return the largest target box ratio allowed for a zoom factor."""
return AUTOTRACKING_MAX_AREA_RATIO ** (1 / zoom_factor)
def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
# Determine if the PTZ was in motion at the set frame time
# for non ptz/autotracking cameras, this will always return False
@@ -180,7 +186,7 @@ class PtzMotionEstimator:
return self.coord_transformations
class PtzAutoTrackerThread(threading.Thread):
class PtzAutoTracker(threading.Thread):
def __init__(
self,
config: FrigateConfig,
@@ -190,56 +196,14 @@ class PtzAutoTrackerThread(threading.Thread):
stop_event: MpEvent,
) -> None:
super().__init__(name="ptz_autotracker")
self.ptz_autotracker = PtzAutoTracker(
config, onvif, ptz_metrics, dispatcher, stop_event
)
self.stop_event = stop_event
self.config = config
def run(self):
while not self.stop_event.wait(1):
self.ptz_autotracker.check_for_updates()
for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled:
continue
if camera_config.onvif.autotracking.enabled:
future = asyncio.run_coroutine_threadsafe(
self.ptz_autotracker.camera_maintenance(camera),
self.ptz_autotracker.onvif.loop,
)
# Wait for the coroutine to complete
future.result()
else:
# disabled dynamically by mqtt
if self.ptz_autotracker.tracked_object.get(camera):
self.ptz_autotracker.tracked_object[camera] = None
self.ptz_autotracker.tracked_object_history[camera].clear()
self.ptz_autotracker.config_subscriber.stop()
logger.info("Exiting autotracker...")
class PtzAutoTracker:
def __init__(
self,
config: FrigateConfig,
onvif: OnvifController,
ptz_metrics: PTZMetrics,
dispatcher: Dispatcher,
stop_event: MpEvent,
) -> None:
self.config = config
self.onvif = onvif
self.ptz_metrics = ptz_metrics
self.dispatcher = dispatcher
self.stop_event = stop_event
self.tracked_object: dict[str, object] = {}
self.tracked_object: dict[str, TrackedObject | None] = {}
self.tracked_object_history: dict[str, object] = {}
self.tracked_object_metrics: dict[str, object] = {}
self.object_types: dict[str, object] = {}
self.required_zones: dict[str, object] = {}
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
self.move_queues: dict[str, object] = {}
self.move_queue_locks: dict[str, object] = {}
self.move_threads: dict[str, object] = {}
@@ -249,7 +213,6 @@ class PtzAutoTracker:
self.intercept: dict[str, object] = {}
self.move_coefficients: dict[str, object] = {}
self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {}
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
@@ -277,44 +240,37 @@ class PtzAutoTracker:
# Wait for the coroutine to complete
future.result()
def check_for_updates(self) -> None:
"""Apply camera config updates and mirror autotracking state to ptz metrics.
def run(self) -> None:
while not self.stop_event.wait(1):
self.config_subscriber.check_for_updates()
The camera processes read autotracker_enabled rather than the config, so it
has to follow every path that can change autotracking, not just the mqtt
toggle that writes it directly.
"""
updates = self.config_subscriber.check_for_updates()
for cameras in updates.values():
for camera in cameras:
camera_config = self.config.cameras.get(camera)
metrics = self.ptz_metrics.get(camera)
# a camera added at runtime gets its metrics from the maintainer on
# another thread, which seeds them from this same config value
if camera_config is None or metrics is None:
for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled:
continue
metrics.autotracker_enabled.value = (
camera_config.onvif.autotracking.enabled
)
if camera_config.onvif.autotracking.enabled:
future = asyncio.run_coroutine_threadsafe(
self.camera_maintenance(camera), self.onvif.loop
)
# Wait for the coroutine to complete
future.result()
else:
# disabled dynamically by mqtt
if self.tracked_object.get(camera):
self.tracked_object[camera] = None
self.tracked_object_history[camera].clear()
self.config_subscriber.stop()
logger.info("Exiting autotracker...")
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init")
self.object_types[camera] = camera_config.onvif.autotracking.track
self.required_zones[camera] = camera_config.onvif.autotracking.required_zones
self.zoom_factor[camera] = camera_config.onvif.autotracking.zoom_factor
self.tracked_object[camera] = None
self.tracked_object_history[camera] = deque(
maxlen=round(camera_config.detect.fps * 1.5)
)
self.tracked_object_metrics[camera] = {
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
self._reset_tracked_object_metrics(camera)
self.calibrating[camera] = False
self.move_metrics[camera] = []
@@ -326,43 +282,22 @@ class PtzAutoTracker:
# handle onvif constructor failing due to no connection
if camera not in self.onvif.cams:
logger.warning(
f"Disabling autotracking for {camera}: onvif connection failed"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "onvif connection failed")
return
if not self.onvif.cams[camera]["init"]:
if not await self.onvif._init_onvif(camera):
logger.warning(
f"Disabling autotracking for {camera}: Unable to initialize onvif"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "Unable to initialize onvif")
return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
logger.warning(
f"Disabling autotracking for {camera}: FOV relative movement not supported"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
self._disable(camera, "FOV relative movement not supported")
return
move_status_supported = await self.onvif.get_service_capabilities(camera)
if not (
isinstance(move_status_supported, bool) and move_status_supported
) and not (
isinstance(move_status_supported, str)
and move_status_supported.lower() == "true"
):
logger.warning(
f"Disabling autotracking for {camera}: ONVIF MoveStatus not supported"
)
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
if str(move_status_supported).lower() != "true":
self._disable(camera, "ONVIF MoveStatus not supported")
return
if self.onvif.cams[camera]["init"]:
@@ -374,59 +309,41 @@ class PtzAutoTracker:
)
if camera_config.onvif.autotracking.movement_weights:
if len(camera_config.onvif.autotracking.movement_weights) == 6:
camera_config.onvif.autotracking.movement_weights = [
float(val)
for val in camera_config.onvif.autotracking.movement_weights
]
self.ptz_metrics[
camera
].min_zoom.value = (
camera_config.onvif.autotracking.movement_weights[0]
)
self.ptz_metrics[
camera
].max_zoom.value = (
camera_config.onvif.autotracking.movement_weights[1]
)
self.intercept[camera] = (
camera_config.onvif.autotracking.movement_weights[2]
)
self.move_coefficients[camera] = (
camera_config.onvif.autotracking.movement_weights[3:5]
)
self.zoom_time[camera] = (
camera_config.onvif.autotracking.movement_weights[5]
)
else:
camera_config.onvif.autotracking.enabled = False
self.ptz_metrics[camera].autotracker_enabled.value = False
logger.warning(
f"Autotracker recalibration is required for {camera}. Disabling autotracking."
)
(
self.ptz_metrics[camera].min_zoom.value,
self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera],
*self.move_coefficients[camera],
self.zoom_time[camera],
) = map(float, camera_config.onvif.autotracking.movement_weights)
if camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
self.autotracker_init[camera] = True
def _write_config(self, camera):
config_file = find_config_file()
def _disable(self, camera: str, reason: str) -> None:
logger.warning(f"Disabling autotracking for {camera}: {reason}")
autotracking_config = self.config.cameras[camera].onvif.autotracking
autotracking_config.enabled = False
logger.debug(
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
# the camera process holds its own copy of the config
self.dispatcher.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
autotracking_config,
)
update_yaml_file_bulk(
config_file,
{
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
camera
].onvif.autotracking.movement_weights
},
)
def _reset_tracked_object_metrics(self, camera: str) -> None:
self.tracked_object_metrics[camera] = {}
async def _wait_until_stopped(
self, camera: str, metrics: PTZMetrics | None = None
) -> None:
metrics = metrics or self.ptz_metrics[camera]
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
async def _calibrate_camera(self, camera):
# move the camera from the preset in steps and measure the time it takes to move that amount
@@ -459,8 +376,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -470,8 +386,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -488,8 +403,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
@@ -503,8 +417,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
zoom_stop_time = time.time()
@@ -518,8 +431,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
full_relative_stop_time = time.time()
@@ -531,8 +443,7 @@ class PtzAutoTracker:
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
self.zoom_time[camera] = (
full_relative_stop_time - full_relative_start_time
@@ -558,9 +469,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
for step in range(num_steps):
pan = step_sizes[step]
@@ -569,9 +478,7 @@ class PtzAutoTracker:
start_time = time.time()
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
stop_time = time.time()
self.move_metrics[camera].append(
@@ -590,9 +497,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].reset.set()
self.ptz_metrics[camera].motor_stopped.clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
logger.info(
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
@@ -668,7 +573,14 @@ class PtzAutoTracker:
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
)
self._write_config(camera)
update_yaml_file_bulk(
find_config_file(),
{
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
camera
].onvif.autotracking.movement_weights
},
)
def _predict_movement_time(self, camera, pan, tilt):
combined_movement = abs(pan) + abs(tilt)
@@ -682,6 +594,18 @@ class PtzAutoTracker:
[self.tracked_object_history[camera][-1]["frame_time"] + time],
)
def _predict_target_box(self, camera, predicted_time):
target_box = self.tracked_object_metrics[camera]["target_box"]
if not predicted_time:
return target_box
frame_shape = self.config.cameras[camera].frame_shape
return target_box + self._predict_area_after_time(camera, predicted_time) / (
frame_shape[0] * frame_shape[1]
)
def _calculate_tracked_object_metrics(self, camera, obj):
def remove_outliers(data):
areas = [item["area"] for item in data]
@@ -703,19 +627,20 @@ class PtzAutoTracker:
return filtered_data
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
# Extract areas and calculate weighted average
# grab the largest dimension of the bounding box and create a square from that
# Filter out the initial frame and use a recent time window
# Use a recent time window
current_time = obj.obj_data["frame_time"]
time_window = 1.5 # seconds
history = [
entry
for entry in self.tracked_object_history[camera]
if not entry.get("is_initial_frame", False)
and current_time - entry["frame_time"] <= time_window
if current_time - entry["frame_time"] <= time_window
]
if not history: # Fallback to latest if no recent entries
history = [self.tracked_object_history[camera][-1]]
@@ -748,33 +673,29 @@ class PtzAutoTracker:
)
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
X.reshape(-1, 1), y, rcond=None
)[0]
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
0
]
else:
self.tracked_object_metrics[camera]["area_coefficients"] = np.array([0])
tom["area_coefficients"] = np.array([0])
weights = np.arange(1, len(filtered_areas) + 1)
weighted_area = np.average(
[item["area"] for item in filtered_areas], weights=weights
)
self.tracked_object_metrics[camera]["target_box"] = (
tom["target_box"] = (
weighted_area / (camera_width * camera_height)
) ** self.zoom_factor[camera]
) ** zoom_factor
if "original_target_box" not in self.tracked_object_metrics[camera]:
self.tracked_object_metrics[camera]["original_target_box"] = (
self.tracked_object_metrics[camera]["target_box"]
)
if "original_target_box" not in tom:
tom["original_target_box"] = tom["target_box"]
(
self.tracked_object_metrics[camera]["valid_velocity"],
self.tracked_object_metrics[camera]["velocity"],
tom["valid_velocity"],
tom["velocity"],
) = self._get_valid_velocity(camera, obj)
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
camera, obj
)
tom["distance"] = self._get_distance_threshold(camera, obj)
centroid_distance = np.linalg.norm(
[
@@ -785,9 +706,7 @@ class PtzAutoTracker:
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
centroid_distance < self.tracked_object_metrics[camera]["distance"]
)
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
async def _process_move_queue(self, camera):
move_queue = self.move_queues[camera]
@@ -833,16 +752,12 @@ class PtzAutoTracker:
if pan != 0 or tilt != 0:
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera, metrics)
if zoom > 0 and metrics.zoom_level.value != zoom:
await self.onvif._zoom_absolute(camera, zoom, 1)
# Wait until the camera finishes moving
while not metrics.motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera, metrics)
if camera_config.onvif.autotracking.movement_weights:
logger.debug(
@@ -878,41 +793,20 @@ class PtzAutoTracker:
await move_queue.get()
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
def split_value(value, suppress_diff=True):
clipped = np.clip(value, -1, 1)
# don't make small movements
if -0.05 < clipped < 0.05 and suppress_diff:
diff = 0.0
else:
diff = value - clipped
return clipped, diff
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
if (
frame_time > self.ptz_metrics[camera].start_time.value
(pan != 0 or tilt != 0 or zoom != 0)
and frame_time > self.ptz_metrics[camera].start_time.value
and frame_time > self.ptz_metrics[camera].stop_time.value
and not self.move_queue_locks[camera].locked()
):
# we can split up any large moves caused by velocity estimated movements if necessary
# get an excess amount and assign it instead of 0 below
while pan != 0 or tilt != 0 or zoom != 0:
pan, _ = split_value(pan)
tilt, _ = split_value(tilt)
zoom, _ = split_value(zoom, False)
logger.debug(
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
)
move_data = (frame_time, pan, tilt, zoom)
self.onvif.loop.call_soon_threadsafe(
self.move_queues[camera].put_nowait, move_data
)
# reset values to not split up large movements
pan = 0
tilt = 0
zoom = 0
logger.debug(
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
)
self.onvif.loop.call_soon_threadsafe(
self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
)
def _touching_frame_edges(self, camera, box):
camera_config = self.config.cameras[camera]
@@ -1046,16 +940,17 @@ class PtzAutoTracker:
return distance_threshold
def _should_zoom_in(
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
):
def _should_zoom_in(self, camera: str, box, predicted_time):
# returns True if we should zoom in, False if we should zoom out, None to do nothing
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
max_target_box = calculate_max_target_box(zoom_factor)
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
camera_fps = camera_config.detect.fps
average_velocity = self.tracked_object_metrics[camera]["velocity"]
average_velocity = tom["velocity"]
bb_left, bb_top, bb_right, bb_bottom = box
@@ -1073,13 +968,11 @@ class PtzAutoTracker:
touching_frame_edges = self._touching_frame_edges(camera, box)
# make sure object is centered in the frame
below_distance_threshold = self.tracked_object_metrics[camera][
"below_distance_threshold"
]
below_distance_threshold = tom["below_distance_threshold"]
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
self.zoom_factor[camera] + 0.1
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
zoom_factor + 0.1
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
# ensure object is not moving quickly
below_velocity_threshold = np.all(
@@ -1087,30 +980,16 @@ class PtzAutoTracker:
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
) or np.all(average_velocity == 0)
if not predicted_time:
calculated_target_box = self.tracked_object_metrics[camera]["target_box"]
else:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
] + self._predict_area_after_time(camera, predicted_time) / (
camera_width * camera_height
)
calculated_target_box = self._predict_target_box(camera, predicted_time)
below_area_threshold = (
calculated_target_box
< self.tracked_object_metrics[camera]["max_target_box"]
)
below_area_threshold = calculated_target_box < max_target_box
# introduce some hysteresis to prevent a yo-yo zooming effect
zoom_out_hysteresis = (
calculated_target_box
> self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
)
zoom_in_hysteresis = (
calculated_target_box
< self.tracked_object_metrics[camera]["max_target_box"]
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
)
at_max_zoom = (
@@ -1122,31 +1001,29 @@ class PtzAutoTracker:
== self.ptz_metrics[camera].min_zoom.value
)
# debug zooming
if debug_zooming:
logger.debug(
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
)
logger.debug(
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
)
logger.debug(
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {self.tracked_object_metrics[camera]['target_box']}, calculated: {calculated_target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}"
)
logger.debug(
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}"
)
logger.debug(
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
)
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
logger.debug(
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
)
logger.debug(
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
)
logger.debug(
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
)
logger.debug(
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (zoom_factor + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (zoom_factor + 0.1)}"
)
logger.debug(
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
)
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
logger.debug(
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
)
logger.debug(
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
)
# Zoom in conditions (and)
if (
@@ -1237,7 +1114,7 @@ class PtzAutoTracker:
)
zoom = self._get_zoom_amount(
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
camera, obj, predicted_box, predicted_movement_time
)
if (
@@ -1298,9 +1175,11 @@ class PtzAutoTracker:
obj: TrackedObject,
predicted_box,
predicted_movement_time,
debug_zoom=True,
):
camera_config = self.config.cameras[camera]
tom = self.tracked_object_metrics[camera]
zoom_factor = camera_config.onvif.autotracking.zoom_factor
max_target_box = calculate_max_target_box(zoom_factor)
# frame width and height
camera_width = camera_config.frame_shape[1]
@@ -1317,16 +1196,12 @@ class PtzAutoTracker:
# absolute zooming separately from pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
# don't zoom on initial move
if "target_box" not in self.tracked_object_metrics[camera]:
if "target_box" not in tom:
zoom = current_zoom_level
else:
if (
result := self._should_zoom_in(
camera,
obj,
obj.obj_data["box"],
predicted_movement_time,
debug_zoom,
camera, obj.obj_data["box"], predicted_movement_time
)
) is not None:
# divide zoom in 10 increments and always zoom out more than in
@@ -1342,46 +1217,35 @@ class PtzAutoTracker:
# relative zooming concurrently with pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
# this is our initial zoom in on a new object
if "target_box" not in self.tracked_object_metrics[camera]:
zoom = target_box ** self.zoom_factor[camera]
if zoom > self.tracked_object_metrics[camera]["max_target_box"]:
if "target_box" not in tom:
zoom = target_box**zoom_factor
if zoom > max_target_box:
zoom = -(1 - zoom)
logger.debug(
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}, calc zoom: {zoom}"
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
)
else:
if (
result := self._should_zoom_in(
camera,
obj,
predicted_box
if camera_config.onvif.autotracking.movement_weights
else obj.obj_data["box"],
predicted_movement_time,
debug_zoom,
)
) is not None:
if predicted_movement_time:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
] + self._predict_area_after_time(
camera, predicted_movement_time
) / (camera_width * camera_height)
logger.debug(
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {self.tracked_object_metrics[camera]['target_box']}, calculated box: {calculated_target_box}"
)
else:
calculated_target_box = self.tracked_object_metrics[camera][
"target_box"
]
# zoom value
ratio = (
self.tracked_object_metrics[camera]["max_target_box"]
/ calculated_target_box
calculated_target_box = self._predict_target_box(
camera, predicted_movement_time
)
if predicted_movement_time:
logger.debug(
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
)
# zoom value
ratio = max_target_box / calculated_target_box
zoom = (ratio - 1) / (ratio + 1)
logger.debug(
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
)
if not result:
# zoom out with special condition if zooming out because of velocity, edges, etc.
@@ -1394,12 +1258,6 @@ class PtzAutoTracker:
return zoom
def is_autotracking(self, camera: str):
return self.tracked_object[camera] is not None
def autotracked_object_region(self, camera: str):
return self.tracked_object[camera]["region"]
def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras:
return
@@ -1423,8 +1281,9 @@ class PtzAutoTracker:
# new object
self.tracked_object[camera] is None
and obj.camera_config.name == camera
and obj.obj_data["label"] in self.object_types[camera]
and set(obj.entered_zones) & set(self.required_zones[camera])
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
and set(obj.entered_zones)
& set(camera_config.onvif.autotracking.required_zones)
and not obj.previous["false_positive"]
and not obj.false_positive
and not self.tracked_object_history[camera]
@@ -1433,7 +1292,6 @@ class PtzAutoTracker:
logger.debug(
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
)
self.ptz_metrics[camera].tracking_active.set()
self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "ON", retain=False
)
@@ -1482,7 +1340,7 @@ class PtzAutoTracker:
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that?
self.tracked_object[camera] is None
and obj.camera_config.name == camera
and obj.obj_data["label"] in self.object_types[camera]
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
and not obj.previous["false_positive"]
and not obj.false_positive
and self.tracked_object_history[camera]
@@ -1518,10 +1376,7 @@ class PtzAutoTracker:
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
)
self.tracked_object[camera] = None
self.tracked_object_metrics[camera] = {
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
** (1 / self.zoom_factor[camera])
}
self._reset_tracked_object_metrics(camera)
async def camera_maintenance(self, camera):
# bail and don't check anything if we're not set up yet, calibrating, or
@@ -1538,8 +1393,6 @@ class PtzAutoTracker:
# returns camera to preset after timeout when tracking is over
autotracker_config = self.config.cameras[camera].onvif.autotracking
if not self.autotracker_init[camera]:
self._autotracker_setup(self.config.cameras[camera], camera)
# regularly update camera status
if not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
@@ -1560,8 +1413,7 @@ class PtzAutoTracker:
self.tracked_object[camera] = None
self.tracked_object_history[camera].clear()
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
logger.debug(
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
)
@@ -1571,10 +1423,8 @@ class PtzAutoTracker:
)
# update stored zoom level from preset
while not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
await self._wait_until_stopped(camera)
self.ptz_metrics[camera].tracking_active.clear()
self.dispatcher.publish(
f"{camera}/ptz_autotracker/active", "OFF", retain=False
)
+205 -300
View File
@@ -10,8 +10,7 @@ from pathlib import Path
from typing import Any
import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService
from zeep.exceptions import Fault, TransportError
from onvif import ONVIFCamera, ONVIFService
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
focus_out = "focus_out"
PAN_TILT_VELOCITY = {
OnvifCommandEnum.move_left: (-0.5, 0),
OnvifCommandEnum.move_right: (0.5, 0),
OnvifCommandEnum.move_up: (0, 0.5),
OnvifCommandEnum.move_down: (0, -0.5),
}
class OnvifController:
ptz_metrics: dict[str, PTZMetrics]
@@ -61,14 +68,6 @@ class OnvifController:
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
self.loop_thread.start()
self.camera_configs = {}
for cam_name, cam in config.cameras.items():
if not cam.enabled:
continue
if cam.onvif.host:
self.camera_configs[cam_name] = cam
self.status_locks[cam_name] = asyncio.Lock()
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
@@ -92,8 +91,9 @@ class OnvifController:
async def _init_cameras(self) -> None:
"""Initialize all configured cameras."""
for cam_name in self.camera_configs:
await self._init_single_camera(cam_name)
for cam_name, cam in list(self.config.cameras.items()):
if cam.enabled and cam.onvif.host:
await self._init_single_camera(cam_name)
async def _poll_config_updates(self) -> None:
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
@@ -129,13 +129,9 @@ class OnvifController:
async def _remove_camera(self, cam_name: str) -> None:
"""Tear down the ONVIF session for a camera removed at runtime."""
if cam_name not in self.cams and cam_name not in self.camera_configs:
return
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
await self._close_camera(cam_name)
self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
self.status_locks.pop(cam_name, None)
@@ -143,25 +139,14 @@ class OnvifController:
"""Re-initialize a camera after config change."""
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
# close existing session before re-init
# close existing session and reset state before re-init
await self._close_camera(cam_name)
cam = self.config.cameras.get(cam_name)
if not cam or not cam.onvif.host:
# ONVIF removed from config, clean up
self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
return
# update stored config and reset state
self.camera_configs[cam_name] = cam
if cam_name not in self.status_locks:
self.status_locks[cam_name] = asyncio.Lock()
self.cams.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
await self._init_single_camera(cam_name)
cam = self.config.cameras.get(cam_name)
if cam and cam.onvif.host:
await self._init_single_camera(cam_name)
async def _init_single_camera(self, cam_name: str) -> bool:
"""Initialize a single camera by name.
@@ -172,11 +157,12 @@ class OnvifController:
Returns:
bool: True if initialization succeeded, False otherwise
"""
if cam_name not in self.camera_configs:
cam = self.config.cameras.get(cam_name)
if cam is None:
logger.error(f"No configuration found for camera {cam_name}")
return False
cam = self.camera_configs[cam_name]
self.status_locks.setdefault(cam_name, asyncio.Lock())
try:
self.cams[cam_name] = {
"onvif": ONVIFCamera(
@@ -195,12 +181,11 @@ class OnvifController:
"profiles": [],
}
return True
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures
self.failed_cams[cam_name] = {
"retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(),
}
return False
@@ -211,7 +196,8 @@ class OnvifController:
if camera_config is None:
return False
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
cam = self.cams[camera_name]
onvif: ONVIFCamera = cam["onvif"]
try:
await onvif.update_xaddrs()
except Exception as e:
@@ -226,7 +212,7 @@ class OnvifController:
# this will fire an exception if camera is not a ptz
capabilities = onvif.get_definition("ptz")
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
)
@@ -235,7 +221,7 @@ class OnvifController:
try:
profiles = await media.GetProfiles()
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
)
@@ -254,7 +240,7 @@ class OnvifController:
]
# store available profiles for API response and log for debugging
self.cams[camera_name]["profiles"] = [
cam["profiles"] = [
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
for p in valid_profiles
]
@@ -267,19 +253,18 @@ class OnvifController:
)
configured_profile = camera_config.onvif.profile
profile = None
if configured_profile is not None:
# match by exact token first, then by name
for p in valid_profiles:
if p.token == configured_profile:
profile = p
break
if profile is None:
for p in valid_profiles:
if getattr(p, "Name", None) == configured_profile:
profile = p
break
profile = next(
(
p
for key in ("token", "Name")
for p in valid_profiles
if getattr(p, key, None) == configured_profile
),
None,
)
if profile is None:
available = [
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
@@ -304,39 +289,30 @@ class OnvifController:
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
# get the PTZ config for the profile
try:
configs = profile.PTZConfiguration
logger.debug(
f"Onvif ptz config for media profile in {camera_name}: {configs}"
)
except Exception as e:
logger.error(
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
)
return False
configs = profile.PTZConfiguration
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
cam["ptz"] = ptz
try:
imaging: ONVIFService = await onvif.create_imaging_service()
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
imaging = None
self.cams[camera_name]["imaging"] = imaging
cam["imaging"] = imaging
try:
video_sources = await media.GetVideoSources()
if video_sources and len(video_sources) > 0:
self.cams[camera_name]["video_source_token"] = video_sources[0].token
except (Fault, ONVIFError, TransportError, Exception) as e:
cam["video_source_token"] = video_sources[0].token
except Exception as e:
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
self.cams[camera_name]["video_source_token"] = None
cam["video_source_token"] = None
# setup continuous moving request
move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token
self.cams[camera_name]["move_request"] = move_request
cam["move_request"] = move_request
# get PTZ configuration options for feature detection and relative movement
ptz_config = None
@@ -349,7 +325,7 @@ class OnvifController:
logger.debug(
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(
f"Unable to get PTZ configuration options for {camera_name}: {e}"
)
@@ -375,18 +351,6 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled
)
# these are local and cost nothing to build, and autotracking can be enabled
# after a camera is initialized, so always create them rather than baking the
# current config value into init state
status_request = ptz.create_type("GetStatus")
status_request.ProfileToken = profile.token
self.cams[camera_name]["status_request"] = status_request
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name]["service_capabilities_request"] = (
service_capabilities_request
)
# setup relative move request when FOV relative movement is supported
if (
fov_space_id is not None
@@ -395,9 +359,7 @@ class OnvifController:
# one-off GetStatus to seed Translation field
status = None
try:
one_off_status_request = ptz.create_type("GetStatus")
one_off_status_request.ProfileToken = profile.token
status = await ptz.GetStatus(one_off_status_request)
status = await ptz.GetStatus({"ProfileToken": profile.token})
logger.debug(f"Onvif status for {camera_name}: {status}")
except Exception as e:
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
@@ -424,7 +386,7 @@ class OnvifController:
# configure zoom on relative move request
if (
autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled
and autotracking_config.zooming == ZoomingModeEnum.relative
):
zoom_space_id = next(
(
@@ -463,21 +425,16 @@ class OnvifController:
)
if rel_move_request.Speed is None:
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
rel_move_request.Speed = configs.DefaultPTZSpeed
logger.debug(
f"{camera_name}: Relative move request after setup: {rel_move_request}"
)
self.cams[camera_name]["relative_move_request"] = rel_move_request
# setup absolute move request
abs_move_request = ptz.create_type("AbsoluteMove")
abs_move_request.ProfileToken = profile.token
self.cams[camera_name]["absolute_move_request"] = abs_move_request
cam["relative_move_request"] = rel_move_request
# setup existing presets
try:
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
presets = []
@@ -490,7 +447,7 @@ class OnvifController:
preset_name = preset_name.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
cam["presets"][preset_name.lower()] = preset["token"]
# get list of supported features
supported_features = []
@@ -504,64 +461,40 @@ class OnvifController:
if configs.DefaultRelativePanTiltTranslationSpace:
supported_features.append("pt-r")
spaces = getattr(ptz_config, "Spaces", None)
if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r")
if ptz_config is not None:
try:
self.cams[camera_name]["relative_zoom_range"] = (
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
)
except Exception as e:
if autotracking_config.zooming == ZoomingModeEnum.relative:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
if getattr(spaces, "RelativeZoomTranslationSpace", None):
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
if configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
if ptz_config is not None:
try:
self.cams[camera_name]["absolute_zoom_range"] = (
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
)
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
except Exception as e:
if autotracking_config.zooming != ZoomingModeEnum.disabled:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
# disable autotracking zoom if required ranges are unavailable
if autotracking_config.zooming != ZoomingModeEnum.disabled:
if autotracking_config.zooming == ZoomingModeEnum.relative:
if "relative_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
)
if autotracking_config.zooming == ZoomingModeEnum.absolute:
if "absolute_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
)
if (
self.cams[camera_name]["video_source_token"] is not None
and imaging is not None
# autotracking zoom needs the range for its mode, and get_camera_status
# reads the absolute range in both modes
zooming = autotracking_config.zooming
if zooming != ZoomingModeEnum.disabled and (
"absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
):
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: {zooming.value} zoom range unavailable"
)
if cam["video_source_token"] is not None and imaging is not None:
try:
imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
{"VideoSourceToken": cam["video_source_token"]}
)
if (
hasattr(imaging_capabilities, "Focus")
and imaging_capabilities.Focus
):
supported_features.append("focus")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Focus not supported for {camera_name}: {e}")
# detect FOV relative movement support
@@ -570,17 +503,18 @@ class OnvifController:
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
supported_features.append("pt-r-fov")
self.cams[camera_name]["relative_fov_range"] = (
cam["relative_fov_range"] = (
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
)
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
cam["features"] = supported_features
cam["init"] = True
return True
async def _stop(self, camera_name: str) -> None:
move_request = self.cams[camera_name]["move_request"]
await self.cams[camera_name]["ptz"].Stop(
cam = self.cams[camera_name]
move_request = cam["move_request"]
await cam["ptz"].Stop(
{
"ProfileToken": move_request.ProfileToken,
"PanTilt": True,
@@ -588,88 +522,75 @@ class OnvifController:
}
)
if (
"focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"]
and self.cams[camera_name]["imaging"] is not None
"focus" in cam["features"]
and cam["video_source_token"]
and cam["imaging"] is not None
):
try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
stop_request.VideoSourceToken = self.cams[camera_name][
"video_source_token"
]
await self.cams[camera_name]["imaging"].Stop(stop_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
stop_request = cam["imaging"].create_type("Stop")
stop_request.VideoSourceToken = cam["video_source_token"]
await cam["imaging"].Stop(stop_request)
except Exception as e:
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "pt" not in self.cams[camera_name]["features"]:
if "pt" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
cam["active"] = True
move_request = cam["move_request"]
if command == OnvifCommandEnum.move_left:
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}}
elif command == OnvifCommandEnum.move_right:
move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}}
elif command == OnvifCommandEnum.move_up:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": 0.5,
}
}
elif command == OnvifCommandEnum.move_down:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": -0.5,
}
}
x, y = PAN_TILT_VELOCITY[command]
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
try:
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["ptz"].ContinuousMove(move_request)
except Exception as e:
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
if "pt-r-fov" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "pt-r-fov" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return
metrics = self.ptz_metrics.get(camera_name)
camera_config = self.config.cameras.get(camera_name)
if metrics is None:
if metrics is None or camera_config is None:
return
logger.debug(
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
)
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
# only track start_time for autotracking
if metrics.autotracker_enabled.value:
if camera_config.onvif.autotracking.enabled:
metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["relative_move_request"]
move_request = cam["relative_move_request"]
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
@@ -677,55 +598,49 @@ class OnvifController:
pan,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
cam["relative_fov_range"]["XRange"]["Min"],
cam["relative_fov_range"]["XRange"]["Max"],
],
)
tilt = numpy.interp(
tilt,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
cam["relative_fov_range"]["YRange"]["Min"],
cam["relative_fov_range"]["YRange"]["Max"],
],
)
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
}
move_speed = {"PanTilt": {"x": speed, "y": speed}}
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
# include zoom if requested and camera supports relative zoom
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
"Zoom": {"x": speed},
}
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
if include_zoom:
move_speed["Zoom"] = {"x": speed}
move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
move_request.Speed = move_speed
await cam["ptz"].RelativeMove(move_request)
# reset after the move request
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
if include_zoom:
del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
cam = self.cams[camera_name]
preset = preset.lower()
if preset not in self.cams[camera_name]["presets"]:
if preset not in cam["presets"]:
logger.error(f"{preset} is not a valid preset for {camera_name}")
return
@@ -734,44 +649,48 @@ class OnvifController:
if metrics is None:
return
self.cams[camera_name]["active"] = True
cam["active"] = True
metrics.start_time.value = 0
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["move_request"]
preset_token = self.cams[camera_name]["presets"][preset]
move_request = cam["move_request"]
preset_token = cam["presets"][preset]
await self.cams[camera_name]["ptz"].GotoPreset(
await cam["ptz"].GotoPreset(
{
"ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token,
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "zoom" not in self.cams[camera_name]["features"]:
if "zoom" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF zooming.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
cam["active"] = True
move_request = cam["move_request"]
if command == OnvifCommandEnum.zoom_in:
move_request.Velocity = {"Zoom": {"x": 0.5}}
elif command == OnvifCommandEnum.zoom_out:
move_request.Velocity = {"Zoom": {"x": -0.5}}
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
await cam["ptz"].ContinuousMove(move_request)
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
if "zoom-a" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "zoom-a" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return
@@ -782,56 +701,59 @@ class OnvifController:
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["absolute_move_request"]
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
)
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
await cam["ptz"].AbsoluteMove(
{
"ProfileToken": cam["move_request"].ProfileToken,
"Position": {"Zoom": zoom},
"Speed": {"Zoom": speed},
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
await self._stop(camera_name)
if (
"focus" not in self.cams[camera_name]["features"]
or not self.cams[camera_name]["video_source_token"]
or self.cams[camera_name]["imaging"] is None
"focus" not in cam["features"]
or not cam["video_source_token"]
or cam["imaging"] is None
):
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["imaging"].create_type("Move")
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
cam["active"] = True
move_request = cam["imaging"].create_type("Move")
move_request.VideoSourceToken = cam["video_source_token"]
move_request.Focus = {
"Continuous": {
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
@@ -839,10 +761,10 @@ class OnvifController:
}
try:
await self.cams[camera_name]["imaging"].Move(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["imaging"].Move(move_request)
except Exception as e:
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
@@ -883,7 +805,7 @@ class OnvifController:
await self._focus(camera_name, command)
else:
await self._move(camera_name, command)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(f"Unable to handle onvif command: {e}")
def handle_command(
@@ -906,6 +828,15 @@ class OnvifController:
f"Error executing command {command} for camera {camera_name}: {e}"
)
def _camera_info(self, camera_name: str) -> dict[str, Any]:
cam = self.cams[camera_name]
return {
"name": camera_name,
"features": cam["features"],
"presets": list(cam["presets"]),
"profiles": cam["profiles"],
}
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
"""
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
@@ -929,56 +860,21 @@ class OnvifController:
return {}
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
"profiles": self.cams[camera_name].get("profiles", []),
}
return self._camera_info(camera_name)
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
success = await self._init_single_camera(camera_name)
if not success:
return {}
# Reset retry count after timeout
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
failed = self.failed_cams.get(camera_name, {})
attempts = failed.get("retry_attempts", 0)
last_attempt = failed.get("last_attempt", 0)
# Reset retry count after timeout
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0
self.failed_cams[camera_name]["retry_attempts"] = 0
# Attempt initialization/reconnection
if attempts < self.max_retries:
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
if camera_name in self.failed_cams:
del self.failed_cams[camera_name]
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
@@ -987,8 +883,24 @@ class OnvifController:
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
return {}
logger.debug(f"Could not initialize ONVIF for {camera_name}")
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
self.failed_cams.pop(camera_name, None)
return self._camera_info(camera_name)
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(f"Error during ONVIF initialization for {camera_name}: {e}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
return {}
async def get_service_capabilities(self, camera_name: str) -> None:
@@ -996,16 +908,13 @@ class OnvifController:
logger.error(f"ONVIF is not configured for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
await self._init_onvif(camera_name)
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
try:
service_capabilities = await self.cams[camera_name][
"ptz"
].GetServiceCapabilities(service_capabilities_request)
service_capabilities = await cam["ptz"].GetServiceCapabilities()
logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
@@ -1031,13 +940,16 @@ class OnvifController:
if metrics is None or camera_config is None:
return
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
if not await self._init_onvif(camera_name):
return
status_request = self.cams[camera_name]["status_request"]
try:
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
status = await cam["ptz"].GetStatus(
{"ProfileToken": cam["move_request"].ProfileToken}
)
except Exception:
pass # We're unsupported, that'll be reported in the next check.
@@ -1068,7 +980,7 @@ class OnvifController:
if pan_tilt_status == "IDLE" and (
zoom_status is None or zoom_status == "IDLE"
):
self.cams[camera_name]["active"] = False
cam["active"] = False
if not metrics.motor_stopped.is_set():
metrics.motor_stopped.set()
@@ -1078,7 +990,7 @@ class OnvifController:
metrics.stop_time.value = metrics.frame_time.value
else:
self.cams[camera_name]["active"] = True
cam["active"] = True
if metrics.motor_stopped.is_set():
metrics.motor_stopped.clear()
@@ -1094,8 +1006,8 @@ class OnvifController:
metrics.zoom_level.value = numpy.interp(
round(status.Position.Zoom.x, 2),
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
@@ -1134,7 +1046,7 @@ class OnvifController:
def close(self) -> None:
"""Gracefully shut down the ONVIF controller."""
if not hasattr(self, "loop") or self.loop.is_closed():
if self.loop.is_closed():
logger.debug("ONVIF controller already closed")
return
@@ -1151,13 +1063,6 @@ class OnvifController:
self.config_subscriber.stop()
def stop_and_cleanup():
try:
self.loop.stop()
except Exception as e:
logger.error(f"Error during loop cleanup: {e}")
# Schedule stop and cleanup in the loop thread
self.loop.call_soon_threadsafe(stop_and_cleanup)
self.loop.call_soon_threadsafe(self.loop.stop)
self.loop_thread.join()
+1 -1
View File
@@ -1375,7 +1375,7 @@ class RecordingExporter(threading.Thread):
if preview.end_time > self.end_time:
playlist_lines.append(
f"outpoint {int(preview.end_time - self.end_time)}"
f"outpoint {int(self.end_time - preview.start_time)}"
)
ffmpeg_input = (
+35 -38
View File
@@ -691,6 +691,22 @@ class RecordingMaintainer(threading.Thread):
if start_resolved is not None:
start_resolved.set()
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments, every
# retention decision below depends on complete stats for the segment
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
< end_time
):
return None
record_config = self.config.cameras[camera].record
# sub's alerts/detections carry the retain mode directly, unlike
@@ -718,43 +734,28 @@ class RecordingMaintainer(threading.Thread):
# we should first just check if this segment matches that
# and avoid any DB calls
if highest is not None:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
record_mode = (
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
)
segment_stats = self.segment_stats(camera, start_time, end_time)
# ensure delayed segment info does not lead to lost segments
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
>= end_time
):
record_mode = (
RetainModeEnum.all
if highest == "continuous"
else RetainModeEnum.motion
# Here we only check if we should move the segment based on non-object recording retention
# we will always want to check for overlapping review items below before dropping the segment
if not segment_stats.should_discard_segment(record_mode):
return await self.move_segment(
camera,
stream_type,
start_time,
end_time,
duration,
cache_path,
segment_stats,
has_audio,
audio_rate,
audio_codec,
video_codec,
keyframes,
)
segment_stats = self.segment_stats(camera, start_time, end_time)
# Here we only check if we should move the segment based on non-object recording retention
# we will always want to check for overlapping review items below before dropping the segment
if not segment_stats.should_discard_segment(record_mode):
return await self.move_segment(
camera,
stream_type,
start_time,
end_time,
duration,
cache_path,
segment_stats,
has_audio,
audio_rate,
audio_codec,
video_codec,
keyframes,
)
# we fell through the continuous / motion check, so we need to check the review items
# if the cached segment overlaps with the review items:
@@ -816,10 +817,6 @@ class RecordingMaintainer(threading.Thread):
# continuous/motion retention (either disabled or segment_stats said
# discard), so waiting longer just fills the cache.
else:
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
retain_cutoff = datetime.datetime.fromtimestamp(
most_recently_processed_frame_time - record_config.event_pre_capture
).astimezone(datetime.UTC)
+135 -55
View File
@@ -66,6 +66,9 @@ class PendingReviewSegment:
self.zones = zones
self.audio = audio
self.classification_state_changes: list[dict[str, Any]] = []
# detection-level activity after the last alert activity, split by the
# detection cutoff, these are published when the alert is cut off
self.pending_detections: list[PendingReviewSegment] = []
self.thumb_time: float | None = None
self.last_alert_time: float | None = None
self.last_detection_time: float = frame_time
@@ -83,6 +86,20 @@ class PendingReviewSegment:
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
)
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
"""Add a tracked object's label, sub label, and zones to the segment."""
if not obj["sub_label"]:
self.detections[obj["id"]] = obj["label"]
elif obj["sub_label"][0] in attributes:
self.detections[obj["id"]] = obj["sub_label"][0]
else:
self.detections[obj["id"]] = f"{obj['label']}-verified"
self.sub_labels[obj["id"]] = obj["sub_label"][0]
for zone in obj["current_zones"]:
if zone not in self.zones:
self.zones.append(zone)
def update_frame(
self,
camera_config: CameraConfig,
@@ -435,9 +452,29 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.last_detection_time = now
prev_data = segment.get_data(False)
return self._publish_segment_end(segment, prev_data)
end_time = self._publish_segment_end(segment, prev_data)
self._publish_pending_detections(segment, None)
return end_time
return None
def _publish_pending_detections(
self, segment: PendingReviewSegment, ongoing_since: float | None
) -> None:
"""Publish the detections held while an ended alert was active.
A detection with activity after ongoing_since stays open, only the
latest can. With None every detection is ended, this does not read the
camera config since a removed camera is no longer in it.
"""
for pending in segment.pending_detections:
self._activate_segment(pending)
self._publish_segment_start(pending)
if ongoing_since is None or pending.last_detection_time < ongoing_since:
self._publish_segment_end(pending, pending.get_data(False))
segment.pending_detections = []
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
"""Determine the review severity for a manual event label.
@@ -470,6 +507,55 @@ class ReviewSegmentMaintainer(threading.Thread):
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
def _track_pending_detection(
self,
segment: PendingReviewSegment,
camera_config: CameraConfig,
frame_name: str,
frame_time: float,
objects: list[dict[str, Any]],
) -> None:
"""Hold detection-level activity seen after an alert's last alert
activity, starting a separate detection when the gap since the previous
activity exceeds the detection cutoff."""
pending = segment.pending_detections[-1] if segment.pending_detections else None
if pending is None or frame_time > (
pending.last_detection_time + camera_config.review.detections.cutoff_time
):
pending = PendingReviewSegment(
segment.camera,
frame_time,
SeverityEnum.detection,
{},
sub_labels={},
audio=set(),
zones=[],
)
segment.pending_detections.append(pending)
pending.last_detection_time = frame_time
for obj in objects:
pending.add_object(obj, self.config.all_attributes)
if len(objects) <= pending.frame_active_count:
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
return
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment(
self,
segment: PendingReviewSegment,
@@ -505,6 +591,21 @@ class ReviewSegmentMaintainer(threading.Thread):
should_update_state = True
should_update_image = True
# alert activity resumed, so the pending detection activity
# falls within this alert
for pending in segment.pending_detections:
segment.detections.update(pending.detections)
segment.sub_labels.update(pending.sub_labels)
for zone in pending.zones:
if zone not in segment.zones:
segment.zones.append(zone)
Path(pending.frame_path).unlink(missing_ok=True)
should_update_state = True
segment.pending_detections = []
if activity.has_activity_category(SeverityEnum.detection):
if (
segment.last_detection_time is None
@@ -512,6 +613,8 @@ class ReviewSegmentMaintainer(threading.Thread):
):
segment.last_detection_time = frame_time
pending_objects: list[dict[str, Any]] = []
for object in activity.get_all_objects():
# Alert-level objects should always be added (they extend/upgrade the segment)
# Detection-level objects should only be added if:
@@ -522,23 +625,22 @@ class ReviewSegmentMaintainer(threading.Thread):
if not is_alert_object and segment.severity == SeverityEnum.alert:
# This is a detection-level object
if (
segment.last_alert_time is not None
and frame_time > segment.last_alert_time
):
pending_objects.append(object)
# Only add if it started during the alert's active period
if object["start_time"] > segment.last_alert_time:
continue
if not object["sub_label"]:
segment.detections[object["id"]] = object["label"]
elif object["sub_label"][0] in self.config.all_attributes:
segment.detections[object["id"]] = object["sub_label"][0]
else:
segment.detections[object["id"]] = f"{object['label']}-verified"
segment.sub_labels[object["id"]] = object["sub_label"][0]
segment.add_object(object, self.config.all_attributes)
# keep zones up to date
if len(object["current_zones"]) > 0:
for zone in object["current_zones"]:
if zone not in segment.zones:
segment.zones.append(zone)
if pending_objects:
self._track_pending_detection(
segment, camera_config, frame_name, frame_time, pending_objects
)
if len(activity.get_all_objects()) > segment.frame_active_count:
should_update_state = True
@@ -590,50 +692,28 @@ class ReviewSegmentMaintainer(threading.Thread):
except FileNotFoundError:
return
if (
segment.severity == SeverityEnum.alert
and segment.last_alert_time is not None
and frame_time
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
):
needs_new_detection = (
segment.last_detection_time > segment.last_alert_time
and (
segment.last_detection_time
+ camera_config.review.detections.cutoff_time
)
> frame_time
)
last_detection_time = segment.last_detection_time
end_time = self._publish_segment_end(segment, prev_data)
if needs_new_detection:
new_detections: dict[str, str] = {}
new_zones = set()
for o in activity.categorized_objects["detections"]:
new_detections[o["id"]] = o["label"]
new_zones.update(o["current_zones"])
if new_detections:
new_segment = PendingReviewSegment(
segment.camera,
end_time,
SeverityEnum.detection,
new_detections,
sub_labels={},
audio=set(),
zones=list(new_zones),
)
self._activate_segment(new_segment)
self._publish_segment_start(new_segment)
new_segment.last_detection_time = last_detection_time
elif segment.severity == SeverityEnum.detection and frame_time > (
# detection-level activity must not keep an alert open, it continues
# in a new detection segment once the alert is cut off
if (
segment.severity == SeverityEnum.alert
and segment.last_alert_time is not None
and frame_time
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
):
self._publish_segment_end(segment, prev_data)
self._publish_pending_detections(
segment, frame_time - camera_config.review.detections.cutoff_time
)
elif (
not has_activity
and segment.severity == SeverityEnum.detection
and frame_time
> (
segment.last_detection_time
+ camera_config.review.detections.cutoff_time
):
self._publish_segment_end(segment, prev_data)
)
):
self._publish_segment_end(segment, prev_data)
def check_if_new_segment(
self,
+22 -5
View File
@@ -236,6 +236,15 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
return round(skipped_fps / camera_fps * 100, 1)
def get_go2rtc_pid(cpu_usages: dict[str, dict[str, Any]]) -> int | None:
"""Find the pid of the running go2rtc process in the cpu usages."""
for pid, usage in cpu_usages.items():
if usage.get("cmdline", "").split(" ")[0].endswith("/go2rtc"):
return int(pid)
return None
def stats_snapshot(
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
@@ -248,8 +257,9 @@ def stats_snapshot(
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
stats["cameras"] = {}
for name, camera_stats in camera_metrics.items():
if name not in config.cameras:
for name, camera_stats in list(camera_metrics.items()):
camera_config = config.cameras.get(name)
if camera_config is None:
continue
total_camera_fps += camera_stats.camera_fps.value
@@ -266,7 +276,7 @@ def stats_snapshot(
# Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent
expected_fps = config.cameras[name].detect.fps
expected_fps = camera_config.detect.fps
current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value
@@ -299,7 +309,7 @@ def stats_snapshot(
config.cameras[name].enabled,
),
"detection_fps": round(camera_stats.detection_fps.value, 2),
"detection_enabled": config.cameras[name].detect.enabled,
"detection_enabled": camera_config.detect.enabled,
"pid": pid,
"capture_pid": capture_pid,
"ffmpeg_pid": ffmpeg_pid,
@@ -355,6 +365,14 @@ def stats_snapshot(
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
cpu_usages = stats.get("cpu_usages", {})
# go2rtc is supervised by s6, so its pid changes when s6 restarts it
go2rtc_pid = get_go2rtc_pid(cpu_usages)
if go2rtc_pid is not None:
stats_tracking["processes"]["go2rtc"] = go2rtc_pid
stats["processes"] = {}
for name, pid in stats_tracking["processes"].items():
stats["processes"][name] = {
@@ -363,7 +381,6 @@ def stats_snapshot(
# Embed cpu/mem stats into detectors, cameras, and processes
# so history consumers don't need the full cpu_usages dict
cpu_usages = stats.get("cpu_usages", {})
for det_stats in stats["detectors"].values():
pid_str = str(det_stats.get("pid", ""))
+6 -2
View File
@@ -145,7 +145,11 @@ class BaseTestHttp(unittest.TestCase):
pass
def create_app(
self, stats=None, event_metadata_publisher=None, notice_registry=None
self,
stats=None,
event_metadata_publisher=None,
notice_registry=None,
enforce_default_admin=False,
):
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -160,7 +164,7 @@ class BaseTestHttp(unittest.TestCase):
event_metadata_publisher,
None,
DebugReplayManager(),
enforce_default_admin=False,
enforce_default_admin=enforce_default_admin,
notice_registry=notice_registry,
)
+44
View File
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
@@ -49,6 +50,25 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert response.json()["front_door"]["usage_percent"] == 25.0
def test_camera_name_collision_keeps_admin_default(self):
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app(enforce_default_admin=True)
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with AuthTestClient(app) as client:
assert client.get("/faces", headers=viewer).status_code == 403
assert client.get("/faces").status_code == 200
assert (
client.post("/faces/train/person/classify", headers=viewer).status_code
== 403
)
# Camera routes for the same name stay reachable by viewers
response = client.get("/faces/recordings/summary", headers=viewer)
assert response.status_code == 200
def test_config_set_in_memory_replaces_objects_track_list(self):
self.minimal_config["cameras"]["front_door"]["objects"] = {
"track": ["person", "car"],
@@ -92,6 +112,30 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
def test_config_response_hides_notification_email_from_viewers(self):
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
app = super().create_app()
assert app.frigate_config.notifications.email == "me@example.com"
with AuthTestClient(app) as client:
response = client.get(
"/config",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
assert response.status_code == 200
config = response.json()
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
assert (
config["cameras"]["front_door"]["notifications"]["email"]
== REDACTED_CREDENTIAL_SENTINEL
)
response = client.get("/config")
assert response.json()["notifications"]["email"] == "me@example.com"
def test_config_response_keeps_plus_model_reference(self):
model_id = "test_plus_reference"
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
+102
View File
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id
assert events[1]["id"] == id2
def test_get_event_list_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
f"event-{i}", start_time=now + i, data={"score": score}
)
params = {"sort": "score_desc"}
full = [e["id"] for e in client.get("/events", params=params).json()]
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events", params={**params, "limit": 2, "offset": offset}
).json()
]
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
assert paged == full
def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh"
attribute = "中文标签"
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 1
assert events[0]["id"] == event_id
def test_events_search_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
ids = [f"event-{i}" for i in range(len(scores))]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
ids[i], start_time=now + i, data={"score": score}
)
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": "score_desc",
}
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events/search",
params={**params, "limit": 2, "offset": offset},
).json()
]
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
def test_events_search_offset_pages_orders_ties_by_id(self):
now = datetime.now().timestamp()
ids = ["event-c", "event-a", "event-b"]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1) for event_id in ids
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, event_id in enumerate(ids):
super().insert_mock_event(
event_id, start_time=now + i, data={"score": 0.8}
)
for sort in ("score_desc", "relevance"):
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": sort,
}
paged = [
e["id"]
for offset in (0, 1, 2)
for e in client.get(
"/events/search",
params={**params, "limit": 1, "offset": offset},
).json()
]
assert paged == ["event-a", "event-b", "event-c"]
def test_event_list_rejects_negative_offset(self):
with AuthTestClient(self.app) as client:
response = client.get("/events", params={"offset": -5})
assert response.status_code == 422
response = client.get(
"/events/search",
params={"query": "car", "offset": -5},
)
assert response.status_code == 422
def test_similarity_search_hides_unauthorized_anchor_event(self):
mock_embeddings = Mock()
self.app.frigate_config.semantic_search.enabled = True
+92
View File
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
assert len(response_json) == 1
assert response_json[0]["id"] == id_reviewed
def test_get_review_with_label_filter_matches_verified(self):
"""Test that a label filter also matches the `-verified` variant."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.person", now, now + 2, data={"objects": ["person"]}
)
super().insert_mock_review_segment(
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", now, now + 2, data={"objects": ["car"]}
)
params = {
"labels": "person",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert {r["id"] for r in response_json} == {
"123456.person",
"123456.verified",
}
def test_get_review_with_label_filter_does_not_match_prefix(self):
"""Test that a label filter does not match labels that only share a prefix."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
)
params = {
"labels": "car",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
assert len(response.json()) == 0
def test_get_review_with_audio_label_filter(self):
"""Test that a label filter still matches audio labels."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.audio", now, now + 2, data={"audio": ["speech"]}
)
params = {
"labels": "speech",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == "123456.audio"
####################################################################################################################
################################### GET /review/summary Endpoint #################################################
####################################################################################################################
def test_get_review_summary_label_filter_matches_verified(self):
"""Test that the summary label filter also matches the `-verified` variant."""
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.verified", data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
)
params = {
"cameras": "front_door",
"labels": "person",
"zones": "all",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
response_json = response.json()
assert response_json["last24Hours"]["total_alert"] == 1
assert response_json["last24Hours"]["total_detection"] == 0
today_formatted = datetime.today().strftime("%Y-%m-%d")
assert response_json[today_formatted]["total_alert"] == 1
assert response_json[today_formatted]["total_detection"] == 0
def test_get_review_summary_all_filters(self):
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
+5 -6
View File
@@ -59,7 +59,6 @@ def build_watchdog(
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
@@ -108,8 +107,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_goes_to_the_matching_role_topic(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
watchdog.requestor.send_data.assert_any_call(
"front_door/status/record", "online"
@@ -121,9 +120,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_is_cached_per_stream(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
assert watchdog.requestor.send_data.call_count == 2
+26
View File
@@ -6,6 +6,7 @@ from copy import deepcopy
from unittest.mock import patch
import numpy as np
import requests
from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError
@@ -1595,6 +1596,31 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
@patch(
"frigate.plus.PlusApi.get_model_download_url",
side_effect=requests.exceptions.ConnectionError,
)
def test_plus_unreachable_is_validation_error(self, _):
config = {
"mqtt": {"host": "mqtt"},
"models": [{"path": "plus://unreachable", "devices": ["cpu"]}],
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
with self.assertRaisesRegex(ValidationError, "Unable to connect to Frigate+"):
FrigateConfig(**config)
def test_fails_on_invalid_role(self):
config = {
"mqtt": {"host": "mqtt"},
+25
View File
@@ -257,6 +257,31 @@ class TestMigrateConfigFile(unittest.TestCase):
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"])
self.assertNotIn("detectors", migrated)
def test_top_level_changes_survive_later_steps(self):
# 0.16 adds detect and 0.17 splits genai, both at the top level
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"genai:\n"
" provider: ollama\n"
" model: llava\n"
" prompt: describe it\n"
"cameras: {}\n"
"version: 0.15-1\n"
)
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
self.assertTrue(migrated["detect"]["enabled"])
self.assertEqual(migrated["objects"]["genai"], {"prompt": "describe it"})
self.assertEqual(
migrated["genai"]["default"],
{
"provider": "ollama",
"model": "llava",
"roles": ["descriptions", "chat"],
},
)
def test_a_migrated_config_is_left_alone(self):
migrated = self._migrate(
"mqtt:\n"
+70
View File
@@ -0,0 +1,70 @@
"""Tests for resolving the go2rtc pid from cpu usages."""
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from frigate.stats.util import get_go2rtc_pid, stats_snapshot
class TestGo2rtcPid(unittest.TestCase):
def test_finds_go2rtc_by_binary_path(self):
cpu_usages = {
"frigate.full_system": {"cpu": "1.0", "mem": "2.0"},
"100": {"cmdline": "ffmpeg -i rtsp://127.0.0.1:8554/go2rtc_cam"},
"200": {
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=/dev/shm/go2rtc.yaml"
},
"300": {"cmdline": "frigate.recording"},
}
self.assertEqual(get_go2rtc_pid(cpu_usages), 200)
def test_finds_custom_go2rtc_binary(self):
self.assertEqual(get_go2rtc_pid({"42": {"cmdline": "/config/go2rtc"}}), 42)
def test_returns_none_when_go2rtc_is_not_running(self):
self.assertIsNone(get_go2rtc_pid({"100": {"cmdline": "ffmpeg -i x"}}))
self.assertIsNone(get_go2rtc_pid({}))
class TestGo2rtcPidInSnapshot(unittest.TestCase):
def snapshot(self, tracking: dict, go2rtc_pid: int) -> dict:
def update_stats(stats: dict) -> None:
stats["cpu_usages"] = {
str(go2rtc_pid): {
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=x",
"cpu": str(go2rtc_pid / 100),
"mem": str(go2rtc_pid / 10),
}
}
config = SimpleNamespace(
cameras={},
telemetry=SimpleNamespace(stats=SimpleNamespace(network_bandwidth=False)),
)
hardware_stats = Mock()
hardware_stats.update_stats.side_effect = update_stats
with (
patch("frigate.stats.util.get_detector_stats", return_value={}),
patch("frigate.stats.util.embeddings_stats", return_value={}),
patch("frigate.stats.util.calculate_shm_requirements", return_value={}),
):
return stats_snapshot(config, tracking, hardware_stats)
def test_snapshot_follows_go2rtc_restart(self):
tracking = {
"camera_metrics": {},
"detectors": {},
"started": 0,
"latest_frigate_version": "",
"processes": {"go2rtc": 200, "recording": 50},
"storage_maintainer": None,
}
first = self.snapshot(tracking, 200)["processes"]["go2rtc"]
self.assertEqual(first, {"pid": 200, "cpu": "2.0", "mem": "20.0"})
restarted = self.snapshot(tracking, 300)["processes"]["go2rtc"]
self.assertEqual(restarted, {"pid": 300, "cpu": "3.0", "mem": "30.0"})
+75 -1
View File
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
sys.modules[name] = MagicMock()
# Now import the class under test
from frigate.config import FrigateConfig # noqa: E402
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
# Restore original modules (or remove mock if there was no original)
@@ -124,6 +124,80 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
# Regression: a segment overlapping an active_objects review must not
# be dropped while detection metadata lags behind the segment end,
# the missing frames may hold the active objects (or continuous
# retention would keep it anyway).
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 7
camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
camera_config.record.get_review_pre_capture.return_value = 5
camera_config.record.get_review_post_capture.return_value = 5
config.cameras = {"test_cam": camera_config}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {
cache_path: (end_time, 10.0, False, None, None, None, [])
}
# Metadata has only reached partway into the segment.
maintainer.object_recordings_info["test_cam"] = [
(end_time.timestamp() - 8, [], [], [])
]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.move_segment = AsyncMock(return_value=None)
maintainer.recordings_publisher = MagicMock()
review = MagicMock()
review.severity = "alert"
review.start_time = start_time.timestamp() - 30
review.end_time = None
result = await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
self.assertIsNone(result)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_not_awaited()
# Once metadata passes the segment end, continuous retention keeps it.
maintainer.object_recordings_info["test_cam"].append(
(now.timestamp(), [], [], [])
)
await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_awaited_once()
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
-1
View File
@@ -29,7 +29,6 @@ class TestImprovedMotionDetector(unittest.TestCase):
class DummyPTZ:
def __init__(self):
self.autotracker_enabled = _Stub(False)
self.motor_stopped = _Stub(False)
self.stop_time = _Stub(0)
+35 -17
View File
@@ -126,12 +126,18 @@ class TestMqttClientLifecycle(unittest.TestCase):
os.makedirs(MODEL_CACHE_DIR)
self.config = build_config()
self.client = MqttClient(self.config)
self.client = self._build_client()
self.receiver = RuntimeSnapshotReceiver()
self.client.attach_dispatcher(build_dispatcher(self.config, []))
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
def _build_client(self) -> MqttClient:
client = MqttClient(self.config)
self.addCleanup(client._wake_recv.close)
self.addCleanup(client._wake_send.close)
return client
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
client = self._build_client()
with patch.object(client, "_start_worker") as mock_start_worker:
client.subscribe(self.receiver._receive)
@@ -142,7 +148,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_start_worker.assert_not_called()
def test_attach_dispatcher_supplies_command_surface(self) -> None:
client = MqttClient(self.config)
client = self._build_client()
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
@@ -295,6 +301,13 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.assertEqual(self.client._subscription_mid, 42)
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
def test_publish_wakes_worker(self) -> None:
self.client.connected = True
self.client.publish("events", "payload")
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
self,
) -> None:
@@ -448,7 +461,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
def test_publish_direct_waits_for_flush_barrier(self) -> None:
mock_client = MagicMock()
mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.client = mock_client
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
# inflight tracking checks once, then _wait_for_publish polls
@@ -456,11 +468,14 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_client.publish.return_value = message_info
barrier = MagicMock()
self.client._publish_direct(
QueuedPublish("frigate/available", "stopped", True, barrier)
)
with patch.object(
self.client, "_loop_client", return_value=mqtt.MQTT_ERR_SUCCESS
) as mock_loop:
self.client._publish_direct(
QueuedPublish("frigate/available", "stopped", True, barrier)
)
mock_client.loop.assert_called_once()
mock_loop.assert_called_once()
barrier.set.assert_called_once()
def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
@@ -643,9 +658,8 @@ class TestMqttClientLifecycle(unittest.TestCase):
loop_calls[0] += 1
return mqtt.MQTT_ERR_SUCCESS
mock_client.loop.side_effect = loop_side_effect
self.client._wait_for_publish(message_info)
with patch.object(self.client, "_loop_client", side_effect=loop_side_effect):
self.client._wait_for_publish(message_info)
self.assertEqual(loop_calls[0], 1)
self.assertIsNone(self.client.client)
@@ -669,16 +683,20 @@ class TestMqttClientLifecycle(unittest.TestCase):
def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None:
self.client.client = MagicMock()
self.client.client.loop.side_effect = OSError("socket closed")
def stop_after_reconnect() -> None:
self.client._stop_event.set()
with patch.object(
self.client,
"_schedule_reconnect",
side_effect=stop_after_reconnect,
) as mock_schedule_reconnect:
with (
patch.object(
self.client, "_loop_client", side_effect=OSError("socket closed")
),
patch.object(
self.client,
"_schedule_reconnect",
side_effect=stop_after_reconnect,
) as mock_schedule_reconnect,
):
self.client._mqtt_loop_worker()
mock_schedule_reconnect.assert_called_once()
+146
View File
@@ -0,0 +1,146 @@
import fcntl
import resource
import selectors
import socket
import time
import unittest
from unittest.mock import MagicMock, patch
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from frigate.comms.mqtt import MqttClient
class TestMqttNetworkLoop(unittest.TestCase):
def setUp(self) -> None:
self.transport = object.__new__(MqttClient)
self.client = MagicMock()
self.client.want_write.return_value = False
self.client.loop_read.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.loop_write.return_value = mqtt.MQTT_ERR_SUCCESS
self.client.loop_misc.return_value = mqtt.MQTT_ERR_SUCCESS
self.transport.client = self.client
self.sock, self.peer = socket.socketpair()
self.addCleanup(self.sock.close)
self.addCleanup(self.peer.close)
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
self.transport._wake_recv.setblocking(False)
self.transport._wake_send.setblocking(False)
self.addCleanup(self.transport._wake_recv.close)
self.addCleanup(self.transport._wake_send.close)
self.client.socket.return_value = self.sock
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
"""Process real MQTT packets on a socket beyond select()'s FD limit."""
if selectors.DefaultSelector is selectors.SelectSelector:
self.skipTest("This platform has no selector supporting high socket FDs")
original_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
soft, hard = original_limit
if soft <= 1024:
if hard != resource.RLIM_INFINITY and hard <= 1024:
self.skipTest("The hard file descriptor limit is too low")
new_soft = 2048 if hard == resource.RLIM_INFINITY else min(2048, hard)
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
self.addCleanup(resource.setrlimit, resource.RLIMIT_NOFILE, original_limit)
fd = fcntl.fcntl(self.sock.fileno(), fcntl.F_DUPFD, 1024)
with socket.socket(fileno=fd) as high_sock:
high_sock.setblocking(False)
self.peer.settimeout(1)
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="high-fd-test")
client._sock = high_sock
self.transport.client = client
connected, subscribed, received = [], [], []
client.on_connect = lambda *args: connected.append(args[3])
client.on_subscribe = lambda *args: subscribed.append(args[2])
client.on_message = lambda client, userdata, message: received.append(
message.payload
)
self.assertGreaterEqual(high_sock.fileno(), 1024)
self.peer.sendall(b"\x20\x02\x00\x00")
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(len(connected), 1)
self.assertTrue(client.is_connected())
result, mid = client.subscribe("diagnostic", qos=1)
self.assertEqual(result, mqtt.MQTT_ERR_SUCCESS)
self.peer.recv(1024)
self.peer.sendall(b"\x90\x03" + mid.to_bytes(2, "big") + b"\x01")
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(subscribed, [mid])
info = client.publish("diagnostic", b"outgoing", qos=1)
self.peer.recv(1024)
self.peer.sendall(b"\x40\x02" + info.mid.to_bytes(2, "big"))
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertTrue(info.is_published())
payload = b"\x00\x0adiagnosticincoming"
self.peer.sendall(b"\x30" + bytes([len(payload)]) + payload)
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
self.assertEqual(received, [b"incoming"])
def test_idle_socket_still_runs_keepalive(self) -> None:
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
self.client.loop_read.assert_not_called()
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_called_once()
def test_writable_socket_flushes_pending_packets(self) -> None:
self.client.want_write.return_value = True
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
self.client.loop_write.assert_called_once()
self.client.loop_read.assert_not_called()
def test_tls_buffer_is_read_without_waiting_for_socket_readiness(self) -> None:
tls_sock = MagicMock()
tls_sock.fileno.return_value = self.sock.fileno()
tls_sock.pending.return_value = 1
self.client.socket.return_value = tls_sock
with patch("frigate.comms.mqtt.selectors.DefaultSelector") as selector:
selector.return_value.__enter__.return_value.select.return_value = []
self.assertEqual(self.transport._loop_client(1), mqtt.MQTT_ERR_SUCCESS)
selector.return_value.__enter__.return_value.select.assert_called_once_with(
0.0
)
self.client.loop_read.assert_called_once()
def test_read_failure_does_not_write_or_run_keepalive(self) -> None:
self.peer.sendall(b"ready")
self.client.want_write.return_value = True
self.client.loop_read.return_value = mqtt.MQTT_ERR_CONN_LOST
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_not_called()
def test_socket_closed_during_read_does_not_write(self) -> None:
self.peer.sendall(b"ready")
self.client.want_write.return_value = True
self.client.socket.side_effect = [self.sock, None]
self.transport._loop_client(0)
self.client.loop_write.assert_not_called()
self.client.loop_misc.assert_not_called()
def test_write_failure_does_not_run_keepalive(self) -> None:
self.client.want_write.return_value = True
self.client.loop_write.return_value = mqtt.MQTT_ERR_CONN_LOST
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_misc.assert_not_called()
def test_missing_socket_reports_no_connection(self) -> None:
self.client.socket.return_value = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
self.transport._wake_worker()
start = time.monotonic()
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
self.assertLess(time.monotonic() - start, 1)
self.client.loop_read.assert_not_called()
start = time.monotonic()
self.transport._loop_client(0.2)
self.assertGreater(time.monotonic() - start, 0.15)
+43 -40
View File
@@ -7,9 +7,8 @@ KeyError on the autotracker thread or silently keep the wrong state:
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
constructed, so runtime-enabled cameras raised KeyError on lookup.
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
updated it when autotracking was enabled through a config save, so it stayed
False and the tracker never built a motion estimator.
- _disable only changed the main process config, so the camera process kept
running its motion estimator for a camera that could not autotrack.
"""
import unittest
@@ -17,7 +16,8 @@ from unittest.mock import MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.config.camera.updater import CameraConfigUpdateEnum
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
CAMERA = "ptz_cam"
@@ -53,8 +53,9 @@ def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
onvif over the network. Only the config/metrics state is relevant here."""
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
tracker.config = _config(autotracking_enabled)
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
tracker.ptz_metrics = {CAMERA: PTZMetrics()}
tracker.onvif = MagicMock()
tracker.dispatcher = MagicMock()
tracker.config_subscriber = MagicMock()
tracker.autotracker_init = {}
tracker.calibrating = {}
@@ -83,47 +84,49 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
tracker.onvif.get_camera_status.assert_not_called()
class TestAutotrackerMetricSync(unittest.TestCase):
def test_metric_follows_config_when_enabled_by_update(self) -> None:
# autotracking enabled via a config save: the metric was seeded False when
# the camera was added and nothing else updates it
class TestAutotrackerEnqueueMove(unittest.TestCase):
def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
tracker = _make_tracker()
tracker.move_queues = {CAMERA: MagicMock()}
tracker.move_queue_locks = {CAMERA: MagicMock()}
tracker.move_queue_locks[CAMERA].locked.return_value = False
tracker._enqueue_move(CAMERA, 1000.0, pan, tilt, zoom)
return tracker.onvif.loop.call_soon_threadsafe
def test_move_is_clipped_to_the_onvif_range(self) -> None:
# velocity estimates can push the predicted centroid outside the frame
call_soon = self._enqueue(1.7, -2.5, 0.4)
call_soon.assert_called_once()
self.assertEqual(call_soon.call_args.args[1], (1000.0, 1.0, -1.0, 0.4))
def test_empty_move_is_not_enqueued(self) -> None:
self._enqueue(0, 0, 0).assert_not_called()
class TestAutotrackerDisable(unittest.TestCase):
def test_disable_publishes_to_camera_process(self) -> None:
tracker = _make_tracker(autotracking_enabled=True)
metrics = tracker.ptz_metrics[CAMERA]
self.assertFalse(metrics.autotracker_enabled.value)
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
tracker.check_for_updates()
tracker._disable(CAMERA, "onvif connection failed")
self.assertTrue(metrics.autotracker_enabled.value)
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
self.assertFalse(autotracking.enabled)
def test_metric_follows_config_when_disabled_by_update(self) -> None:
tracker = _make_tracker(autotracking_enabled=False)
metrics = tracker.ptz_metrics[CAMERA]
metrics.autotracker_enabled.value = True
publish = tracker.dispatcher.config_updater.publish_update
publish.assert_called_once()
topic, payload = publish.call_args.args
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.autotracking)
self.assertEqual(topic.camera, CAMERA)
self.assertIs(payload, autotracking)
tracker.config_subscriber.check_for_updates.return_value = {
"autotracking": [CAMERA]
}
tracker.check_for_updates()
self.assertFalse(metrics.autotracker_enabled.value)
def test_metric_sync_skips_camera_without_metrics(self) -> None:
# `add` reaches the maintainer and the autotracker on separate threads with
# no ordering guarantee, so the metrics may not exist yet
tracker = _make_tracker()
tracker.ptz_metrics = {}
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
tracker.check_for_updates()
def test_metric_sync_skips_unknown_camera(self) -> None:
tracker = _make_tracker()
tracker.config_subscriber.check_for_updates.return_value = {
"add": ["not_in_config"]
}
tracker.check_for_updates()
class TestMaxTargetBox(unittest.TestCase):
def test_follows_zoom_factor(self) -> None:
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
if __name__ == "__main__":
+17 -39
View File
@@ -2,14 +2,9 @@
Regression coverage for a camera that is initialized while autotracking is off and
has it enabled later, which is the normal wizard flow: set the camera up first,
configure autotracking afterwards. The autotracking-only request objects used to
be created only when autotracking was enabled at init time, so the camera was left
with init=True but no status_request. get_camera_status skips its re-init branch
when init is True, so it went straight to the missing key and raised KeyError on
the tracking thread.
The request objects are built from the locally parsed WSDL and cost no network, so
they are always created and init=True now implies they exist.
configure autotracking afterwards. get_camera_status skips its re-init branch when
init is True, so everything it reads must exist whether or not autotracking was
enabled at init time.
Also covers the inverse direction: the ptz movement timestamps must not be written
for a camera that has autotracking off, because nothing clears them back out.
@@ -99,7 +94,6 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
controller.config = config
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
controller.failed_cams = {}
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.ptz_metrics = {CAMERA: MagicMock()}
return controller
@@ -110,7 +104,6 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController)
controller.config = config
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.failed_cams = {}
ptz = MagicMock()
@@ -128,45 +121,30 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
},
}
}
controller.ptz_metrics = {
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
}
controller.ptz_metrics = {CAMERA: PTZMetrics()}
return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
async def test_status_request_created_when_autotracking_disabled(self) -> None:
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
# the wizard flow: onvif configured first, autotracking enabled later
controller = _make_controller(autotracking_enabled=False)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertTrue(cam["init"])
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_status_request_created_when_autotracking_enabled(self) -> None:
controller = _make_controller(autotracking_enabled=True)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_init_implies_status_request_exists(self) -> None:
# the invariant get_camera_status relies on: it skips re-init when init is
# True and then reads status_request without guarding
for autotracking_enabled in (True, False):
with self.subTest(autotracking_enabled=autotracking_enabled):
controller = _make_controller(autotracking_enabled)
controller.status_locks = {CAMERA: asyncio.Lock()}
await controller._init_onvif(CAMERA)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
if cam["init"]:
self.assertEqual(cam["status_request"].request_type, "GetStatus")
status = MagicMock()
status.MoveStatus.PanTilt = "IDLE"
status.MoveStatus.Zoom = "IDLE"
ptz = controller.cams[CAMERA]["ptz"]
ptz.GetStatus = AsyncMock(return_value=status)
await controller.get_camera_status(CAMERA)
ptz.GetStatus.assert_awaited_once_with({"ProfileToken": "profile_1"})
self.assertFalse(controller.cams[CAMERA]["active"])
async def test_requests_built_without_contacting_camera(self) -> None:
# create_type is a local WSDL lookup; cameras that do not implement
+857
View File
@@ -0,0 +1,857 @@
"""Tests for how tracked objects flow into review segments.
Frames are fed through the maintainer's run loop with mocked subscribers so
the dispatch between starting and updating segments is exercised, and the
published review updates are checked for the expected alert, detection, or
lack of a review item.
"""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from frigate.comms.detections_updater import DetectionTypeEnum
from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer
CAMERA = "front_door"
BASE_CONFIG = """
mqtt:
enabled: False
record:
enabled: True
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 640
height: 360
fps: 5
zones:
driveway:
coordinates: 0,0,320,0,320,360,0,360
yard:
coordinates: 320,0,640,0,640,360,320,360
%s
"""
class ReviewFlowTestCase(unittest.TestCase):
review_config = ""
def setUp(self) -> None:
self.clips_dir = clips_dir = tempfile.TemporaryDirectory()
self.addCleanup(clips_dir.cleanup)
clips_patch = patch("frigate.review.maintainer.CLIPS_DIR", clips_dir.name)
clips_patch.start()
self.addCleanup(clips_patch.stop)
self.maintainer = self._make_maintainer(self.review_config)
def _make_maintainer(self, review_config: str) -> ReviewSegmentMaintainer:
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
sockets and shared memory)."""
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
threading.Thread.__init__(maintainer)
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
maintainer.active_review_segments = {}
maintainer.indefinite_events = {}
maintainer.recent_classification_state_changes = {}
maintainer.requestor = MagicMock()
maintainer.review_publisher = MagicMock()
maintainer.config_subscriber = MagicMock()
maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
shape, np.uint8
)
return maintainer
@property
def stationary_threshold(self) -> int:
return self.maintainer.config.cameras[CAMERA].detect.stationary.threshold
@property
def alert_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.alerts.cutoff_time
@property
def detection_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.detections.cutoff_time
def tracked(
self,
obj_id: str,
label: str,
frame_time: float,
*,
start_time: float = 0,
zones: list[str] | None = None,
stationary: bool = False,
loitering: bool = False,
moved: bool = True,
false_positive: bool = False,
sub_label: tuple[str, float] | None = None,
) -> dict[str, Any]:
"""Build a tracked object as published by the object processor."""
return {
"id": obj_id,
"label": label,
"sub_label": sub_label,
"frame_time": frame_time,
"start_time": start_time,
"motionless_count": self.stationary_threshold if stationary else 0,
"pending_loitering": loitering,
"position_changes": 1 if moved else 0,
"false_positive": false_positive,
"current_zones": zones or [],
"box": (100, 100, 200, 200),
}
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
"""Run the maintainer loop over the given (frame_time, objects) frames."""
queue = [
(
DetectionTypeEnum.video.value,
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
)
for frame_time, objects in frames
]
self.maintainer.stop_event = threading.Event()
def next_update(timeout: float) -> Any:
if not queue:
self.maintainer.stop_event.set()
return None
return queue.pop(0)
self.maintainer.detection_subscriber.check_for_update.side_effect = next_update
self.maintainer.run()
def reviews(self) -> list[dict[str, Any]]:
"""All review updates published on the reviews topic."""
return [
json.loads(c.args[1])
for c in self.maintainer.requestor.send_data.call_args_list
if c.args[0] == "reviews"
]
def review_summary(self) -> list[tuple[str, str]]:
return [(r["type"], r["after"]["severity"]) for r in self.reviews()]
def non_update_summary(self) -> list[tuple[str, str]]:
return [(t, s) for t, s in self.review_summary() if t != "update"]
def thumbnails(self) -> set[str]:
"""Names of the review thumbnails on disk."""
return {t.name for t in Path(self.clips_dir.name, "review").iterdir()}
def assert_no_review(self) -> None:
self.assertEqual(self.reviews(), [])
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
class TestReviewSeverity(ReviewFlowTestCase):
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["person"])
def test_non_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
def test_alert_and_detection_objects_create_single_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertCountEqual(
self.reviews()[0]["after"]["data"]["objects"], ["person", "dog"]
)
def test_no_objects_creates_nothing(self) -> None:
self.feed((1, []), (2, []))
self.assert_no_review()
class TestIgnoredObjects(ReviewFlowTestCase):
def test_stationary_object_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, stationary=True)]))
self.assert_no_review()
def test_stationary_loitering_object_creates_alert(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, stationary=True, loitering=True)])
)
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_object_that_never_moved_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, moved=False)]))
self.assert_no_review()
def test_object_not_detected_in_current_frame_creates_nothing(self) -> None:
self.feed((2, [self.tracked("p1", "person", 1)]))
self.assert_no_review()
def test_false_positive_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, false_positive=True)]))
self.assert_no_review()
class TestAlertRequiredZones(ReviewFlowTestCase):
review_config = """
review:
alerts:
required_zones: driveway
"""
def test_alert_label_in_required_zone_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["zones"], ["driveway"])
def test_alert_label_outside_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_alert_label_in_no_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_upgrades_to_alert_when_object_enters_required_zone(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, zones=["yard"])]),
(2, [self.tracked("p1", "person", 2, zones=["driveway"])]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
self.assertEqual(
self.reviews()[0]["after"]["id"], self.reviews()[1]["after"]["id"]
)
class TestDetectionRequiredZones(ReviewFlowTestCase):
review_config = """
review:
detections:
required_zones: yard
"""
def test_detection_label_in_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_label_outside_required_zone_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["driveway"])]))
self.assert_no_review()
def test_alert_label_ignores_detection_required_zones(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_activity_outside_required_zone_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["driveway"])]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
def test_activity_inside_required_zone_after_alert_is_split_out(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["yard"])]),
(2 + self.alert_cutoff, []),
)
# the dog is past the detection cutoff, so it is ended right away
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
detection = self.reviews()[-1]["after"]
self.assertEqual(detection["data"]["objects"], ["dog"])
self.assertEqual(detection["data"]["zones"], ["yard"])
class TestDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
detections:
labels:
- dog
"""
def test_listed_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_unlisted_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("c1", "cat", 1)]))
self.assert_no_review()
def test_unlisted_object_is_left_out_of_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1), self.tracked("c1", "cat", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
class TestAlertsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
"""
def test_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
class TestDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
detections:
enabled: False
"""
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_non_alert_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assert_no_review()
def test_activity_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
class TestAlertsAndDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
detections:
enabled: False
"""
def test_nothing_is_created(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assert_no_review()
class TestReviewLifecycle(ReviewFlowTestCase):
def test_detection_upgrades_to_alert_when_alert_object_appears(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2), self.tracked("p1", "person", 2)]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
new, update = self.reviews()
self.assertEqual(new["after"]["id"], update["after"]["id"])
self.assertCountEqual(update["after"]["data"]["objects"], ["dog", "person"])
def test_alert_does_not_downgrade_when_only_detection_objects_remain(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2)]),
(3, [self.tracked("d1", "dog", 3)]),
)
self.assertEqual({severity for _, severity in self.review_summary()}, {"alert"})
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].severity.value, "alert"
)
def test_alert_stays_open_until_cutoff(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(1 + self.alert_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertIsNotNone(self.maintainer.active_review_segments.get(CAMERA))
def test_alert_ends_after_cutoff_at_last_alert_activity(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(5, [self.tracked("p1", "person", 5)]),
(6, []),
(5 + self.alert_cutoff + 1, []),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "alert")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_ongoing_alert_activity_extends_alert(self) -> None:
last_activity = 1 + self.alert_cutoff * 2
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(
1 + self.alert_cutoff,
[self.tracked("p1", "person", 1 + self.alert_cutoff)],
),
(last_activity, [self.tracked("p1", "person", last_activity)]),
(last_activity + 1, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].last_alert_time,
last_activity,
)
def test_detection_ends_after_cutoff_at_last_detection_activity(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(5, [self.tracked("d1", "dog", 5)]),
(5 + self.detection_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.feed((5 + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_stationary_object_does_not_extend_alert(self) -> None:
self.feed(
(1, [self.tracked("c1", "car", 1)]),
(2, [self.tracked("c1", "car", 2, stationary=True)]),
(
2 + self.alert_cutoff,
[self.tracked("c1", "car", 2 + self.alert_cutoff, stationary=True)],
),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["end_time"], 1)
def test_new_activity_after_end_creates_new_review(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, [self.tracked("d1", "dog", 3 + self.alert_cutoff)]),
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
ids = {r["after"]["id"] for r in self.reviews() if r["type"] != "update"}
self.assertEqual(len(ids), 2)
def test_alert_splits_into_detection_when_detection_activity_continues(
self,
) -> None:
# the person leaves after the first frame while the dog keeps moving
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=1)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed(
(
1,
[
self.tracked("p1", "person", 1, start_time=1),
self.tracked("d1", "dog", 1, start_time=1),
],
),
*dog_frames,
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
# the detection ends once the dog stops moving
last_dog_time = dog_frames[-1][0]
self.feed((last_dog_time + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["id"], detection.id)
self.assertEqual(end["after"]["end_time"], last_dog_time)
def test_detection_starting_after_alert_activity_is_split_out(self) -> None:
# the dog only shows up after the person has left
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
self.assertEqual(alert_end["after"]["data"]["objects"], ["person"])
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
def test_detection_leaving_before_alert_cutoff_gets_detection(self) -> None:
# the dog comes and goes after the person left, all before the alert
# cutoff, so nothing is active when the alert ends
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(11, [self.tracked("d1", "dog", 11, start_time=11)]),
(21, [self.tracked("d1", "dog", 21, start_time=11)]),
(31, []),
(2 + self.alert_cutoff, []),
(22 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 11)
self.assertEqual(ends[1]["end_time"], 21)
def test_detection_older_than_detection_cutoff_gets_detection(self) -> None:
# the dog is gone longer than the detection cutoff by the time the
# alert ends, its activity still needs a detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(5, [self.tracked("d1", "dog", 5, start_time=3)]),
(6, []),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, []),
)
self.assertGreater(2 + self.alert_cutoff, 5 + self.detection_cutoff)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 3)
self.assertEqual(ends[1]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_separate_detection_activity_after_alert_is_not_combined(self) -> None:
# the dog and cat are seen further apart than the detection cutoff
# while the alert is waiting to be cut off
cat_time = 3 + self.detection_cutoff + 6
self.assertLess(cat_time, 1 + self.alert_cutoff)
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(4, []),
(cat_time, [self.tracked("c1", "cat", cat_time, start_time=cat_time)]),
(2 + self.alert_cutoff, []),
(cat_time + self.detection_cutoff + 1, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual((ends[1]["start_time"], ends[1]["end_time"]), (3, 3))
self.assertEqual(ends[2]["data"]["objects"], ["cat"])
self.assertEqual(
(ends[2]["start_time"], ends[2]["end_time"]), (cat_time, cat_time)
)
for detection in ends[1:]:
self.assertTrue(Path(detection["thumb_path"]).is_file())
self.assertIsNotNone(detection["data"]["thumb_time"])
def test_resumed_alert_publishes_pending_detection_objects(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
(10, [self.tracked("p1", "person", 10, start_time=1)]),
)
latest = self.reviews()[-1]
self.assertEqual(latest["type"], "update")
self.assertEqual(latest["after"]["severity"], "alert")
self.assertCountEqual(latest["after"]["data"]["objects"], ["person", "dog"])
def test_split_detection_has_thumbnail_of_its_activity(self) -> None:
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
new_detection = next(
r["after"]
for r in self.reviews()
if r["type"] == "new" and r["after"]["severity"] == "detection"
)
self.assertTrue(Path(new_detection["thumb_path"]).is_file())
# captured from the dog's first frame, not a later fallback frame
self.assertIsNotNone(new_detection["data"]["thumb_time"])
self.assertIn(
f"{CAMERA}_11",
[c.args[0] for c in self.maintainer.frame_manager.get.call_args_list],
)
def assert_force_end_publishes_pending_detection(self, topic: str) -> None:
# the dog is held as a pending detection when the alert is ended
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
if topic == "remove":
# the config updater has already dropped the camera
self.maintainer.config.cameras.pop(CAMERA)
self.maintainer.config_subscriber.check_for_updates.side_effect = [
{topic: [CAMERA]}
]
self.feed()
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertEqual(end["data"]["objects"], ["dog"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 5))
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
# every thumbnail on disk belongs to a published review
published = {Path(r["after"]["thumb_path"]).name for r in self.reviews()}
self.assertEqual(self.thumbnails(), published)
def test_disabled_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("enabled")
def test_removed_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("remove")
def test_pending_thumbnail_removed_when_alert_resumes(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
alert = self.maintainer.active_review_segments[CAMERA]
pending_thumb = Path(alert.pending_detections[0].frame_path)
self.assertTrue(pending_thumb.is_file())
self.feed((10, [self.tracked("p1", "person", 10, start_time=1)]))
self.assertEqual(alert.pending_detections, [])
self.assertFalse(pending_thumb.exists())
self.assertEqual(self.thumbnails(), {Path(alert.frame_path).name})
def test_multiple_pending_objects_share_one_detection(self) -> None:
# objects within the detection cutoff of each other, whether seen
# together or later, make up one detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked("d1", "dog", 5, start_time=5),
self.tracked("c1", "cat", 5, start_time=5),
],
),
(20, [self.tracked("b1", "bird", 20, start_time=20)]),
(21, []),
(2 + self.alert_cutoff, []),
(21 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertCountEqual(end["data"]["objects"], ["dog", "cat", "bird"])
self.assertCountEqual(end["data"]["detections"], ["d1", "c1", "b1"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 20))
# the thumbnail is framed on both objects seen together, the bird
# alone is fewer objects so it does not replace it
frames = [c.args[0] for c in self.maintainer.frame_manager.get.call_args_list]
self.assertIn(f"{CAMERA}_5", frames)
self.assertNotIn(f"{CAMERA}_20", frames)
class TestSplitDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
alerts:
labels:
- person
"""
def test_split_detection_keeps_sub_labels_and_zones(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked(
"d1",
"dog",
5,
start_time=5,
zones=["yard"],
sub_label=("Rex", 0.95),
),
self.tracked(
"c1",
"car",
5,
start_time=5,
zones=["driveway"],
sub_label=("fedex", 0.9),
),
],
),
(2 + self.alert_cutoff, []),
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(len(ends), 2)
alert_end, detection = ends
self.assertEqual(alert_end["data"]["objects"], ["person"])
self.assertEqual(alert_end["data"]["sub_labels"], [])
self.assertEqual(alert_end["data"]["zones"], [])
self.assertEqual(detection["severity"], "detection")
# attributes replace the label, other sub labels verify it
self.assertCountEqual(detection["data"]["objects"], ["dog-verified", "fedex"])
self.assertEqual(detection["data"]["verified_objects"], ["dog-verified"])
self.assertEqual(detection["data"]["sub_labels"], ["Rex"])
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
if __name__ == "__main__":
unittest.main()
+125 -1
View File
@@ -1,16 +1,24 @@
"""Tests for embedding cleanup on the main Frigate database.
"""Tests for embedding storage and cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
The write paths need the real extension, since the behavior under test belongs
to vec0 itself, so those tests are skipped when it is not installed.
"""
import os
import struct
import tempfile
import unittest
from peewee import OperationalError
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_failure_is_logged_not_raised(self) -> None:
self._create_thumbnails_table()
self.db.execute_sql(
"""
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
"""
).fetchall()
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self.assertIn("Failed to delete embeddings", logs.output[0])
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def _vector(value: float) -> bytes:
return struct.pack("768f", *([value] * 768))
@unittest.skipUnless(
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
)
class TestEmbeddingsTableWrites(unittest.TestCase):
"""Covers the vec0 writes behind semantic search reindexing."""
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
)
self.db.start()
self.db.create_embeddings_tables()
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _vec_tables(self) -> list[str]:
return [
row[0]
for row in self.db.execute_sql(
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
)
]
def _make_legacy(self, table: str) -> None:
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
# Frigate 0.17 and earlier do not have one
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
def _stored(self, table: str, column: str, event_id: str) -> str | None:
row = self.db.execute_sql(
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
).fetchone()
return row[0] if row else None
def test_write_error_is_raised(self) -> None:
# queued writes hide their exception in the returned cursor
with self.assertRaises(OperationalError):
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
def test_drop_tables_removes_legacy_tables(self) -> None:
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.assertEqual(self._vec_tables(), [])
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
self.db.drop_embeddings_tables()
self.db.drop_embeddings_tables()
def test_upsert_replaces_existing_embedding(self) -> None:
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
def test_upsert_keeps_one_row_per_event(self) -> None:
for _ in range(3):
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
)
count = self.db.execute_sql(
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
).fetchone()[0]
self.assertEqual(count, 1)
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
"""The 0.18 upgrade path: old vectors in, new vectors out."""
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.db.create_embeddings_tables()
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
+66
View File
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import MagicMock, patch
from frigate.events.types import EventStateEnum
from frigate.models import Timeline
from frigate.timeline import TimelineProcessor
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
return {
"id": "event-1",
"frame_time": 1000.0,
"box": [0, 0, 10, 10],
"region": [0, 0, 100, 100],
"label": "car",
"sub_label": None,
"score": 0.8,
"has_clip": has_clip,
"has_snapshot": has_snapshot,
"current_zones": [],
"stationary": False,
"attributes": {},
"current_attributes": [],
}
class TestTimelineProcessor(unittest.TestCase):
def setUp(self):
camera_config = MagicMock()
camera_config.detect.width = 1280
camera_config.detect.height = 720
config = MagicMock()
config.cameras.get.return_value = camera_config
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
@patch.object(Timeline, "insert")
def test_unsaved_event_writes_no_timeline_rows(self, insert):
event = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, event
)
self.processor.handle_object_detection(
"front", EventStateEnum.end, event, event
)
insert.assert_not_called()
self.assertEqual(self.processor.pre_event_cache, {})
@patch.object(Timeline, "insert")
def test_cached_entries_flush_when_event_is_saved(self, insert):
start = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, start
)
insert.assert_not_called()
end = make_event(has_clip=True, has_snapshot=False)
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
self.assertEqual(class_types, ["visible", "gone"])
self.assertEqual(self.processor.pre_event_cache, {})
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread):
timeline_entry[Timeline.class_type] = "gone"
self.insert_or_save(timeline_entry, prev_event_data, event_data)
# drop entries for events that ended without being saved
self.pre_event_cache.pop(event_id, None)
def handle_api_entry(
self,
camera: str,
+4 -4
View File
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
),
}
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
self.ptz_motion_estimator = PtzMotionEstimator(
self.camera_config, self.ptz_metrics
)
@@ -515,7 +515,7 @@ class NorfairTracker(ObjectTracker):
yuv_frame: np.ndarray | None = None
if (
self.ptz_metrics.autotracker_enabled.value
self.camera_config.onvif.autotracking.enabled
or self.detect_config.stationary.classifier
):
yuv_frame = self.frame_manager.get(
@@ -534,7 +534,7 @@ class NorfairTracker(ObjectTracker):
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
embedding = None
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
embedding = get_histogram(
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
)
@@ -559,7 +559,7 @@ class NorfairTracker(ObjectTracker):
coord_transformations = None
if self.ptz_metrics.autotracker_enabled.value:
if self.camera_config.onvif.autotracking.enabled:
# we must have been enabled by mqtt, so set up the estimator
if not self.ptz_motion_estimator:
self.ptz_motion_estimator = PtzMotionEstimator(
+34 -19
View File
@@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import (
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import (
CameraConfig,
CameraMqttConfig,
FrigateConfig,
RecordConfig,
@@ -40,7 +41,7 @@ from frigate.const import (
)
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event, ReviewSegment, Timeline
from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.track.tracked_object import TrackedObject
from frigate.util.image import SharedMemoryFrameManager
@@ -59,7 +60,7 @@ class TrackedObjectProcessor(threading.Thread):
config: FrigateConfig,
dispatcher: Dispatcher,
tracked_objects_queue: MpQueue,
ptz_autotracker_thread: PtzAutoTrackerThread,
ptz_autotracker_thread: PtzAutoTracker,
stop_event: MpEvent,
) -> None:
super().__init__(name="detected_frames_processor")
@@ -129,8 +130,10 @@ class TrackedObjectProcessor(threading.Thread):
)
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
after = obj.to_dict()
message = {
"before": obj.previous,
@@ -150,12 +153,14 @@ class TrackedObjectProcessor(threading.Thread):
)
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj)
self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
# populate has_snapshot
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
# write thumbnail to disk if it will be saved as an event
if obj.has_snapshot or obj.has_clip:
@@ -172,7 +177,7 @@ class TrackedObjectProcessor(threading.Thread):
"type": "end",
}
self.dispatcher.publish("events", json.dumps(message), retain=False)
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
self.ptz_autotracker_thread.end_object(camera, obj)
self.event_sender.publish(
(
@@ -185,8 +190,8 @@ class TrackedObjectProcessor(threading.Thread):
)
def snapshot(camera: str, obj: TrackedObject) -> bool:
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
jpg_bytes, _ = obj.get_img_bytes(
ext="jpg",
timestamp=mqtt_config.timestamp,
@@ -241,11 +246,13 @@ class TrackedObjectProcessor(threading.Thread):
with self.camera_states_lock:
self.camera_states[camera] = camera_state
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
def should_save_snapshot(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
if obj.false_positive:
return False
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
snapshot_config: SnapshotsConfig = camera_config.snapshots
if not snapshot_config.enabled:
return False
@@ -264,11 +271,13 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
def should_retain_recording(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
if obj.false_positive:
return False
record_config: RecordConfig = self.config.cameras[camera].record
record_config: RecordConfig = camera_config.record
# Recording is disabled
if not record_config.enabled:
@@ -284,13 +293,15 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool:
def should_mqtt_snapshot(
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
) -> bool:
# object never changed position
if obj.is_stationary():
return False
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].mqtt.required_zones
required_zones = mqtt_config.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
logger.debug(
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
@@ -300,7 +311,11 @@ class TrackedObjectProcessor(threading.Thread):
return True
def update_mqtt_motion(
self, camera: str, frame_time: float, motion_boxes: list
self,
camera: str,
camera_config: CameraConfig,
frame_time: float,
motion_boxes: list,
) -> None:
# publish if motion is currently being detected
if motion_boxes:
@@ -315,7 +330,7 @@ class TrackedObjectProcessor(threading.Thread):
# always updated latest motion
self.last_motion_detected[camera] = frame_time
elif self.last_motion_detected.get(camera, 0) > 0:
mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay
mqtt_delay = camera_config.motion.mqtt_off_delay
# If no motion, make sure the off_delay has passed
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
@@ -818,7 +833,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
)
self.update_mqtt_motion(camera, frame_time, motion_boxes)
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
tracked_objects = [
o.to_dict() for o in camera_state.tracked_objects.values()
+6 -6
View File
@@ -191,7 +191,7 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.14":
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
new_config = migrate_014(config)
new_config = migrate_014(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.14"
@@ -209,35 +209,35 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.15-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
new_config = migrate_015_0(config)
new_config = migrate_015_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-0"
if previous_version < "0.15-1":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
new_config = migrate_015_1(config)
new_config = migrate_015_1(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-1"
if previous_version < "0.16-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
new_config = migrate_016_0(config)
new_config = migrate_016_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.16-0"
if previous_version < "0.17-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
new_config = migrate_017_0(config)
new_config = migrate_017_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.17-0"
if previous_version < "0.18-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
new_config = migrate_018_0(config)
new_config = migrate_018_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.18-0"
+10 -2
View File
@@ -94,6 +94,7 @@ class CameraTracker(FrigateProcess):
self.config.detect.fps,
name=self.config.name,
ptz_metrics=self.ptz_metrics,
autotracking_enabled=self.config.onvif.autotracking.enabled,
)
object_detector = RemoteObjectDetector(
self.config.name,
@@ -195,10 +196,12 @@ def process_frames(
None,
{camera_config.name: camera_config},
[
CameraConfigUpdateEnum.autotracking,
CameraConfigUpdateEnum.detect,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.onvif,
],
)
@@ -235,6 +238,11 @@ def process_frames(
motion_detector.config = camera_config.motion
motion_detector.update_mask()
if "autotracking" in updated_configs or "onvif" in updated_configs:
motion_detector.autotracking_enabled = (
camera_config.onvif.autotracking.enabled
)
if (
not camera_enabled
and prev_enabled != camera_enabled
@@ -349,8 +357,8 @@ def process_frames(
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
# the ptz timestamps are only maintained while autotracking is on, so gate
# on the metric rather than trusting them to be reset otherwise
ptz_moving = ptz_metrics.autotracker_enabled.value and (
# on the config rather than trusting them to be reset otherwise
ptz_moving = camera_config.onvif.autotracking.enabled and (
ptz_moving_at_frame_time(
frame_time,
ptz_metrics.start_time.value,
+65 -48
View File
@@ -6,6 +6,7 @@ import subprocess as sp
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
@@ -18,6 +19,7 @@ from frigate.comms.recordings_updater import (
RecordingsDataTypeEnum,
)
from frigate.config import CameraConfig, LoggerConfig
from frigate.config.camera.ffmpeg import CameraRoleEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
@@ -107,6 +109,27 @@ def capture_frames(
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
@dataclass
class RoleStatus:
"""Publishes a role's status when it changes or the resend interval elapses."""
requestor: InterProcessRequestor
topic: str
resend_interval: float
last_status: str | None = None
last_update_time: float = 0.0
def send(self, status: str, now: float) -> None:
"""Publish a changed status or resend it after the configured interval."""
if (
status != self.last_status
or (now - self.last_update_time) >= self.resend_interval
):
self.requestor.send_data(self.topic, status)
self.last_status = status
self.last_update_time = now
class CameraWatchdog(threading.Thread):
def __init__(
self,
@@ -185,31 +208,30 @@ class CameraWatchdog(threading.Thread):
self._stall_active: bool = False
# Status caching to reduce message volume
self._last_detect_status: str | None = None
self._last_record_status: dict[str, str] = {}
self._last_status_update_time: float = 0.0
self.detect_status = self._role_status("detect")
self.record_status = {
stream_type: self._role_status(role)
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
}
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_status_update_time = now
def _role_status(self, role: str) -> RoleStatus:
return RoleStatus(
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
)
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
"""Send a record stream's status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status.get(stream_type)
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
)
self._last_record_status[stream_type] = status
self._last_status_update_time = now
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
"""Send offline status for each role of a restarted ffmpeg process."""
for role in roles:
# record roles go through the status cache so the recovery to
# online is published once the stream is healthy again
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
if stream_type is not None:
self.record_status[stream_type].send("offline", now)
else:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
def _reset_segment_times(self) -> None:
self.latest_valid_segment_time.clear()
@@ -371,11 +393,11 @@ class CameraWatchdog(threading.Thread):
# update camera status
now = datetime.now().timestamp()
self._send_detect_status("disabled", now)
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
self.detect_status.send("disabled", now)
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
self.was_enabled = enabled
continue
@@ -448,7 +470,7 @@ class CameraWatchdog(threading.Thread):
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
@@ -460,7 +482,7 @@ class CameraWatchdog(threading.Thread):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
@@ -470,7 +492,7 @@ class CameraWatchdog(threading.Thread):
self.reset_capture_thread(drain_output=False)
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self._send_detect_status("offline", now)
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
@@ -480,7 +502,7 @@ class CameraWatchdog(threading.Thread):
last_restart_time = now
else:
# process is running normally
self._send_detect_status("online", now)
self.detect_status.send("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
@@ -514,18 +536,16 @@ class CameraWatchdog(threading.Thread):
ffmpeg_process=p["process"],
)
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
self._send_roles_offline(p["roles"], now)
self._grant_restart_grace(recorded_streams, now_utc)
last_restart_time = now
continue
elif stale_stream is None:
for stream_type in recorded_streams:
self._send_record_status(stream_type, "online", now)
if poll is None:
for stream_type in recorded_streams:
self.record_status[stream_type].send("online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
@@ -535,31 +555,28 @@ class CameraWatchdog(threading.Thread):
if poll is None:
continue
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
self._send_roles_offline(p["roles"], now)
p["process"] = start_or_restart_ffmpeg(
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
if (
self.detect_process_records_sub
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
and self.capture_thread is not None
and self.capture_thread.is_alive()
if self.detect_process_records_sub and self.config.record.stream_enabled(
STREAM_TYPE_SUB
):
now_utc = datetime.now().astimezone(UTC)
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
if stale_reason is None:
self._send_record_status(STREAM_TYPE_SUB, "online", now)
if self.detect_status.last_status == "offline":
# the sub stream is down whenever the detect process is
self.record_status[STREAM_TYPE_SUB].send("offline", now)
elif stale_reason is None:
self.record_status[STREAM_TYPE_SUB].send("online", now)
elif can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
)
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
self.record_status[STREAM_TYPE_SUB].send("offline", now)
self.reset_capture_thread()
last_restart_time = now
+11
View File
@@ -0,0 +1,11 @@
import type { FrigateApp } from "../fixtures/frigate-test";
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
// of every tab for measurement and hides the ones that do not fit behind a
// kebab. The selected tab always stays in the strip.
export function systemTab(frigateApp: FrigateApp, name: string) {
return frigateApp.page
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
.first();
}
+3 -2
View File
@@ -7,12 +7,13 @@
*/
import { test, expect } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
await expect(systemTab(frigateApp, "general")).toBeVisible({
timeout: 15_000,
});
});
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
timeout: 5_000,
});
});
+12
View File
@@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => {
// Similarity search URL param
// ---------------------------------------------------------------------------
test.describe("Explore: back button @high", () => {
test("direct visits do not show a back button", async ({ frigateApp }) => {
await frigateApp.goto("/explore?labels=person");
await expect(frigateApp.page.getByLabel("Labels").first()).toBeVisible({
timeout: 10_000,
});
await expect(
frigateApp.page.getByRole("button", { name: "Go back" }),
).toHaveCount(0);
});
});
test.describe("Explore — similarity search (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
+70 -31
View File
@@ -30,40 +30,51 @@ function groupedFacesMock() {
});
}
async function installGroupedFaces(app: FrigateApp) {
const GROUPED_EVENT = {
id: GROUPED_EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
};
async function installGroupedFaces(
app: FrigateApp,
opts: { withEventIds?: boolean } = {},
) {
await app.api.install({
events: [
{
id: GROUPED_EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
},
],
events: [GROUPED_EVENT],
faces: groupedFacesMock(),
});
// api-mocker does not cover /api/event_ids, which the card needs to link to
// Explore. Registered after install so it takes precedence.
if (opts.withEventIds) {
await app.page.route("**/api/event_ids**", (route) =>
route.fulfill({ json: [GROUPED_EVENT] }),
);
}
}
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
@@ -512,6 +523,34 @@ test.describe("FaceSelectionDialog @high", () => {
});
});
test.describe("Face Library: return from Explore @high", () => {
test("Explore back button returns to an outlined collection", async ({
frigateApp,
}) => {
await installGroupedFaces(frigateApp, { withEventIds: true });
await frigateApp.goto("/faces");
// Mobile opens the collection as a MobilePage, which has no dialog role
const card = frigateApp.page
.locator('img[src*="clips/faces/train/"]')
.first()
.locator("xpath=..");
await card.click();
await frigateApp.page.getByLabel("View in Explore").click();
await expect(frigateApp.page).toHaveURL(
new RegExp(`/explore\\?event_id=${GROUPED_EVENT_ID}`),
);
const back = frigateApp.page.getByRole("button", { name: "Go back" });
await expect(back).toBeVisible({ timeout: 5_000 });
await back.click();
await expect(frigateApp.page).toHaveURL(/\/faces/);
await expect(card).toHaveClass(/outline-selected/, { timeout: 5_000 });
await expect(card).not.toHaveClass(/outline-selected/, { timeout: 5_000 });
});
});
test.describe("Face Library — mobile @high @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
+19
View File
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live camera groups @medium", () => {
test("a group with an invalid icon renders a fallback icon", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
camera_groups: {
outdoor: { cameras: ["front_door"], icon: "generic" },
},
},
});
await frigateApp.goto("/");
const group = frigateApp.page
.locator('[aria-label="Camera Groups"]:not([inert] *)')
.first();
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
});
});
+3 -2
View File
@@ -6,6 +6,7 @@
*/
import { test, expect } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile";
const NOW = Math.floor(Date.now() / 1000);
@@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => {
});
await frigateApp.goto("/system");
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => {
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
+66 -71
View File
@@ -6,42 +6,66 @@
* RestartDialog cancel flow.
*/
import { test, expect } from "../fixtures/frigate-test";
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
import { systemTab } from "../helpers/system-tabs";
async function selectTab(frigateApp: FrigateApp, name: string) {
const kebab = frigateApp.page.getByLabel("Show all tabs");
if (frigateApp.isMobile && (await kebab.isVisible())) {
await kebab.click();
await frigateApp.page
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
.last()
.click();
return;
}
await systemTab(frigateApp, name).click();
}
async function expectTabActive(frigateApp: FrigateApp, name: string) {
await expect(systemTab(frigateApp, name)).toHaveAttribute(
"data-state",
"on",
{
timeout: 5_000,
},
);
}
test.describe("System — tabs @medium", () => {
test("general tab is active by default via #general hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
if (!frigateApp.isMobile) {
await expect(systemTab(frigateApp, "storage")).toBeVisible();
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
}
});
test("Storage tab activates and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"off",
);
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
test("Cameras tab activates", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
});
test("general tab shows version and last-refreshed", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
// On desktop, tab buttons render text labels so the word "storage"
// always appears in #pageRoot after switching. On mobile, tabs are
// icon-only, so we verify the general-tab content disappears instead
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
} else {
// Mobile: tab activation (data-state "on") already asserted above.
// Additionally confirm general tab is no longer the active tab.
await expect(
frigateApp.page.getByLabel("Select general"),
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"off",
{ timeout: 5_000 },
);
}
});
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
// Cameras tab lists every camera from config/stats. The default
// mock has front_door, backyard, garage.
for (const cam of ["front_door", "backyard", "garage"]) {
@@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => {
config: { semantic_search: { enabled: true } },
});
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
await enrichTab.click();
await expect(enrichTab).toHaveAttribute("data-state", "on", {
timeout: 5_000,
});
await selectTab(frigateApp, "enrichments");
await expectTabActive(frigateApp, "enrichments");
});
});
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
test("tabs render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
await expect(systemTab(frigateApp, "general")).toBeVisible({
timeout: 15_000,
});
});
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await selectTab(frigateApp, "storage");
await expectTabActive(frigateApp, "storage");
});
test("header controls leave the logo uncovered on a narrow phone", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
const logo = frigateApp.page.locator("svg.fill-current").first();
const tabs = frigateApp.page
.locator("[data-radix-scroll-area-viewport]")
.filter({ has: frigateApp.page.getByLabel("Select general") });
const kebab = frigateApp.page.getByLabel("Show all tabs");
await expect(kebab).toBeVisible();
const tabs = kebab.locator("..");
const refreshed = frigateApp.page.getByText(/Just now|ago/);
const logoBox = await logo.boundingBox();
@@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => {
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
// the clipped tabs stay reachable by scrolling
const overflow = await tabs.evaluate((el) => ({
scroll: el.scrollWidth,
client: el.clientWidth,
}));
expect(overflow.scroll).toBeGreaterThan(overflow.client);
await tabs.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
// the tabs that do not fit stay reachable through the kebab
await selectTab(frigateApp, "cameras");
await expectTabActive(frigateApp, "cameras");
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
});
});
+160 -89
View File
@@ -108,7 +108,7 @@
"postcss": "^8.5.12",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.19",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
@@ -4231,14 +4231,12 @@
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"license": "MIT"
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@@ -4386,6 +4384,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
@@ -4400,15 +4403,21 @@
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
@@ -4623,10 +4632,15 @@
"license": "MIT"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -4639,9 +4653,6 @@
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
@@ -4650,7 +4661,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -4792,7 +4802,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
@@ -4802,6 +4811,11 @@
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
"integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4834,7 +4848,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
@@ -5824,6 +5837,11 @@
"node": ">= 10.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -6457,6 +6475,20 @@
"node": ">=0.8.19"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -6518,7 +6550,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
@@ -6694,10 +6725,9 @@
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
"integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==",
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7162,22 +7192,17 @@
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"license": "MIT",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
"node": ">=10"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -8202,6 +8227,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -8335,7 +8372,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
@@ -8390,7 +8426,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -8426,6 +8461,14 @@
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -8688,6 +8731,14 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8728,10 +8779,9 @@
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
"engines": {
"node": ">= 6"
}
@@ -8861,36 +8911,36 @@
}
}
},
"node_modules/postcss-load-config/node_modules/lilconfig": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz",
"integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==",
"engines": {
"node": ">=14"
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
"integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
"dependencies": {
"postcss-selector-parser": "^6.1.1"
"postcss-selector-parser": "^6.0.11"
},
"engines": {
"node": ">=12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"license": "MIT",
"version": "6.0.13",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
"integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -9537,7 +9587,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
@@ -9951,17 +10000,16 @@
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"license": "MIT",
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
"integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"glob": "7.1.6",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
@@ -9969,7 +10017,26 @@
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": ">=8"
}
},
"node_modules/sucrase/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/supports-color": {
@@ -10059,33 +10126,33 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"chokidar": "^3.5.3",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"fast-glob": "^3.3.0",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"jiti": "^1.21.0",
"lilconfig": "^2.1.0",
"micromatch": "^4.0.5",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"picocolors": "^1.0.0",
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
"postcss-load-config": "^4.0.1",
"postcss-nested": "^6.0.1",
"postcss-selector-parser": "^6.0.11",
"resolve": "^1.22.2",
"sucrase": "^3.32.0"
},
"bin": {
"tailwind": "lib/cli.js",
@@ -10107,7 +10174,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
@@ -10116,7 +10182,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
@@ -10128,6 +10193,7 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -10144,6 +10210,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -10161,6 +10228,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -10227,8 +10295,7 @@
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0"
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/tslib": {
"version": "2.8.1",
@@ -10520,8 +10587,7 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vaul": {
"version": "1.1.2",
@@ -10767,6 +10833,11 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1 -1
View File
@@ -120,7 +120,7 @@
"postcss": "^8.5.12",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.19",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
+8 -2
View File
@@ -1162,7 +1162,9 @@
"actions": "Actions",
"noRoles": "No custom roles found.",
"editCameras": "Edit Cameras",
"deleteRole": "Delete Role"
"deleteRole": "Delete Role",
"cameraCount_one": "{{count}} camera",
"cameraCount_other": "{{count}} cameras"
},
"toast": {
"success": {
@@ -2038,7 +2040,11 @@
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function.",
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function.",
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
},
"notifications": {
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
+3
View File
@@ -14,6 +14,8 @@
},
"title": "System",
"metrics": "System metrics",
"showAllTabs": "Show all tabs",
"showLessTabs": "Show less",
"health": {
"title": "Health",
"notices": {
@@ -339,6 +341,7 @@
"moreMessages_one": "+{{count}}",
"moreMessages_other": "+{{count}}",
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
"reindexEmbeddingsFailed": "Reindexing embeddings failed, check the logs",
"cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
+65 -21
View File
@@ -1,12 +1,20 @@
import { baseUrl } from "@/api/baseUrl";
import useContextMenu from "@/hooks/use-contextmenu";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import {
ClassificationItemData,
ClassificationThreshold,
ClassifiedEvent,
} from "@/types/classification";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo";
@@ -16,6 +24,7 @@ import { LuSearch, LuInfo } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { useNavigate } from "react-router-dom";
import { HiSquare2Stack } from "react-icons/hi2";
import scrollIntoView from "scroll-into-view-if-needed";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import {
Dialog,
@@ -85,9 +94,14 @@ export const ClassificationCard = forwardRef<
// interaction
const cardRef = useRef<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
useContextMenu(imgRef, () => {
useImperativeHandle(ref, () => cardRef.current!);
// Listen on the whole card, since overlays cover most of the image
useContextMenu(cardRef, () => {
onClick(data, true);
});
@@ -101,9 +115,9 @@ export const ClassificationCard = forwardRef<
return (
<div
ref={ref}
ref={cardRef}
className={cn(
"relative flex size-full flex-col overflow-hidden rounded-lg outline outline-[3px]",
"relative flex size-full select-none flex-col overflow-hidden rounded-lg outline outline-[3px]",
className,
selected
? "shadow-selected outline-selected"
@@ -117,11 +131,7 @@ export const ClassificationCard = forwardRef<
}
onClick(data, isMeta);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(data, true);
}}
style={isIOS ? { WebkitTouchCallout: "none" } : undefined}
>
<img
ref={imgRef}
@@ -130,14 +140,6 @@ export const ClassificationCard = forwardRef<
imgClassName,
isMobile && "w-full",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
loading="lazy"
onLoad={() => setImageLoaded(true)}
@@ -156,7 +158,7 @@ export const ClassificationCard = forwardRef<
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
<div className="absolute bottom-0 flex w-full flex-row items-center justify-between gap-2 p-2">
<div
className={cn(
"flex flex-col items-start text-white",
@@ -216,6 +218,41 @@ export function GroupedClassificationCard({
const { t } = useTranslation(["views/explore", i18nLibrary]);
const [detailOpen, setDetailOpen] = useState(false);
// Explore stores this event in history state so going back can point out the
// card the user came from
const cardRef = useRef<HTMLDivElement | null>(null);
const [returnEventId, setReturnEventId] = useOverlayState<string | undefined>(
"returnEventId",
);
const [highlighted, setHighlighted] = useState(false);
useEffect(() => {
if (!returnEventId || classifiedEvent?.id !== returnEventId) {
return;
}
setReturnEventId(undefined, true);
setHighlighted(true);
}, [classifiedEvent?.id, returnEventId, setReturnEventId]);
useEffect(() => {
if (!highlighted) {
return;
}
if (cardRef.current) {
scrollIntoView(cardRef.current, {
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
}
const timeout = setTimeout(() => setHighlighted(false), 3000);
return () => clearTimeout(timeout);
}, [highlighted]);
// If the component unmounts while the detail overlay is open, we need to
// pop the history state that was pushed by useHistoryBack, otherwise it
// leaves a stale entry that breaks back navigation.
@@ -308,9 +345,10 @@ export function GroupedClassificationCard({
return (
<>
<ClassificationCard
ref={cardRef}
data={bestItem}
threshold={threshold}
selected={selectedItems.includes(bestItem.filename)}
selected={highlighted || selectedItems.includes(bestItem.filename)}
clickable={true}
i18nLibrary={i18nLibrary}
count={group.length}
@@ -404,13 +442,19 @@ export function GroupedClassificationCard({
isMobile && "absolute right-4 top-8",
)}
>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div
className="cursor-pointer"
tabIndex={-1}
aria-label={t("details.item.button.viewInExplore", {
ns: "views/explore",
})}
onClick={() => {
navigate(`/explore?event_id=${classifiedEvent.id}`);
setReturnEventId(classifiedEvent.id, true);
navigate(`/explore?event_id=${classifiedEvent.id}`, {
state: { canGoBack: true },
});
}}
>
<LuSearch className="size-4 text-secondary-foreground" />
@@ -8,6 +8,23 @@ const notifications: SectionConfigOverrides = {
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
fieldMessages: [
{
key: "profile-base-notifications-disabled",
field: "enabled",
messageKey: "configMessages.notifications.profileBaseDisabled",
severity: "warning",
position: "after",
condition: (ctx) =>
!!ctx.profileName &&
ctx.formData?.enabled === true &&
!Object.values(ctx.fullConfig.cameras).some(
(camera) =>
camera.enabled_in_config &&
camera.notifications.enabled_in_config,
),
},
],
},
global: {
uiSchema: {
@@ -31,6 +31,21 @@ const record: SectionConfigOverrides = {
},
},
],
fieldMessages: [
{
key: "profile-base-record-disabled",
field: "enabled",
messageKey: "configMessages.record.profileBaseDisabled",
severity: "warning",
position: "after",
docLink:
"/configuration/profiles#why-cant-a-profile-enable-recording-when-its-disabled-in-the-base-config",
condition: (ctx) =>
!!ctx.profileName &&
ctx.formData?.enabled === true &&
ctx.fullCameraConfig?.record.enabled_in_config === false,
},
],
fieldDocs: {
"alerts.pre_capture":
"/configuration/record#pre-capture-and-post-capture",
@@ -8,6 +8,7 @@ export type MessageConditionContext = {
fullCameraConfig?: CameraConfig;
level: "global" | "camera";
cameraName?: string;
profileName?: string;
formData: ConfigSectionData;
};
@@ -627,7 +627,11 @@ export default function NotificationsSettingsExtras({
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
aria-label={
registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")
}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
@@ -649,9 +649,10 @@ export function ConfigSection({
: undefined,
level: effectiveLevel,
cameraName,
profileName,
formData: currentFormData as ConfigSectionData,
};
}, [config, currentFormData, effectiveLevel, cameraName]);
}, [config, currentFormData, effectiveLevel, cameraName, profileName]);
const { activeMessages, activeFieldMessages } = useConfigMessages(
sectionConfig.messages,
+26 -166
View File
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { HiDotsHorizontal } from "react-icons/hi";
import { IoClose } from "react-icons/io5";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import OverflowStrip from "../mobile/OverflowStrip";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu";
import {
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
// mobile overflow reveal - the group strip sits left of the logo and is
// clipped (not scrollable) when there are too many groups, so render only
// the buttons that fully fit and surface a kebab next to the last visible
// one that expands a panel revealing all of them
const [expanded, setExpanded] = useState(false);
// null => all buttons fit, render them all with no kebab; a number => only
// that many fit alongside the kebab
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (isDesktop) {
return;
}
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) {
return;
}
const gap = 8; // gap-2 between buttons in the strip
const wrapperGap = 4; // gap-1 between the strip and the kebab
const compute = () => {
const buttons = Array.from(measure.children) as HTMLElement[];
if (buttons.length === 0) {
return;
}
// the trailing child of the measurement row is a kebab clone
const kebab = buttons[buttons.length - 1];
const groupButtons = buttons.slice(0, -1);
const available = wrapper.clientWidth;
const fullWidth =
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(groupButtons.length - 1, 0) * gap;
if (fullWidth <= available) {
setVisibleCount(null);
return;
}
const budget = available - kebab.offsetWidth - wrapperGap;
let used = 0;
let count = 0;
for (const el of groupButtons) {
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
if (used + next <= budget) {
used += next;
count += 1;
} else {
break;
}
}
setVisibleCount(Math.max(count, 1));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
return () => observer.disconnect();
}, [groups, isAdmin]);
const groupButtons = (afterSelect?: () => void) => {
const groupButtons = () => {
const buttons = [
<Button
key="default-group"
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
if (group) {
setGroup("default", true);
}
afterSelect?.();
}}
>
<MdHome className="size-5" />
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setGroup(name, group != "default");
afterSelect?.();
}}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
)}
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-5"
/>
</Button>
)),
];
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setAddGroup(true);
afterSelect?.();
}}
>
<LuPencil className="size-5 text-primary-variant" />
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
onMouseEnter={() => showTooltip(name)}
onMouseLeave={() => showTooltip(undefined)}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-4"
/>
</Button>
</TooltipTrigger>
<TooltipPortal>
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
)}
</div>
) : (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleCount == null
? groupButtons()
: groupButtons().slice(0, visibleCount)}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showAll")}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural button widths so we
can render exactly the buttons that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div ref={measureRef} className="flex w-max items-center gap-2">
{groupButtons()}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="group-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex flex-wrap items-center gap-2">
{groupButtons(() => setExpanded(false))}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showLess")}
onClick={() => setExpanded(false)}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<OverflowStrip
className={className}
items={groupButtons()}
activeIndex={groups.findIndex(([name]) => name == group) + 1}
showAllLabel={t("group.showAll")}
showLessLabel={t("group.showLess")}
/>
)}
</>
);
+1 -1
View File
@@ -218,7 +218,7 @@ export default function InputWithTags({
}
return current_suggestions.filter((suggestion) =>
suggestion.toLowerCase().startsWith(currentWord),
suggestion.toLowerCase().startsWith(currentWord.toLowerCase()),
);
},
[inputValue, suggestions, currentFilterType],
+167
View File
@@ -0,0 +1,167 @@
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { HiDotsHorizontal } from "react-icons/hi";
import { IoClose } from "react-icons/io5";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
type OverflowStripProps = {
className?: string;
items: ReactNode[];
activeIndex?: number;
gapClassName?: string;
showAllLabel: string;
showLessLabel: string;
};
// Renders only the items that fully fit and surfaces a kebab next to the last
// visible one. The kebab expands a panel over the nearest positioned ancestor
// that reveals every item.
export default function OverflowStrip({
className,
items,
activeIndex = 0,
gapClassName = "gap-2",
showAllLabel,
showLessLabel,
}: OverflowStripProps) {
const [expanded, setExpanded] = useState(false);
// null => all items fit, render them all with no kebab; a number => only
// that many fit alongside the kebab
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) {
return;
}
const wrapperGap = 4; // gap-1 between the strip and the kebab
const compute = () => {
const children = Array.from(measure.children) as HTMLElement[];
if (children.length === 0) {
return;
}
// the trailing child of the measurement row is a kebab clone
const kebab = children[children.length - 1];
const start = children[0].offsetLeft;
const ends = children
.slice(0, -1)
.map((el) => el.offsetLeft + el.offsetWidth - start);
const available = wrapper.clientWidth;
if (ends[ends.length - 1] <= available) {
setVisibleCount(null);
return;
}
const budget = available - kebab.offsetWidth - wrapperGap;
const count = ends.filter((end) => end <= budget).length;
setVisibleCount(Math.max(count, 1));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
observer.observe(measure);
return () => observer.disconnect();
}, [items.length, gapClassName]);
// a selected item past the cut takes the last visible slot
const visibleItems =
visibleCount == null
? items
: activeIndex >= visibleCount
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
: items.slice(0, visibleCount);
return (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div
className={cn(
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
gapClassName,
)}
>
{visibleItems}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={showAllLabel}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural item widths so we can
render exactly the items that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div
ref={measureRef}
className={cn("flex w-max items-center", gapClassName)}
>
{items}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="overflow-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{/* a tap on any item bubbles up and collapses the panel */}
<div
className={cn("flex flex-wrap items-center", gapClassName)}
onClick={() => setExpanded(false)}
>
{items}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={showLessLabel}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -844,6 +844,7 @@ export function TrackingDetails({
<div className="text-sm text-secondary-foreground">
<Link
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
state={{ canGoBack: true }}
className="text-sm"
>
{event.data.recognized_license_plate}
@@ -631,7 +631,7 @@ export function SpeedFilterContent({
const value = e.target.value;
if (value) {
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
setSpeedRange(parseInt(value), maxSpeed ?? 150);
}
}}
/>
@@ -727,6 +727,7 @@ function EventList({
<div className="text-sm text-secondary-foreground">
<Link
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
state={{ canGoBack: true }}
className="text-sm"
>
{event.data.recognized_license_plate}
+4 -1
View File
@@ -135,7 +135,9 @@ export default function EventMenu({
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => {
navigate(`/explore?event_id=${event.id}`);
navigate(`/explore?event_id=${event.id}`, {
state: { canGoBack: true },
});
}}
>
{t("details.item.button.viewInExplore")}
@@ -177,6 +179,7 @@ export default function EventMenu({
else
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
{ state: { canGoBack: true } },
);
}}
>
+9 -5
View File
@@ -44,8 +44,9 @@ export default function useStatusMessages(): StatusMessage[] {
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
clearMessages("embeddings-reindex");
if (reindexState.status === "indexing") {
addMessage(
"embeddings-reindex",
t("stats.reindexingEmbeddings", {
@@ -55,9 +56,12 @@ export default function useStatusMessages(): StatusMessage[] {
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
} else if (reindexState.status === "failed") {
addMessage(
"embeddings-reindex",
t("stats.reindexEmbeddingsFailed"),
"error",
);
}
}
}, [reindexState, addMessage, clearMessages, t]);
-2
View File
@@ -287,7 +287,6 @@ function ConfigEditor() {
</Button>
<Button
size="sm"
disabled={!hasChanges}
className="flex items-center gap-2"
aria-label={t("saveAndRestart")}
onClick={handleSaveAndRestart}
@@ -300,7 +299,6 @@ function ConfigEditor() {
</Button>
<Button
size="sm"
disabled={!hasChanges}
className="flex items-center gap-2"
aria-label={t("saveOnly")}
onClick={() => onHandleSaveConfig("saveonly")}
+26 -6
View File
@@ -198,7 +198,19 @@ export default function Explore() {
const [url, params] = searchQuery;
const isAscending = params.sort?.includes("date_asc");
// a start_time cursor only works when rows are ordered by start_time,
// so every other sort pages by offset
const isDateSort =
params.sort === "date_asc" ||
params.sort === "date_desc" ||
(!params.sort && url === "events");
if (pageIndex > 0 && !isDateSort) {
return [
url,
{ ...params, offset: pageIndex * API_LIMIT, limit: API_LIMIT },
];
}
if (pageIndex > 0 && previousPageData) {
const lastDate = previousPageData[previousPageData.length - 1].start_time;
@@ -206,7 +218,8 @@ export default function Explore() {
url,
{
...params,
[isAscending ? "after" : "before"]: lastDate.toString(),
[params.sort === "date_asc" ? "after" : "before"]:
lastDate.toString(),
limit: API_LIMIT,
},
];
@@ -238,10 +251,17 @@ export default function Explore() {
},
});
const searchResults = useMemo(
() => (data ? ([] as SearchResult[]).concat(...data) : []),
[data],
);
// offset pages can overlap when results shift between page fetches
const searchResults = useMemo(() => {
if (!data) return [];
const seen = new Set<string>();
return data.flat().filter((result) => {
if (seen.has(result.id)) return false;
seen.add(result.id);
return true;
});
}, [data]);
const isLoadingInitialData = !data && !isValidating;
const isLoadingMore =
isLoadingInitialData ||
+63 -30
View File
@@ -1,8 +1,10 @@
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useEffect, useMemo, useRef, useState } from "react";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Toggle } from "@/components/ui/toggle";
import OverflowStrip from "@/components/mobile/OverflowStrip";
import { isDesktop, isMobile } from "react-device-detect";
import GeneralMetrics from "@/views/system/GeneralMetrics";
import StorageMetrics from "@/views/system/StorageMetrics";
@@ -36,6 +38,14 @@ const allMetrics = [
] as const;
type SystemMetric = (typeof allMetrics)[number];
const metricIcons: Record<SystemMetric, ReactNode> = {
health: <LuHeartPulse className="size-4" />,
general: <LuActivity className="size-4" />,
enrichments: <LuSearchCode className="size-4" />,
storage: <LuHardDrive className="size-4" />,
cameras: <FaVideo className="size-4" />,
};
function System() {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config", {
@@ -98,43 +108,66 @@ function System() {
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
{isMobile ? (
<div className="w-[calc(50%-1rem)]">
<OverflowStrip
items={metrics.map((item) => (
<Toggle
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
className={cn(
"shrink-0 rounded-md px-3 py-4",
pageToggle != item && "*:text-muted-foreground",
)}
size="sm"
pressed={pageToggle == item}
onPressedChange={() => setPageToggle(item)}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && (
<div className="smart-capitalize">{t(item + ".title")}</div>
)}
</ToggleGroupItem>
{metricIcons[item]}
</Toggle>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
activeIndex={metrics.indexOf(pageToggle)}
gapClassName="gap-0.5"
showAllLabel={t("showAllTabs")}
showLessLabel={t("showLessTabs")}
/>
</div>
</ScrollArea>
) : (
<ScrollArea className="whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{metricIcons[item]}
<div className="smart-capitalize">{t(item + ".title")}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
)}
<div className="flex h-full items-center">
{pageToggle == "health" && (
+1
View File
@@ -105,6 +105,7 @@ export type SearchQueryParams = {
max_speed?: number;
search_type?: string;
limit?: number;
offset?: number;
in_progress?: number;
include_thumbnails?: number;
query?: string;
+1 -1
View File
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
{isMobile && (
<div className="relative flex h-11 items-center justify-between">
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
<div className="w-[45%]">
<div className="w-[calc(50%-1rem)]">
<CameraGroupSelector />
</div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (

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