mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 23:02:49 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a0adf7e1 |
@@ -17,14 +17,9 @@ 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'
|
||||
|
||||
@@ -824,38 +824,6 @@ 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-audio-aac
|
||||
record: preset-record-generic
|
||||
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||
# record_sub: preset-record-generic-audio-aac
|
||||
# record_sub: preset-record-generic
|
||||
# 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,8 +571,6 @@ 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 > Confidence threshold** | Set to `0.7` |
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ 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**
|
||||
|
||||
@@ -568,28 +567,6 @@ 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.
|
||||
|
||||
@@ -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 > 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 |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
|
||||
To override score filters for a specific camera, navigate to <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 > 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 |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
|
||||
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
|
||||
@@ -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 > 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 |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
||||
|
||||
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
|
||||
@@ -191,12 +191,14 @@ cameras:
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: true
|
||||
enabled: false
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
@@ -249,12 +251,6 @@ 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.
|
||||
|
||||
@@ -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"). Frigate derives the trigger's
|
||||
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- 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.
|
||||
|
||||
@@ -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 **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
|
||||
4. Configure zone options such as **Friendly 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, 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.
|
||||
3. In the zone editor, enter the real-world **Distances** 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>
|
||||
|
||||
@@ -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. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
|
||||
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.
|
||||
|
||||
## 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. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
|
||||
The median score an object must reach to be considered a true positive.
|
||||
|
||||
## Top Score
|
||||
|
||||
|
||||
@@ -75,9 +75,6 @@ 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**
|
||||
|
||||
@@ -341,32 +338,6 @@ 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.
|
||||
|
||||
@@ -11,12 +11,6 @@ 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,10 +27,6 @@ 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.
|
||||
|
||||
@@ -21,13 +21,7 @@ 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 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.
|
||||
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.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -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 **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -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`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `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`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,12 +77,9 @@ 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 `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
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.
|
||||
|
||||
- **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`
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -397,11 +397,19 @@ 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 go2rtc stream configuration
|
||||
#### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
||||
|
||||
#### Step 7: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
#### Step 7: Check system resources
|
||||
#### Step 8: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
|
||||
+22
-30
@@ -3,9 +3,6 @@ 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",
|
||||
@@ -26,17 +23,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.
|
||||
@@ -48,8 +45,8 @@ const config: Config = {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
</style>`,
|
||||
backgroundColor: "#005f73",
|
||||
textColor: "#e0fbfc",
|
||||
backgroundColor: '#005f73',
|
||||
textColor: '#e0fbfc',
|
||||
isCloseable: false,
|
||||
},
|
||||
docs: {
|
||||
@@ -86,15 +83,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"],
|
||||
@@ -134,11 +131,6 @@ 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",
|
||||
@@ -156,19 +148,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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Vendored
-20
@@ -4493,16 +4493,6 @@ 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
|
||||
@@ -4808,16 +4798,6 @@ 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
|
||||
|
||||
+3
-14
@@ -311,12 +311,9 @@ def config(request: Request):
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
is_admin = request.headers.get("remote-role") == "admin"
|
||||
|
||||
# hide environment_vars and the notification email from non-admin users
|
||||
if not is_admin:
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
redact_credential(config["notifications"], "email")
|
||||
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
@@ -373,15 +370,7 @@ def config(request: Request):
|
||||
camera_name
|
||||
)
|
||||
if 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"
|
||||
)
|
||||
camera_dict["base_config"] = base_sections
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
|
||||
+24
-24
@@ -130,18 +130,23 @@ def require_admin_by_default():
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Internal port requests have admin role set automatically
|
||||
@@ -319,17 +324,11 @@ 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:
|
||||
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"
|
||||
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||
trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
@@ -474,11 +473,12 @@ 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
|
||||
# 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).
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -56,7 +55,6 @@ 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"
|
||||
|
||||
+2
-11
@@ -129,7 +129,6 @@ def events(
|
||||
zones = zone
|
||||
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
after = params.after
|
||||
before = params.before
|
||||
time_range = params.time_range
|
||||
@@ -362,15 +361,11 @@ 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, *tiebreaker)
|
||||
.order_by(order_by)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
@@ -539,7 +534,6 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -846,9 +840,6 @@ 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():
|
||||
@@ -906,7 +897,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[offset:][:limit]
|
||||
processed_events = processed_events[:limit]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
+11
-20
@@ -44,22 +44,6 @@ 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],
|
||||
@@ -109,7 +93,10 @@ async def review(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
if zones != "all":
|
||||
@@ -252,7 +239,10 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
if zones != "all":
|
||||
# use matching so segments with multiple zones
|
||||
@@ -350,8 +340,9 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||
|
||||
label_clauses.append(
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
# Find the time range of available data
|
||||
|
||||
+7
-3
@@ -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 PtzAutoTracker
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.record.cleanup import RecordingCleanup
|
||||
from frigate.record.export import migrate_exports
|
||||
@@ -166,7 +166,11 @@ 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()
|
||||
self.ptz_metrics[camera_name] = PTZMetrics(
|
||||
autotracker_enabled=self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
def init_queues(self) -> None:
|
||||
# Queue for cameras to push tracked objects to
|
||||
@@ -439,7 +443,7 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start_ptz_autotracker(self) -> None:
|
||||
self.ptz_autotracker_thread = PtzAutoTracker(
|
||||
self.ptz_autotracker_thread = PtzAutoTrackerThread(
|
||||
self.config,
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
|
||||
@@ -43,6 +43,8 @@ class CameraMetrics:
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
autotracker_enabled: Synchronized
|
||||
|
||||
start_time: Synchronized
|
||||
stop_time: Synchronized
|
||||
frame_time: Synchronized
|
||||
@@ -50,10 +52,13 @@ class PTZMetrics:
|
||||
max_zoom: Synchronized
|
||||
min_zoom: Synchronized
|
||||
|
||||
tracking_active: Event
|
||||
motor_stopped: Event
|
||||
reset: Event
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||
|
||||
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]
|
||||
@@ -61,6 +66,7 @@ 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()
|
||||
|
||||
|
||||
@@ -104,13 +104,12 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
all_objects.extend(new_objects)
|
||||
@@ -235,13 +234,12 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_detections = new_activity[camera].get("detections", [])
|
||||
if self.compare_audio_activity(camera, new_detections, now):
|
||||
|
||||
@@ -120,7 +120,9 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics()
|
||||
self.ptz_metrics[name] = PTZMetrics(
|
||||
autotracker_enabled=config.onvif.autotracking.enabled
|
||||
)
|
||||
self.region_grids[name] = get_camera_regions_grid(
|
||||
name,
|
||||
config.detect,
|
||||
|
||||
+13
-9
@@ -16,7 +16,7 @@ from frigate.config import (
|
||||
ZoomingModeEnum,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
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: PtzAutoTracker,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
@@ -115,13 +115,17 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
|
||||
and self.ptz_autotracker_thread.tracked_object[self.name]
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||
self.name
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
is not None
|
||||
and obj["id"]
|
||||
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
].obj_data["id"]
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -134,9 +138,9 @@ class CameraState:
|
||||
and self.camera_config.detect.width is not None
|
||||
and self.camera_config.detect.height is not None
|
||||
):
|
||||
max_target_box = calculate_max_target_box(
|
||||
self.camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||
self.name
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
|
||||
@@ -759,13 +759,15 @@ class Dispatcher:
|
||||
"Autotracking must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
if not ptz_autotracker_settings.enabled:
|
||||
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
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 ptz_autotracker_settings.enabled:
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
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
|
||||
|
||||
@@ -780,9 +782,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -799,9 +799,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -816,9 +814,7 @@ class Dispatcher:
|
||||
def _on_global_notification_command(self, payload: str) -> None:
|
||||
"""Callback for global notification topic."""
|
||||
if payload != "ON" and payload != "OFF":
|
||||
logger.warning(
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
)
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
return
|
||||
|
||||
notification_settings = self.config.notifications
|
||||
|
||||
+3
-58
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -58,11 +56,6 @@ 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
|
||||
@@ -92,7 +85,6 @@ 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:
|
||||
@@ -108,11 +100,9 @@ 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:
|
||||
@@ -368,7 +358,7 @@ class MqttClient(Communicator):
|
||||
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
|
||||
while not message_info.is_published() and time.monotonic() < deadline:
|
||||
if (
|
||||
self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
!= mqtt.MQTT_ERR_SUCCESS
|
||||
):
|
||||
break
|
||||
@@ -378,51 +368,6 @@ 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.
|
||||
@@ -439,7 +384,7 @@ class MqttClient(Communicator):
|
||||
|
||||
assert self.client is not None
|
||||
try:
|
||||
result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
|
||||
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT loop error: %s", err)
|
||||
self._schedule_reconnect()
|
||||
@@ -672,7 +617,7 @@ class MqttClient(Communicator):
|
||||
return
|
||||
|
||||
try:
|
||||
result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT publish wait failed: %s", err)
|
||||
self._schedule_reconnect()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
@@ -12,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: EnvString | None = Field(
|
||||
email: str | None = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
|
||||
@@ -176,7 +176,6 @@ 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]
|
||||
@@ -196,11 +195,11 @@ class LicensePlateProcessingMixin:
|
||||
norm_image = norm_image[np.newaxis, :]
|
||||
norm_images.append(norm_image)
|
||||
|
||||
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 [], []
|
||||
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 [], []
|
||||
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
|
||||
+15
-76
@@ -1,10 +1,8 @@
|
||||
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__)
|
||||
@@ -19,7 +17,6 @@ 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:
|
||||
@@ -56,22 +53,6 @@ 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.
|
||||
|
||||
@@ -82,17 +63,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
return
|
||||
|
||||
# the embeddings tables are only created once semantic search has run
|
||||
if not self._table_exists(table):
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
|
||||
if cursor.fetchone() is None:
|
||||
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||
return
|
||||
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
|
||||
# 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)
|
||||
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
|
||||
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||
@@ -100,67 +81,25 @@ 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:
|
||||
for table in ("vec_descriptions", "vec_thumbnails"):
|
||||
self._restore_vec_info_table(table)
|
||||
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_descriptions;
|
||||
""")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_thumbnails;
|
||||
""")
|
||||
|
||||
def create_embeddings_tables(self) -> None:
|
||||
"""Create vec0 virtual table for embeddings"""
|
||||
self.execute_write("""
|
||||
self.execute_sql("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
self.execute_write("""
|
||||
self.execute_sql("""
|
||||
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
|
||||
)
|
||||
|
||||
@@ -186,28 +186,15 @@ class ModelConfig(BaseModel):
|
||||
|
||||
# download the model if it doesn't exist
|
||||
if not os.path.isfile(self.path):
|
||||
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
|
||||
|
||||
download_url = plus_api.get_model_download_url(model_id)
|
||||
r = requests.get(download_url)
|
||||
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(model_info, f)
|
||||
json.dump(plus_api.get_model_info(model_id), f)
|
||||
|
||||
model_info = load_plus_model_info(model_id)
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from peewee import DatabaseError, DoesNotExist, IntegrityError
|
||||
from peewee import DoesNotExist, IntegrityError
|
||||
from PIL import Image
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -208,10 +207,12 @@ class Embeddings:
|
||||
embedding = self.vision_embedding([thumbnail])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails",
|
||||
"thumbnail_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -250,12 +251,19 @@ class Embeddings:
|
||||
embeddings = self.vision_embedding(valid_thumbs)
|
||||
|
||||
if upsert:
|
||||
items = {}
|
||||
items = []
|
||||
for i in range(len(valid_ids)):
|
||||
items[valid_ids[i]] = serialize(embeddings[i])
|
||||
items.append(valid_ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
self.image_eps.update()
|
||||
|
||||
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.image_inference_speed.update(duration / len(valid_ids))
|
||||
@@ -269,10 +277,12 @@ class Embeddings:
|
||||
embedding = self.text_embedding([description])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions",
|
||||
"description_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -292,14 +302,19 @@ class Embeddings:
|
||||
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
items = {}
|
||||
items = []
|
||||
|
||||
for i in range(len(ids)):
|
||||
items[ids[i]] = serialize(embeddings[i])
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
self.text_eps.update()
|
||||
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", items
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -307,17 +322,6 @@ 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()
|
||||
@@ -342,24 +346,17 @@ class Embeddings:
|
||||
batch_size = 32
|
||||
current_page = 1
|
||||
|
||||
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",
|
||||
}
|
||||
)
|
||||
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",
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -365,7 +365,6 @@ 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
|
||||
|
||||
@@ -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 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",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
||||
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
@@ -46,7 +45,6 @@ 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:
|
||||
@@ -61,7 +59,8 @@ 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.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and not self.ptz_metrics.motor_stopped.is_set()
|
||||
):
|
||||
return [
|
||||
(
|
||||
@@ -163,7 +162,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.autotracking_enabled
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and self.ptz_metrics.motor_stopped.is_set()
|
||||
and (
|
||||
self.last_stop_time is None
|
||||
|
||||
+4
-15
@@ -9,9 +9,7 @@ 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
|
||||
|
||||
@@ -103,13 +101,6 @@ 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
|
||||
@@ -120,9 +111,7 @@ class PlusApi:
|
||||
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
|
||||
)
|
||||
parts = self.key.split(":")
|
||||
r = self._session.get(
|
||||
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
|
||||
)
|
||||
r = requests.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()
|
||||
@@ -132,19 +121,19 @@ class PlusApi:
|
||||
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
|
||||
|
||||
def _get(self, path: str) -> Response:
|
||||
return self._session.get(
|
||||
return requests.get(
|
||||
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
||||
)
|
||||
|
||||
def _post(self, path: str, data: dict) -> Response:
|
||||
return self._session.post(
|
||||
return requests.post(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
)
|
||||
|
||||
def _put(self, path: str, data: dict) -> Response:
|
||||
return self._session.put(
|
||||
return requests.put(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
|
||||
+323
-173
@@ -23,7 +23,6 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
AUTOTRACKING_MAX_AREA_RATIO,
|
||||
@@ -43,11 +42,6 @@ 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
|
||||
@@ -186,7 +180,7 @@ class PtzMotionEstimator:
|
||||
return self.coord_transformations
|
||||
|
||||
|
||||
class PtzAutoTracker(threading.Thread):
|
||||
class PtzAutoTrackerThread(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -196,14 +190,56 @@ class PtzAutoTracker(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, TrackedObject | None] = {}
|
||||
self.tracked_object: dict[str, object] = {}
|
||||
self.tracked_object_history: dict[str, object] = {}
|
||||
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
|
||||
self.tracked_object_metrics: dict[str, object] = {}
|
||||
self.object_types: dict[str, object] = {}
|
||||
self.required_zones: dict[str, object] = {}
|
||||
self.move_queues: dict[str, object] = {}
|
||||
self.move_queue_locks: dict[str, object] = {}
|
||||
self.move_threads: dict[str, object] = {}
|
||||
@@ -213,6 +249,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
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,
|
||||
@@ -240,37 +277,44 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.wait(1):
|
||||
self.config_subscriber.check_for_updates()
|
||||
def check_for_updates(self) -> None:
|
||||
"""Apply camera config updates and mirror autotracking state to ptz metrics.
|
||||
|
||||
for camera, camera_config in list(self.config.cameras.items()):
|
||||
if not camera_config.enabled:
|
||||
The camera processes read autotracker_enabled rather than the config, so it
|
||||
has to follow every path that can change autotracking, not just the mqtt
|
||||
toggle that writes it directly.
|
||||
"""
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
for cameras in updates.values():
|
||||
for camera in cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
metrics = self.ptz_metrics.get(camera)
|
||||
|
||||
# a camera added at runtime gets its metrics from the maintainer on
|
||||
# another thread, which seeds them from this same config value
|
||||
if camera_config is None or metrics is None:
|
||||
continue
|
||||
|
||||
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...")
|
||||
metrics.autotracker_enabled.value = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
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._reset_tracked_object_metrics(camera)
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
|
||||
self.calibrating[camera] = False
|
||||
self.move_metrics[camera] = []
|
||||
@@ -282,22 +326,43 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
# handle onvif constructor failing due to no connection
|
||||
if camera not in self.onvif.cams:
|
||||
self._disable(camera, "onvif connection failed")
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: onvif connection failed"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
return
|
||||
|
||||
if not self.onvif.cams[camera]["init"]:
|
||||
if not await self.onvif._init_onvif(camera):
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
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
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
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
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
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
|
||||
return
|
||||
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
@@ -309,41 +374,59 @@ class PtzAutoTracker(threading.Thread):
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
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 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."
|
||||
)
|
||||
|
||||
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 _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
|
||||
def _write_config(self, camera):
|
||||
config_file = find_config_file()
|
||||
|
||||
# the camera process holds its own copy of the config
|
||||
self.dispatcher.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
logger.debug(
|
||||
f"{camera}: Writing new config with autotracker motion coefficients: {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)
|
||||
update_yaml_file_bulk(
|
||||
config_file,
|
||||
{
|
||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||
camera
|
||||
].onvif.autotracking.movement_weights
|
||||
},
|
||||
)
|
||||
|
||||
async def _calibrate_camera(self, camera):
|
||||
# move the camera from the preset in steps and measure the time it takes to move that amount
|
||||
@@ -376,7 +459,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -386,7 +470,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -403,7 +488,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -417,7 +503,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_stop_time = time.time()
|
||||
|
||||
@@ -431,7 +518,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
full_relative_stop_time = time.time()
|
||||
|
||||
@@ -443,7 +531,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
1,
|
||||
)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
self.zoom_time[camera] = (
|
||||
full_relative_stop_time - full_relative_start_time
|
||||
@@ -469,7 +558,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
for step in range(num_steps):
|
||||
pan = step_sizes[step]
|
||||
@@ -478,7 +569,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
start_time = time.time()
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
stop_time = time.time()
|
||||
|
||||
self.move_metrics[camera].append(
|
||||
@@ -497,7 +590,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
logger.info(
|
||||
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
||||
@@ -573,14 +668,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
|
||||
)
|
||||
|
||||
update_yaml_file_bulk(
|
||||
find_config_file(),
|
||||
{
|
||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||
camera
|
||||
].onvif.autotracking.movement_weights
|
||||
},
|
||||
)
|
||||
self._write_config(camera)
|
||||
|
||||
def _predict_movement_time(self, camera, pan, tilt):
|
||||
combined_movement = abs(pan) + abs(tilt)
|
||||
@@ -594,18 +682,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
[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]
|
||||
@@ -627,20 +703,19 @@ class PtzAutoTracker(threading.Thread):
|
||||
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
|
||||
# Use a recent time window
|
||||
# Filter out the initial frame and 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 current_time - entry["frame_time"] <= time_window
|
||||
if not entry.get("is_initial_frame", False)
|
||||
and current_time - entry["frame_time"] <= time_window
|
||||
]
|
||||
if not history: # Fallback to latest if no recent entries
|
||||
history = [self.tracked_object_history[camera][-1]]
|
||||
@@ -673,29 +748,33 @@ class PtzAutoTracker(threading.Thread):
|
||||
)
|
||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||
|
||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
||||
0
|
||||
]
|
||||
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
|
||||
X.reshape(-1, 1), y, rcond=None
|
||||
)[0]
|
||||
else:
|
||||
tom["area_coefficients"] = np.array([0])
|
||||
self.tracked_object_metrics[camera]["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
|
||||
)
|
||||
|
||||
tom["target_box"] = (
|
||||
self.tracked_object_metrics[camera]["target_box"] = (
|
||||
weighted_area / (camera_width * camera_height)
|
||||
) ** zoom_factor
|
||||
) ** self.zoom_factor[camera]
|
||||
|
||||
if "original_target_box" not in tom:
|
||||
tom["original_target_box"] = tom["target_box"]
|
||||
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"]
|
||||
)
|
||||
|
||||
(
|
||||
tom["valid_velocity"],
|
||||
tom["velocity"],
|
||||
self.tracked_object_metrics[camera]["valid_velocity"],
|
||||
self.tracked_object_metrics[camera]["velocity"],
|
||||
) = self._get_valid_velocity(camera, obj)
|
||||
tom["distance"] = self._get_distance_threshold(camera, obj)
|
||||
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
|
||||
camera, obj
|
||||
)
|
||||
|
||||
centroid_distance = np.linalg.norm(
|
||||
[
|
||||
@@ -706,7 +785,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
|
||||
|
||||
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
|
||||
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
|
||||
centroid_distance < self.tracked_object_metrics[camera]["distance"]
|
||||
)
|
||||
|
||||
async def _process_move_queue(self, camera):
|
||||
move_queue = self.move_queues[camera]
|
||||
@@ -752,12 +833,16 @@ class PtzAutoTracker(threading.Thread):
|
||||
if pan != 0 or tilt != 0:
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
@@ -793,20 +878,41 @@ class PtzAutoTracker(threading.Thread):
|
||||
await move_queue.get()
|
||||
|
||||
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
|
||||
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (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
|
||||
|
||||
if (
|
||||
(pan != 0 or tilt != 0 or zoom != 0)
|
||||
and frame_time > self.ptz_metrics[camera].start_time.value
|
||||
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()
|
||||
):
|
||||
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)
|
||||
)
|
||||
# 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
|
||||
|
||||
def _touching_frame_edges(self, camera, box):
|
||||
camera_config = self.config.cameras[camera]
|
||||
@@ -940,17 +1046,16 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
return distance_threshold
|
||||
|
||||
def _should_zoom_in(self, camera: str, box, predicted_time):
|
||||
def _should_zoom_in(
|
||||
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
|
||||
):
|
||||
# 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 = tom["velocity"]
|
||||
average_velocity = self.tracked_object_metrics[camera]["velocity"]
|
||||
|
||||
bb_left, bb_top, bb_right, bb_bottom = box
|
||||
|
||||
@@ -968,11 +1073,13 @@ class PtzAutoTracker(threading.Thread):
|
||||
touching_frame_edges = self._touching_frame_edges(camera, box)
|
||||
|
||||
# make sure object is centered in the frame
|
||||
below_distance_threshold = tom["below_distance_threshold"]
|
||||
below_distance_threshold = self.tracked_object_metrics[camera][
|
||||
"below_distance_threshold"
|
||||
]
|
||||
|
||||
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
|
||||
zoom_factor + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
|
||||
self.zoom_factor[camera] + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
|
||||
|
||||
# ensure object is not moving quickly
|
||||
below_velocity_threshold = np.all(
|
||||
@@ -980,16 +1087,30 @@ class PtzAutoTracker(threading.Thread):
|
||||
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
|
||||
) or np.all(average_velocity == 0)
|
||||
|
||||
calculated_target_box = self._predict_target_box(camera, predicted_time)
|
||||
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
|
||||
)
|
||||
|
||||
below_area_threshold = calculated_target_box < max_target_box
|
||||
below_area_threshold = (
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
)
|
||||
|
||||
# introduce some hysteresis to prevent a yo-yo zooming effect
|
||||
zoom_out_hysteresis = (
|
||||
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
calculated_target_box
|
||||
> self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
)
|
||||
zoom_in_hysteresis = (
|
||||
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
)
|
||||
|
||||
at_max_zoom = (
|
||||
@@ -1001,29 +1122,31 @@ class PtzAutoTracker(threading.Thread):
|
||||
== self.ptz_metrics[camera].min_zoom.value
|
||||
)
|
||||
|
||||
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']}"
|
||||
)
|
||||
# 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']}"
|
||||
)
|
||||
|
||||
# Zoom in conditions (and)
|
||||
if (
|
||||
@@ -1114,7 +1237,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
)
|
||||
|
||||
zoom = self._get_zoom_amount(
|
||||
camera, obj, predicted_box, predicted_movement_time
|
||||
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -1175,11 +1298,9 @@ class PtzAutoTracker(threading.Thread):
|
||||
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]
|
||||
@@ -1196,12 +1317,16 @@ class PtzAutoTracker(threading.Thread):
|
||||
# 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 tom:
|
||||
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||
zoom = current_zoom_level
|
||||
else:
|
||||
if (
|
||||
result := self._should_zoom_in(
|
||||
camera, obj.obj_data["box"], predicted_movement_time
|
||||
camera,
|
||||
obj,
|
||||
obj.obj_data["box"],
|
||||
predicted_movement_time,
|
||||
debug_zoom,
|
||||
)
|
||||
) is not None:
|
||||
# divide zoom in 10 increments and always zoom out more than in
|
||||
@@ -1217,35 +1342,46 @@ class PtzAutoTracker(threading.Thread):
|
||||
# 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 tom:
|
||||
zoom = target_box**zoom_factor
|
||||
if zoom > max_target_box:
|
||||
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"]:
|
||||
zoom = -(1 - zoom)
|
||||
logger.debug(
|
||||
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
|
||||
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['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:
|
||||
calculated_target_box = self._predict_target_box(
|
||||
camera, predicted_movement_time
|
||||
)
|
||||
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: {tom['target_box']}, calculated box: {calculated_target_box}"
|
||||
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 = max_target_box / calculated_target_box
|
||||
ratio = (
|
||||
self.tracked_object_metrics[camera]["max_target_box"]
|
||||
/ calculated_target_box
|
||||
)
|
||||
zoom = (ratio - 1) / (ratio + 1)
|
||||
logger.debug(
|
||||
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
)
|
||||
if not result:
|
||||
# zoom out with special condition if zooming out because of velocity, edges, etc.
|
||||
@@ -1258,6 +1394,12 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
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
|
||||
@@ -1281,9 +1423,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
# new object
|
||||
self.tracked_object[camera] is None
|
||||
and obj.camera_config.name == 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 obj.obj_data["label"] in self.object_types[camera]
|
||||
and set(obj.entered_zones) & set(self.required_zones[camera])
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and not self.tracked_object_history[camera]
|
||||
@@ -1292,6 +1433,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
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
|
||||
)
|
||||
@@ -1340,7 +1482,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
# 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 camera_config.onvif.autotracking.track
|
||||
and obj.obj_data["label"] in self.object_types[camera]
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and self.tracked_object_history[camera]
|
||||
@@ -1376,7 +1518,10 @@ class PtzAutoTracker(threading.Thread):
|
||||
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
|
||||
)
|
||||
self.tracked_object[camera] = None
|
||||
self._reset_tracked_object_metrics(camera)
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
|
||||
async def camera_maintenance(self, camera):
|
||||
# bail and don't check anything if we're not set up yet, calibrating, or
|
||||
@@ -1393,6 +1538,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
# 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)
|
||||
@@ -1413,7 +1560,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera].clear()
|
||||
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
logger.debug(
|
||||
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
||||
)
|
||||
@@ -1423,8 +1571,10 @@ class PtzAutoTracker(threading.Thread):
|
||||
)
|
||||
|
||||
# update stored zoom level from preset
|
||||
await self._wait_until_stopped(camera)
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(
|
||||
f"{camera}/ptz_autotracker/active", "OFF", retain=False
|
||||
)
|
||||
|
||||
+300
-205
@@ -10,7 +10,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
@@ -40,14 +41,6 @@ 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]
|
||||
|
||||
@@ -68,6 +61,14 @@ 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,
|
||||
@@ -91,9 +92,8 @@ class OnvifController:
|
||||
|
||||
async def _init_cameras(self) -> None:
|
||||
"""Initialize all configured cameras."""
|
||||
for cam_name, cam in list(self.config.cameras.items()):
|
||||
if cam.enabled and cam.onvif.host:
|
||||
await self._init_single_camera(cam_name)
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _poll_config_updates(self) -> None:
|
||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||
@@ -129,9 +129,13 @@ 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)
|
||||
|
||||
@@ -139,14 +143,25 @@ 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 and reset state before re-init
|
||||
# close existing session before re-init
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if not cam or not cam.onvif.host:
|
||||
# ONVIF removed from config, clean up
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
return
|
||||
|
||||
# update stored config and reset state
|
||||
self.camera_configs[cam_name] = cam
|
||||
if cam_name not in self.status_locks:
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
self.cams.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam and cam.onvif.host:
|
||||
await self._init_single_camera(cam_name)
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||
"""Initialize a single camera by name.
|
||||
@@ -157,12 +172,11 @@ class OnvifController:
|
||||
Returns:
|
||||
bool: True if initialization succeeded, False otherwise
|
||||
"""
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam is None:
|
||||
if cam_name not in self.camera_configs:
|
||||
logger.error(f"No configuration found for camera {cam_name}")
|
||||
return False
|
||||
|
||||
self.status_locks.setdefault(cam_name, asyncio.Lock())
|
||||
cam = self.camera_configs[cam_name]
|
||||
try:
|
||||
self.cams[cam_name] = {
|
||||
"onvif": ONVIFCamera(
|
||||
@@ -181,11 +195,12 @@ class OnvifController:
|
||||
"profiles": [],
|
||||
}
|
||||
return True
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, 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
|
||||
@@ -196,8 +211,7 @@ class OnvifController:
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
cam = self.cams[camera_name]
|
||||
onvif: ONVIFCamera = cam["onvif"]
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
try:
|
||||
await onvif.update_xaddrs()
|
||||
except Exception as e:
|
||||
@@ -212,7 +226,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 Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -221,7 +235,7 @@ class OnvifController:
|
||||
try:
|
||||
profiles = await media.GetProfiles()
|
||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -240,7 +254,7 @@ class OnvifController:
|
||||
]
|
||||
|
||||
# store available profiles for API response and log for debugging
|
||||
cam["profiles"] = [
|
||||
self.cams[camera_name]["profiles"] = [
|
||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||
for p in valid_profiles
|
||||
]
|
||||
@@ -253,18 +267,19 @@ class OnvifController:
|
||||
)
|
||||
|
||||
configured_profile = camera_config.onvif.profile
|
||||
profile = None
|
||||
|
||||
if configured_profile is not None:
|
||||
# match by exact token first, then by name
|
||||
profile = next(
|
||||
(
|
||||
p
|
||||
for key in ("token", "Name")
|
||||
for p in valid_profiles
|
||||
if getattr(p, key, None) == configured_profile
|
||||
),
|
||||
None,
|
||||
)
|
||||
for p in valid_profiles:
|
||||
if p.token == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
for p in valid_profiles:
|
||||
if getattr(p, "Name", None) == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
available = [
|
||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||
@@ -289,30 +304,39 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
|
||||
configs = profile.PTZConfiguration
|
||||
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
|
||||
# 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
|
||||
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
cam["ptz"] = ptz
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
cam["imaging"] = imaging
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
if video_sources and len(video_sources) > 0:
|
||||
cam["video_source_token"] = video_sources[0].token
|
||||
except Exception as e:
|
||||
self.cams[camera_name]["video_source_token"] = video_sources[0].token
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
||||
cam["video_source_token"] = None
|
||||
self.cams[camera_name]["video_source_token"] = None
|
||||
|
||||
# setup continuous moving request
|
||||
move_request = ptz.create_type("ContinuousMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
cam["move_request"] = move_request
|
||||
self.cams[camera_name]["move_request"] = move_request
|
||||
|
||||
# get PTZ configuration options for feature detection and relative movement
|
||||
ptz_config = None
|
||||
@@ -325,7 +349,7 @@ class OnvifController:
|
||||
logger.debug(
|
||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||
)
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||
)
|
||||
@@ -351,6 +375,18 @@ 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
|
||||
@@ -359,7 +395,9 @@ class OnvifController:
|
||||
# one-off GetStatus to seed Translation field
|
||||
status = None
|
||||
try:
|
||||
status = await ptz.GetStatus({"ProfileToken": profile.token})
|
||||
one_off_status_request = ptz.create_type("GetStatus")
|
||||
one_off_status_request.ProfileToken = profile.token
|
||||
status = await ptz.GetStatus(one_off_status_request)
|
||||
logger.debug(f"Onvif status for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||
@@ -386,7 +424,7 @@ class OnvifController:
|
||||
# configure zoom on relative move request
|
||||
if (
|
||||
autotracking_enabled
|
||||
and autotracking_config.zooming == ZoomingModeEnum.relative
|
||||
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||
):
|
||||
zoom_space_id = next(
|
||||
(
|
||||
@@ -425,16 +463,21 @@ class OnvifController:
|
||||
)
|
||||
|
||||
if rel_move_request.Speed is None:
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
||||
)
|
||||
cam["relative_move_request"] = 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
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
||||
presets = []
|
||||
|
||||
@@ -447,7 +490,7 @@ class OnvifController:
|
||||
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
pass
|
||||
cam["presets"][preset_name.lower()] = preset["token"]
|
||||
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
|
||||
|
||||
# get list of supported features
|
||||
supported_features = []
|
||||
@@ -461,40 +504,64 @@ 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 getattr(spaces, "RelativeZoomTranslationSpace", None):
|
||||
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
|
||||
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 configs.DefaultAbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
|
||||
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
|
||||
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}"
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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_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": cam["video_source_token"]}
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
)
|
||||
if (
|
||||
hasattr(imaging_capabilities, "Focus")
|
||||
and imaging_capabilities.Focus
|
||||
):
|
||||
supported_features.append("focus")
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||
|
||||
# detect FOV relative movement support
|
||||
@@ -503,18 +570,17 @@ class OnvifController:
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
supported_features.append("pt-r-fov")
|
||||
cam["relative_fov_range"] = (
|
||||
self.cams[camera_name]["relative_fov_range"] = (
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
||||
)
|
||||
|
||||
cam["features"] = supported_features
|
||||
cam["init"] = True
|
||||
self.cams[camera_name]["features"] = supported_features
|
||||
self.cams[camera_name]["init"] = True
|
||||
return True
|
||||
|
||||
async def _stop(self, camera_name: str) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
move_request = cam["move_request"]
|
||||
await cam["ptz"].Stop(
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
await self.cams[camera_name]["ptz"].Stop(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PanTilt": True,
|
||||
@@ -522,75 +588,88 @@ class OnvifController:
|
||||
}
|
||||
)
|
||||
if (
|
||||
"focus" in cam["features"]
|
||||
and cam["video_source_token"]
|
||||
and cam["imaging"] is not None
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = cam["imaging"].create_type("Stop")
|
||||
stop_request.VideoSourceToken = cam["video_source_token"]
|
||||
await cam["imaging"].Stop(stop_request)
|
||||
except Exception as e:
|
||||
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:
|
||||
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "pt" not in cam["features"]:
|
||||
if "pt" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
|
||||
x, y = PAN_TILT_VELOCITY[command]
|
||||
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
await cam["ptz"].ContinuousMove(move_request)
|
||||
except Exception as e:
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
except (Fault, ONVIFError, TransportError, 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:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "pt-r-fov" not in cam["features"]:
|
||||
if "pt-r-fov" not in self.cams[camera_name]["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 or camera_config is None:
|
||||
if metrics is None:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||
)
|
||||
|
||||
if cam["active"]:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
self.cams[camera_name]["active"] = True
|
||||
|
||||
# only track start_time for autotracking
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
if metrics.autotracker_enabled.value:
|
||||
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 = cam["relative_move_request"]
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
|
||||
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
||||
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
|
||||
@@ -598,49 +677,55 @@ class OnvifController:
|
||||
pan,
|
||||
[-1, 1],
|
||||
[
|
||||
cam["relative_fov_range"]["XRange"]["Min"],
|
||||
cam["relative_fov_range"]["XRange"]["Max"],
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
tilt = numpy.interp(
|
||||
tilt,
|
||||
[-1, 1],
|
||||
[
|
||||
cam["relative_fov_range"]["YRange"]["Min"],
|
||||
cam["relative_fov_range"]["YRange"]["Max"],
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_speed = {"PanTilt": {"x": speed, "y": speed}}
|
||||
move_request.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
|
||||
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
|
||||
|
||||
if include_zoom:
|
||||
move_speed["Zoom"] = {"x": speed}
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||
|
||||
move_request.Speed = move_speed
|
||||
|
||||
await cam["ptz"].RelativeMove(move_request)
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if include_zoom:
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
del move_request["Translation"]["Zoom"]
|
||||
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["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 cam["presets"]:
|
||||
if preset not in self.cams[camera_name]["presets"]:
|
||||
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
||||
return
|
||||
|
||||
@@ -649,48 +734,44 @@ class OnvifController:
|
||||
if metrics is None:
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
self.cams[camera_name]["active"] = True
|
||||
metrics.start_time.value = 0
|
||||
metrics.stop_time.value = 0
|
||||
move_request = cam["move_request"]
|
||||
preset_token = cam["presets"][preset]
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
|
||||
await cam["ptz"].GotoPreset(
|
||||
await self.cams[camera_name]["ptz"].GotoPreset(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "zoom" not in cam["features"]:
|
||||
if "zoom" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["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 cam["ptz"].ContinuousMove(move_request)
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
|
||||
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "zoom-a" not in cam["features"]:
|
||||
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||
return
|
||||
|
||||
@@ -701,59 +782,56 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
||||
|
||||
if cam["active"]:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
self.cams[camera_name]["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],
|
||||
[
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {"Zoom": speed}
|
||||
move_request.Position = {"Zoom": zoom}
|
||||
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
await cam["ptz"].AbsoluteMove(
|
||||
{
|
||||
"ProfileToken": cam["move_request"].ProfileToken,
|
||||
"Position": {"Zoom": zoom},
|
||||
"Speed": {"Zoom": speed},
|
||||
}
|
||||
)
|
||||
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
||||
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if (
|
||||
"focus" not in cam["features"]
|
||||
or not cam["video_source_token"]
|
||||
or cam["imaging"] is None
|
||||
"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
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
cam["active"] = True
|
||||
move_request = cam["imaging"].create_type("Move")
|
||||
move_request.VideoSourceToken = cam["video_source_token"]
|
||||
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"]
|
||||
move_request.Focus = {
|
||||
"Continuous": {
|
||||
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
||||
@@ -761,10 +839,10 @@ class OnvifController:
|
||||
}
|
||||
|
||||
try:
|
||||
await cam["imaging"].Move(move_request)
|
||||
except Exception as e:
|
||||
await self.cams[camera_name]["imaging"].Move(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
async def handle_command_async(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
@@ -805,7 +883,7 @@ class OnvifController:
|
||||
await self._focus(camera_name, command)
|
||||
else:
|
||||
await self._move(camera_name, command)
|
||||
except Exception as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(f"Unable to handle onvif command: {e}")
|
||||
|
||||
def handle_command(
|
||||
@@ -828,15 +906,6 @@ 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
|
||||
@@ -860,21 +929,56 @@ class OnvifController:
|
||||
return {}
|
||||
|
||||
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
||||
return self._camera_info(camera_name)
|
||||
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", []),
|
||||
}
|
||||
|
||||
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 {}
|
||||
|
||||
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
|
||||
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
|
||||
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
|
||||
|
||||
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(
|
||||
@@ -883,24 +987,8 @@ 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.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(),
|
||||
}
|
||||
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
||||
return {}
|
||||
|
||||
async def get_service_capabilities(self, camera_name: str) -> None:
|
||||
@@ -908,13 +996,16 @@ class OnvifController:
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
if not self.cams[camera_name]["init"]:
|
||||
await self._init_onvif(camera_name)
|
||||
|
||||
service_capabilities_request = self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
]
|
||||
try:
|
||||
service_capabilities = await cam["ptz"].GetServiceCapabilities()
|
||||
service_capabilities = await self.cams[camera_name][
|
||||
"ptz"
|
||||
].GetServiceCapabilities(service_capabilities_request)
|
||||
|
||||
logger.debug(
|
||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||
@@ -940,16 +1031,13 @@ class OnvifController:
|
||||
if metrics is None or camera_config is None:
|
||||
return
|
||||
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
if not self.cams[camera_name]["init"]:
|
||||
if not await self._init_onvif(camera_name):
|
||||
return
|
||||
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
try:
|
||||
status = await cam["ptz"].GetStatus(
|
||||
{"ProfileToken": cam["move_request"].ProfileToken}
|
||||
)
|
||||
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
@@ -980,7 +1068,7 @@ class OnvifController:
|
||||
if pan_tilt_status == "IDLE" and (
|
||||
zoom_status is None or zoom_status == "IDLE"
|
||||
):
|
||||
cam["active"] = False
|
||||
self.cams[camera_name]["active"] = False
|
||||
if not metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.set()
|
||||
|
||||
@@ -990,7 +1078,7 @@ class OnvifController:
|
||||
|
||||
metrics.stop_time.value = metrics.frame_time.value
|
||||
else:
|
||||
cam["active"] = True
|
||||
self.cams[camera_name]["active"] = True
|
||||
if metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.clear()
|
||||
|
||||
@@ -1006,8 +1094,8 @@ class OnvifController:
|
||||
metrics.zoom_level.value = numpy.interp(
|
||||
round(status.Position.Zoom.x, 2),
|
||||
[
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
[0, 1],
|
||||
)
|
||||
@@ -1046,7 +1134,7 @@ class OnvifController:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if self.loop.is_closed():
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
logger.debug("ONVIF controller already closed")
|
||||
return
|
||||
|
||||
@@ -1063,6 +1151,13 @@ class OnvifController:
|
||||
|
||||
self.config_subscriber.stop()
|
||||
|
||||
self.loop.call_soon_threadsafe(self.loop.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_thread.join()
|
||||
|
||||
@@ -1375,7 +1375,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if preview.end_time > self.end_time:
|
||||
playlist_lines.append(
|
||||
f"outpoint {int(self.end_time - preview.start_time)}"
|
||||
f"outpoint {int(preview.end_time - self.end_time)}"
|
||||
)
|
||||
|
||||
ffmpeg_input = (
|
||||
|
||||
@@ -691,22 +691,6 @@ 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
|
||||
@@ -734,28 +718,43 @@ class RecordingMaintainer(threading.Thread):
|
||||
# we should first just check if this segment matches that
|
||||
# and avoid any DB calls
|
||||
if highest is not None:
|
||||
record_mode = (
|
||||
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
|
||||
# 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
|
||||
)
|
||||
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,
|
||||
# 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
|
||||
)
|
||||
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:
|
||||
@@ -817,6 +816,10 @@ 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)
|
||||
|
||||
+55
-135
@@ -66,9 +66,6 @@ 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
|
||||
@@ -86,20 +83,6 @@ 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,
|
||||
@@ -452,29 +435,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = now
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(segment, None)
|
||||
return end_time
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
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.
|
||||
|
||||
@@ -507,55 +470,6 @@ 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,
|
||||
@@ -591,21 +505,6 @@ 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
|
||||
@@ -613,8 +512,6 @@ 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:
|
||||
@@ -625,22 +522,23 @@ 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
|
||||
|
||||
segment.add_object(object, self.config.all_attributes)
|
||||
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]
|
||||
|
||||
if pending_objects:
|
||||
self._track_pending_detection(
|
||||
segment, camera_config, frame_name, frame_time, pending_objects
|
||||
)
|
||||
# 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 len(activity.get_all_objects()) > segment.frame_active_count:
|
||||
should_update_state = True
|
||||
@@ -692,28 +590,50 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
# 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
|
||||
> (
|
||||
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 > (
|
||||
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,
|
||||
|
||||
+5
-22
@@ -236,15 +236,6 @@ 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,
|
||||
@@ -257,9 +248,8 @@ def stats_snapshot(
|
||||
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in list(camera_metrics.items()):
|
||||
camera_config = config.cameras.get(name)
|
||||
if camera_config is None:
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
@@ -276,7 +266,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 = camera_config.detect.fps
|
||||
expected_fps = config.cameras[name].detect.fps
|
||||
current_fps = camera_stats.camera_fps.value
|
||||
reconnects = camera_stats.reconnects_last_hour.value
|
||||
stalls = camera_stats.stalls_last_hour.value
|
||||
@@ -309,7 +299,7 @@ def stats_snapshot(
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": camera_config.detect.enabled,
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"pid": pid,
|
||||
"capture_pid": capture_pid,
|
||||
"ffmpeg_pid": ffmpeg_pid,
|
||||
@@ -365,14 +355,6 @@ 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] = {
|
||||
@@ -381,6 +363,7 @@ 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", ""))
|
||||
|
||||
@@ -145,11 +145,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
pass
|
||||
|
||||
def create_app(
|
||||
self,
|
||||
stats=None,
|
||||
event_metadata_publisher=None,
|
||||
notice_registry=None,
|
||||
enforce_default_admin=False,
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
@@ -164,7 +160,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=enforce_default_admin,
|
||||
enforce_default_admin=False,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -50,25 +49,6 @@ 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"],
|
||||
@@ -112,30 +92,6 @@ 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)
|
||||
|
||||
@@ -168,29 +168,6 @@ 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 = "中文标签"
|
||||
@@ -242,85 +219,6 @@ 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
|
||||
|
||||
@@ -240,101 +240,9 @@ 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")
|
||||
|
||||
@@ -59,6 +59,7 @@ def build_watchdog(
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
watchdog.requestor = MagicMock()
|
||||
return watchdog
|
||||
|
||||
|
||||
@@ -107,8 +108,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_goes_to_the_matching_role_topic(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
|
||||
|
||||
watchdog.requestor.send_data.assert_any_call(
|
||||
"front_door/status/record", "online"
|
||||
@@ -120,9 +121,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_is_cached_per_stream(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
assert watchdog.requestor.send_data.call_count == 2
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -1596,31 +1595,6 @@ 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"},
|
||||
|
||||
@@ -257,31 +257,6 @@ 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"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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"})
|
||||
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
|
||||
sys.modules[name] = MagicMock()
|
||||
|
||||
# Now import the class under test
|
||||
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
|
||||
from frigate.config import FrigateConfig # noqa: E402
|
||||
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
|
||||
|
||||
# Restore original modules (or remove mock if there was no original)
|
||||
@@ -124,80 +124,6 @@ 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 = {}
|
||||
|
||||
@@ -29,6 +29,7 @@ class TestImprovedMotionDetector(unittest.TestCase):
|
||||
|
||||
class DummyPTZ:
|
||||
def __init__(self):
|
||||
self.autotracker_enabled = _Stub(False)
|
||||
self.motor_stopped = _Stub(False)
|
||||
self.stop_time = _Stub(0)
|
||||
|
||||
|
||||
@@ -126,18 +126,12 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
os.makedirs(MODEL_CACHE_DIR)
|
||||
|
||||
self.config = build_config()
|
||||
self.client = self._build_client()
|
||||
self.client = MqttClient(self.config)
|
||||
self.receiver = RuntimeSnapshotReceiver()
|
||||
self.client.attach_dispatcher(build_dispatcher(self.config, []))
|
||||
|
||||
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()
|
||||
client = MqttClient(self.config)
|
||||
|
||||
with patch.object(client, "_start_worker") as mock_start_worker:
|
||||
client.subscribe(self.receiver._receive)
|
||||
@@ -148,7 +142,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
mock_start_worker.assert_not_called()
|
||||
|
||||
def test_attach_dispatcher_supplies_command_surface(self) -> None:
|
||||
client = self._build_client()
|
||||
client = MqttClient(self.config)
|
||||
|
||||
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
|
||||
|
||||
@@ -301,13 +295,6 @@ 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:
|
||||
@@ -461,6 +448,7 @@ 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
|
||||
@@ -468,14 +456,11 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
mock_client.publish.return_value = message_info
|
||||
barrier = MagicMock()
|
||||
|
||||
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)
|
||||
)
|
||||
self.client._publish_direct(
|
||||
QueuedPublish("frigate/available", "stopped", True, barrier)
|
||||
)
|
||||
|
||||
mock_loop.assert_called_once()
|
||||
mock_client.loop.assert_called_once()
|
||||
barrier.set.assert_called_once()
|
||||
|
||||
def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
|
||||
@@ -658,8 +643,9 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
loop_calls[0] += 1
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
with patch.object(self.client, "_loop_client", side_effect=loop_side_effect):
|
||||
self.client._wait_for_publish(message_info)
|
||||
mock_client.loop.side_effect = loop_side_effect
|
||||
|
||||
self.client._wait_for_publish(message_info)
|
||||
|
||||
self.assertEqual(loop_calls[0], 1)
|
||||
self.assertIsNone(self.client.client)
|
||||
@@ -683,20 +669,16 @@ 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, "_loop_client", side_effect=OSError("socket closed")
|
||||
),
|
||||
patch.object(
|
||||
self.client,
|
||||
"_schedule_reconnect",
|
||||
side_effect=stop_after_reconnect,
|
||||
) as mock_schedule_reconnect,
|
||||
):
|
||||
with 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()
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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)
|
||||
@@ -7,8 +7,9 @@ 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.
|
||||
- _disable only changed the main process config, so the camera process kept
|
||||
running its motion estimator for a camera that could not autotrack.
|
||||
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
|
||||
updated it when autotracking was enabled through a config save, so it stayed
|
||||
False and the tracker never built a motion estimator.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
@@ -16,8 +17,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
|
||||
@@ -53,9 +53,8 @@ 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()}
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
|
||||
tracker.onvif = MagicMock()
|
||||
tracker.dispatcher = MagicMock()
|
||||
tracker.config_subscriber = MagicMock()
|
||||
tracker.autotracker_init = {}
|
||||
tracker.calibrating = {}
|
||||
@@ -84,49 +83,47 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
|
||||
tracker.onvif.get_camera_status.assert_not_called()
|
||||
|
||||
|
||||
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:
|
||||
class TestAutotrackerMetricSync(unittest.TestCase):
|
||||
def test_metric_follows_config_when_enabled_by_update(self) -> None:
|
||||
# autotracking enabled via a config save: the metric was seeded False when
|
||||
# the camera was added and nothing else updates it
|
||||
tracker = _make_tracker(autotracking_enabled=True)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
tracker._disable(CAMERA, "onvif connection failed")
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
|
||||
tracker.check_for_updates()
|
||||
|
||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||
self.assertFalse(autotracking.enabled)
|
||||
self.assertTrue(metrics.autotracker_enabled.value)
|
||||
|
||||
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)
|
||||
def test_metric_follows_config_when_disabled_by_update(self) -> None:
|
||||
tracker = _make_tracker(autotracking_enabled=False)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
metrics.autotracker_enabled.value = True
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"autotracking": [CAMERA]
|
||||
}
|
||||
tracker.check_for_updates()
|
||||
|
||||
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)
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
def test_metric_sync_skips_camera_without_metrics(self) -> None:
|
||||
# `add` reaches the maintainer and the autotracker on separate threads with
|
||||
# no ordering guarantee, so the metrics may not exist yet
|
||||
tracker = _make_tracker()
|
||||
tracker.ptz_metrics = {}
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
|
||||
|
||||
tracker.check_for_updates()
|
||||
|
||||
def test_metric_sync_skips_unknown_camera(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"add": ["not_in_config"]
|
||||
}
|
||||
|
||||
tracker.check_for_updates()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
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. 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.
|
||||
configure autotracking afterwards. The autotracking-only request objects used to
|
||||
be created only when autotracking was enabled at init time, so the camera was left
|
||||
with init=True but no status_request. get_camera_status skips its re-init branch
|
||||
when init is True, so it went straight to the missing key and raised KeyError on
|
||||
the tracking thread.
|
||||
|
||||
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||
they are always created and init=True now implies they exist.
|
||||
|
||||
Also covers the inverse direction: the ptz movement timestamps must not be written
|
||||
for a camera that has autotracking off, because nothing clears them back out.
|
||||
@@ -94,6 +99,7 @@ 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
|
||||
|
||||
@@ -104,6 +110,7 @@ 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()
|
||||
@@ -121,30 +128,45 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
},
|
||||
}
|
||||
}
|
||||
controller.ptz_metrics = {CAMERA: PTZMetrics()}
|
||||
controller.ptz_metrics = {
|
||||
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||
}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
# the wizard flow: onvif configured first, autotracking enabled later
|
||||
controller = _make_controller(autotracking_enabled=False)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertTrue(cam["init"])
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_status_request_created_when_autotracking_enabled(self) -> None:
|
||||
controller = _make_controller(autotracking_enabled=True)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_init_implies_status_request_exists(self) -> None:
|
||||
# the invariant get_camera_status relies on: it skips re-init when init is
|
||||
# True and then reads status_request without guarding
|
||||
for autotracking_enabled in (True, False):
|
||||
with self.subTest(autotracking_enabled=autotracking_enabled):
|
||||
controller = _make_controller(autotracking_enabled)
|
||||
controller.status_locks = {CAMERA: asyncio.Lock()}
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
await controller._init_onvif(CAMERA)
|
||||
|
||||
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"])
|
||||
cam = controller.cams[CAMERA]
|
||||
if cam["init"]:
|
||||
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
||||
|
||||
async def test_requests_built_without_contacting_camera(self) -> None:
|
||||
# create_type is a local WSDL lookup; cameras that do not implement
|
||||
|
||||
@@ -1,857 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,24 +1,16 @@
|
||||
"""Tests for embedding storage and cleanup on the main Frigate database.
|
||||
"""Tests for embedding cleanup on the main Frigate database.
|
||||
|
||||
Embeddings are deleted whether or not semantic search is currently enabled, so
|
||||
the delete path has to tolerate databases where the vec0 tables were never
|
||||
created and installs where the sqlite-vec extension is unavailable.
|
||||
|
||||
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:
|
||||
@@ -60,21 +52,6 @@ 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
|
||||
@@ -84,104 +61,3 @@ 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)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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()
|
||||
@@ -191,9 +191,6 @@ 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,
|
||||
|
||||
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
|
||||
),
|
||||
}
|
||||
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
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.camera_config.onvif.autotracking.enabled
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
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.camera_config.onvif.autotracking.enabled:
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
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.camera_config.onvif.autotracking.enabled:
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
# we must have been enabled by mqtt, so set up the estimator
|
||||
if not self.ptz_motion_estimator:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
|
||||
@@ -24,7 +24,6 @@ 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,
|
||||
@@ -41,7 +40,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event, ReviewSegment, Timeline
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
@@ -60,7 +59,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
dispatcher: Dispatcher,
|
||||
tracked_objects_queue: MpQueue,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(name="detected_frames_processor")
|
||||
@@ -130,10 +129,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
after = obj.to_dict()
|
||||
message = {
|
||||
"before": obj.previous,
|
||||
@@ -153,14 +150,12 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
self.ptz_autotracker_thread.autotrack_object(camera, obj)
|
||||
self.ptz_autotracker_thread.ptz_autotracker.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_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
|
||||
# write thumbnail to disk if it will be saved as an event
|
||||
if obj.has_snapshot or obj.has_clip:
|
||||
@@ -177,7 +172,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
"type": "end",
|
||||
}
|
||||
self.dispatcher.publish("events", json.dumps(message), retain=False)
|
||||
self.ptz_autotracker_thread.end_object(camera, obj)
|
||||
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
|
||||
|
||||
self.event_sender.publish(
|
||||
(
|
||||
@@ -190,8 +185,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def snapshot(camera: str, obj: TrackedObject) -> bool:
|
||||
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
|
||||
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
||||
jpg_bytes, _ = obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=mqtt_config.timestamp,
|
||||
@@ -246,13 +241,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
with self.camera_states_lock:
|
||||
self.camera_states[camera] = camera_state
|
||||
|
||||
def should_save_snapshot(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
snapshot_config: SnapshotsConfig = camera_config.snapshots
|
||||
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
|
||||
|
||||
if not snapshot_config.enabled:
|
||||
return False
|
||||
@@ -271,13 +264,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_retain_recording(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
record_config: RecordConfig = camera_config.record
|
||||
record_config: RecordConfig = self.config.cameras[camera].record
|
||||
|
||||
# Recording is disabled
|
||||
if not record_config.enabled:
|
||||
@@ -293,15 +284,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_mqtt_snapshot(
|
||||
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
def should_mqtt_snapshot(self, camera: str, 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 = mqtt_config.required_zones
|
||||
required_zones = self.config.cameras[camera].mqtt.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"
|
||||
@@ -311,11 +300,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return True
|
||||
|
||||
def update_mqtt_motion(
|
||||
self,
|
||||
camera: str,
|
||||
camera_config: CameraConfig,
|
||||
frame_time: float,
|
||||
motion_boxes: list,
|
||||
self, camera: str, frame_time: float, motion_boxes: list
|
||||
) -> None:
|
||||
# publish if motion is currently being detected
|
||||
if motion_boxes:
|
||||
@@ -330,7 +315,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 = camera_config.motion.mqtt_off_delay
|
||||
mqtt_delay = self.config.cameras[camera].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:
|
||||
@@ -833,7 +818,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
|
||||
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
|
||||
self.update_mqtt_motion(camera, frame_time, motion_boxes)
|
||||
|
||||
tracked_objects = [
|
||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
||||
|
||||
@@ -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(new_config)
|
||||
new_config = migrate_014(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(new_config)
|
||||
new_config = migrate_015_0(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(new_config)
|
||||
new_config = migrate_015_1(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(new_config)
|
||||
new_config = migrate_016_0(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(new_config)
|
||||
new_config = migrate_017_0(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(new_config)
|
||||
new_config = migrate_018_0(config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.18-0"
|
||||
|
||||
+2
-10
@@ -94,7 +94,6 @@ 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,
|
||||
@@ -196,12 +195,10 @@ def process_frames(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.autotracking,
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.onvif,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -238,11 +235,6 @@ 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
|
||||
@@ -357,8 +349,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 config rather than trusting them to be reset otherwise
|
||||
ptz_moving = camera_config.onvif.autotracking.enabled and (
|
||||
# on the metric rather than trusting them to be reset otherwise
|
||||
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||
ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
|
||||
+48
-65
@@ -6,7 +6,6 @@ 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
|
||||
@@ -19,7 +18,6 @@ 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,
|
||||
@@ -109,27 +107,6 @@ 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,
|
||||
@@ -208,30 +185,31 @@ class CameraWatchdog(threading.Thread):
|
||||
self._stall_active: bool = False
|
||||
|
||||
# Status caching to reduce message volume
|
||||
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()
|
||||
}
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_status_update_time: float = 0.0
|
||||
|
||||
def _role_status(self, role: str) -> RoleStatus:
|
||||
return RoleStatus(
|
||||
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
|
||||
)
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_detect_status
|
||||
or (now - self._last_status_update_time) >= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(f"{self.config.name}/status/detect", status)
|
||||
self._last_detect_status = status
|
||||
self._last_status_update_time = now
|
||||
|
||||
def _send_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 _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 _reset_segment_times(self) -> None:
|
||||
self.latest_valid_segment_time.clear()
|
||||
@@ -393,11 +371,11 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# update camera status
|
||||
now = datetime.now().timestamp()
|
||||
self.detect_status.send("disabled", now)
|
||||
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
|
||||
self._send_detect_status("disabled", now)
|
||||
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
|
||||
# cameras without a sub stream never get a record_sub topic
|
||||
if self.config.record.sub.enabled:
|
||||
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
|
||||
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -470,7 +448,7 @@ class CameraWatchdog(threading.Thread):
|
||||
can_restart = time_since_last_restart >= self.sleeptime
|
||||
|
||||
if not self.capture_thread.is_alive():
|
||||
self.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
@@ -482,7 +460,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.fps_overflow_count += 1
|
||||
|
||||
if self.fps_overflow_count == 3:
|
||||
self.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.fps_overflow_count = 0
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
@@ -492,7 +470,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.detect_status.send("offline", now)
|
||||
self._send_detect_status("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||
@@ -502,7 +480,7 @@ class CameraWatchdog(threading.Thread):
|
||||
last_restart_time = now
|
||||
else:
|
||||
# process is running normally
|
||||
self.detect_status.send("online", now)
|
||||
self._send_detect_status("online", now)
|
||||
self.fps_overflow_count = 0
|
||||
|
||||
for p in self.ffmpeg_other_processes:
|
||||
@@ -536,16 +514,18 @@ class CameraWatchdog(threading.Thread):
|
||||
ffmpeg_process=p["process"],
|
||||
)
|
||||
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
self._grant_restart_grace(recorded_streams, now_utc)
|
||||
last_restart_time = now
|
||||
|
||||
continue
|
||||
elif stale_stream is None:
|
||||
if poll is None:
|
||||
for stream_type in recorded_streams:
|
||||
self.record_status[stream_type].send("online", now)
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
|
||||
p["latest_segment_time"] = max(
|
||||
self.latest_cache_segment_time[stream_type]
|
||||
@@ -555,28 +535,31 @@ class CameraWatchdog(threading.Thread):
|
||||
if poll is None:
|
||||
continue
|
||||
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
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
|
||||
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()
|
||||
):
|
||||
now_utc = datetime.now().astimezone(UTC)
|
||||
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
|
||||
|
||||
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)
|
||||
if stale_reason is None:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "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.record_status[STREAM_TYPE_SUB].send("offline", now)
|
||||
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
|
||||
self.reset_capture_thread()
|
||||
last_restart_time = now
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -7,13 +7,12 @@
|
||||
*/
|
||||
|
||||
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(systemTab(frigateApp, "general")).toBeVisible({
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
@@ -29,7 +28,7 @@ test.describe("Auth — admin access @high", () => {
|
||||
|
||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,18 +216,6 @@ 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,
|
||||
|
||||
@@ -30,51 +30,40 @@ function groupedFacesMock() {
|
||||
});
|
||||
}
|
||||
|
||||
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 } = {},
|
||||
) {
|
||||
async function installGroupedFaces(app: FrigateApp) {
|
||||
await app.api.install({
|
||||
events: [GROUPED_EVENT],
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
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> {
|
||||
@@ -523,34 +512,6 @@ 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");
|
||||
|
||||
|
||||
@@ -287,22 +287,3 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
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);
|
||||
@@ -56,7 +55,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -802,7 +801,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -6,66 +6,42 @@
|
||||
* RestartDialog cancel flow.
|
||||
*/
|
||||
|
||||
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||
import { test, expect } 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(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
if (!frigateApp.isMobile) {
|
||||
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||
}
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Storage tab activates and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
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(
|
||||
"data-state",
|
||||
"off",
|
||||
);
|
||||
@@ -73,20 +49,24 @@ test.describe("System — tabs @medium", () => {
|
||||
|
||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("general tab shows version and last-refreshed", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -107,13 +87,17 @@ test.describe("System — tabs @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// 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
|
||||
@@ -128,23 +112,25 @@ 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(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByLabel("Select general"),
|
||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// Cameras tab lists every camera from config/stats. The default
|
||||
// mock has front_door, backyard, garage.
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
@@ -165,13 +151,17 @@ test.describe("System — tabs @medium", () => {
|
||||
config: { semantic_search: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "enrichments");
|
||||
await expectTabActive(frigateApp, "enrichments");
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,27 +223,31 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
|
||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -261,9 +255,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 kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
await expect(kebab).toBeVisible();
|
||||
const tabs = kebab.locator("..");
|
||||
const tabs = frigateApp.page
|
||||
.locator("[data-radix-scroll-area-viewport]")
|
||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||
|
||||
const logoBox = await logo.boundingBox();
|
||||
@@ -273,9 +267,20 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||
|
||||
// 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);
|
||||
// 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+89
-160
@@ -108,7 +108,7 @@
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0"
|
||||
@@ -4231,12 +4231,14 @@
|
||||
"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=="
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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"
|
||||
@@ -4384,11 +4386,6 @@
|
||||
"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",
|
||||
@@ -4403,21 +4400,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -4632,15 +4623,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"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/"
|
||||
}
|
||||
],
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@@ -4653,6 +4639,9 @@
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
@@ -4661,6 +4650,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -4802,6 +4792,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -4811,11 +4802,6 @@
|
||||
"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",
|
||||
@@ -4848,6 +4834,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -5837,11 +5824,6 @@
|
||||
"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",
|
||||
@@ -6475,20 +6457,6 @@
|
||||
"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",
|
||||
@@ -6550,6 +6518,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -6725,9 +6694,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
|
||||
"integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==",
|
||||
"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",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7192,17 +7162,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -8227,18 +8202,6 @@
|
||||
"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",
|
||||
@@ -8372,6 +8335,7 @@
|
||||
"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",
|
||||
@@ -8426,6 +8390,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -8461,14 +8426,6 @@
|
||||
"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",
|
||||
@@ -8731,14 +8688,6 @@
|
||||
"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",
|
||||
@@ -8779,9 +8728,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
@@ -8911,36 +8861,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.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
|
||||
"integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "^6.0.11"
|
||||
"postcss-selector-parser": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"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==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -9587,6 +9537,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10000,16 +9951,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
|
||||
"integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
@@ -10017,26 +9969,7 @@
|
||||
"sucrase-node": "bin/sucrase-node"
|
||||
},
|
||||
"engines": {
|
||||
"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": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
@@ -10126,33 +10059,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
|
||||
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.3.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.21.0",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"jiti": "^1.21.7",
|
||||
"lilconfig": "^3.1.3",
|
||||
"micromatch": "^4.0.8",
|
||||
"normalize-path": "^3.0.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"postcss": "^8.4.23",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-js": "^4.0.1",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"bin": {
|
||||
"tailwind": "lib/cli.js",
|
||||
@@ -10174,6 +10107,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -10182,6 +10116,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10193,7 +10128,6 @@
|
||||
"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",
|
||||
@@ -10210,7 +10144,6 @@
|
||||
"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"
|
||||
@@ -10228,7 +10161,6 @@
|
||||
"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"
|
||||
@@ -10295,7 +10227,8 @@
|
||||
"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=="
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
@@ -10587,7 +10520,8 @@
|
||||
"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=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
@@ -10833,11 +10767,6 @@
|
||||
"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
@@ -120,7 +120,7 @@
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0"
|
||||
|
||||
@@ -1162,9 +1162,7 @@
|
||||
"actions": "Actions",
|
||||
"noRoles": "No custom roles found.",
|
||||
"editCameras": "Edit Cameras",
|
||||
"deleteRole": "Delete Role",
|
||||
"cameraCount_one": "{{count}} camera",
|
||||
"cameraCount_other": "{{count}} cameras"
|
||||
"deleteRole": "Delete Role"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
@@ -2040,11 +2038,7 @@
|
||||
},
|
||||
"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.",
|
||||
"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."
|
||||
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
|
||||
},
|
||||
"birdseye": {
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"showAllTabs": "Show all tabs",
|
||||
"showLessTabs": "Show less",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
@@ -341,7 +339,6 @@
|
||||
"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)",
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
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,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
@@ -24,7 +16,6 @@ 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,
|
||||
@@ -94,14 +85,9 @@ export const ClassificationCard = forwardRef<
|
||||
|
||||
// interaction
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => cardRef.current!);
|
||||
|
||||
// Listen on the whole card, since overlays cover most of the image
|
||||
|
||||
useContextMenu(cardRef, () => {
|
||||
useContextMenu(imgRef, () => {
|
||||
onClick(data, true);
|
||||
});
|
||||
|
||||
@@ -115,9 +101,9 @@ export const ClassificationCard = forwardRef<
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex size-full select-none flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||
"relative flex size-full flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||
className,
|
||||
selected
|
||||
? "shadow-selected outline-selected"
|
||||
@@ -131,7 +117,11 @@ export const ClassificationCard = forwardRef<
|
||||
}
|
||||
onClick(data, isMeta);
|
||||
}}
|
||||
style={isIOS ? { WebkitTouchCallout: "none" } : undefined}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(data, true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -140,6 +130,14 @@ export const ClassificationCard = forwardRef<
|
||||
imgClassName,
|
||||
isMobile && "w-full",
|
||||
)}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
@@ -158,7 +156,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 flex-row items-center justify-between gap-2 p-2">
|
||||
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start text-white",
|
||||
@@ -218,41 +216,6 @@ 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.
|
||||
@@ -345,10 +308,9 @@ export function GroupedClassificationCard({
|
||||
return (
|
||||
<>
|
||||
<ClassificationCard
|
||||
ref={cardRef}
|
||||
data={bestItem}
|
||||
threshold={threshold}
|
||||
selected={highlighted || selectedItems.includes(bestItem.filename)}
|
||||
selected={selectedItems.includes(bestItem.filename)}
|
||||
clickable={true}
|
||||
i18nLibrary={i18nLibrary}
|
||||
count={group.length}
|
||||
@@ -442,19 +404,13 @@ export function GroupedClassificationCard({
|
||||
isMobile && "absolute right-4 top-8",
|
||||
)}
|
||||
>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
tabIndex={-1}
|
||||
aria-label={t("details.item.button.viewInExplore", {
|
||||
ns: "views/explore",
|
||||
})}
|
||||
onClick={() => {
|
||||
setReturnEventId(classifiedEvent.id, true);
|
||||
navigate(`/explore?event_id=${classifiedEvent.id}`, {
|
||||
state: { canGoBack: true },
|
||||
});
|
||||
navigate(`/explore?event_id=${classifiedEvent.id}`);
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-4 text-secondary-foreground" />
|
||||
|
||||
@@ -8,23 +8,6 @@ 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,21 +31,6 @@ 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,7 +8,6 @@ export type MessageConditionContext = {
|
||||
fullCameraConfig?: CameraConfig;
|
||||
level: "global" | "camera";
|
||||
cameraName?: string;
|
||||
profileName?: string;
|
||||
formData: ConfigSectionData;
|
||||
};
|
||||
|
||||
|
||||
@@ -627,11 +627,7 @@ export default function NotificationsSettingsExtras({
|
||||
<SettingsGroupCard title={t("notification.deviceSpecific")}>
|
||||
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
|
||||
<Button
|
||||
aria-label={
|
||||
registration != null
|
||||
? t("notification.unregisterDevice")
|
||||
: t("notification.registerDevice")
|
||||
}
|
||||
aria-label={t("notification.registerDevice")}
|
||||
className="w-full md:w-auto"
|
||||
disabled={!shouldFetchPubKey || publicKey == undefined}
|
||||
onClick={() => {
|
||||
|
||||
@@ -649,10 +649,9 @@ export function ConfigSection({
|
||||
: undefined,
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
profileName,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
};
|
||||
}, [config, currentFormData, effectiveLevel, cameraName, profileName]);
|
||||
}, [config, currentFormData, effectiveLevel, cameraName]);
|
||||
|
||||
const { activeMessages, activeFieldMessages } = useConfigMessages(
|
||||
sectionConfig.messages,
|
||||
|
||||
@@ -8,8 +8,18 @@ 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, useMemo, useState } from "react";
|
||||
import OverflowStrip from "../mobile/OverflowStrip";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@@ -146,7 +156,80 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
const [addGroup, setAddGroup] = useState(false);
|
||||
|
||||
const groupButtons = () => {
|
||||
// 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 buttons = [
|
||||
<Button
|
||||
key="default-group"
|
||||
@@ -162,6 +245,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
@@ -179,16 +263,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-5"
|
||||
/>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
@@ -202,6 +282,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-5 text-primary-variant" />
|
||||
@@ -269,14 +350,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
onMouseEnter={() => showTooltip(name)}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer
|
||||
icon={LuIcons[config.icon]}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
@@ -311,13 +390,74 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<OverflowStrip
|
||||
className={className}
|
||||
items={groupButtons()}
|
||||
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||
showAllLabel={t("group.showAll")}
|
||||
showLessLabel={t("group.showLess")}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function InputWithTags({
|
||||
}
|
||||
|
||||
return current_suggestions.filter((suggestion) =>
|
||||
suggestion.toLowerCase().startsWith(currentWord.toLowerCase()),
|
||||
suggestion.toLowerCase().startsWith(currentWord),
|
||||
);
|
||||
},
|
||||
[inputValue, suggestions, currentFilterType],
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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,7 +844,6 @@ 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 ?? 150);
|
||||
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -727,7 +727,6 @@ 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}
|
||||
|
||||
@@ -135,9 +135,7 @@ export default function EventMenu({
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
navigate(`/explore?event_id=${event.id}`, {
|
||||
state: { canGoBack: true },
|
||||
});
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
{t("details.item.button.viewInExplore")}
|
||||
@@ -179,7 +177,6 @@ export default function EventMenu({
|
||||
else
|
||||
navigate(
|
||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||
{ state: { canGoBack: true } },
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -44,9 +44,8 @@ export default function useStatusMessages(): StatusMessage[] {
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
clearMessages("embeddings-reindex");
|
||||
|
||||
if (reindexState.status === "indexing") {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
@@ -56,12 +55,9 @@ export default function useStatusMessages(): StatusMessage[] {
|
||||
),
|
||||
}),
|
||||
);
|
||||
} else if (reindexState.status === "failed") {
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexEmbeddingsFailed"),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
@@ -287,6 +287,7 @@ function ConfigEditor() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("saveAndRestart")}
|
||||
onClick={handleSaveAndRestart}
|
||||
@@ -299,6 +300,7 @@ function ConfigEditor() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("saveOnly")}
|
||||
onClick={() => onHandleSaveConfig("saveonly")}
|
||||
|
||||
@@ -198,19 +198,7 @@ export default function Explore() {
|
||||
|
||||
const [url, params] = searchQuery;
|
||||
|
||||
// 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 },
|
||||
];
|
||||
}
|
||||
const isAscending = params.sort?.includes("date_asc");
|
||||
|
||||
if (pageIndex > 0 && previousPageData) {
|
||||
const lastDate = previousPageData[previousPageData.length - 1].start_time;
|
||||
@@ -218,8 +206,7 @@ export default function Explore() {
|
||||
url,
|
||||
{
|
||||
...params,
|
||||
[params.sort === "date_asc" ? "after" : "before"]:
|
||||
lastDate.toString(),
|
||||
[isAscending ? "after" : "before"]: lastDate.toString(),
|
||||
limit: API_LIMIT,
|
||||
},
|
||||
];
|
||||
@@ -251,17 +238,10 @@ export default function Explore() {
|
||||
},
|
||||
});
|
||||
|
||||
// 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 searchResults = useMemo(
|
||||
() => (data ? ([] as SearchResult[]).concat(...data) : []),
|
||||
[data],
|
||||
);
|
||||
const isLoadingInitialData = !data && !isValidating;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
|
||||
+31
-64
@@ -1,10 +1,8 @@
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { 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";
|
||||
@@ -38,14 +36,6 @@ 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", {
|
||||
@@ -108,66 +98,43 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
{isMobile ? (
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<OverflowStrip
|
||||
items={metrics.map((item) => (
|
||||
<Toggle
|
||||
<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
|
||||
key={item}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-4",
|
||||
pageToggle != item && "*:text-muted-foreground",
|
||||
)}
|
||||
size="sm"
|
||||
pressed={pageToggle == item}
|
||||
onPressedChange={() => setPageToggle(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]}
|
||||
</Toggle>
|
||||
))}
|
||||
activeIndex={metrics.indexOf(pageToggle)}
|
||||
gapClassName="gap-0.5"
|
||||
showAllLabel={t("showAllTabs")}
|
||||
showLessLabel={t("showLessTabs")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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]}
|
||||
{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>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
|
||||
@@ -105,7 +105,6 @@ export type SearchQueryParams = {
|
||||
max_speed?: number;
|
||||
search_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
in_progress?: number;
|
||||
include_thumbnails?: number;
|
||||
query?: string;
|
||||
|
||||
@@ -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-[calc(50%-1rem)]">
|
||||
<div className="w-[45%]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user