mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 19:36:57 +03:00
Compare commits
3
Commits
a0342bc5f5
...
1f0994dfc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0994dfc8 | ||
|
|
a8fce5818d | ||
|
|
3b14ec0c87 |
@@ -8,6 +8,7 @@ amdgpu
|
||||
analyzeduration
|
||||
Annke
|
||||
apexcharts
|
||||
Aqara
|
||||
arange
|
||||
argmax
|
||||
argmin
|
||||
@@ -64,6 +65,7 @@ dsize
|
||||
dtype
|
||||
ECONNRESET
|
||||
edgetpu
|
||||
Eufy
|
||||
facenet
|
||||
fastapi
|
||||
faststart
|
||||
|
||||
@@ -11,7 +11,7 @@ Object classification allows you to train a custom MobileNetV2 classification mo
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom object classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
Training a custom object classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ State classification allows you to train a custom MobileNetV2 classification mod
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom state classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
Training a custom state classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -67,4 +67,6 @@ If your stream won't play, has no audio, uses excessive CPU, or otherwise misbeh
|
||||
|
||||
## Homekit Configuration
|
||||
|
||||
To add camera streams to Homekit Frigate must be configured in docker to use `host` networking mode. Once that is done, you can use the go2rtc WebUI (accessed via port 1984, which is disabled by default) to export a camera to Homekit. Any changes made will automatically be saved to `/config/go2rtc_homekit.yml`.
|
||||
To export camera streams to HomeKit, Frigate must be configured in docker to use `host` networking mode. HomeKit settings are stored in `/config/go2rtc_homekit.yml` rather than in your Frigate config, and are edited through the go2rtc config editor at `http://<frigate_host>:1984/editor.html`. Pairings are saved back to that file automatically.
|
||||
|
||||
See the [HomeKit integration docs](/integrations/homekit) for the full setup, including the video and audio requirements HomeKit places on the stream.
|
||||
|
||||
@@ -126,7 +126,7 @@ Only the fields you explicitly set in a profile override are applied. All other
|
||||
|
||||
## Activating Profiles
|
||||
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), the [HTTP API](../integrations/api/camera-set-camera-camera-name-set-feature-sub-command-put.api.mdx), or the Home Assistant integration.
|
||||
|
||||
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ The following models are downloaded automatically the first time their associate
|
||||
| [Custom classification](/configuration/custom_classification/state_classification) (training) | MobileNetV2 ImageNet base weights (via Keras) | Google storage |
|
||||
| [Audio transcription](/configuration/advanced/system) | Whisper or Sherpa-ONNX streaming model | HuggingFace / OpenAI |
|
||||
|
||||
:::note
|
||||
|
||||
The MobileNetV2 base weights are the one exception to the `/config/model_cache/` rule. They are also the only entry that is not downloaded when the feature is enabled: Frigate fetches them when a training run actually starts.
|
||||
|
||||
:::
|
||||
|
||||
### Hardware-Specific Detector Models
|
||||
|
||||
If you are using one of the following hardware detectors and have not provided your own model file, a default model will be downloaded on first startup:
|
||||
@@ -75,7 +81,7 @@ If your Frigate instance has restricted internet access, you can point model dow
|
||||
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
|
||||
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
|
||||
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Google storage (Keras default) | Custom classification training |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Unset (Keras uses its own default) | Custom classification training |
|
||||
|
||||
## Optional Cloud Services
|
||||
|
||||
@@ -147,9 +153,23 @@ When running as a Home Assistant App, the go2rtc startup script queries the loca
|
||||
To run Frigate in an air-gapped or offline environment:
|
||||
|
||||
1. **Pre-download models**: Start Frigate with internet access once with all desired features enabled. Models will be cached in `/config/model_cache/`.
|
||||
2. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
3. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
4. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
5. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, and `GITHUB_RAW_ENDPOINT` environment variables to point to local mirrors.
|
||||
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
||||
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
|
||||
### Manually Copying the Training Base Weights
|
||||
|
||||
On a machine with internet access, download the weights:
|
||||
|
||||
```bash
|
||||
curl -L -o mobilenet_v2_weights.h5 \
|
||||
"https://storage.googleapis.com/tensorflow/keras-applications/mobilenet_v2/mobilenet_v2_weights_tf_dim_ordering_tf_kernels_0.35_224_no_top.h5"
|
||||
```
|
||||
|
||||
Copy the file into your Frigate config volume as `/config/model_cache/MobileNet/mobilenet_v2_weights.h5`, keeping that exact filename, then set the environment variable `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` in your Docker compose file to the URL above and restart Frigate.
|
||||
|
||||
The variable must be set even though the URL is never contacted. If it is unset, Frigate ignores the copied file and asks Keras to download the weights instead.
|
||||
|
||||
@@ -3,35 +3,100 @@ id: homekit
|
||||
title: HomeKit
|
||||
---
|
||||
|
||||
Frigate cameras can be integrated with Apple HomeKit through go2rtc. This allows you to view your camera streams directly in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
||||
Frigate cameras can be exported to Apple HomeKit through go2rtc. Each exported camera appears as an accessory in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
||||
|
||||
## Overview
|
||||
|
||||
HomeKit integration is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server to expose your cameras to HomeKit.
|
||||
Exporting cameras is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server, so your camera is published to HomeKit as an accessory in its own right.
|
||||
|
||||
## Setup
|
||||
:::note
|
||||
|
||||
All HomeKit configuration and pairing should be done through the **go2rtc WebUI**.
|
||||
This is the opposite of importing a HomeKit camera. go2rtc can also pair with an existing HomeKit camera (Aqara, Eve, Eufy, and similar) and use it as a stream source, which is what the `add` page of the go2rtc WebUI is for. That page discovers HomeKit accessories on your network and will not list your Frigate cameras. It is not used for exporting.
|
||||
|
||||
### Accessing the go2rtc WebUI
|
||||
|
||||
The go2rtc WebUI is available at:
|
||||
|
||||
```
|
||||
http://<frigate_host>:1984
|
||||
```
|
||||
|
||||
Replace `<frigate_host>` with the IP address or hostname of your Frigate server.
|
||||
|
||||
### Pairing Cameras
|
||||
|
||||
1. Navigate to the go2rtc WebUI at `http://<frigate_host>:1984`
|
||||
2. Use the `add` section to add a new camera to HomeKit
|
||||
3. Follow the on-screen instructions to generate pairing codes for your cameras
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
- Frigate must be accessible on your local network using host network_mode
|
||||
- Your iOS device must be on the same network as Frigate
|
||||
- Port 1984 must be accessible for the go2rtc WebUI
|
||||
- For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc)
|
||||
- Frigate must be running with `network_mode: host` so that HomeKit can discover your cameras over mDNS
|
||||
- Your Apple device must be on the same network as Frigate
|
||||
- Port 1984 must be accessible so you can reach the go2rtc WebUI
|
||||
|
||||
HomeKit also places strict limits on the stream itself. go2rtc passes your stream through without resizing or re-encoding it, so the stream you export must already meet these requirements:
|
||||
|
||||
- **Video:** H.264 at 1920x1080, 1280x720, or 320x240
|
||||
- **Audio:** Opus, mono, 16 kHz
|
||||
|
||||
A camera's full resolution stream usually does not qualify. See [Exporting a compatible stream](#exporting-a-compatible-stream) below.
|
||||
|
||||
## Configuration
|
||||
|
||||
HomeKit settings are stored in `/config/go2rtc_homekit.yml`. This is a separate file from your Frigate config, because go2rtc needs to write your pairings back to it when you pair a device.
|
||||
|
||||
Edit it using the go2rtc config editor, which writes to that file directly:
|
||||
|
||||
```
|
||||
http://<frigate_host>:1984/editor.html
|
||||
```
|
||||
|
||||
Replace `<frigate_host>` with the IP address or hostname of your Frigate server. The editor will be empty until you add a HomeKit section, since this file holds only your HomeKit settings and not the rest of your go2rtc config.
|
||||
|
||||
:::warning
|
||||
|
||||
Do not put the `homekit:` section in the `go2rtc:` section of your Frigate config.
|
||||
|
||||
Frigate regenerates that config on every startup, so go2rtc cannot save your pairings to it. Pairing will appear to succeed and then fail after the next restart with `PairVerify with unknown client_id`. If the section exists in both places, your saved pairings are erased on every restart.
|
||||
|
||||
:::
|
||||
|
||||
Add an entry for each camera you want to export. The key must match the name of a go2rtc stream, and the pin must be 8 digits. This is the number the Home app calls the setup code:
|
||||
|
||||
```yaml
|
||||
homekit:
|
||||
front_door:
|
||||
name: Front Door
|
||||
pin: "12345678"
|
||||
```
|
||||
|
||||
If the key does not match a go2rtc stream, go2rtc logs `[homekit] missing stream:` at startup and the camera will not appear in the Home app.
|
||||
|
||||
:::note
|
||||
|
||||
go2rtc derives each accessory's HomeKit identity from this key, so renaming it later means the camera appears as a new accessory and has to be paired again. Settle on the name before you pair.
|
||||
|
||||
:::
|
||||
|
||||
Frigate keeps only the `homekit:` section of this file when it starts, so do not store streams or other go2rtc settings in it.
|
||||
|
||||
### Exporting a compatible stream
|
||||
|
||||
If a camera's stream does not meet the requirements listed above, define a scaled restream in your Frigate config and point HomeKit at that stream instead of the original:
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
front_door:
|
||||
- rtsp://user:password@192.168.1.50:554/stream
|
||||
front_door_homekit:
|
||||
- "ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# /config/go2rtc_homekit.yml
|
||||
homekit:
|
||||
front_door_homekit:
|
||||
name: Front Door
|
||||
pin: "12345678"
|
||||
```
|
||||
|
||||
Add `#hardware=cuda`, `#hardware=vaapi`, or the appropriate value for your system to transcode using your GPU. Note that NVENC cannot encode H.264 wider than 4096 pixels, so very wide streams must be scaled down as shown above rather than only re-encoded.
|
||||
|
||||
## Pairing Cameras
|
||||
|
||||
1. Restart Frigate after adding the `homekit:` section
|
||||
2. In the Apple Home app, choose **Add Accessory**, then **More options** to enter a code manually
|
||||
3. Select your camera and enter the pin you configured as the setup code
|
||||
4. Confirm that a `pairings:` list now appears under the camera in `/config/go2rtc_homekit.yml`
|
||||
|
||||
Pairings are saved back to that file automatically. If step 4 shows no `pairings:` list, check the Frigate log for `[homekit] can't save`, which means the `homekit:` section is missing from `/config/go2rtc_homekit.yml`.
|
||||
|
||||
For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc).
|
||||
|
||||
Vendored
+74
@@ -693,6 +693,43 @@ paths:
|
||||
**Access:** Admin role required.
|
||||
|
||||
Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
operationId:
|
||||
camera_set_camera__camera_name__set__feature___sub_command__put
|
||||
parameters:
|
||||
@@ -746,6 +783,43 @@ paths:
|
||||
**Access:** Admin role required.
|
||||
|
||||
Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
operationId: camera_set_camera__camera_name__set__feature__put
|
||||
parameters:
|
||||
- name: camera_name
|
||||
|
||||
+39
-1
@@ -1328,7 +1328,45 @@ def camera_set(
|
||||
body: CameraSetBody,
|
||||
sub_command: str | None = None,
|
||||
):
|
||||
"""Set a camera feature state. Use camera_name='*' to target all cameras."""
|
||||
"""Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
"""
|
||||
dispatcher = request.app.dispatcher
|
||||
frigate_config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
|
||||
+12
-8
@@ -625,14 +625,18 @@ class OnvifController:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name].motor_stopped.clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
|
||||
camera_name
|
||||
].frame_time.value
|
||||
self.ptz_metrics[camera_name].stop_time.value = 0
|
||||
|
||||
# only track start_time for autotracking
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
self.ptz_metrics[camera_name].motor_stopped.clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
|
||||
camera_name
|
||||
].frame_time.value
|
||||
self.ptz_metrics[camera_name].stop_time.value = 0
|
||||
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
|
||||
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ONVIF init state that must not depend on the autotracking config.
|
||||
"""Tests for ONVIF state that must not depend on the autotracking config.
|
||||
|
||||
Regression coverage for a camera that is initialized while autotracking is off and
|
||||
has it enabled later, which is the normal wizard flow: set the camera up first,
|
||||
@@ -10,12 +10,17 @@ the tracking thread.
|
||||
|
||||
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||
they are always created and init=True now implies they exist.
|
||||
|
||||
Also covers the inverse direction: the ptz movement timestamps must not be written
|
||||
for a camera that has autotracking off, because nothing clears them back out.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
@@ -97,6 +102,36 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
return controller
|
||||
|
||||
|
||||
def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
"""Build an already initialized controller for a camera that supports relative
|
||||
FOV movement, with real metrics so the timestamp writes can be asserted on."""
|
||||
config = _config(autotracking_enabled)
|
||||
controller = OnvifController.__new__(OnvifController)
|
||||
controller.config = config
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.failed_cams = {}
|
||||
|
||||
ptz = MagicMock()
|
||||
ptz.RelativeMove = AsyncMock()
|
||||
controller.cams = {
|
||||
CAMERA: {
|
||||
"init": True,
|
||||
"active": False,
|
||||
"ptz": ptz,
|
||||
"features": ["pt", "pt-r-fov"],
|
||||
"relative_move_request": MagicMock(),
|
||||
"relative_fov_range": {
|
||||
"XRange": {"Min": -1.0, "Max": 1.0},
|
||||
"YRange": {"Min": -1.0, "Max": 1.0},
|
||||
},
|
||||
}
|
||||
}
|
||||
controller.ptz_metrics = {
|
||||
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||
}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
# the wizard flow: onvif configured first, autotracking enabled later
|
||||
@@ -143,5 +178,61 @@ class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
ptz.GetStatus.assert_not_called()
|
||||
|
||||
|
||||
class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
||||
"""A manual move from the UI (click to move, drag to zoom) sends move_relative
|
||||
for any camera that advertises pt-r-fov, autotracking or not."""
|
||||
|
||||
async def test_metrics_untouched_when_autotracking_disabled(self) -> None:
|
||||
# only camera_maintenance polls get_camera_status, and only for autotracking
|
||||
# cameras, so a manual move that starts the clock here is never stopped
|
||||
controller = _make_move_controller(autotracking_enabled=False)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
controller.cams[CAMERA]["ptz"].RelativeMove.assert_awaited_once()
|
||||
self.assertEqual(metrics.start_time.value, 0)
|
||||
self.assertEqual(metrics.stop_time.value, 0)
|
||||
self.assertTrue(metrics.motor_stopped.is_set())
|
||||
|
||||
async def test_detection_regions_not_suppressed_after_manual_move(self) -> None:
|
||||
# the symptom of the bug: object detection stops entirely because motion
|
||||
# boxes are never promoted to detection regions again
|
||||
controller = _make_move_controller(autotracking_enabled=False)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
for later_frame_time in (1001.0, 1060.0, 4600.0):
|
||||
with self.subTest(frame_time=later_frame_time):
|
||||
self.assertFalse(
|
||||
ptz_moving_at_frame_time(
|
||||
later_frame_time,
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
)
|
||||
|
||||
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
||||
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
||||
# autotracking path keeps its motion estimation timestamps
|
||||
controller = _make_move_controller(autotracking_enabled=True)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
self.assertEqual(metrics.start_time.value, 1000.0)
|
||||
self.assertEqual(metrics.stop_time.value, 0)
|
||||
self.assertFalse(metrics.motor_stopped.is_set())
|
||||
self.assertTrue(
|
||||
ptz_moving_at_frame_time(
|
||||
1001.0, metrics.start_time.value, metrics.stop_time.value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+11
-6
@@ -358,12 +358,17 @@ def process_frames(
|
||||
]
|
||||
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
):
|
||||
# the ptz timestamps are only maintained while autotracking is on, so gate
|
||||
# on the metric rather than trusting them to be reset otherwise
|
||||
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||
ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
)
|
||||
)
|
||||
|
||||
if not motion_detector.is_calibrating() and not ptz_moving:
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Camera live playback stream settings tests -- MEDIUM tier.
|
||||
*
|
||||
* The live streams field maps a display name to a go2rtc stream. Switching
|
||||
* cameras from the selector keeps the form mounted and only swaps its data, so
|
||||
* the stream name input has to follow the newly selected camera. It used to be
|
||||
* an uncontrolled input, which left the previous camera's stream name on screen
|
||||
* and renamed the wrong key if the stale text was ever committed.
|
||||
*
|
||||
* Renames are committed per keystroke so the section is marked as modified
|
||||
* right away, except while the typed name belongs to another stream, since
|
||||
* renaming onto an existing name merges the two entries.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const GO2RTC_STREAMS = {
|
||||
front_door_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
|
||||
backyard_main: ["rtsp://user:pass@192.168.0.21:554/Stream1"],
|
||||
};
|
||||
|
||||
const CAMERA_LIVE_STREAMS = {
|
||||
front_door: { front_door: "front_door_main" },
|
||||
backyard: { backyard: "backyard_main" },
|
||||
};
|
||||
|
||||
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
|
||||
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorStreams: Record<string, string> = CAMERA_LIVE_STREAMS.front_door,
|
||||
) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
front_door: { live: { streams: frontDoorStreams } },
|
||||
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
|
||||
},
|
||||
});
|
||||
|
||||
let lastSavedConfig: unknown = null;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
front_door: { live: { streams: frontDoorStreams } },
|
||||
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSavedConfig = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
|
||||
return { capturedConfig: () => lastSavedConfig };
|
||||
}
|
||||
|
||||
async function selectCamera(page: Page, friendlyName: string) {
|
||||
await page.getByRole("button", { name: "Select a camera" }).click();
|
||||
await page.getByRole("switch", { name: friendlyName }).click();
|
||||
}
|
||||
|
||||
function streamNameInputs(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Stream name" });
|
||||
}
|
||||
|
||||
function streamNames(page: Page) {
|
||||
return streamNameInputs(page).evaluateAll((inputs) =>
|
||||
inputs.map((input) => (input as HTMLInputElement).value),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows render in config order, which is not the order they were declared in. */
|
||||
async function streamNameRow(page: Page, name: string) {
|
||||
await expect.poll(() => streamNames(page)).toContain(name);
|
||||
const names = await streamNames(page);
|
||||
return streamNameInputs(page).nth(names.indexOf(name));
|
||||
}
|
||||
|
||||
test.describe("camera live playback streams @medium", () => {
|
||||
test("switching cameras updates the stream name field", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const streamName = frigateApp.page.getByRole("textbox", {
|
||||
name: "Stream name",
|
||||
});
|
||||
await expect(streamName).toHaveValue("front_door");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
|
||||
).toContainText("front_door_main");
|
||||
|
||||
await selectCamera(frigateApp.page, "Backyard");
|
||||
|
||||
await expect(streamName).toHaveValue("backyard");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
|
||||
).toContainText("backyard_main");
|
||||
});
|
||||
|
||||
test("typing a new name enables Save without leaving the field", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
const streamName = await streamNameRow(frigateApp.page, "front_door");
|
||||
await streamName.click();
|
||||
await frigateApp.page.keyboard.press("End");
|
||||
await frigateApp.page.keyboard.type("_hd");
|
||||
|
||||
// Still focused: the rename is committed per keystroke, not on blur.
|
||||
await expect(save).toBeEnabled();
|
||||
await expect(streamName).toBeFocused();
|
||||
await expect(streamName).toHaveValue("front_door_hd");
|
||||
});
|
||||
|
||||
test("typing through another stream's name keeps both streams", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
front: "front_door_main",
|
||||
front_door: "backyard_main",
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const streamName = await streamNameRow(frigateApp.page, "front_door");
|
||||
await streamName.click();
|
||||
await frigateApp.page.keyboard.press("End");
|
||||
// "front_door" passes through "front", which the other row already uses.
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await expect(streamName).toHaveValue("front");
|
||||
await frigateApp.page.keyboard.type("yard");
|
||||
await streamName.blur();
|
||||
|
||||
expect(await streamNames(frigateApp.page)).toEqual(["frontyard", "front"]);
|
||||
});
|
||||
|
||||
test("renaming a stream saves the new name for the selected camera", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await selectCamera(frigateApp.page, "Backyard");
|
||||
|
||||
const streamName = frigateApp.page.getByRole("textbox", {
|
||||
name: "Stream name",
|
||||
});
|
||||
await expect(streamName).toHaveValue("backyard");
|
||||
await streamName.fill("Backyard HD");
|
||||
// The rename is committed on blur, not on every keystroke.
|
||||
await streamName.blur();
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
cameras: {
|
||||
backyard: {
|
||||
live: { streams: { "Backyard HD": "backyard_main" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { canExpand } from "@rjsf/utils";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
interface AddPropertyButtonProps {
|
||||
/** Callback fired when the add button is clicked */
|
||||
@@ -67,6 +68,72 @@ export function AddPropertyButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface MapKeyInputProps {
|
||||
/** DOM id used for label association */
|
||||
id: string;
|
||||
/** The committed key as it exists in the form data */
|
||||
value: string;
|
||||
/** Placeholder shown when the input is empty */
|
||||
placeholder?: string;
|
||||
/** Whether the input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Called with the edited key when it is safe to commit */
|
||||
onCommit: (next: string) => void;
|
||||
/** Whether another entry already uses this key, which defers the commit */
|
||||
isKeyTaken?: (next: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text input for the key of a map entry (e.g. a live stream name).
|
||||
*
|
||||
* The edit is kept in local state so that the draft can be re-synced whenever
|
||||
* the committed key changes underneath the input, which is what happens when
|
||||
* the selected camera changes while the field stays mounted.
|
||||
*
|
||||
* Each keystroke is committed so the section is marked as modified right away,
|
||||
* except while the typed key belongs to another entry: renaming onto an
|
||||
* existing key merges the two entries, so a name typed through a neighbor's
|
||||
* name would silently drop it. Those keystrokes stay local until the key is
|
||||
* free again or the input is blurred.
|
||||
*/
|
||||
export function MapKeyInput({
|
||||
id,
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
onCommit,
|
||||
isKeyTaken,
|
||||
}: MapKeyInputProps) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setDraft(next);
|
||||
|
||||
if (!isKeyTaken?.(next)) {
|
||||
onCommit(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onBlur={() => onCommit(draft)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdvancedCollapsibleProps {
|
||||
/** Number of advanced fields */
|
||||
count: number;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
import { MapKeyInput } from "../components";
|
||||
|
||||
type KnownPlatesData = Record<string, string[]>;
|
||||
|
||||
@@ -194,12 +195,16 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
defaultValue={key}
|
||||
value={key}
|
||||
placeholder={namePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={(e) => handleRenameKey(key, e.target.value)}
|
||||
onCommit={(next) => handleRenameKey(key, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== key &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { MapKeyInput } from "../components";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
@@ -288,12 +288,16 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
|
||||
<Input
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
defaultValue={key}
|
||||
value={key}
|
||||
placeholder={streamNamePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={(e) => handleRenameKey(key, e.target.value)}
|
||||
onCommit={(next) => handleRenameKey(key, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== key &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
|
||||
Reference in New Issue
Block a user