mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 11:20:32 +03:00
Compare commits
10
Commits
d5cea92195
...
9e7f3ce24a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7f3ce24a | ||
|
|
c007661a71 | ||
|
|
9b02c7318d | ||
|
|
7a22963e6d | ||
|
|
3aadc88a29 | ||
|
|
470abbab9d | ||
|
|
50591cb2fa | ||
|
|
b4b946c624 | ||
|
|
c79d6cf2ea | ||
|
|
7b83b936ab |
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
+16
-8
@@ -254,7 +254,14 @@ rateLimiter = RateLimiter()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
direct_addr = request.client.host if request.client else None
|
||||
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if not forwarded_for:
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
route = list(reversed(forwarded_for.split(",")))
|
||||
logger.debug(f"IP Route: {[r for r in route]}")
|
||||
trusted_proxies = []
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies:
|
||||
@@ -291,13 +298,8 @@ def get_remote_addr(request: Request):
|
||||
logger.debug(f"First untrusted IP: {str(ip)}")
|
||||
return str(ip)
|
||||
|
||||
# if there wasn't anything in the route, just return the default
|
||||
remote_addr = None
|
||||
|
||||
if hasattr(request, "remote_addr"):
|
||||
remote_addr = request.remote_addr
|
||||
|
||||
return remote_addr or "127.0.0.1"
|
||||
# every hop in the route was trusted, so fall back to the direct peer
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
|
||||
def _cleanup_first_load_seen() -> None:
|
||||
@@ -416,6 +418,12 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
@@ -650,6 +650,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"]))],
|
||||
@@ -807,6 +833,7 @@ async def onvif_probe(
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
pan_tilt_supported = False
|
||||
presets_count = 0
|
||||
autotrack_supported = False
|
||||
|
||||
@@ -840,6 +867,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
|
||||
@@ -959,6 +995,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,
|
||||
}
|
||||
|
||||
+9
-11
@@ -269,12 +269,12 @@ async def no_recordings(
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
cameras = ",".join(filtered)
|
||||
camera_list = list(requested.intersection(allowed_cameras))
|
||||
else:
|
||||
cameras = allowed_cameras
|
||||
camera_list = list(allowed_cameras)
|
||||
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
@@ -283,12 +283,10 @@ async def no_recordings(
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses = [
|
||||
(Recordings.end_time >= after) & (Recordings.start_time <= before),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
|
||||
@@ -13,6 +13,10 @@ from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +33,11 @@ class CameraActivityManager:
|
||||
self.zone_all_object_counts: dict[str, Counter] = {}
|
||||
self.zone_active_object_counts: dict[str, Counter] = {}
|
||||
self.all_zone_labels: dict[str, set[str]] = {}
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
config,
|
||||
config.cameras,
|
||||
[CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects],
|
||||
)
|
||||
|
||||
for camera_config in config.cameras.values():
|
||||
if not camera_config.enabled_in_config:
|
||||
@@ -56,7 +65,40 @@ class CameraActivityManager:
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
def __rebuild_zone_labels(self) -> None:
|
||||
"""Rebuild zone label tracking after a runtime zones/objects change."""
|
||||
new_zone_labels: dict[str, set[str]] = {}
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if not camera_config.enabled_in_config or camera_config.name is None:
|
||||
continue
|
||||
|
||||
for zone, zone_config in camera_config.zones.items():
|
||||
new_zone_labels.setdefault(zone, set()).update(
|
||||
zone_config.objects
|
||||
if zone_config.objects
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
# drop counters for zones that no longer exist
|
||||
for zone in list(self.zone_all_object_counts.keys()):
|
||||
if zone not in new_zone_labels:
|
||||
self.zone_all_object_counts.pop(zone, None)
|
||||
self.zone_active_object_counts.pop(zone, None)
|
||||
|
||||
# ensure counters exist for new zones so the first count is published
|
||||
for zone in new_zone_labels:
|
||||
self.zone_all_object_counts.setdefault(zone, Counter())
|
||||
self.zone_active_object_counts.setdefault(zone, Counter())
|
||||
|
||||
self.all_zone_labels = new_zone_labels
|
||||
|
||||
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
if "zones" in updated_topics or "objects" in updated_topics:
|
||||
self.__rebuild_zone_labels()
|
||||
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
@@ -161,6 +203,9 @@ class CameraActivityManager:
|
||||
self.publish(f"{camera}/all", sum(list(all_objects.values())))
|
||||
self.publish(f"{camera}/all/active", sum(list(active_objects.values())))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
|
||||
|
||||
class AudioActivityManager:
|
||||
def __init__(
|
||||
|
||||
@@ -397,6 +397,8 @@ class Dispatcher:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
|
||||
+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")
|
||||
|
||||
@@ -475,3 +475,55 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self):
|
||||
"""cameras=all must not error and must only consider allowed cameras.
|
||||
|
||||
allowed_cameras is mocked to ["front_door"]. A back_door recording that
|
||||
would otherwise fill the gap must be ignored, and the request must not
|
||||
500 the way it did when cameras was reassigned to a list.
|
||||
"""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# front_door has a 20s gap (1010-1030).
|
||||
Recordings.insert(
|
||||
id="front_a",
|
||||
path="/media/recordings/front_a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1010,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="front_b",
|
||||
path="/media/recordings/front_b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1030,
|
||||
end_time=1040,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
# back_door is not in allowed_cameras; its full-window coverage must
|
||||
# not mask the front_door gap.
|
||||
Recordings.insert(
|
||||
id="back_a",
|
||||
path="/media/recordings/back_a.mp4",
|
||||
camera="back_door",
|
||||
start_time=1000,
|
||||
end_time=1040,
|
||||
duration=40,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1040,
|
||||
"scale": 5,
|
||||
"cameras": "all",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -1263,7 +1263,6 @@ function ObjectDetailsTab({
|
||||
}
|
||||
|
||||
setState("submitted");
|
||||
setSearch({ ...search, plus_id: "new_upload" });
|
||||
mutate(
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
@@ -1286,7 +1285,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
}
|
||||
},
|
||||
[search, mutate, mapSearchResults, setSearch, isEventsKey, t],
|
||||
[search, mutate, mapSearchResults, isEventsKey, t],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -47,6 +47,7 @@ type HlsVideoPlayerProps = {
|
||||
frigateControls?: boolean;
|
||||
inpointOffset?: number;
|
||||
onClipEnded?: (currentTime: number) => void;
|
||||
onClipPrevious?: (diff: number) => void;
|
||||
onPlayerLoaded?: () => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onPlaying?: () => void;
|
||||
@@ -74,6 +75,7 @@ export default function HlsVideoPlayer({
|
||||
frigateControls = true,
|
||||
inpointOffset = 0,
|
||||
onClipEnded,
|
||||
onClipPrevious,
|
||||
onPlayerLoaded,
|
||||
onTimeUpdate,
|
||||
onPlaying,
|
||||
@@ -339,11 +341,17 @@ export default function HlsVideoPlayer({
|
||||
onSeek={(diff) => {
|
||||
const currentTime = videoRef.current?.currentTime;
|
||||
|
||||
if (!videoRef.current || !currentTime) {
|
||||
if (!videoRef.current || currentTime == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||
const newTime = currentTime + diff;
|
||||
|
||||
if (newTime < 0 && onClipPrevious) {
|
||||
onClipPrevious(diff);
|
||||
} else {
|
||||
videoRef.current.currentTime = Math.max(0, newTime);
|
||||
}
|
||||
}}
|
||||
onSetPlaybackRate={(rate) => {
|
||||
setPlaybackRate(rate, true);
|
||||
|
||||
@@ -49,6 +49,7 @@ type DynamicVideoPlayerProps = {
|
||||
onControllerReady: (controller: DynamicVideoController) => void;
|
||||
onTimestampUpdate?: (timestamp: number) => void;
|
||||
onClipEnded?: () => void;
|
||||
onClipPrevious?: (diff: number) => void;
|
||||
onSeekToTime?: (timestamp: number, play?: boolean) => void;
|
||||
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
toggleFullscreen: () => void;
|
||||
@@ -68,6 +69,7 @@ export default function DynamicVideoPlayer({
|
||||
onControllerReady,
|
||||
onTimestampUpdate,
|
||||
onClipEnded,
|
||||
onClipPrevious,
|
||||
onSeekToTime,
|
||||
setFullResolution,
|
||||
toggleFullscreen,
|
||||
@@ -343,6 +345,7 @@ export default function DynamicVideoPlayer({
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlayerLoaded={onPlayerLoaded}
|
||||
onClipEnded={onValidateClipEnd}
|
||||
onClipPrevious={onClipPrevious}
|
||||
onSeekToTime={(timestamp, play) => {
|
||||
if (onSeekToTime) {
|
||||
onSeekToTime(timestamp, play);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -336,6 +336,16 @@ export function RecordingView({
|
||||
[currentTimeRange, updateSelectedSegment],
|
||||
);
|
||||
|
||||
const onClipPrevious = useCallback(
|
||||
(diff: number) => {
|
||||
manuallySetCurrentTime(
|
||||
currentTime + diff,
|
||||
mainControllerRef.current?.isPlaying() ?? false,
|
||||
);
|
||||
},
|
||||
[currentTime, manuallySetCurrentTime],
|
||||
);
|
||||
|
||||
const onShareReviewLink = useCallback(
|
||||
(timestamp: number) => {
|
||||
const reviewUrl = createRecordingReviewUrl(location.pathname, {
|
||||
@@ -902,6 +912,7 @@ export function RecordingView({
|
||||
);
|
||||
}}
|
||||
onClipEnded={onClipEnded}
|
||||
onClipPrevious={onClipPrevious}
|
||||
onSeekToTime={manuallySetCurrentTime}
|
||||
onControllerReady={(controller) => {
|
||||
mainControllerRef.current = controller;
|
||||
|
||||
Reference in New Issue
Block a user