Compare commits

...
Author SHA1 Message Date
Josh HawkinsandGitHub 2d92954d5c Merge 7a22963e6d into d982b3a782 2026-06-21 05:03:00 -10:00
d982b3a782 perf(util): use monotonic clock and bounded deque in EventsPerSecond (#23520)
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
* perf(util): use monotonic clock and bounded deque in EventsPerSecond

EventsPerSecond is updated on every captured frame, every detection and
every processed frame across all cameras and detectors. The previous
implementation derived timestamps from datetime.now().timestamp() (wall
clock), so an NTP or manual clock adjustment could skew the rolling-window
expiry; it also stored timestamps in a list and expired them with
del self._timestamps[0] (O(n) per removal) plus a periodic slice-copy to
cap growth.

Switch to time.monotonic() for the interval math (correct by construction
and immune to wall-clock jumps) and a collections.deque(maxlen=...) so
expiry is O(1) (popleft) and retention is bounded automatically. This
mirrors the deque-based expiry already used in video/ffmpeg.py and
watchdog.py. Observable output is unchanged.

Adds frigate/test/test_builtin.py covering rate calculation, window
expiry and the memory bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop test_timestamps_are_memory_bounded

It only asserted that deque(maxlen=) caps length, which is stdlib behavior
rather than something this change needs to verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:38:41 -06:00
Josh HawkinsandGitHub d036061e3f cache the preview_frames directory listing so concurrent per-camera frame requests share one scan instead of each re-listing the whole directory (#23526)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-06-20 14:56:05 -05:00
Josh HawkinsandGitHub 5003ab895c add camera search, select-all/clear, and group selection to the multi-camera export dialog (#23516)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
2026-06-19 15:50:19 -06:00
Josh HawkinsandGitHub 652ea2454f Miscellaneous fixes (#23513)
* display zone names consistently using friendly_name or raw id without transformation

* enforce camera-level access on go2rtc live stream websocket endpoints
2026-06-19 10:10:22 -06:00
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
20 changed files with 1022 additions and 62 deletions
+74 -1
View File
@@ -12,6 +12,7 @@ import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
@@ -26,7 +27,11 @@ from frigate.api.defs.request.app_body import (
AppPutRoleBody,
)
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri
from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
)
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
@@ -658,6 +663,10 @@ def auth(request: Request):
if deny_status is not None:
return Response("", status_code=deny_status)
deny_status = deny_response_for_go2rtc_stream(original_url, role, request)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
# now apply authentication
@@ -757,6 +766,10 @@ def auth(request: Request):
if deny_status is not None:
return Response("", status_code=deny_status)
deny_status = deny_response_for_go2rtc_stream(original_url, role, request)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
except Exception as e:
logger.error(f"Error parsing jwt: {e}")
@@ -1112,6 +1125,66 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
return owner_cameras
# nginx proxies these paths straight to go2rtc with authentication-only checks
# (see auth_request.conf). Each names the desired stream via the `src` query
# param, so the camera-level check must happen here in the `/auth` subrequest —
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
# endpoint, not these proxied live-stream paths.
GO2RTC_STREAM_PROXY_PATHS = frozenset(
{
"/live/mse/api/ws",
"/live/webrtc/api/ws",
"/api/go2rtc/webrtc",
}
)
def deny_response_for_go2rtc_stream(
original_url: Optional[str], role: Optional[str], request: Request
) -> Optional[int]:
"""Block role-restricted users from go2rtc live streams they cannot access.
Returns 403 when any `src` stream named in `original_url` resolves to a
camera outside the role's allow-list (or when no `src` is provided on a
stream-proxy path), otherwise None. Mirrors the resolution logic in
`require_go2rtc_stream_access` so substream names map to their owning
camera correctly.
"""
if not original_url:
return None
parsed = urlparse(original_url)
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
return None
frigate_config = request.app.frigate_config
# admin and full-access roles (no allow-list) bypass the camera check
if not role or not is_role_restricted(role, frigate_config):
return None
sources = parse_qs(parsed.query).get("src", [])
if not sources:
# a stream-proxy request naming no stream has nothing legitimate to
# show a restricted user
return 403
allowed_cameras = set(
User.get_allowed_cameras(
role,
frigate_config.auth.roles,
set(frigate_config.cameras.keys()),
)
)
# deny if any requested source resolves outside the allow-list
for src in sources:
if not (_get_stream_owner_cameras(request, src) & allowed_cameras):
return 403
return None
async def require_go2rtc_stream_access(
stream_name: Optional[str] = None,
request: Request = None,
+37
View File
@@ -637,6 +637,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 +820,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 +854,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 +982,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,
}
+36 -16
View File
@@ -1,7 +1,9 @@
"""Preview apis."""
import bisect
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
import pytz
@@ -133,6 +135,32 @@ def preview_hour(
return preview_ts(camera_name, start_ts, end_ts, allowed_cameras)
# cache one sorted listing of the shared preview_frames dir
_preview_listing_lock = threading.Lock()
_preview_listing_cache: tuple[float, list[str]] = (-1.0, [])
def _get_preview_frame_listing(preview_dir: str) -> list[str]:
"""Return the sorted preview_frames listing, cached until the dir changes."""
global _preview_listing_cache
# mtime bumps when a frame is added or removed, invalidating the cache
mtime = os.stat(preview_dir).st_mtime
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
with _preview_listing_lock:
# another thread may have refreshed the cache while we waited
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
files = sorted(entry.name for entry in os.scandir(preview_dir))
_preview_listing_cache = (mtime, files)
return files
@router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
response_model=PreviewFramesResponse,
@@ -149,23 +177,15 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
files = _get_preview_frame_listing(preview_dir)
# a camera's frames form a contiguous slice of the sorted listing;
# bisect locates it without scanning the whole directory
left = bisect.bisect_left(files, start_file)
right = bisect.bisect_right(files, end_file)
selected_previews = [
file for file in files[left:right] if file.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
if file < start_file:
continue
if file > end_file:
break
selected_previews.append(file)
return JSONResponse(
content=selected_previews,
+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")
+41
View File
@@ -0,0 +1,41 @@
"""Tests for frigate.util.builtin helpers."""
import unittest
from unittest.mock import patch
from frigate.util.builtin import EventsPerSecond
class TestEventsPerSecond(unittest.TestCase):
def test_eps_is_zero_before_any_events(self) -> None:
eps = EventsPerSecond()
with patch("frigate.util.builtin.time.monotonic", return_value=100.0):
self.assertEqual(eps.eps(), 0.0)
def test_eps_counts_events_in_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# one event per second for five seconds
for _ in range(5):
clock[0] += 1.0
eps.update()
# five events over the five seconds since start
self.assertAlmostEqual(eps.eps(), 1.0)
def test_old_timestamps_expire_from_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [0.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
for _ in range(10):
clock[0] += 1.0
eps.update()
# jump well past the window so every timestamp ages out
clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
"""Unit tests for `deny_response_for_go2rtc_stream`.
Covers the camera-level authorization enforced in the `/auth` subrequest for
the nginx-proxied go2rtc live-stream paths (MSE/WebRTC WebSockets and the
WebRTC signaling endpoint). These paths name the stream via the `src` query
param, which the static-media auth in `media_auth` does not inspect.
"""
import types
import unittest
from frigate.api.auth import deny_response_for_go2rtc_stream
from frigate.config import FrigateConfig
_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {
"roles": {
"limited_user": ["front_door"],
"dual_user": ["front_door", "back_door"],
}
},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
# go2rtc stream name differs from the camera name (substream)
"live": {"streams": {"Main Stream": "front_door_sub"}},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"garage": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
def _request(config: FrigateConfig) -> types.SimpleNamespace:
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
def setUp(self) -> None:
self.config = FrigateConfig(**_CONFIG)
self.request = _request(self.config)
def _deny(self, url: str, role: str):
return deny_response_for_go2rtc_stream(url, role, self.request)
# --- non-stream paths pass through ---
def test_non_stream_path_passes_through(self):
self.assertIsNone(
self._deny("http://host/clips/back_door-1.jpg", "limited_user")
)
def test_empty_url_passes_through(self):
self.assertIsNone(self._deny("", "limited_user"))
def test_jsmpeg_path_not_handled_here(self):
# jsmpeg is authorized per-frame in the output pipeline, not here
self.assertIsNone(
self._deny("http://host/live/jsmpeg/back_door", "limited_user")
)
# --- restricted role: allowed vs forbidden cameras ---
def test_mse_allowed_camera(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door", "limited_user")
)
def test_mse_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=back_door", "limited_user"),
403,
)
def test_webrtc_ws_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/webrtc/api/ws?src=back_door", "limited_user"),
403,
)
def test_webrtc_signaling_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/api/go2rtc/webrtc?src=back_door", "limited_user"),
403,
)
def test_unknown_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=nonexistent", "limited_user"),
403,
)
def test_missing_src_denied(self):
self.assertEqual(self._deny("http://host/live/mse/api/ws", "limited_user"), 403)
# --- multi-camera role: each assigned camera allowed, others denied ---
def test_multi_camera_role_allows_first_assigned(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door", "dual_user")
)
def test_multi_camera_role_allows_second_assigned(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "dual_user")
)
def test_multi_camera_role_denies_unassigned(self):
# garage is configured but not in dual_user's allow-list
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=garage", "dual_user"),
403,
)
# --- substream names resolve to their owning camera ---
def test_allowed_substream_resolves_to_owning_camera(self):
# front_door_sub is owned by front_door, which limited_user may access
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door_sub", "limited_user")
)
# --- multiple src values: deny if any is forbidden ---
def test_multiple_src_one_forbidden_denied(self):
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=back_door",
"limited_user",
),
403,
)
def test_multiple_src_all_allowed(self):
self.assertIsNone(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=front_door_sub",
"limited_user",
)
)
# --- privileged roles bypass the check ---
def test_admin_bypasses(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "admin")
)
def test_builtin_viewer_role_bypasses(self):
# the built-in viewer role is not in the config allow-list map, so it
# is treated as full access
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "viewer")
)
def test_missing_role_bypasses(self):
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
if __name__ == "__main__":
unittest.main()
+7 -9
View File
@@ -2,7 +2,6 @@
import ast
import copy
import datetime
import logging
import math
import multiprocessing.queues
@@ -10,7 +9,9 @@ import queue
import re
import shlex
import struct
import time
import urllib.parse
from collections import deque
from collections.abc import Mapping
from multiprocessing.managers import ValueProxy
from pathlib import Path
@@ -32,23 +33,20 @@ class EventsPerSecond:
self._start = None
self._max_events = max_events
self._last_n_seconds = last_n_seconds
self._timestamps = []
self._timestamps: deque[float] = deque(maxlen=max_events)
def start(self) -> None:
self._start = datetime.datetime.now().timestamp()
self._start = time.monotonic()
def update(self) -> None:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
self._timestamps.append(now)
# truncate the list when it goes 100 over the max_size
if len(self._timestamps) > self._max_events + 100:
self._timestamps = self._timestamps[(1 - self._max_events) :]
self.expire_timestamps(now)
def eps(self) -> float:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
# compute the (approximate) events in the last n seconds
@@ -63,7 +61,7 @@ class EventsPerSecond:
def expire_timestamps(self, now: float) -> None:
threshold = now - self._last_n_seconds
while self._timestamps and self._timestamps[0] < threshold:
del self._timestamps[0]
self._timestamps.popleft()
class InferenceSpeed:
@@ -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);
});
});
@@ -70,6 +70,13 @@
"selectFromTimeline": "Select from Timeline",
"cameraSelection": "Cameras",
"cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
"searchOrSelectGroup": "Search, or select a camera group...",
"selectAll": "Select all cameras",
"clearSelection": "Clear selection",
"selectWithActivity": "Cameras with tracked objects",
"selectGroup": "Select group",
"noMatchingCameras": "No cameras match your search",
"selectedCount": "{{selected}} / {{total}} selected",
"checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available",
"detectionCount_one": "1 tracked object",
+11 -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": {
@@ -243,12 +243,7 @@ export default function CameraReviewClassification({
handleZoneToggle("alerts.required_zones", zone.name)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
<Label className="font-normal">
{zone.friendly_name || zone.name}
</Label>
</div>
@@ -29,8 +29,8 @@ function getZoneDisplayName(zoneName: string, context?: FormContext): string {
}
}
}
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
// Fallback to the raw zone id verbatim (no friendly_name available)
return String(zoneName);
}
export function ZoneSwitchesWidget(props: WidgetProps) {
+191 -9
View File
@@ -39,6 +39,16 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../ui/command";
import { IconRenderer } from "../icons/IconPicker";
import * as LuIcons from "react-icons/lu";
import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay";
@@ -376,6 +386,9 @@ export function ExportContent({
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
const cameraMenuRef = useRef<HTMLDivElement>(null);
const multiRangeKey = useMemo(() => {
if (activeTab !== "multi" || !range) {
return undefined;
@@ -577,6 +590,75 @@ export function ExportContent({
);
}, []);
const availableCameraIds = useMemo(
() => cameraActivities.map((activity) => activity.camera),
[cameraActivities],
);
const activeCameraIds = useMemo(
() =>
cameraActivities
.filter((activity) => activity.hasDetections)
.map((activity) => activity.camera),
[cameraActivities],
);
const cameraGroups = useMemo(
() =>
Object.entries(config?.camera_groups ?? {})
.map(([name, group]) => ({
name,
icon: group.icon,
order: group.order,
cameras: group.cameras.filter((cameraId) =>
availableCameraIds.includes(cameraId),
),
}))
.filter((group) => group.cameras.length > 0)
.sort((a, b) => a.order - b.order),
[config?.camera_groups, availableCameraIds],
);
// Filter the rendered camera cards by the search query
const filteredCameraActivities = useMemo(() => {
const query = cameraSearch.trim().toLowerCase();
if (!query) {
return cameraActivities;
}
return cameraActivities.filter((activity) => {
const friendlyName = resolveCameraName(config, activity.camera);
return (
activity.camera.toLowerCase().includes(query) ||
friendlyName.toLowerCase().includes(query)
);
});
}, [cameraActivities, cameraSearch, config]);
// Group/all/activity selection replaces the current selection
const applyCameraSelection = useCallback((cameraIds: string[]) => {
setHasManualCameraSelection(true);
setSelectedCameraIds(cameraIds);
setCameraMenuOpen(false);
}, []);
// Close the dropdown when focus leaves the camera selection control entirely
const handleCameraInputBlur = useCallback((event: React.FocusEvent) => {
if (
cameraMenuRef.current &&
!cameraMenuRef.current.contains(event.relatedTarget as Node)
) {
setCameraMenuOpen(false);
}
}, []);
// Reset the search and dropdown when leaving the multi-camera tab
useEffect(() => {
if (activeTab !== "multi") {
setCameraSearch("");
setCameraMenuOpen(false);
}
}, [activeTab]);
const startBatchExport = useCallback(async () => {
if (isStartingBatchExport) {
return;
@@ -802,7 +884,7 @@ export function ExportContent({
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.case.label")}
</Label>
<Select
@@ -859,7 +941,7 @@ export function ExportContent({
)}
>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.multiCamera.timeRange")}
</Label>
<div className="flex items-center gap-2">
@@ -902,16 +984,109 @@ export function ExportContent({
</div>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.cameraSelection")}
</Label>
<div className="flex items-center justify-between gap-2">
<Label className="text-sm text-primary">
{t("export.multiCamera.cameraSelection")}
</Label>
{availableCameraIds.length > 0 && (
<span className="text-xs text-muted-foreground">
{t("export.multiCamera.selectedCount", {
selected: selectedCameraCount,
total: availableCameraIds.length,
})}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{t("export.multiCamera.cameraSelectionHelp")}
</div>
{!isEventsLoading && availableCameraIds.length > 0 && (
<div className="relative" ref={cameraMenuRef}>
<Command
shouldFilter={false}
className="overflow-visible rounded-md border bg-secondary/40"
>
<CommandInput
value={cameraSearch}
onValueChange={setCameraSearch}
onFocus={() => setCameraMenuOpen(true)}
onBlur={handleCameraInputBlur}
placeholder={t("export.multiCamera.searchOrSelectGroup")}
/>
{/* Hide the actions/groups menu while a search query is
active so it doesn't cover the filtered camera cards. */}
{cameraMenuOpen && cameraSearch.trim().length === 0 && (
<CommandList className="absolute top-full z-10 mt-1 max-h-72 w-full rounded-md border bg-background shadow-md">
<CommandGroup>
<CommandItem
value="action:select-all"
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(availableCameraIds)
}
>
<span>{t("export.multiCamera.selectAll")}</span>
<span className="ml-auto text-xs text-muted-foreground">
{availableCameraIds.length}
</span>
</CommandItem>
<CommandItem
value="action:clear"
className="cursor-pointer"
onSelect={() => applyCameraSelection([])}
>
{t("export.multiCamera.clearSelection")}
</CommandItem>
<CommandItem
value="action:activity"
className="cursor-pointer"
onSelect={() => applyCameraSelection(activeCameraIds)}
>
<span>
{t("export.multiCamera.selectWithActivity")}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{activeCameraIds.length}
</span>
</CommandItem>
</CommandGroup>
{cameraGroups.length > 0 && (
<>
<CommandSeparator />
<CommandGroup
heading={t("export.multiCamera.selectGroup")}
>
{cameraGroups.map((group) => (
<CommandItem
key={group.name}
value={`group:${group.name}`}
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(group.cameras)
}
>
<IconRenderer
icon={LuIcons[group.icon]}
className="mr-2 size-4 text-secondary-foreground"
/>
<span className="truncate">{group.name}</span>
<span className="ml-auto text-xs text-muted-foreground">
{group.cameras.length}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
)}
</Command>
</div>
)}
<div
className={cn(
"scrollbar-container space-y-2",
isDesktop && "max-h-64 overflow-y-auto pr-1",
isDesktop && "max-h-64 overflow-y-auto p-0.5 pr-1",
)}
>
{isEventsLoading && (
@@ -924,7 +1099,14 @@ export function ExportContent({
{t("export.multiCamera.noCameras")}
</div>
)}
{cameraActivities.map((activity) => {
{!isEventsLoading &&
cameraActivities.length > 0 &&
filteredCameraActivities.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.noMatchingCameras")}
</div>
)}
{filteredCameraActivities.map((activity) => {
const isSelected = selectedCameraIds.includes(activity.camera);
return (
@@ -981,7 +1163,7 @@ export function ExportContent({
</div>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.multiCamera.nameLabel")}
</Label>
<Input
@@ -994,7 +1176,7 @@ export function ExportContent({
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.case.label")}
</Label>
<Select
@@ -1197,14 +1197,7 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`,
}}
/>
<span
className={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
</span>
<span>{item.data?.zones_friendly_names?.[zidx]}</span>
</Badge>
);
})}
@@ -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);
+5 -5
View File
@@ -7,12 +7,12 @@ export function resolveZoneName(
zoneId: string,
cameraId?: string,
) {
if (!config) return String(zoneId).replace(/_/g, " ");
if (!config) return String(zoneId);
if (cameraId) {
const camera = config.cameras?.[String(cameraId)];
const zone = camera?.zones?.[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
return zone?.friendly_name || String(zoneId);
}
for (const camKey in config.cameras) {
@@ -21,12 +21,12 @@ export function resolveZoneName(
if (!cam?.zones) continue;
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
const zone = cam.zones[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
return zone?.friendly_name || String(zoneId);
}
}
// Fallback: return a cleaned-up zoneId string
return String(zoneId).replace(/_/g, " ");
// Fallback: display the raw zone id verbatim (no friendly_name available)
return String(zoneId);
}
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
+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;