mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-02 21:06:52 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ed433e9a | ||
|
|
5ab8666d4a | ||
|
|
9b02a077e9 | ||
|
|
d1cb8395ea | ||
|
|
3d0d12366f | ||
|
|
321e78e65b | ||
|
|
17a8efa09c | ||
|
|
0936f4b4ef | ||
|
|
160d213025 | ||
|
|
24a236a152 | ||
|
|
b749b0bbc5 | ||
|
|
e37041c879 | ||
|
|
61e50a366f | ||
|
|
4e196516dd | ||
|
|
3941355051 | ||
|
|
1a278630da | ||
|
|
9d0d8a99bb | ||
|
|
bbc412763d | ||
|
|
ac9ac50df5 | ||
|
|
93aa6c4174 | ||
|
|
26e6adee88 | ||
|
|
de416b7ae7 | ||
|
|
06967fec91 | ||
|
|
d69107de33 | ||
|
|
04480a18b6 | ||
|
|
b02aea03cd | ||
|
|
51171319a4 | ||
|
|
b1b725b80a | ||
|
|
50a2b6729e |
@@ -17,9 +17,14 @@ runs:
|
||||
shell: bash
|
||||
# This creates a virtual volume at /var/lib/docker to maximize the size
|
||||
# As of 2/14/2024, this results in 97G for docker images
|
||||
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
|
||||
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
|
||||
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
|
||||
- name: Maximize build space
|
||||
uses: easimon/maximize-build-space@master
|
||||
with:
|
||||
root-reserve-mb: 8192
|
||||
temp-reserve-mb: 4096
|
||||
remove-dotnet: 'true'
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
|
||||
@@ -824,6 +824,38 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
xdna2:
|
||||
title: AMD XDNA2
|
||||
models:
|
||||
- key: yolov9
|
||||
label: YOLOv9
|
||||
recommended: true
|
||||
download: |-
|
||||
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `320` |
|
||||
| **Object detection model input height** | `320` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nchw` |
|
||||
| **Model Input D Type** | `float` |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- zmq:tcp://xdna:5555
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/models/yolov9-c-320.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
|
||||
@@ -294,9 +294,9 @@ ffmpeg:
|
||||
# Optional: output args for detect streams (default: shown below)
|
||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||
# Optional: output args for record streams (default: shown below)
|
||||
record: preset-record-generic
|
||||
record: preset-record-generic-audio-aac
|
||||
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||
# record_sub: preset-record-generic
|
||||
# record_sub: preset-record-generic-audio-aac
|
||||
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
||||
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
||||
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
||||
|
||||
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
| Field | Description |
|
||||
| --------------------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
||||
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
||||
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
@@ -567,6 +568,28 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## AMD XDNA2
|
||||
|
||||
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
|
||||
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
|
||||
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
|
||||
detector interface.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
|
||||
are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Follow the frigate-xdna setup instructions to prepare and start the sidecar
|
||||
before starting Frigate.
|
||||
|
||||
### Configuration {#configuration-xdna2}
|
||||
|
||||
Using the detector config below will connect Frigate to the sidecar:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
|
||||
|
||||
The example assumes Frigate and the sidecar share a Docker network where the
|
||||
sidecar is named `xdna`.
|
||||
|
||||
## MemryX MX3
|
||||
|
||||
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
|
||||
|
||||
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
|
||||
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
|
||||
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
|
||||
|
||||
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
|
||||
@@ -191,14 +191,12 @@ cameras:
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: false
|
||||
enabled: true
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
@@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
### Why can't a profile enable recording when it's disabled in the base config?
|
||||
|
||||
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
|
||||
|
||||
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
|
||||
|
||||
### Can I schedule profiles to be enabled or disabled at certain times?
|
||||
|
||||
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
||||
|
||||
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
|
||||
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
|
||||
3. In the **Create Trigger** wizard:
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
|
||||
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
|
||||
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
|
||||
|
||||
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Under the **Zones** section, click the plus icon to add a new zone.
|
||||
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
|
||||
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
|
||||
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
|
||||
5. Press **Save** when finished.
|
||||
|
||||
</TabItem>
|
||||
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `sidewalk`).
|
||||
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
|
||||
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
|
||||
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||
|
||||
</TabItem>
|
||||
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
|
||||
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
|
||||
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
|
||||
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
|
||||
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
|
||||
|
||||
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone with distances configured.
|
||||
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
|
||||
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
|
||||
- The unit is kph or mph, depending on the **Unit system** setting
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
|
||||
|
||||
## Min Score
|
||||
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
|
||||
|
||||
## Model
|
||||
|
||||
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
|
||||
|
||||
## Threshold
|
||||
|
||||
The median score an object must reach to be considered a true positive.
|
||||
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
|
||||
|
||||
## Top Score
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||
- Runs best on discrete AMD GPUs
|
||||
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
|
||||
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
|
||||
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
@@ -338,6 +341,32 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
### AMD Ryzen AI / XDNA2
|
||||
|
||||
Frigate supports AMD XDNA2 NPUs through the community-maintained
|
||||
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
|
||||
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
|
||||
once on the target system and cached for subsequent use.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
|
||||
devices are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Measured YOLOv9 detector latency on Strix Halo:
|
||||
|
||||
| Model | 320 | 640 |
|
||||
| ----- | ---: | ---: |
|
||||
| YOLOv9-T | ~7.4 ms | unsupported |
|
||||
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
|
||||
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
|
||||
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
|
||||
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
|
||||
|
||||
**YOLOv9-C at 320 is the recommended quality/performance balance.**
|
||||
C at 640 is also usable where the lower throughput is acceptable.
|
||||
|
||||
Setup, model preparation, and compatibility details are available
|
||||
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -11,6 +11,12 @@ MQTT requires a network connection to your broker. This is typically local, but
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
|
||||
@@ -27,6 +27,10 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
|
||||
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
|
||||
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
|
||||
|
||||
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
|
||||
|
||||
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
|
||||
|
||||
## [Frigate Notify](https://github.com/0x2142/frigate-notify)
|
||||
|
||||
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
|
||||
|
||||
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
||||
|
||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -63,20 +63,20 @@ Frigate+ models generally have much higher scores than the default model provide
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
|
||||
|
||||
| Object | Min Score | Threshold |
|
||||
| ----------------- | --------- | --------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
| Object | Minimum confidence | Confidence threshold |
|
||||
| ----------------- | ------------------ | -------------------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
|
||||
|
||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
|
||||
|
||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||
|
||||
#### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
||||
|
||||
#### Step 7: Verify go2rtc stream configuration
|
||||
#### Step 6: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
#### Step 8: Check system resources
|
||||
#### Step 7: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
|
||||
+30
-22
@@ -3,6 +3,9 @@ import * as path from "node:path";
|
||||
import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
||||
@@ -23,17 +26,17 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
localeConfigs: {
|
||||
en: {
|
||||
label: 'English',
|
||||
}
|
||||
label: "English",
|
||||
},
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
announcementBar: {
|
||||
id: 'frigate_plus',
|
||||
id: "frigate_plus",
|
||||
content: `
|
||||
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
||||
Get more relevant and accurate detections with Frigate+ models.
|
||||
@@ -45,8 +48,8 @@ const config: Config = {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
</style>`,
|
||||
backgroundColor: '#005f73',
|
||||
textColor: '#e0fbfc',
|
||||
backgroundColor: "#005f73",
|
||||
textColor: "#e0fbfc",
|
||||
isCloseable: false,
|
||||
},
|
||||
docs: {
|
||||
@@ -83,15 +86,15 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
prism: {
|
||||
magicComments:[
|
||||
magicComments: [
|
||||
{
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
className: "theme-code-block-highlighted-line",
|
||||
line: "highlight-next-line",
|
||||
block: { start: "highlight-start", end: "highlight-end" },
|
||||
},
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
line: 'highlight-error-line',
|
||||
className: "code-block-error-line",
|
||||
line: "highlight-error-line",
|
||||
},
|
||||
],
|
||||
additionalLanguages: ["bash", "json"],
|
||||
@@ -131,6 +134,11 @@ const config: Config = {
|
||||
srcDark: "img/branding/logo-dark.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate/releases",
|
||||
label: `${STABLE_VERSION}`,
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
to: "/",
|
||||
activeBasePath: "docs",
|
||||
@@ -148,19 +156,19 @@ const config: Config = {
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
type: "localeDropdown",
|
||||
position: "right",
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
label: '简体中文(社区翻译)',
|
||||
href: 'https://docs.frigate-cn.video',
|
||||
}
|
||||
]
|
||||
label: "简体中文(社区翻译)",
|
||||
href: "https://docs.frigate-cn.video",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
href: "https://github.com/blakeblackshear/frigate",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Vendored
+20
@@ -4493,6 +4493,16 @@ paths:
|
||||
- type: 'null'
|
||||
default: 100
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
@@ -4798,6 +4808,16 @@ paths:
|
||||
- type: 'null'
|
||||
default: 50
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: cameras
|
||||
in: query
|
||||
required: false
|
||||
|
||||
+24
-24
@@ -130,23 +130,18 @@ def require_admin_by_default():
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# Dynamic camera path exemption:
|
||||
# Any path whose first segment matches a configured camera name should
|
||||
# bypass the global admin requirement. These endpoints enforce access
|
||||
# via route-level dependencies (e.g. require_camera_access) to ensure
|
||||
# per-camera authorization. This allows non-admin authenticated users
|
||||
# (e.g. viewer role) to access camera-specific resources without
|
||||
# needing admin privileges.
|
||||
try:
|
||||
if path.startswith("/"):
|
||||
first_segment = path.split("/", 2)[1]
|
||||
if (
|
||||
first_segment
|
||||
and first_segment in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Camera routes enforce per-camera access via route-level dependencies
|
||||
# (e.g. require_camera_access). Match on the route template, not the raw
|
||||
# path, so a camera named like another namespace (e.g. "faces") can't
|
||||
# waive the admin check for that namespace's routes.
|
||||
route = request.scope.get("route")
|
||||
if (
|
||||
route is not None
|
||||
and route.path.startswith("/{camera_name}")
|
||||
and request.path_params.get("camera_name")
|
||||
in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Internal port requests have admin role set automatically
|
||||
@@ -324,11 +319,17 @@ def get_remote_addr(request: Request):
|
||||
network = ipaddress.ip_network(proxy)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to parse trusted network: {proxy}")
|
||||
continue
|
||||
trusted_proxies.append(network)
|
||||
|
||||
# return the first remote address that is not trusted
|
||||
for addr in route:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid address in X-Forwarded-For header")
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||
trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
@@ -473,12 +474,11 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
# X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
|
||||
# preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
|
||||
zone: str | None = "all"
|
||||
zones: str | None = "all"
|
||||
limit: int | None = 100
|
||||
offset: int | None = Field(0, ge=0)
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
time_range: str | None = DEFAULT_TIME_RANGE
|
||||
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
deprecated=True,
|
||||
)
|
||||
limit: int | None = 50
|
||||
offset: int | None = Field(0, ge=0)
|
||||
cameras: str | None = "all"
|
||||
labels: str | None = "all"
|
||||
sub_labels: str | None = "all"
|
||||
|
||||
+11
-2
@@ -129,6 +129,7 @@ def events(
|
||||
zones = zone
|
||||
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
after = params.after
|
||||
before = params.before
|
||||
time_range = params.time_range
|
||||
@@ -361,11 +362,15 @@ def events(
|
||||
else:
|
||||
order_by = Event.start_time.desc()
|
||||
|
||||
# offset paging needs a stable order when scores or speeds tie
|
||||
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
|
||||
|
||||
events = (
|
||||
Event.select(*selected_columns)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(order_by)
|
||||
.order_by(order_by, *tiebreaker)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
@@ -534,6 +539,7 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -840,6 +846,9 @@ def events_search(
|
||||
if search_results:
|
||||
events_query = events_query.where(Event.id << list(search_results.keys()))
|
||||
|
||||
# sorts below are stable, so this orders ties for offset paging
|
||||
events_query = events_query.order_by(Event.id)
|
||||
|
||||
# Fetch events and process them in a single pass
|
||||
processed_events = []
|
||||
for event in events_query.dicts():
|
||||
@@ -897,7 +906,7 @@ def events_search(
|
||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||
|
||||
# Limit the number of events returned
|
||||
processed_events = processed_events[:limit]
|
||||
processed_events = processed_events[offset:][:limit]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
+20
-11
@@ -44,6 +44,22 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
def get_label_clause(label: str, include_audio: bool = True):
|
||||
"""Build a clause matching a label within a review segment's data.
|
||||
|
||||
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
|
||||
so that variant is matched as well.
|
||||
"""
|
||||
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
|
||||
)
|
||||
|
||||
if include_audio:
|
||||
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
|
||||
|
||||
return clause
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
@@ -93,10 +109,7 @@ async def review(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
label_clauses.append(get_label_clause(label))
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
if zones != "all":
|
||||
@@ -239,10 +252,7 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
label_clauses.append(get_label_clause(label))
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
if zones != "all":
|
||||
# use matching so segments with multiple zones
|
||||
@@ -340,9 +350,8 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
||||
)
|
||||
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
# Find the time range of available data
|
||||
|
||||
@@ -104,12 +104,13 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
all_objects.extend(new_objects)
|
||||
@@ -234,12 +235,13 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
new_detections = new_activity[camera].get("detections", [])
|
||||
if self.compare_audio_activity(camera, new_detections, now):
|
||||
|
||||
@@ -782,7 +782,9 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -799,7 +801,9 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -814,7 +818,9 @@ class Dispatcher:
|
||||
def _on_global_notification_command(self, payload: str) -> None:
|
||||
"""Callback for global notification topic."""
|
||||
if payload != "ON" and payload != "OFF":
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
notification_settings = self.config.notifications
|
||||
|
||||
@@ -176,6 +176,7 @@ class LicensePlateProcessingMixin:
|
||||
"""
|
||||
input_shape = [3, 48, 320]
|
||||
num_images = len(images)
|
||||
outputs: list[np.ndarray] = []
|
||||
|
||||
for index in range(0, num_images, self.batch_size):
|
||||
input_h, input_w = input_shape[1], input_shape[2]
|
||||
@@ -195,11 +196,11 @@ class LicensePlateProcessingMixin:
|
||||
norm_image = norm_image[np.newaxis, :]
|
||||
norm_images.append(norm_image)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
try:
|
||||
outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
|
||||
+76
-15
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import regex
|
||||
from peewee import DatabaseError
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
self.load_vec_extension: bool = load_vec_extension
|
||||
# no extension necessary, sqlite will load correctly for each platform
|
||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||
self.upsert_lock = threading.Lock()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
|
||||
conn.create_function("REGEXP", 2, regexp)
|
||||
|
||||
def execute_write(self, sql: str, params: Any = None) -> None:
|
||||
"""Run a write and wait for it, so that failures are raised here.
|
||||
|
||||
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
|
||||
stores any exception on the cursor it returns, so callers that ignore
|
||||
that cursor never learn the write failed.
|
||||
"""
|
||||
self.execute_sql(sql, params).fetchall()
|
||||
|
||||
def _table_exists(self, table: str) -> bool:
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
|
||||
"""Delete embeddings for the given events, if the table exists.
|
||||
|
||||
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
return
|
||||
|
||||
# the embeddings tables are only created once semantic search has run
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
|
||||
if cursor.fetchone() is None:
|
||||
if not self._table_exists(table):
|
||||
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||
return
|
||||
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
|
||||
# callers treat cleanup as best effort, so log rather than propagate
|
||||
try:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
except DatabaseError:
|
||||
logger.exception("Failed to delete embeddings from %s", table)
|
||||
|
||||
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_descriptions", event_ids)
|
||||
|
||||
def _restore_vec_info_table(self, table: str) -> None:
|
||||
"""Recreate the _info shadow table a legacy vec0 table is missing.
|
||||
|
||||
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
|
||||
table is destroyed, so tables written by Frigate 0.17 and earlier fail
|
||||
to drop. An empty stub is enough, and leaving it unseeded keeps the
|
||||
table reading as pre-0.1.10 if the drop does not follow.
|
||||
"""
|
||||
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
|
||||
return
|
||||
|
||||
logger.debug("Restoring the %s_info shadow table before dropping", table)
|
||||
self.execute_write(
|
||||
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
|
||||
)
|
||||
|
||||
def drop_embeddings_tables(self) -> None:
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_descriptions;
|
||||
""")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_thumbnails;
|
||||
""")
|
||||
for table in ("vec_descriptions", "vec_thumbnails"):
|
||||
self._restore_vec_info_table(table)
|
||||
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
||||
|
||||
def create_embeddings_tables(self) -> None:
|
||||
"""Create vec0 virtual table for embeddings"""
|
||||
self.execute_sql("""
|
||||
self.execute_write("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
self.execute_sql("""
|
||||
self.execute_write("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
description_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
|
||||
def upsert_embeddings(
|
||||
self, table: str, column: str, embeddings: dict[str, bytes]
|
||||
) -> None:
|
||||
"""Write embeddings for the given event ids, replacing any that exist.
|
||||
|
||||
vec0 implements neither REPLACE nor UPSERT, so rows that are already
|
||||
there have to be deleted first.
|
||||
"""
|
||||
if not embeddings:
|
||||
return
|
||||
|
||||
event_ids = list(embeddings.keys())
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
params: list[Any] = []
|
||||
|
||||
for event_id in event_ids:
|
||||
params.extend((event_id, embeddings[event_id]))
|
||||
|
||||
values = ", ".join(["(?, ?)"] * len(event_ids))
|
||||
|
||||
# reindexing and live embedding run on separate threads, and each write
|
||||
# is queued separately, so the delete and the insert have to be held
|
||||
# together or an interleaved pair fails on the vec0 primary key
|
||||
with self.upsert_lock:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
self.execute_write(
|
||||
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
|
||||
)
|
||||
|
||||
@@ -6,9 +6,10 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist, IntegrityError
|
||||
from peewee import DatabaseError, DoesNotExist, IntegrityError
|
||||
from PIL import Image
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -207,12 +208,10 @@ class Embeddings:
|
||||
embedding = self.vision_embedding([thumbnail])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails",
|
||||
"thumbnail_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
)
|
||||
|
||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -251,19 +250,12 @@ class Embeddings:
|
||||
embeddings = self.vision_embedding(valid_thumbs)
|
||||
|
||||
if upsert:
|
||||
items = []
|
||||
items = {}
|
||||
for i in range(len(valid_ids)):
|
||||
items.append(valid_ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
items[valid_ids[i]] = serialize(embeddings[i])
|
||||
self.image_eps.update()
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
|
||||
items,
|
||||
)
|
||||
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.image_inference_speed.update(duration / len(valid_ids))
|
||||
@@ -277,12 +269,10 @@ class Embeddings:
|
||||
embedding = self.text_embedding([description])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions",
|
||||
"description_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -302,19 +292,14 @@ class Embeddings:
|
||||
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
items = []
|
||||
items = {}
|
||||
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
items[ids[i]] = serialize(embeddings[i])
|
||||
self.text_eps.update()
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", items
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -322,6 +307,17 @@ class Embeddings:
|
||||
return embeddings
|
||||
|
||||
def reindex(self) -> None:
|
||||
"""Rebuild every tracked object embedding from scratch."""
|
||||
totals: dict[str, Any] = {"status": "indexing"}
|
||||
|
||||
try:
|
||||
self._reindex(totals)
|
||||
except DatabaseError:
|
||||
logger.exception("Unable to reindex tracked object embeddings")
|
||||
totals["status"] = "failed"
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
def _reindex(self, totals: dict[str, Any]) -> None:
|
||||
logger.info("Indexing tracked object embeddings...")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
@@ -346,17 +342,24 @@ class Embeddings:
|
||||
batch_size = 32
|
||||
current_page = 1
|
||||
|
||||
totals = {
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1 if total_events < batch_size else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
totals.update(
|
||||
{
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1
|
||||
if total_events < batch_size
|
||||
else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
)
|
||||
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
# a single batch sends no progress, so the first message shows it nearly done
|
||||
totals["processed_objects"] = 0
|
||||
|
||||
events = (
|
||||
Event.select()
|
||||
.order_by(Event.start_time.desc())
|
||||
|
||||
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
|
||||
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
Event.delete().where(Event.id << chunk).execute()
|
||||
Timeline.delete().where(Timeline.source_id << chunk).execute()
|
||||
|
||||
# embeddings are always cleaned up, even when semantic search
|
||||
# is disabled, so that they don't outlive their events
|
||||
|
||||
@@ -126,8 +126,8 @@ PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
||||
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
||||
|
||||
@@ -1397,9 +1397,6 @@ class PtzAutoTracker:
|
||||
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
|
||||
@@ -1538,8 +1535,6 @@ class PtzAutoTracker:
|
||||
# returns camera to preset after timeout when tracking is over
|
||||
autotracker_config = self.config.cameras[camera].onvif.autotracking
|
||||
|
||||
if not self.autotracker_init[camera]:
|
||||
self._autotracker_setup(self.config.cameras[camera], camera)
|
||||
# regularly update camera status
|
||||
if not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
@@ -966,6 +966,10 @@ class OnvifController:
|
||||
}
|
||||
else:
|
||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||
self.failed_cams[camera_name] = {
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during ONVIF initialization for {camera_name}: {e}"
|
||||
|
||||
@@ -1375,7 +1375,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if preview.end_time > self.end_time:
|
||||
playlist_lines.append(
|
||||
f"outpoint {int(preview.end_time - self.end_time)}"
|
||||
f"outpoint {int(self.end_time - preview.start_time)}"
|
||||
)
|
||||
|
||||
ffmpeg_input = (
|
||||
|
||||
@@ -691,6 +691,22 @@ class RecordingMaintainer(threading.Thread):
|
||||
if start_resolved is not None:
|
||||
start_resolved.set()
|
||||
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments, every
|
||||
# retention decision below depends on complete stats for the segment
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
< end_time
|
||||
):
|
||||
return None
|
||||
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# sub's alerts/detections carry the retain mode directly, unlike
|
||||
@@ -718,43 +734,28 @@ class RecordingMaintainer(threading.Thread):
|
||||
# we should first just check if this segment matches that
|
||||
# and avoid any DB calls
|
||||
if highest is not None:
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
record_mode = (
|
||||
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
>= end_time
|
||||
):
|
||||
record_mode = (
|
||||
RetainModeEnum.all
|
||||
if highest == "continuous"
|
||||
else RetainModeEnum.motion
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
)
|
||||
|
||||
# we fell through the continuous / motion check, so we need to check the review items
|
||||
# if the cached segment overlaps with the review items:
|
||||
@@ -816,10 +817,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
# continuous/motion retention (either disabled or segment_stats said
|
||||
# discard), so waiting longer just fills the cache.
|
||||
else:
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
retain_cutoff = datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time - record_config.event_pre_capture
|
||||
).astimezone(datetime.UTC)
|
||||
|
||||
+135
-55
@@ -66,6 +66,9 @@ class PendingReviewSegment:
|
||||
self.zones = zones
|
||||
self.audio = audio
|
||||
self.classification_state_changes: list[dict[str, Any]] = []
|
||||
# detection-level activity after the last alert activity, split by the
|
||||
# detection cutoff, these are published when the alert is cut off
|
||||
self.pending_detections: list[PendingReviewSegment] = []
|
||||
self.thumb_time: float | None = None
|
||||
self.last_alert_time: float | None = None
|
||||
self.last_detection_time: float = frame_time
|
||||
@@ -83,6 +86,20 @@ class PendingReviewSegment:
|
||||
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
|
||||
)
|
||||
|
||||
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
|
||||
"""Add a tracked object's label, sub label, and zones to the segment."""
|
||||
if not obj["sub_label"]:
|
||||
self.detections[obj["id"]] = obj["label"]
|
||||
elif obj["sub_label"][0] in attributes:
|
||||
self.detections[obj["id"]] = obj["sub_label"][0]
|
||||
else:
|
||||
self.detections[obj["id"]] = f"{obj['label']}-verified"
|
||||
self.sub_labels[obj["id"]] = obj["sub_label"][0]
|
||||
|
||||
for zone in obj["current_zones"]:
|
||||
if zone not in self.zones:
|
||||
self.zones.append(zone)
|
||||
|
||||
def update_frame(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
@@ -435,9 +452,29 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = now
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(segment, None)
|
||||
return end_time
|
||||
return None
|
||||
|
||||
def _publish_pending_detections(
|
||||
self, segment: PendingReviewSegment, ongoing_since: float | None
|
||||
) -> None:
|
||||
"""Publish the detections held while an ended alert was active.
|
||||
|
||||
A detection with activity after ongoing_since stays open, only the
|
||||
latest can. With None every detection is ended, this does not read the
|
||||
camera config since a removed camera is no longer in it.
|
||||
"""
|
||||
for pending in segment.pending_detections:
|
||||
self._activate_segment(pending)
|
||||
self._publish_segment_start(pending)
|
||||
|
||||
if ongoing_since is None or pending.last_detection_time < ongoing_since:
|
||||
self._publish_segment_end(pending, pending.get_data(False))
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
|
||||
"""Determine the review severity for a manual event label.
|
||||
|
||||
@@ -470,6 +507,55 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self.indefinite_events.pop(camera, None)
|
||||
self.recent_classification_state_changes.pop(camera, None)
|
||||
|
||||
def _track_pending_detection(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
camera_config: CameraConfig,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Hold detection-level activity seen after an alert's last alert
|
||||
activity, starting a separate detection when the gap since the previous
|
||||
activity exceeds the detection cutoff."""
|
||||
pending = segment.pending_detections[-1] if segment.pending_detections else None
|
||||
|
||||
if pending is None or frame_time > (
|
||||
pending.last_detection_time + camera_config.review.detections.cutoff_time
|
||||
):
|
||||
pending = PendingReviewSegment(
|
||||
segment.camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
{},
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=[],
|
||||
)
|
||||
segment.pending_detections.append(pending)
|
||||
|
||||
pending.last_detection_time = frame_time
|
||||
|
||||
for obj in objects:
|
||||
pending.add_object(obj, self.config.all_attributes)
|
||||
|
||||
if len(objects) <= pending.frame_active_count:
|
||||
return
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if yuv_frame is None:
|
||||
logger.debug(f"Failed to get frame {frame_name} from SHM")
|
||||
return
|
||||
|
||||
pending.update_frame(camera_config, yuv_frame, objects)
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
@@ -505,6 +591,21 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
should_update_state = True
|
||||
should_update_image = True
|
||||
|
||||
# alert activity resumed, so the pending detection activity
|
||||
# falls within this alert
|
||||
for pending in segment.pending_detections:
|
||||
segment.detections.update(pending.detections)
|
||||
segment.sub_labels.update(pending.sub_labels)
|
||||
|
||||
for zone in pending.zones:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
|
||||
Path(pending.frame_path).unlink(missing_ok=True)
|
||||
should_update_state = True
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
if activity.has_activity_category(SeverityEnum.detection):
|
||||
if (
|
||||
segment.last_detection_time is None
|
||||
@@ -512,6 +613,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
segment.last_detection_time = frame_time
|
||||
|
||||
pending_objects: list[dict[str, Any]] = []
|
||||
|
||||
for object in activity.get_all_objects():
|
||||
# Alert-level objects should always be added (they extend/upgrade the segment)
|
||||
# Detection-level objects should only be added if:
|
||||
@@ -522,23 +625,22 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if not is_alert_object and segment.severity == SeverityEnum.alert:
|
||||
# This is a detection-level object
|
||||
if (
|
||||
segment.last_alert_time is not None
|
||||
and frame_time > segment.last_alert_time
|
||||
):
|
||||
pending_objects.append(object)
|
||||
|
||||
# Only add if it started during the alert's active period
|
||||
if object["start_time"] > segment.last_alert_time:
|
||||
continue
|
||||
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in self.config.all_attributes:
|
||||
segment.detections[object["id"]] = object["sub_label"][0]
|
||||
else:
|
||||
segment.detections[object["id"]] = f"{object['label']}-verified"
|
||||
segment.sub_labels[object["id"]] = object["sub_label"][0]
|
||||
segment.add_object(object, self.config.all_attributes)
|
||||
|
||||
# keep zones up to date
|
||||
if len(object["current_zones"]) > 0:
|
||||
for zone in object["current_zones"]:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
if pending_objects:
|
||||
self._track_pending_detection(
|
||||
segment, camera_config, frame_name, frame_time, pending_objects
|
||||
)
|
||||
|
||||
if len(activity.get_all_objects()) > segment.frame_active_count:
|
||||
should_update_state = True
|
||||
@@ -590,50 +692,28 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
needs_new_detection = (
|
||||
segment.last_detection_time > segment.last_alert_time
|
||||
and (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
)
|
||||
> frame_time
|
||||
)
|
||||
last_detection_time = segment.last_detection_time
|
||||
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
|
||||
if needs_new_detection:
|
||||
new_detections: dict[str, str] = {}
|
||||
new_zones = set()
|
||||
|
||||
for o in activity.categorized_objects["detections"]:
|
||||
new_detections[o["id"]] = o["label"]
|
||||
new_zones.update(o["current_zones"])
|
||||
|
||||
if new_detections:
|
||||
new_segment = PendingReviewSegment(
|
||||
segment.camera,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
self._activate_segment(new_segment)
|
||||
self._publish_segment_start(new_segment)
|
||||
new_segment.last_detection_time = last_detection_time
|
||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||
# detection-level activity must not keep an alert open, it continues
|
||||
# in a new detection segment once the alert is cut off
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(
|
||||
segment, frame_time - camera_config.review.detections.cutoff_time
|
||||
)
|
||||
elif (
|
||||
not has_activity
|
||||
and segment.severity == SeverityEnum.detection
|
||||
and frame_time
|
||||
> (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
|
||||
def check_if_new_segment(
|
||||
self,
|
||||
|
||||
@@ -248,8 +248,9 @@ def stats_snapshot(
|
||||
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
for name, camera_stats in list(camera_metrics.items()):
|
||||
camera_config = config.cameras.get(name)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
@@ -266,7 +267,7 @@ def stats_snapshot(
|
||||
# Calculate connection quality based on current state
|
||||
# This is computed at stats-collection time so offline cameras
|
||||
# correctly show as unusable rather than excellent
|
||||
expected_fps = config.cameras[name].detect.fps
|
||||
expected_fps = camera_config.detect.fps
|
||||
current_fps = camera_stats.camera_fps.value
|
||||
reconnects = camera_stats.reconnects_last_hour.value
|
||||
stalls = camera_stats.stalls_last_hour.value
|
||||
@@ -299,7 +300,7 @@ def stats_snapshot(
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"detection_enabled": camera_config.detect.enabled,
|
||||
"pid": pid,
|
||||
"capture_pid": capture_pid,
|
||||
"ffmpeg_pid": ffmpeg_pid,
|
||||
|
||||
@@ -145,7 +145,11 @@ class BaseTestHttp(unittest.TestCase):
|
||||
pass
|
||||
|
||||
def create_app(
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
self,
|
||||
stats=None,
|
||||
event_metadata_publisher=None,
|
||||
notice_registry=None,
|
||||
enforce_default_admin=False,
|
||||
):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
@@ -160,7 +164,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
enforce_default_admin=enforce_default_admin,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,25 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["front_door"]["usage_percent"] == 25.0
|
||||
|
||||
def test_camera_name_collision_keeps_admin_default(self):
|
||||
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app(enforce_default_admin=True)
|
||||
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
assert client.get("/faces", headers=viewer).status_code == 403
|
||||
assert client.get("/faces").status_code == 200
|
||||
assert (
|
||||
client.post("/faces/train/person/classify", headers=viewer).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
# Camera routes for the same name stay reachable by viewers
|
||||
response = client.get("/faces/recordings/summary", headers=viewer)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
|
||||
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert events[0]["id"] == id
|
||||
assert events[1]["id"] == id2
|
||||
|
||||
def test_get_event_list_offset_pages_score_sort(self):
|
||||
now = datetime.now().timestamp()
|
||||
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, score in enumerate(scores):
|
||||
super().insert_mock_event(
|
||||
f"event-{i}", start_time=now + i, data={"score": score}
|
||||
)
|
||||
|
||||
params = {"sort": "score_desc"}
|
||||
full = [e["id"] for e in client.get("/events", params=params).json()]
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 2, 4)
|
||||
for e in client.get(
|
||||
"/events", params={**params, "limit": 2, "offset": offset}
|
||||
).json()
|
||||
]
|
||||
|
||||
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||
assert paged == full
|
||||
|
||||
def test_get_event_list_match_multilingual_attribute(self):
|
||||
event_id = "123456.zh"
|
||||
attribute = "中文标签"
|
||||
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert len(events) == 1
|
||||
assert events[0]["id"] == event_id
|
||||
|
||||
def test_events_search_offset_pages_score_sort(self):
|
||||
now = datetime.now().timestamp()
|
||||
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||
ids = [f"event-{i}" for i in range(len(scores))]
|
||||
mock_embeddings = Mock()
|
||||
mock_embeddings.search_thumbnail.return_value = [
|
||||
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
|
||||
]
|
||||
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
self.app.embeddings = mock_embeddings
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, score in enumerate(scores):
|
||||
super().insert_mock_event(
|
||||
ids[i], start_time=now + i, data={"score": score}
|
||||
)
|
||||
|
||||
params = {
|
||||
"search_type": "similarity",
|
||||
"event_id": ids[0],
|
||||
"sort": "score_desc",
|
||||
}
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 2, 4)
|
||||
for e in client.get(
|
||||
"/events/search",
|
||||
params={**params, "limit": 2, "offset": offset},
|
||||
).json()
|
||||
]
|
||||
|
||||
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||
|
||||
def test_events_search_offset_pages_orders_ties_by_id(self):
|
||||
now = datetime.now().timestamp()
|
||||
ids = ["event-c", "event-a", "event-b"]
|
||||
mock_embeddings = Mock()
|
||||
mock_embeddings.search_thumbnail.return_value = [
|
||||
(event_id, 0.1) for event_id in ids
|
||||
]
|
||||
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
self.app.embeddings = mock_embeddings
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, event_id in enumerate(ids):
|
||||
super().insert_mock_event(
|
||||
event_id, start_time=now + i, data={"score": 0.8}
|
||||
)
|
||||
|
||||
for sort in ("score_desc", "relevance"):
|
||||
params = {
|
||||
"search_type": "similarity",
|
||||
"event_id": ids[0],
|
||||
"sort": sort,
|
||||
}
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 1, 2)
|
||||
for e in client.get(
|
||||
"/events/search",
|
||||
params={**params, "limit": 1, "offset": offset},
|
||||
).json()
|
||||
]
|
||||
|
||||
assert paged == ["event-a", "event-b", "event-c"]
|
||||
|
||||
def test_event_list_rejects_negative_offset(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get("/events", params={"offset": -5})
|
||||
assert response.status_code == 422
|
||||
|
||||
response = client.get(
|
||||
"/events/search",
|
||||
params={"query": "car", "offset": -5},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
||||
mock_embeddings = Mock()
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
|
||||
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert len(response_json) == 1
|
||||
assert response_json[0]["id"] == id_reviewed
|
||||
|
||||
def test_get_review_with_label_filter_matches_verified(self):
|
||||
"""Test that a label filter also matches the `-verified` variant."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.person", now, now + 2, data={"objects": ["person"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.car", now, now + 2, data={"objects": ["car"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "person",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert {r["id"] for r in response_json} == {
|
||||
"123456.person",
|
||||
"123456.verified",
|
||||
}
|
||||
|
||||
def test_get_review_with_label_filter_does_not_match_prefix(self):
|
||||
"""Test that a label filter does not match labels that only share a prefix."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "car",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 0
|
||||
|
||||
def test_get_review_with_audio_label_filter(self):
|
||||
"""Test that a label filter still matches audio labels."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.audio", now, now + 2, data={"audio": ["speech"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "speech",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert len(response_json) == 1
|
||||
assert response_json[0]["id"] == "123456.audio"
|
||||
|
||||
####################################################################################################################
|
||||
################################### GET /review/summary Endpoint #################################################
|
||||
####################################################################################################################
|
||||
def test_get_review_summary_label_filter_matches_verified(self):
|
||||
"""Test that the summary label filter also matches the `-verified` variant."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.verified", data={"objects": ["person-verified"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
|
||||
)
|
||||
|
||||
params = {
|
||||
"cameras": "front_door",
|
||||
"labels": "person",
|
||||
"zones": "all",
|
||||
"timezone": "utc",
|
||||
}
|
||||
response = client.get("/review/summary", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert response_json["last24Hours"]["total_alert"] == 1
|
||||
assert response_json["last24Hours"]["total_detection"] == 0
|
||||
|
||||
today_formatted = datetime.today().strftime("%Y-%m-%d")
|
||||
assert response_json[today_formatted]["total_alert"] == 1
|
||||
assert response_json[today_formatted]["total_detection"] == 0
|
||||
|
||||
def test_get_review_summary_all_filters(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
|
||||
@@ -257,6 +257,31 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"])
|
||||
self.assertNotIn("detectors", migrated)
|
||||
|
||||
def test_top_level_changes_survive_later_steps(self):
|
||||
# 0.16 adds detect and 0.17 splits genai, both at the top level
|
||||
migrated = self._migrate(
|
||||
"mqtt:\n"
|
||||
" enabled: false\n"
|
||||
"genai:\n"
|
||||
" provider: ollama\n"
|
||||
" model: llava\n"
|
||||
" prompt: describe it\n"
|
||||
"cameras: {}\n"
|
||||
"version: 0.15-1\n"
|
||||
)
|
||||
|
||||
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
|
||||
self.assertTrue(migrated["detect"]["enabled"])
|
||||
self.assertEqual(migrated["objects"]["genai"], {"prompt": "describe it"})
|
||||
self.assertEqual(
|
||||
migrated["genai"]["default"],
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "llava",
|
||||
"roles": ["descriptions", "chat"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_a_migrated_config_is_left_alone(self):
|
||||
migrated = self._migrate(
|
||||
"mqtt:\n"
|
||||
|
||||
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
|
||||
sys.modules[name] = MagicMock()
|
||||
|
||||
# Now import the class under test
|
||||
from frigate.config import FrigateConfig # noqa: E402
|
||||
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
|
||||
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
|
||||
|
||||
# Restore original modules (or remove mock if there was no original)
|
||||
@@ -124,6 +124,80 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_called_once_with(cache_path)
|
||||
|
||||
async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
|
||||
# Regression: a segment overlapping an active_objects review must not
|
||||
# be dropped while detection metadata lags behind the segment end,
|
||||
# the missing frames may hold the active objects (or continuous
|
||||
# retention would keep it anyway).
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
|
||||
camera_config = MagicMock()
|
||||
camera_config.record.enabled = True
|
||||
camera_config.record.continuous.days = 7
|
||||
camera_config.record.motion.days = 0
|
||||
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
|
||||
camera_config.record.get_review_pre_capture.return_value = 5
|
||||
camera_config.record.get_review_post_capture.return_value = 5
|
||||
config.cameras = {"test_cam": camera_config}
|
||||
|
||||
stop_event = MagicMock()
|
||||
maintainer = RecordingMaintainer(config, stop_event)
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
start_time = now - datetime.timedelta(seconds=20)
|
||||
end_time = now - datetime.timedelta(seconds=10)
|
||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
||||
}
|
||||
# Metadata has only reached partway into the segment.
|
||||
maintainer.object_recordings_info["test_cam"] = [
|
||||
(end_time.timestamp() - 8, [], [], [])
|
||||
]
|
||||
maintainer.audio_recordings_info["test_cam"] = []
|
||||
|
||||
maintainer.drop_segment = MagicMock()
|
||||
maintainer.move_segment = AsyncMock(return_value=None)
|
||||
maintainer.recordings_publisher = MagicMock()
|
||||
|
||||
review = MagicMock()
|
||||
review.severity = "alert"
|
||||
review.start_time = start_time.timestamp() - 30
|
||||
review.end_time = None
|
||||
|
||||
result = await maintainer.validate_and_move_segment(
|
||||
"test_cam",
|
||||
reviews=[review],
|
||||
recording={
|
||||
"start_time": start_time,
|
||||
"cache_path": cache_path,
|
||||
"stream_type": "main",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_not_called()
|
||||
maintainer.move_segment.assert_not_awaited()
|
||||
|
||||
# Once metadata passes the segment end, continuous retention keeps it.
|
||||
maintainer.object_recordings_info["test_cam"].append(
|
||||
(now.timestamp(), [], [], [])
|
||||
)
|
||||
|
||||
await maintainer.validate_and_move_segment(
|
||||
"test_cam",
|
||||
reviews=[review],
|
||||
recording={
|
||||
"start_time": start_time,
|
||||
"cache_path": cache_path,
|
||||
"stream_type": "main",
|
||||
},
|
||||
)
|
||||
|
||||
maintainer.drop_segment.assert_not_called()
|
||||
maintainer.move_segment.assert_awaited_once()
|
||||
|
||||
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
config.cameras = {}
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
"""Tests for how tracked objects flow into review segments.
|
||||
|
||||
Frames are fed through the maintainer's run loop with mocked subscribers so
|
||||
the dispatch between starting and updating segments is exercised, and the
|
||||
published review updates are checked for the expected alert, detection, or
|
||||
lack of a review item.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.detections_updater import DetectionTypeEnum
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.review.maintainer import ReviewSegmentMaintainer
|
||||
|
||||
CAMERA = "front_door"
|
||||
|
||||
BASE_CONFIG = """
|
||||
mqtt:
|
||||
enabled: False
|
||||
record:
|
||||
enabled: True
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://10.0.0.1:554/video
|
||||
roles:
|
||||
- detect
|
||||
detect:
|
||||
width: 640
|
||||
height: 360
|
||||
fps: 5
|
||||
zones:
|
||||
driveway:
|
||||
coordinates: 0,0,320,0,320,360,0,360
|
||||
yard:
|
||||
coordinates: 320,0,640,0,640,360,320,360
|
||||
%s
|
||||
"""
|
||||
|
||||
|
||||
class ReviewFlowTestCase(unittest.TestCase):
|
||||
review_config = ""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.clips_dir = clips_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(clips_dir.cleanup)
|
||||
clips_patch = patch("frigate.review.maintainer.CLIPS_DIR", clips_dir.name)
|
||||
clips_patch.start()
|
||||
self.addCleanup(clips_patch.stop)
|
||||
|
||||
self.maintainer = self._make_maintainer(self.review_config)
|
||||
|
||||
def _make_maintainer(self, review_config: str) -> ReviewSegmentMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
|
||||
sockets and shared memory)."""
|
||||
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||
threading.Thread.__init__(maintainer)
|
||||
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
|
||||
maintainer.active_review_segments = {}
|
||||
maintainer.indefinite_events = {}
|
||||
maintainer.recent_classification_state_changes = {}
|
||||
maintainer.requestor = MagicMock()
|
||||
maintainer.review_publisher = MagicMock()
|
||||
maintainer.config_subscriber = MagicMock()
|
||||
maintainer.config_subscriber.check_for_updates.return_value = {}
|
||||
maintainer.detection_subscriber = MagicMock()
|
||||
maintainer.frame_manager = MagicMock()
|
||||
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
|
||||
shape, np.uint8
|
||||
)
|
||||
return maintainer
|
||||
|
||||
@property
|
||||
def stationary_threshold(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].detect.stationary.threshold
|
||||
|
||||
@property
|
||||
def alert_cutoff(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].review.alerts.cutoff_time
|
||||
|
||||
@property
|
||||
def detection_cutoff(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].review.detections.cutoff_time
|
||||
|
||||
def tracked(
|
||||
self,
|
||||
obj_id: str,
|
||||
label: str,
|
||||
frame_time: float,
|
||||
*,
|
||||
start_time: float = 0,
|
||||
zones: list[str] | None = None,
|
||||
stationary: bool = False,
|
||||
loitering: bool = False,
|
||||
moved: bool = True,
|
||||
false_positive: bool = False,
|
||||
sub_label: tuple[str, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a tracked object as published by the object processor."""
|
||||
return {
|
||||
"id": obj_id,
|
||||
"label": label,
|
||||
"sub_label": sub_label,
|
||||
"frame_time": frame_time,
|
||||
"start_time": start_time,
|
||||
"motionless_count": self.stationary_threshold if stationary else 0,
|
||||
"pending_loitering": loitering,
|
||||
"position_changes": 1 if moved else 0,
|
||||
"false_positive": false_positive,
|
||||
"current_zones": zones or [],
|
||||
"box": (100, 100, 200, 200),
|
||||
}
|
||||
|
||||
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
|
||||
"""Run the maintainer loop over the given (frame_time, objects) frames."""
|
||||
queue = [
|
||||
(
|
||||
DetectionTypeEnum.video.value,
|
||||
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
|
||||
)
|
||||
for frame_time, objects in frames
|
||||
]
|
||||
self.maintainer.stop_event = threading.Event()
|
||||
|
||||
def next_update(timeout: float) -> Any:
|
||||
if not queue:
|
||||
self.maintainer.stop_event.set()
|
||||
return None
|
||||
|
||||
return queue.pop(0)
|
||||
|
||||
self.maintainer.detection_subscriber.check_for_update.side_effect = next_update
|
||||
self.maintainer.run()
|
||||
|
||||
def reviews(self) -> list[dict[str, Any]]:
|
||||
"""All review updates published on the reviews topic."""
|
||||
return [
|
||||
json.loads(c.args[1])
|
||||
for c in self.maintainer.requestor.send_data.call_args_list
|
||||
if c.args[0] == "reviews"
|
||||
]
|
||||
|
||||
def review_summary(self) -> list[tuple[str, str]]:
|
||||
return [(r["type"], r["after"]["severity"]) for r in self.reviews()]
|
||||
|
||||
def non_update_summary(self) -> list[tuple[str, str]]:
|
||||
return [(t, s) for t, s in self.review_summary() if t != "update"]
|
||||
|
||||
def thumbnails(self) -> set[str]:
|
||||
"""Names of the review thumbnails on disk."""
|
||||
return {t.name for t in Path(self.clips_dir.name, "review").iterdir()}
|
||||
|
||||
def assert_no_review(self) -> None:
|
||||
self.assertEqual(self.reviews(), [])
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
|
||||
class TestReviewSeverity(ReviewFlowTestCase):
|
||||
def test_alert_label_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["person"])
|
||||
|
||||
def test_non_alert_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
|
||||
|
||||
def test_alert_and_detection_objects_create_single_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertCountEqual(
|
||||
self.reviews()[0]["after"]["data"]["objects"], ["person", "dog"]
|
||||
)
|
||||
|
||||
def test_no_objects_creates_nothing(self) -> None:
|
||||
self.feed((1, []), (2, []))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestIgnoredObjects(ReviewFlowTestCase):
|
||||
def test_stationary_object_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, stationary=True)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_stationary_loitering_object_creates_alert(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, stationary=True, loitering=True)])
|
||||
)
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_object_that_never_moved_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, moved=False)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_object_not_detected_in_current_frame_creates_nothing(self) -> None:
|
||||
self.feed((2, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_false_positive_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, false_positive=True)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestAlertRequiredZones(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
required_zones: driveway
|
||||
"""
|
||||
|
||||
def test_alert_label_in_required_zone_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["zones"], ["driveway"])
|
||||
|
||||
def test_alert_label_outside_required_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["yard"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_alert_label_in_no_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_detection_upgrades_to_alert_when_object_enters_required_zone(
|
||||
self,
|
||||
) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, zones=["yard"])]),
|
||||
(2, [self.tracked("p1", "person", 2, zones=["driveway"])]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.review_summary(), [("new", "detection"), ("update", "alert")]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.reviews()[0]["after"]["id"], self.reviews()[1]["after"]["id"]
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionRequiredZones(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
required_zones: yard
|
||||
"""
|
||||
|
||||
def test_detection_label_in_required_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1, zones=["yard"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_detection_label_outside_required_zone_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1, zones=["driveway"])]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_alert_label_ignores_detection_required_zones(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_activity_outside_required_zone_after_alert_is_not_held(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["driveway"])]),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].pending_detections, []
|
||||
)
|
||||
|
||||
self.feed((2 + self.alert_cutoff, []))
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
|
||||
)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
self.assertEqual(len(self.thumbnails()), 1)
|
||||
|
||||
def test_activity_inside_required_zone_after_alert_is_split_out(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["yard"])]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
# the dog is past the detection cutoff, so it is ended right away
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
detection = self.reviews()[-1]["after"]
|
||||
self.assertEqual(detection["data"]["objects"], ["dog"])
|
||||
self.assertEqual(detection["data"]["zones"], ["yard"])
|
||||
|
||||
|
||||
class TestDetectionLabels(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
labels:
|
||||
- dog
|
||||
"""
|
||||
|
||||
def test_listed_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_unlisted_label_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("c1", "cat", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_unlisted_object_is_left_out_of_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1), self.tracked("c1", "cat", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
|
||||
|
||||
|
||||
class TestAlertsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_alert_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
|
||||
class TestDetectionsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_alert_label_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_non_alert_label_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_activity_after_alert_is_not_held(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].pending_detections, []
|
||||
)
|
||||
|
||||
self.feed((2 + self.alert_cutoff, []))
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
|
||||
)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
self.assertEqual(len(self.thumbnails()), 1)
|
||||
|
||||
|
||||
class TestAlertsAndDetectionsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
detections:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_nothing_is_created(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestReviewLifecycle(ReviewFlowTestCase):
|
||||
def test_detection_upgrades_to_alert_when_alert_object_appears(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("d1", "dog", 1)]),
|
||||
(2, [self.tracked("d1", "dog", 2), self.tracked("p1", "person", 2)]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.review_summary(), [("new", "detection"), ("update", "alert")]
|
||||
)
|
||||
new, update = self.reviews()
|
||||
self.assertEqual(new["after"]["id"], update["after"]["id"])
|
||||
self.assertCountEqual(update["after"]["data"]["objects"], ["dog", "person"])
|
||||
|
||||
def test_alert_does_not_downgrade_when_only_detection_objects_remain(
|
||||
self,
|
||||
) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]),
|
||||
(2, [self.tracked("d1", "dog", 2)]),
|
||||
(3, [self.tracked("d1", "dog", 3)]),
|
||||
)
|
||||
|
||||
self.assertEqual({severity for _, severity in self.review_summary()}, {"alert"})
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].severity.value, "alert"
|
||||
)
|
||||
|
||||
def test_alert_stays_open_until_cutoff(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(1 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
self.assertIsNotNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_alert_ends_after_cutoff_at_last_alert_activity(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(5, [self.tracked("p1", "person", 5)]),
|
||||
(6, []),
|
||||
(5 + self.alert_cutoff + 1, []),
|
||||
)
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "alert")
|
||||
self.assertEqual(end["after"]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_ongoing_alert_activity_extends_alert(self) -> None:
|
||||
last_activity = 1 + self.alert_cutoff * 2
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(
|
||||
1 + self.alert_cutoff,
|
||||
[self.tracked("p1", "person", 1 + self.alert_cutoff)],
|
||||
),
|
||||
(last_activity, [self.tracked("p1", "person", last_activity)]),
|
||||
(last_activity + 1, []),
|
||||
)
|
||||
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].last_alert_time,
|
||||
last_activity,
|
||||
)
|
||||
|
||||
def test_detection_ends_after_cutoff_at_last_detection_activity(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("d1", "dog", 1)]),
|
||||
(5, [self.tracked("d1", "dog", 5)]),
|
||||
(5 + self.detection_cutoff, []),
|
||||
)
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
|
||||
self.feed((5 + self.detection_cutoff + 1, []))
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "detection")
|
||||
self.assertEqual(end["after"]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_stationary_object_does_not_extend_alert(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("c1", "car", 1)]),
|
||||
(2, [self.tracked("c1", "car", 2, stationary=True)]),
|
||||
(
|
||||
2 + self.alert_cutoff,
|
||||
[self.tracked("c1", "car", 2 + self.alert_cutoff, stationary=True)],
|
||||
),
|
||||
)
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["end_time"], 1)
|
||||
|
||||
def test_new_activity_after_end_creates_new_review(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(3 + self.alert_cutoff, [self.tracked("d1", "dog", 3 + self.alert_cutoff)]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
ids = {r["after"]["id"] for r in self.reviews() if r["type"] != "update"}
|
||||
self.assertEqual(len(ids), 2)
|
||||
|
||||
def test_alert_splits_into_detection_when_detection_activity_continues(
|
||||
self,
|
||||
) -> None:
|
||||
# the person leaves after the first frame while the dog keeps moving
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=1)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed(
|
||||
(
|
||||
1,
|
||||
[
|
||||
self.tracked("p1", "person", 1, start_time=1),
|
||||
self.tracked("d1", "dog", 1, start_time=1),
|
||||
],
|
||||
),
|
||||
*dog_frames,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
alert_end = next(r for r in self.reviews() if r["type"] == "end")
|
||||
self.assertEqual(alert_end["after"]["end_time"], 1)
|
||||
|
||||
detection = self.maintainer.active_review_segments[CAMERA]
|
||||
self.assertEqual(detection.severity.value, "detection")
|
||||
self.assertEqual(detection.start_time, 11)
|
||||
self.assertEqual(list(detection.detections.values()), ["dog"])
|
||||
|
||||
# the detection ends once the dog stops moving
|
||||
last_dog_time = dog_frames[-1][0]
|
||||
self.feed((last_dog_time + self.detection_cutoff + 1, []))
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "detection")
|
||||
self.assertEqual(end["after"]["id"], detection.id)
|
||||
self.assertEqual(end["after"]["end_time"], last_dog_time)
|
||||
|
||||
def test_detection_starting_after_alert_activity_is_split_out(self) -> None:
|
||||
# the dog only shows up after the person has left
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=11)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
alert_end = next(r for r in self.reviews() if r["type"] == "end")
|
||||
self.assertEqual(alert_end["after"]["end_time"], 1)
|
||||
self.assertEqual(alert_end["after"]["data"]["objects"], ["person"])
|
||||
|
||||
detection = self.maintainer.active_review_segments[CAMERA]
|
||||
self.assertEqual(detection.severity.value, "detection")
|
||||
self.assertEqual(detection.start_time, 11)
|
||||
self.assertEqual(list(detection.detections.values()), ["dog"])
|
||||
|
||||
def test_detection_leaving_before_alert_cutoff_gets_detection(self) -> None:
|
||||
# the dog comes and goes after the person left, all before the alert
|
||||
# cutoff, so nothing is active when the alert ends
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(11, [self.tracked("d1", "dog", 11, start_time=11)]),
|
||||
(21, [self.tracked("d1", "dog", 21, start_time=11)]),
|
||||
(31, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(22 + self.detection_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["end_time"], 1)
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual(ends[1]["start_time"], 11)
|
||||
self.assertEqual(ends[1]["end_time"], 21)
|
||||
|
||||
def test_detection_older_than_detection_cutoff_gets_detection(self) -> None:
|
||||
# the dog is gone longer than the detection cutoff by the time the
|
||||
# alert ends, its activity still needs a detection
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=3)]),
|
||||
(6, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(3 + self.alert_cutoff, []),
|
||||
)
|
||||
self.assertGreater(2 + self.alert_cutoff, 5 + self.detection_cutoff)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["end_time"], 1)
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual(ends[1]["start_time"], 3)
|
||||
self.assertEqual(ends[1]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_separate_detection_activity_after_alert_is_not_combined(self) -> None:
|
||||
# the dog and cat are seen further apart than the detection cutoff
|
||||
# while the alert is waiting to be cut off
|
||||
cat_time = 3 + self.detection_cutoff + 6
|
||||
self.assertLess(cat_time, 1 + self.alert_cutoff)
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
|
||||
(4, []),
|
||||
(cat_time, [self.tracked("c1", "cat", cat_time, start_time=cat_time)]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(cat_time + self.detection_cutoff + 1, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual((ends[1]["start_time"], ends[1]["end_time"]), (3, 3))
|
||||
self.assertEqual(ends[2]["data"]["objects"], ["cat"])
|
||||
self.assertEqual(
|
||||
(ends[2]["start_time"], ends[2]["end_time"]), (cat_time, cat_time)
|
||||
)
|
||||
for detection in ends[1:]:
|
||||
self.assertTrue(Path(detection["thumb_path"]).is_file())
|
||||
self.assertIsNotNone(detection["data"]["thumb_time"])
|
||||
|
||||
def test_resumed_alert_publishes_pending_detection_objects(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
(10, [self.tracked("p1", "person", 10, start_time=1)]),
|
||||
)
|
||||
|
||||
latest = self.reviews()[-1]
|
||||
self.assertEqual(latest["type"], "update")
|
||||
self.assertEqual(latest["after"]["severity"], "alert")
|
||||
self.assertCountEqual(latest["after"]["data"]["objects"], ["person", "dog"])
|
||||
|
||||
def test_split_detection_has_thumbnail_of_its_activity(self) -> None:
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=11)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
|
||||
|
||||
new_detection = next(
|
||||
r["after"]
|
||||
for r in self.reviews()
|
||||
if r["type"] == "new" and r["after"]["severity"] == "detection"
|
||||
)
|
||||
self.assertTrue(Path(new_detection["thumb_path"]).is_file())
|
||||
# captured from the dog's first frame, not a later fallback frame
|
||||
self.assertIsNotNone(new_detection["data"]["thumb_time"])
|
||||
self.assertIn(
|
||||
f"{CAMERA}_11",
|
||||
[c.args[0] for c in self.maintainer.frame_manager.get.call_args_list],
|
||||
)
|
||||
|
||||
def assert_force_end_publishes_pending_detection(self, topic: str) -> None:
|
||||
# the dog is held as a pending detection when the alert is ended
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
|
||||
if topic == "remove":
|
||||
# the config updater has already dropped the camera
|
||||
self.maintainer.config.cameras.pop(CAMERA)
|
||||
|
||||
self.maintainer.config_subscriber.check_for_updates.side_effect = [
|
||||
{topic: [CAMERA]}
|
||||
]
|
||||
self.feed()
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
end = self.reviews()[-1]["after"]
|
||||
self.assertEqual(end["data"]["objects"], ["dog"])
|
||||
self.assertEqual((end["start_time"], end["end_time"]), (5, 5))
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
# every thumbnail on disk belongs to a published review
|
||||
published = {Path(r["after"]["thumb_path"]).name for r in self.reviews()}
|
||||
self.assertEqual(self.thumbnails(), published)
|
||||
|
||||
def test_disabled_camera_publishes_pending_detections(self) -> None:
|
||||
self.assert_force_end_publishes_pending_detection("enabled")
|
||||
|
||||
def test_removed_camera_publishes_pending_detections(self) -> None:
|
||||
self.assert_force_end_publishes_pending_detection("remove")
|
||||
|
||||
def test_pending_thumbnail_removed_when_alert_resumes(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
alert = self.maintainer.active_review_segments[CAMERA]
|
||||
pending_thumb = Path(alert.pending_detections[0].frame_path)
|
||||
self.assertTrue(pending_thumb.is_file())
|
||||
|
||||
self.feed((10, [self.tracked("p1", "person", 10, start_time=1)]))
|
||||
|
||||
self.assertEqual(alert.pending_detections, [])
|
||||
self.assertFalse(pending_thumb.exists())
|
||||
self.assertEqual(self.thumbnails(), {Path(alert.frame_path).name})
|
||||
|
||||
def test_multiple_pending_objects_share_one_detection(self) -> None:
|
||||
# objects within the detection cutoff of each other, whether seen
|
||||
# together or later, make up one detection
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(
|
||||
5,
|
||||
[
|
||||
self.tracked("d1", "dog", 5, start_time=5),
|
||||
self.tracked("c1", "cat", 5, start_time=5),
|
||||
],
|
||||
),
|
||||
(20, [self.tracked("b1", "bird", 20, start_time=20)]),
|
||||
(21, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(21 + self.detection_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
end = self.reviews()[-1]["after"]
|
||||
self.assertCountEqual(end["data"]["objects"], ["dog", "cat", "bird"])
|
||||
self.assertCountEqual(end["data"]["detections"], ["d1", "c1", "b1"])
|
||||
self.assertEqual((end["start_time"], end["end_time"]), (5, 20))
|
||||
# the thumbnail is framed on both objects seen together, the bird
|
||||
# alone is fewer objects so it does not replace it
|
||||
frames = [c.args[0] for c in self.maintainer.frame_manager.get.call_args_list]
|
||||
self.assertIn(f"{CAMERA}_5", frames)
|
||||
self.assertNotIn(f"{CAMERA}_20", frames)
|
||||
|
||||
|
||||
class TestSplitDetectionLabels(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
"""
|
||||
|
||||
def test_split_detection_keeps_sub_labels_and_zones(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(
|
||||
5,
|
||||
[
|
||||
self.tracked(
|
||||
"d1",
|
||||
"dog",
|
||||
5,
|
||||
start_time=5,
|
||||
zones=["yard"],
|
||||
sub_label=("Rex", 0.95),
|
||||
),
|
||||
self.tracked(
|
||||
"c1",
|
||||
"car",
|
||||
5,
|
||||
start_time=5,
|
||||
zones=["driveway"],
|
||||
sub_label=("fedex", 0.9),
|
||||
),
|
||||
],
|
||||
),
|
||||
(2 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(len(ends), 2)
|
||||
alert_end, detection = ends
|
||||
self.assertEqual(alert_end["data"]["objects"], ["person"])
|
||||
self.assertEqual(alert_end["data"]["sub_labels"], [])
|
||||
self.assertEqual(alert_end["data"]["zones"], [])
|
||||
|
||||
self.assertEqual(detection["severity"], "detection")
|
||||
# attributes replace the label, other sub labels verify it
|
||||
self.assertCountEqual(detection["data"]["objects"], ["dog-verified", "fedex"])
|
||||
self.assertEqual(detection["data"]["verified_objects"], ["dog-verified"])
|
||||
self.assertEqual(detection["data"]["sub_labels"], ["Rex"])
|
||||
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,16 +1,24 @@
|
||||
"""Tests for embedding cleanup on the main Frigate database.
|
||||
"""Tests for embedding storage and cleanup on the main Frigate database.
|
||||
|
||||
Embeddings are deleted whether or not semantic search is currently enabled, so
|
||||
the delete path has to tolerate databases where the vec0 tables were never
|
||||
created and installs where the sqlite-vec extension is unavailable.
|
||||
|
||||
The write paths need the real extension, since the behavior under test belongs
|
||||
to vec0 itself, so those tests are skipped when it is not installed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from peewee import OperationalError
|
||||
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
|
||||
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
|
||||
|
||||
|
||||
class TestDeleteEmbeddings(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self._thumbnail_ids(), ["b"])
|
||||
|
||||
def test_delete_failure_is_logged_not_raised(self) -> None:
|
||||
self._create_thumbnails_table()
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
|
||||
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
|
||||
self.db.delete_embeddings_thumbnail(event_ids=["a"])
|
||||
|
||||
self.assertIn("Failed to delete embeddings", logs.output[0])
|
||||
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
||||
|
||||
def test_delete_skipped_without_extension(self) -> None:
|
||||
self._create_thumbnails_table()
|
||||
self.db.load_vec_extension = False
|
||||
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
||||
|
||||
# the vec0 tables cannot be written without the extension
|
||||
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
||||
|
||||
|
||||
def _vector(value: float) -> bytes:
|
||||
return struct.pack("768f", *([value] * 768))
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
|
||||
)
|
||||
class TestEmbeddingsTableWrites(unittest.TestCase):
|
||||
"""Covers the vec0 writes behind semantic search reindexing."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.db = SqliteVecQueueDatabase(
|
||||
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
|
||||
)
|
||||
self.db.start()
|
||||
self.db.create_embeddings_tables()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.stop()
|
||||
self.db.close()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def _vec_tables(self) -> list[str]:
|
||||
return [
|
||||
row[0]
|
||||
for row in self.db.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
|
||||
)
|
||||
]
|
||||
|
||||
def _make_legacy(self, table: str) -> None:
|
||||
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
|
||||
# Frigate 0.17 and earlier do not have one
|
||||
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
|
||||
|
||||
def _stored(self, table: str, column: str, event_id: str) -> str | None:
|
||||
row = self.db.execute_sql(
|
||||
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def test_write_error_is_raised(self) -> None:
|
||||
# queued writes hide their exception in the returned cursor
|
||||
with self.assertRaises(OperationalError):
|
||||
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
|
||||
|
||||
def test_drop_tables_removes_legacy_tables(self) -> None:
|
||||
self._make_legacy("vec_thumbnails")
|
||||
self._make_legacy("vec_descriptions")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
self.assertEqual(self._vec_tables(), [])
|
||||
|
||||
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
def test_upsert_replaces_existing_embedding(self) -> None:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
|
||||
)
|
||||
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
|
||||
)
|
||||
|
||||
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
|
||||
self.assertTrue(stored.startswith("[0.990000"), stored)
|
||||
|
||||
def test_upsert_keeps_one_row_per_event(self) -> None:
|
||||
for _ in range(3):
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
|
||||
)
|
||||
|
||||
count = self.db.execute_sql(
|
||||
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
|
||||
"""The 0.18 upgrade path: old vectors in, new vectors out."""
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
|
||||
)
|
||||
self._make_legacy("vec_thumbnails")
|
||||
self._make_legacy("vec_descriptions")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
self.db.create_embeddings_tables()
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
|
||||
)
|
||||
|
||||
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
|
||||
self.assertTrue(stored.startswith("[0.990000"), stored)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.events.types import EventStateEnum
|
||||
from frigate.models import Timeline
|
||||
from frigate.timeline import TimelineProcessor
|
||||
|
||||
|
||||
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
|
||||
return {
|
||||
"id": "event-1",
|
||||
"frame_time": 1000.0,
|
||||
"box": [0, 0, 10, 10],
|
||||
"region": [0, 0, 100, 100],
|
||||
"label": "car",
|
||||
"sub_label": None,
|
||||
"score": 0.8,
|
||||
"has_clip": has_clip,
|
||||
"has_snapshot": has_snapshot,
|
||||
"current_zones": [],
|
||||
"stationary": False,
|
||||
"attributes": {},
|
||||
"current_attributes": [],
|
||||
}
|
||||
|
||||
|
||||
class TestTimelineProcessor(unittest.TestCase):
|
||||
def setUp(self):
|
||||
camera_config = MagicMock()
|
||||
camera_config.detect.width = 1280
|
||||
camera_config.detect.height = 720
|
||||
config = MagicMock()
|
||||
config.cameras.get.return_value = camera_config
|
||||
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
|
||||
|
||||
@patch.object(Timeline, "insert")
|
||||
def test_unsaved_event_writes_no_timeline_rows(self, insert):
|
||||
event = make_event(has_clip=False, has_snapshot=False)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.start, None, event
|
||||
)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.end, event, event
|
||||
)
|
||||
|
||||
insert.assert_not_called()
|
||||
self.assertEqual(self.processor.pre_event_cache, {})
|
||||
|
||||
@patch.object(Timeline, "insert")
|
||||
def test_cached_entries_flush_when_event_is_saved(self, insert):
|
||||
start = make_event(has_clip=False, has_snapshot=False)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.start, None, start
|
||||
)
|
||||
insert.assert_not_called()
|
||||
|
||||
end = make_event(has_clip=True, has_snapshot=False)
|
||||
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
|
||||
|
||||
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
|
||||
self.assertEqual(class_types, ["visible", "gone"])
|
||||
self.assertEqual(self.processor.pre_event_cache, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread):
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
||||
|
||||
# drop entries for events that ended without being saved
|
||||
self.pre_event_cache.pop(event_id, None)
|
||||
|
||||
def handle_api_entry(
|
||||
self,
|
||||
camera: str,
|
||||
|
||||
@@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import (
|
||||
CameraConfig,
|
||||
CameraMqttConfig,
|
||||
FrigateConfig,
|
||||
RecordConfig,
|
||||
@@ -129,8 +130,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
after = obj.to_dict()
|
||||
message = {
|
||||
"before": obj.previous,
|
||||
@@ -154,8 +157,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
# populate has_snapshot
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
|
||||
# write thumbnail to disk if it will be saved as an event
|
||||
if obj.has_snapshot or obj.has_clip:
|
||||
@@ -185,8 +190,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def snapshot(camera: str, obj: TrackedObject) -> bool:
|
||||
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
||||
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
|
||||
jpg_bytes, _ = obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=mqtt_config.timestamp,
|
||||
@@ -241,11 +246,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
with self.camera_states_lock:
|
||||
self.camera_states[camera] = camera_state
|
||||
|
||||
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_save_snapshot(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
|
||||
snapshot_config: SnapshotsConfig = camera_config.snapshots
|
||||
|
||||
if not snapshot_config.enabled:
|
||||
return False
|
||||
@@ -264,11 +271,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_retain_recording(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
record_config: RecordConfig = self.config.cameras[camera].record
|
||||
record_config: RecordConfig = camera_config.record
|
||||
|
||||
# Recording is disabled
|
||||
if not record_config.enabled:
|
||||
@@ -284,13 +293,15 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_mqtt_snapshot(
|
||||
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
# object never changed position
|
||||
if obj.is_stationary():
|
||||
return False
|
||||
|
||||
# if there are required zones and there is no overlap
|
||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
||||
required_zones = mqtt_config.required_zones
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
logger.debug(
|
||||
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
||||
@@ -300,7 +311,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return True
|
||||
|
||||
def update_mqtt_motion(
|
||||
self, camera: str, frame_time: float, motion_boxes: list
|
||||
self,
|
||||
camera: str,
|
||||
camera_config: CameraConfig,
|
||||
frame_time: float,
|
||||
motion_boxes: list,
|
||||
) -> None:
|
||||
# publish if motion is currently being detected
|
||||
if motion_boxes:
|
||||
@@ -315,7 +330,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# always updated latest motion
|
||||
self.last_motion_detected[camera] = frame_time
|
||||
elif self.last_motion_detected.get(camera, 0) > 0:
|
||||
mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay
|
||||
mqtt_delay = camera_config.motion.mqtt_off_delay
|
||||
|
||||
# If no motion, make sure the off_delay has passed
|
||||
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
|
||||
@@ -818,7 +833,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
|
||||
self.update_mqtt_motion(camera, frame_time, motion_boxes)
|
||||
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
|
||||
|
||||
tracked_objects = [
|
||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
||||
|
||||
@@ -191,7 +191,7 @@ def migrate_frigate_config(config_file: str):
|
||||
|
||||
if previous_version < "0.14":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(config)
|
||||
new_config = migrate_014(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.14"
|
||||
@@ -209,35 +209,35 @@ def migrate_frigate_config(config_file: str):
|
||||
|
||||
if previous_version < "0.15-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
||||
new_config = migrate_015_0(config)
|
||||
new_config = migrate_015_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.15-0"
|
||||
|
||||
if previous_version < "0.15-1":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
||||
new_config = migrate_015_1(config)
|
||||
new_config = migrate_015_1(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.15-1"
|
||||
|
||||
if previous_version < "0.16-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
||||
new_config = migrate_016_0(config)
|
||||
new_config = migrate_016_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.16-0"
|
||||
|
||||
if previous_version < "0.17-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||
new_config = migrate_017_0(config)
|
||||
new_config = migrate_017_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.17-0"
|
||||
|
||||
if previous_version < "0.18-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
||||
new_config = migrate_018_0(config)
|
||||
new_config = migrate_018_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.18-0"
|
||||
|
||||
+27
-15
@@ -18,6 +18,7 @@ from frigate.comms.recordings_updater import (
|
||||
RecordingsDataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, LoggerConfig
|
||||
from frigate.config.camera.ffmpeg import CameraRoleEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -187,29 +188,45 @@ class CameraWatchdog(threading.Thread):
|
||||
# Status caching to reduce message volume
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_status_update_time: float = 0.0
|
||||
self._last_detect_status_update_time: float = 0.0
|
||||
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
|
||||
|
||||
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
|
||||
or (now - self._last_detect_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
|
||||
self._last_detect_status_update_time = now
|
||||
|
||||
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
|
||||
or (now - self._last_record_status_update_time[stream_type])
|
||||
>= 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
|
||||
self._last_record_status_update_time[stream_type] = 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._send_record_status(stream_type, "offline", now)
|
||||
else:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
def _reset_segment_times(self) -> None:
|
||||
self.latest_valid_segment_time.clear()
|
||||
@@ -514,18 +531,16 @@ class CameraWatchdog(threading.Thread):
|
||||
ffmpeg_process=p["process"],
|
||||
)
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
|
||||
self._grant_restart_grace(recorded_streams, now_utc)
|
||||
last_restart_time = now
|
||||
|
||||
continue
|
||||
elif stale_stream is None:
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
if poll is None:
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
|
||||
p["latest_segment_time"] = max(
|
||||
self.latest_cache_segment_time[stream_type]
|
||||
@@ -535,10 +550,7 @@ class CameraWatchdog(threading.Thread):
|
||||
if poll is None:
|
||||
continue
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
|
||||
p["process"] = start_or_restart_ffmpeg(
|
||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
|
||||
// of every tab for measurement and hides the ones that do not fit behind a
|
||||
// kebab. The selected tab always stays in the strip.
|
||||
|
||||
export function systemTab(frigateApp: FrigateApp, name: string) {
|
||||
return frigateApp.page
|
||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||
.first();
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
||||
|
||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => {
|
||||
// Similarity search URL param
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore: back button @high", () => {
|
||||
test("direct visits do not show a back button", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
await expect(frigateApp.page.getByLabel("Labels").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Go back" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Explore — similarity search (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
|
||||
@@ -30,40 +30,51 @@ function groupedFacesMock() {
|
||||
});
|
||||
}
|
||||
|
||||
async function installGroupedFaces(app: FrigateApp) {
|
||||
const GROUPED_EVENT = {
|
||||
id: GROUPED_EVENT_ID,
|
||||
label: "person",
|
||||
sub_label: null,
|
||||
camera: "front_door",
|
||||
start_time: 1775487131.3863528,
|
||||
end_time: 1775487161.3863528,
|
||||
false_positive: false,
|
||||
zones: ["front_yard"],
|
||||
thumbnail: null,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
plus_id: null,
|
||||
model_hash: "abc123",
|
||||
detector_type: "cpu",
|
||||
model_type: "ssd",
|
||||
data: {
|
||||
top_score: 0.92,
|
||||
score: 0.92,
|
||||
region: [0.1, 0.1, 0.5, 0.8],
|
||||
box: [0.2, 0.15, 0.45, 0.75],
|
||||
area: 0.18,
|
||||
ratio: 0.6,
|
||||
type: "object",
|
||||
path_data: [],
|
||||
},
|
||||
};
|
||||
|
||||
async function installGroupedFaces(
|
||||
app: FrigateApp,
|
||||
opts: { withEventIds?: boolean } = {},
|
||||
) {
|
||||
await app.api.install({
|
||||
events: [
|
||||
{
|
||||
id: GROUPED_EVENT_ID,
|
||||
label: "person",
|
||||
sub_label: null,
|
||||
camera: "front_door",
|
||||
start_time: 1775487131.3863528,
|
||||
end_time: 1775487161.3863528,
|
||||
false_positive: false,
|
||||
zones: ["front_yard"],
|
||||
thumbnail: null,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
plus_id: null,
|
||||
model_hash: "abc123",
|
||||
detector_type: "cpu",
|
||||
model_type: "ssd",
|
||||
data: {
|
||||
top_score: 0.92,
|
||||
score: 0.92,
|
||||
region: [0.1, 0.1, 0.5, 0.8],
|
||||
box: [0.2, 0.15, 0.45, 0.75],
|
||||
area: 0.18,
|
||||
ratio: 0.6,
|
||||
type: "object",
|
||||
path_data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
events: [GROUPED_EVENT],
|
||||
faces: groupedFacesMock(),
|
||||
});
|
||||
|
||||
// api-mocker does not cover /api/event_ids, which the card needs to link to
|
||||
// Explore. Registered after install so it takes precedence.
|
||||
if (opts.withEventIds) {
|
||||
await app.page.route("**/api/event_ids**", (route) =>
|
||||
route.fulfill({ json: [GROUPED_EVENT] }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
|
||||
@@ -512,6 +523,34 @@ test.describe("FaceSelectionDialog @high", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library: return from Explore @high", () => {
|
||||
test("Explore back button returns to an outlined collection", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installGroupedFaces(frigateApp, { withEventIds: true });
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Mobile opens the collection as a MobilePage, which has no dialog role
|
||||
const card = frigateApp.page
|
||||
.locator('img[src*="clips/faces/train/"]')
|
||||
.first()
|
||||
.locator("xpath=..");
|
||||
await card.click();
|
||||
await frigateApp.page.getByLabel("View in Explore").click();
|
||||
await expect(frigateApp.page).toHaveURL(
|
||||
new RegExp(`/explore\\?event_id=${GROUPED_EVENT_ID}`),
|
||||
);
|
||||
|
||||
const back = frigateApp.page.getByRole("button", { name: "Go back" });
|
||||
await expect(back).toBeVisible({ timeout: 5_000 });
|
||||
await back.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/faces/);
|
||||
|
||||
await expect(card).toHaveClass(/outline-selected/, { timeout: 5_000 });
|
||||
await expect(card).not.toHaveClass(/outline-selected/, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — mobile @high @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
|
||||
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live camera groups @medium", () => {
|
||||
test("a group with an invalid icon renders a fallback icon", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
camera_groups: {
|
||||
outdoor: { cameras: ["front_door"], icon: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
const group = frigateApp.page
|
||||
.locator('[aria-label="Camera Groups"]:not([inert] *)')
|
||||
.first();
|
||||
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
@@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -6,42 +6,66 @@
|
||||
* RestartDialog cancel flow.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
|
||||
async function selectTab(frigateApp: FrigateApp, name: string) {
|
||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
|
||||
if (frigateApp.isMobile && (await kebab.isVisible())) {
|
||||
await kebab.click();
|
||||
await frigateApp.page
|
||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||
.last()
|
||||
.click();
|
||||
return;
|
||||
}
|
||||
|
||||
await systemTab(frigateApp, name).click();
|
||||
}
|
||||
|
||||
async function expectTabActive(frigateApp: FrigateApp, name: string) {
|
||||
await expect(systemTab(frigateApp, name)).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{
|
||||
timeout: 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("System — tabs @medium", () => {
|
||||
test("general tab is active by default via #general hash", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||
if (!frigateApp.isMobile) {
|
||||
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Storage tab activates and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
);
|
||||
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
|
||||
|
||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
});
|
||||
|
||||
test("general tab shows version and last-refreshed", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
// On desktop, tab buttons render text labels so the word "storage"
|
||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||
// icon-only, so we verify the general-tab content disappears instead
|
||||
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
|
||||
} else {
|
||||
// Mobile: tab activation (data-state "on") already asserted above.
|
||||
// Additionally confirm general tab is no longer the active tab.
|
||||
await expect(
|
||||
frigateApp.page.getByLabel("Select general"),
|
||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
// Cameras tab lists every camera from config/stats. The default
|
||||
// mock has front_door, backyard, garage.
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
@@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => {
|
||||
config: { semantic_search: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||
await enrichTab.click();
|
||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await selectTab(frigateApp, "enrichments");
|
||||
await expectTabActive(frigateApp, "enrichments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
|
||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
});
|
||||
|
||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||
|
||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||
const tabs = frigateApp.page
|
||||
.locator("[data-radix-scroll-area-viewport]")
|
||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
await expect(kebab).toBeVisible();
|
||||
const tabs = kebab.locator("..");
|
||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||
|
||||
const logoBox = await logo.boundingBox();
|
||||
@@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||
|
||||
// the clipped tabs stay reachable by scrolling
|
||||
const overflow = await tabs.evaluate((el) => ({
|
||||
scroll: el.scrollWidth,
|
||||
client: el.clientWidth,
|
||||
}));
|
||||
expect(overflow.scroll).toBeGreaterThan(overflow.client);
|
||||
await tabs.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// the tabs that do not fit stay reachable through the kebab
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1162,7 +1162,9 @@
|
||||
"actions": "Actions",
|
||||
"noRoles": "No custom roles found.",
|
||||
"editCameras": "Edit Cameras",
|
||||
"deleteRole": "Delete Role"
|
||||
"deleteRole": "Delete Role",
|
||||
"cameraCount_one": "{{count}} camera",
|
||||
"cameraCount_other": "{{count}} cameras"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
@@ -2038,7 +2040,11 @@
|
||||
},
|
||||
"record": {
|
||||
"noRecordRole": "No streams have the record role defined. Recording will not function.",
|
||||
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
|
||||
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function.",
|
||||
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
||||
},
|
||||
"notifications": {
|
||||
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
|
||||
},
|
||||
"birdseye": {
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"showAllTabs": "Show all tabs",
|
||||
"showLessTabs": "Show less",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
@@ -339,6 +341,7 @@
|
||||
"moreMessages_one": "+{{count}}",
|
||||
"moreMessages_other": "+{{count}}",
|
||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||
"reindexEmbeddingsFailed": "Reindexing embeddings failed, check the logs",
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ClassificationItemData,
|
||||
ClassificationThreshold,
|
||||
ClassifiedEvent,
|
||||
} from "@/types/classification";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
@@ -16,6 +24,7 @@ import { LuSearch, LuInfo } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -85,9 +94,14 @@ export const ClassificationCard = forwardRef<
|
||||
|
||||
// interaction
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useContextMenu(imgRef, () => {
|
||||
useImperativeHandle(ref, () => cardRef.current!);
|
||||
|
||||
// Listen on the whole card, since overlays cover most of the image
|
||||
|
||||
useContextMenu(cardRef, () => {
|
||||
onClick(data, true);
|
||||
});
|
||||
|
||||
@@ -101,9 +115,9 @@ export const ClassificationCard = forwardRef<
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"relative flex size-full flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||
"relative flex size-full select-none flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||
className,
|
||||
selected
|
||||
? "shadow-selected outline-selected"
|
||||
@@ -117,11 +131,7 @@ export const ClassificationCard = forwardRef<
|
||||
}
|
||||
onClick(data, isMeta);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(data, true);
|
||||
}}
|
||||
style={isIOS ? { WebkitTouchCallout: "none" } : undefined}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -130,14 +140,6 @@ export const ClassificationCard = forwardRef<
|
||||
imgClassName,
|
||||
isMobile && "w-full",
|
||||
)}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
@@ -156,7 +158,7 @@ export const ClassificationCard = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
|
||||
<div className="absolute bottom-0 flex w-full flex-row items-center justify-between gap-2 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start text-white",
|
||||
@@ -216,6 +218,41 @@ export function GroupedClassificationCard({
|
||||
const { t } = useTranslation(["views/explore", i18nLibrary]);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
// Explore stores this event in history state so going back can point out the
|
||||
// card the user came from
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [returnEventId, setReturnEventId] = useOverlayState<string | undefined>(
|
||||
"returnEventId",
|
||||
);
|
||||
const [highlighted, setHighlighted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!returnEventId || classifiedEvent?.id !== returnEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReturnEventId(undefined, true);
|
||||
setHighlighted(true);
|
||||
}, [classifiedEvent?.id, returnEventId, setReturnEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlighted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
scrollIntoView(cardRef.current, {
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
scrollMode: "if-needed",
|
||||
});
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => setHighlighted(false), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [highlighted]);
|
||||
|
||||
// If the component unmounts while the detail overlay is open, we need to
|
||||
// pop the history state that was pushed by useHistoryBack, otherwise it
|
||||
// leaves a stale entry that breaks back navigation.
|
||||
@@ -308,9 +345,10 @@ export function GroupedClassificationCard({
|
||||
return (
|
||||
<>
|
||||
<ClassificationCard
|
||||
ref={cardRef}
|
||||
data={bestItem}
|
||||
threshold={threshold}
|
||||
selected={selectedItems.includes(bestItem.filename)}
|
||||
selected={highlighted || selectedItems.includes(bestItem.filename)}
|
||||
clickable={true}
|
||||
i18nLibrary={i18nLibrary}
|
||||
count={group.length}
|
||||
@@ -404,13 +442,19 @@ export function GroupedClassificationCard({
|
||||
isMobile && "absolute right-4 top-8",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
tabIndex={-1}
|
||||
aria-label={t("details.item.button.viewInExplore", {
|
||||
ns: "views/explore",
|
||||
})}
|
||||
onClick={() => {
|
||||
navigate(`/explore?event_id=${classifiedEvent.id}`);
|
||||
setReturnEventId(classifiedEvent.id, true);
|
||||
navigate(`/explore?event_id=${classifiedEvent.id}`, {
|
||||
state: { canGoBack: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-4 text-secondary-foreground" />
|
||||
|
||||
@@ -8,6 +8,23 @@ const notifications: SectionConfigOverrides = {
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "profile-base-notifications-disabled",
|
||||
field: "enabled",
|
||||
messageKey: "configMessages.notifications.profileBaseDisabled",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) =>
|
||||
!!ctx.profileName &&
|
||||
ctx.formData?.enabled === true &&
|
||||
!Object.values(ctx.fullConfig.cameras).some(
|
||||
(camera) =>
|
||||
camera.enabled_in_config &&
|
||||
camera.notifications.enabled_in_config,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
uiSchema: {
|
||||
|
||||
@@ -31,6 +31,21 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "profile-base-record-disabled",
|
||||
field: "enabled",
|
||||
messageKey: "configMessages.record.profileBaseDisabled",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
docLink:
|
||||
"/configuration/profiles#why-cant-a-profile-enable-recording-when-its-disabled-in-the-base-config",
|
||||
condition: (ctx) =>
|
||||
!!ctx.profileName &&
|
||||
ctx.formData?.enabled === true &&
|
||||
ctx.fullCameraConfig?.record.enabled_in_config === false,
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"alerts.pre_capture":
|
||||
"/configuration/record#pre-capture-and-post-capture",
|
||||
|
||||
@@ -8,6 +8,7 @@ export type MessageConditionContext = {
|
||||
fullCameraConfig?: CameraConfig;
|
||||
level: "global" | "camera";
|
||||
cameraName?: string;
|
||||
profileName?: string;
|
||||
formData: ConfigSectionData;
|
||||
};
|
||||
|
||||
|
||||
@@ -627,7 +627,11 @@ export default function NotificationsSettingsExtras({
|
||||
<SettingsGroupCard title={t("notification.deviceSpecific")}>
|
||||
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
|
||||
<Button
|
||||
aria-label={t("notification.registerDevice")}
|
||||
aria-label={
|
||||
registration != null
|
||||
? t("notification.unregisterDevice")
|
||||
: t("notification.registerDevice")
|
||||
}
|
||||
className="w-full md:w-auto"
|
||||
disabled={!shouldFetchPubKey || publicKey == undefined}
|
||||
onClick={() => {
|
||||
|
||||
@@ -649,9 +649,10 @@ export function ConfigSection({
|
||||
: undefined,
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
profileName,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
};
|
||||
}, [config, currentFormData, effectiveLevel, cameraName]);
|
||||
}, [config, currentFormData, effectiveLevel, cameraName, profileName]);
|
||||
|
||||
const { activeMessages, activeFieldMessages } = useConfigMessages(
|
||||
sectionConfig.messages,
|
||||
|
||||
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import OverflowStrip from "../mobile/OverflowStrip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
const [addGroup, setAddGroup] = useState(false);
|
||||
|
||||
// mobile overflow reveal - the group strip sits left of the logo and is
|
||||
// clipped (not scrollable) when there are too many groups, so render only
|
||||
// the buttons that fully fit and surface a kebab next to the last visible
|
||||
// one that expands a panel revealing all of them
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null => all buttons fit, render them all with no kebab; a number => only
|
||||
// that many fit alongside the kebab
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
const measure = measureRef.current;
|
||||
|
||||
if (!wrapper || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 8; // gap-2 between buttons in the strip
|
||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||
|
||||
const compute = () => {
|
||||
const buttons = Array.from(measure.children) as HTMLElement[];
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the trailing child of the measurement row is a kebab clone
|
||||
const kebab = buttons[buttons.length - 1];
|
||||
const groupButtons = buttons.slice(0, -1);
|
||||
const available = wrapper.clientWidth;
|
||||
const fullWidth =
|
||||
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(groupButtons.length - 1, 0) * gap;
|
||||
|
||||
if (fullWidth <= available) {
|
||||
setVisibleCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const el of groupButtons) {
|
||||
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
|
||||
|
||||
if (used + next <= budget) {
|
||||
used += next;
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(Math.max(count, 1));
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [groups, isAdmin]);
|
||||
|
||||
const groupButtons = (afterSelect?: () => void) => {
|
||||
const groupButtons = () => {
|
||||
const buttons = [
|
||||
<Button
|
||||
key="default-group"
|
||||
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-5"
|
||||
/>
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-5 text-primary-variant" />
|
||||
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
onMouseEnter={() => showTooltip(name)}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer
|
||||
icon={LuIcons[config.icon]}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{visibleCount == null
|
||||
? groupButtons()
|
||||
: groupButtons().slice(0, visibleCount)}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showAll")}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* invisible row used only to measure natural button widths so we
|
||||
can render exactly the buttons that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div ref={measureRef} className="flex w-max items-center gap-2">
|
||||
{groupButtons()}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="group-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupButtons(() => setExpanded(false))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showLess")}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<OverflowStrip
|
||||
className={className}
|
||||
items={groupButtons()}
|
||||
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||
showAllLabel={t("group.showAll")}
|
||||
showLessLabel={t("group.showLess")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function InputWithTags({
|
||||
}
|
||||
|
||||
return current_suggestions.filter((suggestion) =>
|
||||
suggestion.toLowerCase().startsWith(currentWord),
|
||||
suggestion.toLowerCase().startsWith(currentWord.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[inputValue, suggestions, currentFilterType],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OverflowStripProps = {
|
||||
className?: string;
|
||||
items: ReactNode[];
|
||||
activeIndex?: number;
|
||||
gapClassName?: string;
|
||||
showAllLabel: string;
|
||||
showLessLabel: string;
|
||||
};
|
||||
|
||||
// Renders only the items that fully fit and surfaces a kebab next to the last
|
||||
// visible one. The kebab expands a panel over the nearest positioned ancestor
|
||||
// that reveals every item.
|
||||
export default function OverflowStrip({
|
||||
className,
|
||||
items,
|
||||
activeIndex = 0,
|
||||
gapClassName = "gap-2",
|
||||
showAllLabel,
|
||||
showLessLabel,
|
||||
}: OverflowStripProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null => all items fit, render them all with no kebab; a number => only
|
||||
// that many fit alongside the kebab
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const measure = measureRef.current;
|
||||
|
||||
if (!wrapper || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||
|
||||
const compute = () => {
|
||||
const children = Array.from(measure.children) as HTMLElement[];
|
||||
|
||||
if (children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the trailing child of the measurement row is a kebab clone
|
||||
const kebab = children[children.length - 1];
|
||||
const start = children[0].offsetLeft;
|
||||
const ends = children
|
||||
.slice(0, -1)
|
||||
.map((el) => el.offsetLeft + el.offsetWidth - start);
|
||||
const available = wrapper.clientWidth;
|
||||
|
||||
if (ends[ends.length - 1] <= available) {
|
||||
setVisibleCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||
const count = ends.filter((end) => end <= budget).length;
|
||||
|
||||
setVisibleCount(Math.max(count, 1));
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
observer.observe(measure);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [items.length, gapClassName]);
|
||||
|
||||
// a selected item past the cut takes the last visible slot
|
||||
const visibleItems =
|
||||
visibleCount == null
|
||||
? items
|
||||
: activeIndex >= visibleCount
|
||||
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
|
||||
: items.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
|
||||
gapClassName,
|
||||
)}
|
||||
>
|
||||
{visibleItems}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={showAllLabel}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* invisible row used only to measure natural item widths so we can
|
||||
render exactly the items that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div
|
||||
ref={measureRef}
|
||||
className={cn("flex w-max items-center", gapClassName)}
|
||||
>
|
||||
{items}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="overflow-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{/* a tap on any item bubbles up and collapses the panel */}
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center", gapClassName)}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
{items}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={showLessLabel}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -844,6 +844,7 @@ export function TrackingDetails({
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
<Link
|
||||
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
||||
state={{ canGoBack: true }}
|
||||
className="text-sm"
|
||||
>
|
||||
{event.data.recognized_license_plate}
|
||||
|
||||
@@ -631,7 +631,7 @@ export function SpeedFilterContent({
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
|
||||
setSpeedRange(parseInt(value), maxSpeed ?? 150);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -727,6 +727,7 @@ function EventList({
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
<Link
|
||||
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
||||
state={{ canGoBack: true }}
|
||||
className="text-sm"
|
||||
>
|
||||
{event.data.recognized_license_plate}
|
||||
|
||||
@@ -135,7 +135,9 @@ export default function EventMenu({
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
navigate(`/explore?event_id=${event.id}`, {
|
||||
state: { canGoBack: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("details.item.button.viewInExplore")}
|
||||
@@ -177,6 +179,7 @@ export default function EventMenu({
|
||||
else
|
||||
navigate(
|
||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||
{ state: { canGoBack: true } },
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -44,8 +44,9 @@ export default function useStatusMessages(): StatusMessage[] {
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
clearMessages("embeddings-reindex");
|
||||
|
||||
if (reindexState.status === "indexing") {
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
@@ -55,9 +56,12 @@ export default function useStatusMessages(): StatusMessage[] {
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
} else if (reindexState.status === "failed") {
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexEmbeddingsFailed"),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
@@ -287,7 +287,6 @@ function ConfigEditor() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("saveAndRestart")}
|
||||
onClick={handleSaveAndRestart}
|
||||
@@ -300,7 +299,6 @@ function ConfigEditor() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("saveOnly")}
|
||||
onClick={() => onHandleSaveConfig("saveonly")}
|
||||
|
||||
@@ -198,7 +198,19 @@ export default function Explore() {
|
||||
|
||||
const [url, params] = searchQuery;
|
||||
|
||||
const isAscending = params.sort?.includes("date_asc");
|
||||
// a start_time cursor only works when rows are ordered by start_time,
|
||||
// so every other sort pages by offset
|
||||
const isDateSort =
|
||||
params.sort === "date_asc" ||
|
||||
params.sort === "date_desc" ||
|
||||
(!params.sort && url === "events");
|
||||
|
||||
if (pageIndex > 0 && !isDateSort) {
|
||||
return [
|
||||
url,
|
||||
{ ...params, offset: pageIndex * API_LIMIT, limit: API_LIMIT },
|
||||
];
|
||||
}
|
||||
|
||||
if (pageIndex > 0 && previousPageData) {
|
||||
const lastDate = previousPageData[previousPageData.length - 1].start_time;
|
||||
@@ -206,7 +218,8 @@ export default function Explore() {
|
||||
url,
|
||||
{
|
||||
...params,
|
||||
[isAscending ? "after" : "before"]: lastDate.toString(),
|
||||
[params.sort === "date_asc" ? "after" : "before"]:
|
||||
lastDate.toString(),
|
||||
limit: API_LIMIT,
|
||||
},
|
||||
];
|
||||
@@ -238,10 +251,17 @@ export default function Explore() {
|
||||
},
|
||||
});
|
||||
|
||||
const searchResults = useMemo(
|
||||
() => (data ? ([] as SearchResult[]).concat(...data) : []),
|
||||
[data],
|
||||
);
|
||||
// offset pages can overlap when results shift between page fetches
|
||||
const searchResults = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return data.flat().filter((result) => {
|
||||
if (seen.has(result.id)) return false;
|
||||
seen.add(result.id);
|
||||
return true;
|
||||
});
|
||||
}, [data]);
|
||||
const isLoadingInitialData = !data && !isValidating;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
|
||||
+63
-30
@@ -1,8 +1,10 @@
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import OverflowStrip from "@/components/mobile/OverflowStrip";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||
@@ -36,6 +38,14 @@ const allMetrics = [
|
||||
] as const;
|
||||
type SystemMetric = (typeof allMetrics)[number];
|
||||
|
||||
const metricIcons: Record<SystemMetric, ReactNode> = {
|
||||
health: <LuHeartPulse className="size-4" />,
|
||||
general: <LuActivity className="size-4" />,
|
||||
enrichments: <LuSearchCode className="size-4" />,
|
||||
storage: <LuHardDrive className="size-4" />,
|
||||
cameras: <FaVideo className="size-4" />,
|
||||
};
|
||||
|
||||
function System() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -98,43 +108,66 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
{isMobile ? (
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<OverflowStrip
|
||||
items={metrics.map((item) => (
|
||||
<Toggle
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-4",
|
||||
pageToggle != item && "*:text-muted-foreground",
|
||||
)}
|
||||
size="sm"
|
||||
pressed={pageToggle == item}
|
||||
onPressedChange={() => setPageToggle(item)}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
{metricIcons[item]}
|
||||
</Toggle>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
activeIndex={metrics.indexOf(pageToggle)}
|
||||
gapClassName="gap-0.5"
|
||||
showAllLabel={t("showAllTabs")}
|
||||
showLessLabel={t("showLessTabs")}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea className="whitespace-nowrap">
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{metricIcons[item]}
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
|
||||
@@ -105,6 +105,7 @@ export type SearchQueryParams = {
|
||||
max_speed?: number;
|
||||
search_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
in_progress?: number;
|
||||
include_thumbnails?: number;
|
||||
query?: string;
|
||||
|
||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
||||
{isMobile && (
|
||||
<div className="relative flex h-11 items-center justify-between">
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
<div className="w-[45%]">
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import ExploreView from "../explore/ExploreView";
|
||||
@@ -32,7 +32,9 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { use24HourTime } from "@/hooks/use-date-utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
|
||||
type SearchViewProps = {
|
||||
@@ -80,6 +82,10 @@ export default function SearchView({
|
||||
});
|
||||
const is24Hour = use24HourTime(config);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// set by in-app links, since an iOS home screen app has no back gesture
|
||||
const canGoBack = location.state?.canGoBack === true;
|
||||
|
||||
const { data: exploreEvents } = useSWR<SearchResult[]>(
|
||||
(!searchFilter || Object.keys(searchFilter).length === 0) &&
|
||||
@@ -534,17 +540,39 @@ export default function SearchView({
|
||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||
)}
|
||||
>
|
||||
{config?.semantic_search?.enabled && (
|
||||
<div className={cn("z-[41] w-full lg:absolute lg:top-0 lg:w-1/3")}>
|
||||
<InputWithTags
|
||||
inputFocused={inputFocused}
|
||||
setInputFocused={setInputFocused}
|
||||
filters={searchFilter ?? {}}
|
||||
setFilters={setSearchFilter}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
allSuggestions={suggestionsValues}
|
||||
/>
|
||||
{(canGoBack || config?.semantic_search?.enabled) && (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[41] flex w-full flex-row items-start gap-2 lg:absolute lg:top-0 lg:w-1/3",
|
||||
)}
|
||||
>
|
||||
{canGoBack && (
|
||||
<Button
|
||||
className="flex shrink-0 items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{config?.semantic_search?.enabled && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputWithTags
|
||||
inputFocused={inputFocused}
|
||||
setInputFocused={setInputFocused}
|
||||
filters={searchFilter ?? {}}
|
||||
setFilters={setSearchFilter}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
allSuggestions={suggestionsValues}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -657,7 +657,9 @@ export default function AuthenticationView({
|
||||
</Badge>
|
||||
) : roleData.cameras.length > 5 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roleData.cameras.length} cameras
|
||||
{t("roles.table.cameraCount", {
|
||||
count: roleData.cameras.length,
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -661,7 +661,10 @@ export default function ProfilesView({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAddDialogOpen(false)}
|
||||
onClick={() => {
|
||||
setAddDialogOpen(false);
|
||||
addForm.reset();
|
||||
}}
|
||||
disabled={addingProfile}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
|
||||
@@ -558,6 +558,7 @@ export default function TriggerView({
|
||||
</Badge>
|
||||
<Link
|
||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||
state={{ canGoBack: true }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs text-muted-foreground",
|
||||
!trigger_status?.triggers[trigger.name]
|
||||
@@ -719,6 +720,7 @@ export default function TriggerView({
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||
state={{ canGoBack: true }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-sm",
|
||||
!trigger_status?.triggers[trigger.name]
|
||||
|
||||
Reference in New Issue
Block a user