Compare commits

...
Author SHA1 Message Date
Josh Hawkins 7a22963e6d fix test 2026-06-09 16:37:29 -05:00
Josh Hawkins 3aadc88a29 turn on switch by default if pan and/or tilt capability is available 2026-06-09 16:37:07 -05:00
Josh Hawkins 470abbab9d tweaks 2026-06-09 16:24:16 -05:00
Josh Hawkins 50591cb2fa backend add and remove subscriber 2026-06-09 15:58:34 -05:00
Josh Hawkins b4b946c624 add e2e test 2026-06-09 15:27:48 -05:00
Josh Hawkins c79d6cf2ea i18n 2026-06-09 15:27:38 -05:00
Josh Hawkins 7b83b936ab add ptz controls to camera via wizard when onvif has already been probed 2026-06-09 15:27:31 -05:00
Josh HawkinsandGitHub f3a352ef3f Miscellaneous fixes (#23413)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* update e2e mock data to remove deprecated fields

* remove scream audio label

scream was never mapped to anything in frigate's custom labelmap, yell is used everywhere

* document common audio labels

* deprecate ffmpeg 5

* language tweak

* add field message to recommend presets instead of manual hwaccel args

* add guidance to docs on choosing a detect fps
2026-06-08 09:14:16 -06:00
24 changed files with 630 additions and 27 deletions
@@ -212,7 +212,6 @@ audio:
listen:
- bark
- fire_alarm
- scream
- speech
- yell
# Optional: Filters to configure detection.
+65 -2
View File
@@ -88,7 +88,7 @@ Volume is considered motion for recordings, this means when the `record -> retai
### Configuring Audio Events
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `scream`, `speech`, and `yell` are enabled but these can be customized.
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `speech`, and `yell` are enabled but these can be customized.
<ConfigTabs>
<TabItem value="ui">
@@ -107,7 +107,6 @@ audio:
listen:
- bark
- fire_alarm
- scream
- speech
- yell
```
@@ -115,6 +114,70 @@ audio:
</TabItem>
</ConfigTabs>
### Common Audio Labels
The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly.
Some labels cover several related sounds: `yell` is triggered by shouting, yelling, children shouting, and screaming; `crying` covers baby cries, sobbing, and whimpering; and `speech` covers ordinary talking and conversation.
**Safety and security**
| Label | Detects |
| ---------------- | ---------------------------------- |
| `yell` | Shouting, yelling, screaming |
| `fire_alarm` | Fire and smoke alarm sirens |
| `smoke_detector` | Smoke detector beeps |
| `alarm` | General alarm sounds |
| `car_alarm` | Car alarms |
| `siren` | Emergency vehicle and civil sirens |
| `glass` | Glass clinking |
| `shatter` | Breaking glass |
| `breaking` | Something breaking |
| `gunshot` | Gunshots |
| `explosion` | Explosions |
**People and activity**
| Label | Detects |
| ----------- | ------------------------ |
| `speech` | Talking and conversation |
| `laughter` | Laughing |
| `crying` | Baby crying and sobbing |
| `cough` | Coughing |
| `footsteps` | Footsteps and walking |
| `knock` | Knocking on a door |
| `doorbell` | Doorbell |
| `ding-dong` | Doorbell chime |
**Pets and animals**
| Label | Detects |
| ---------- | ---------------- |
| `bark` | Dog barking |
| `dog` | Other dog sounds |
| `howl` | Howling |
| `growling` | Growling |
| `meow` | Cat meowing |
| `cat` | Other cat sounds |
| `hiss` | Hissing |
**Vehicles and driveway**
| Label | Detects |
| ----------------- | -------------------- |
| `car` | Passing cars |
| `honk` | Car horns |
| `truck` | Trucks |
| `reversing_beeps` | Vehicle backup beeps |
| `motorcycle` | Motorcycles |
| `engine_starting` | Engines starting |
:::tip
Frequently-heard labels like `speech` can generate a lot of events, and each event could save a snapshot and recording based on your configuration, so start with a focused set — the defaults (`bark`, `fire_alarm`, `speech`, `yell`) plus a few of the safety labels above cover most needs — and expand from there. See the [full audio labelmap](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) or the Frigate UI for every available type.
:::
### Audio Transcription
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
+39 -1
View File
@@ -5,7 +5,7 @@ title: Camera setup
Cameras configured to output H.264 video and AAC audio will offer the most compatibility with all features of Frigate and Home Assistant. H.265 has better compression, but less compatibility. Firefox 134+/136+/137+ (Windows/Mac/Linux & Android), Chrome 108+, Safari and Edge are the only browsers able to play H.265 and only support a limited number of H.265 profiles. Ideally, cameras should be configured directly for the desired resolutions and frame rates you want to use in Frigate. Reducing frame rates within Frigate will waste CPU resources decoding extra frames that are discarded. There are three different goals that you want to tune your stream configurations around.
- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The recommended frame rate is 5fps, but may need to be higher (10fps is the recommended maximum for most users) for very fast moving objects. Higher resolutions and frame rates will drive higher CPU usage on your server.
- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The default frame rate of 5fps is correct for almost all cameras and rarely needs to be changed; see [Choosing a detect frame rate](#choosing-a-detect-frame-rate). Higher resolutions and frame rates will drive higher CPU usage on your server.
- **Recording**: This stream should be the resolution you wish to store for reference. Typically, this will be the highest resolution your camera supports. I recommend setting this feed in your camera's firmware to 15 fps.
@@ -25,6 +25,44 @@ Larger resolutions **do** improve performance if the objects are very small in t
![Resolutions](/img/resolutions-min.jpg)
### Choosing a detect frame rate
`detect.fps` controls how many times per second Frigate runs object detection — it does **not** need to match your camera's frame rate. The default of **5** is correct for the vast majority of cameras.
:::warning
Most users who raise `detect.fps` above the default don't need to. Increasing it consumes more CPU/GPU (detection load scales directly with the frame rate) while providing **no benefit to tracking** once objects are already being followed smoothly. Leave it at **5** unless you have a specific scene that fails the test below, and confirm any change actually helps in the debug view.
:::
#### Why 5 is enough for almost everyone
Frigate follows an object by matching its bounding box from one detection frame to the next, which requires the object to be detected often enough while it is on screen. At 5 fps this is satisfied in normal scenes: an object crossing a yard, porch, driveway, or walkway is in view for several seconds and produces ~15 or more detections, which is more than enough for a reliable track and a good snapshot. This includes fast subjects such as a running person or a bolting pet, which on a wide-angle view remain on screen for several seconds.
A higher rate helps only when an object crosses the **entire frame in less than two seconds**, which is determined by camera framing rather than object speed - for example, a camera aimed down a street at fast cross-traffic. In those scenes 5 fps may produce too few detections to hold a track. Cameras covering normal approaches and open areas are unaffected.
#### Checking whether a higher rate is needed
Estimate how long an object is visible as it crosses the area of interest, aiming for roughly 810 detections during the pass:
> **`detect.fps` ≈ 10 ÷ (seconds the object is in view)**
Most objects — people walking or running, pets, and vehicles in a yard, driveway, or walkway — stay in view for two seconds or more, so the default of 5 fps is correct. Slowly try raising it to 10 (the recommended maximum) in increments only when objects routinely cross the entire frame in about a second, such as a camera aimed at a street or sidewalk with fast cross-traffic. Objects that transit in under a second cannot be tracked reliably at any practical rate, so reposition the camera instead.
:::tip
If the formula calls for more than 10, the fix is **camera placement, not frame rate**. Angle the camera so objects move toward it rather than across the view, or aim it where traffic slows. A higher `detect.fps` increases CPU load proportionally without producing more detections of a too-brief object.
:::
#### Verify in the debug view
Confirm any change in the Debug view or Debug Replay. Watch a typical object cross the scene: if its bounding box follows it smoothly while visible, the rate is sufficient. A box that jumps erratically, drops out, or splits one object into multiple events indicates the rate should be increased one step.
#### Dedicated LPR cameras
A dedicated license plate recognition camera is the most common reason to use something higher than 5 fps: the camera is highly zoomed, the plate is small, and it moves at full vehicle speed, so it transits the frame quickly. However, the same ceiling applies: above 10 fps is unnecessary, and **placement matters most**: aim LPR cameras where vehicles slow down, such as gates, driveways, and parking entrances. A tight view of a fast through-road will not likely read plates reliably at any frame rate. See [License Plate Recognition](/configuration/license_plate_recognition) for details.
### Example Camera Configuration
For the Dahua/Loryta 5442 camera, I use the following settings:
+37
View File
@@ -591,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"]))],
@@ -748,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
@@ -781,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
@@ -900,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,
}
+2 -2
View File
@@ -9,7 +9,7 @@ from ..base import FrigateBaseModel
__all__ = ["AudioConfig", "AudioFilterConfig"]
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"]
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "speech", "yell"]
class AudioFilterConfig(FrigateBaseModel):
@@ -41,7 +41,7 @@ class AudioConfig(FrigateBaseModel):
listen: list[str] = Field(
default=DEFAULT_LISTEN_AUDIO,
title="Listen types",
description="List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell).",
description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).",
)
filters: Optional[dict[str, AudioFilterConfig]] = Field(
None,
+1 -1
View File
@@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
path: str = Field(
default="default",
title="FFmpeg path",
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").',
description='Path to the FFmpeg binary to use or a version alias ("7.0" or "8.0").',
)
global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
+27 -1
View File
@@ -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")
+1 -1
View File
@@ -1 +1 @@
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}]
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1780597809.365581, "updated_at": 1780673409.365581}]
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1780677009.365581, "end_time": 1780677039.365581, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1780673409.365581, "end_time": 1780673454.365581, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1780669809.365581, "end_time": 1780669829.365581, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]
+1 -1
View File
@@ -1 +1 @@
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1780680609.365581, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1780673409.365581, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1780682409.365581, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]
@@ -1 +1 @@
{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}
{"2026-06-05": {"day": "2026-06-05", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-06-04": {"day": "2026-06-04", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}
+1 -1
View File
@@ -1 +1 @@
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-06-05T11:30:09.365581", "end_time": "2026-06-05T11:30:39.365581", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-06-05T10:30:09.365581", "end_time": "2026-06-05T10:30:54.365581", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-06-05T09:30:09.365581", "end_time": "2026-06-05T09:30:29.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-06-05T08:30:09.365581", "end_time": "2026-06-05T08:30:24.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]
@@ -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);
});
});
+2 -2
View File
@@ -29,7 +29,7 @@
},
"listen": {
"label": "Listen types",
"description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)."
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
},
"filters": {
"label": "Audio filters",
@@ -156,7 +156,7 @@
"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 (\"5.0\" or \"7.0\")."
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
},
"global_args": {
"label": "FFmpeg global arguments",
+2 -2
View File
@@ -547,7 +547,7 @@
},
"listen": {
"label": "Listen types",
"description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)."
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
},
"filters": {
"label": "Audio filters",
@@ -683,7 +683,7 @@
"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 (\"5.0\" or \"7.0\")."
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
},
"global_args": {
"label": "FFmpeg global arguments",
+14 -1
View File
@@ -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": {
@@ -1914,6 +1924,9 @@
"resolutionHigh": "This detect resolution is higher than recommended and may cause increased resource usage without improving detection accuracy. A detect resolution at or below 1080p is recommended for most cameras.",
"globalResolutionMultipleCameras": "A global detect resolution is set while multiple cameras are configured. Unless all cameras share the same resolution and aspect ratio, the detect width and height should be defined per camera to match each camera's native aspect ratio."
},
"ffmpeg": {
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware."
},
"objects": {
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
},
@@ -22,6 +22,27 @@ const ffmpegArgsWidget = (
const ffmpeg: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/ffmpeg_presets",
fieldMessages: [
{
key: "hwaccel-manual-not-recommended",
field: "hwaccel_args",
position: "after",
messageKey: "configMessages.ffmpeg.hwaccelManualNotRecommended",
severity: "warning",
condition: (ctx) => {
// Manual mode is active when hwaccel_args is an explicit args list
// or a non-preset string
const value = ctx.formData?.hwaccel_args;
if (Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === "string") {
return !value.startsWith("preset-");
}
return false;
},
},
],
fieldDocs: {
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
"inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets",
@@ -386,11 +386,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec);
// Read field-level conditional messages from FieldMessagesContext
// Read field-level conditional messages from FieldMessagesContext.
// For multi-schema fields (anyOf/oneOf), FieldTemplate renders twice for
// the same path (wrapper + inner branch); skip the wrapper pass so the
// message isn't shown twice, mirroring how labels/descriptions dedupe.
const fieldPathStr = pathSegments.join(".");
const fieldMessageSpecs = allFieldMessages.filter(
(m) => m.field === fieldPathStr,
);
const fieldMessageSpecs = isMultiSchemaWrapper
? []
: allFieldMessages.filter((m) => m.field === fieldPathStr);
const beforeMessages = fieldMessageSpecs.filter(
(m) => (m.position ?? "before") === "before",
);
@@ -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);
+14
View File
@@ -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;