mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 19:22:52 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6043aa92 |
@@ -650,32 +650,6 @@ async def _connect_onvif_camera(
|
|||||||
raise first_error
|
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(
|
@router.get(
|
||||||
"/onvif/probe",
|
"/onvif/probe",
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
@@ -833,7 +807,6 @@ async def onvif_probe(
|
|||||||
|
|
||||||
# Check PTZ support and capabilities
|
# Check PTZ support and capabilities
|
||||||
ptz_supported = False
|
ptz_supported = False
|
||||||
pan_tilt_supported = False
|
|
||||||
presets_count = 0
|
presets_count = 0
|
||||||
autotrack_supported = False
|
autotrack_supported = False
|
||||||
|
|
||||||
@@ -867,15 +840,6 @@ async def onvif_probe(
|
|||||||
logger.debug(f"Failed to get presets: {e}")
|
logger.debug(f"Failed to get presets: {e}")
|
||||||
presets_count = 0
|
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
|
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
||||||
if ptz_supported and first_profile_token and ptz_config_token:
|
if ptz_supported and first_profile_token and ptz_config_token:
|
||||||
# First check for FOV relative movement support
|
# First check for FOV relative movement support
|
||||||
@@ -995,7 +959,6 @@ async def onvif_probe(
|
|||||||
"firmware_version": device_info["firmware_version"],
|
"firmware_version": device_info["firmware_version"],
|
||||||
"profiles_count": profiles_count,
|
"profiles_count": profiles_count,
|
||||||
"ptz_supported": ptz_supported,
|
"ptz_supported": ptz_supported,
|
||||||
"pan_tilt_supported": pan_tilt_supported,
|
|
||||||
"presets_count": presets_count,
|
"presets_count": presets_count,
|
||||||
"autotrack_supported": autotrack_supported,
|
"autotrack_supported": autotrack_supported,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for model_config in self.config.classification.custom.values():
|
for model_config in self.config.classification.custom.values():
|
||||||
|
if not model_config.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
self.realtime_processors.append(
|
self.realtime_processors.append(
|
||||||
CustomStateClassificationProcessor(
|
CustomStateClassificationProcessor(
|
||||||
self.config, model_config, self.requestor, self.metrics
|
self.config, model_config, self.requestor, self.metrics
|
||||||
@@ -332,6 +335,25 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
for processor in self.post_processors:
|
for processor in self.post_processors:
|
||||||
processor.update_config(topic, payload)
|
processor.update_config(topic, payload)
|
||||||
|
|
||||||
|
def _remove_custom_classification_processor(self, model_name: str) -> None:
|
||||||
|
"""Shut down and drop any running processor for a custom model."""
|
||||||
|
remaining = []
|
||||||
|
for processor in self.realtime_processors:
|
||||||
|
if (
|
||||||
|
isinstance(
|
||||||
|
processor,
|
||||||
|
(
|
||||||
|
CustomStateClassificationProcessor,
|
||||||
|
CustomObjectClassificationProcessor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
and processor.model_config.name == model_name
|
||||||
|
):
|
||||||
|
processor.shutdown()
|
||||||
|
else:
|
||||||
|
remaining.append(processor)
|
||||||
|
self.realtime_processors = remaining
|
||||||
|
|
||||||
def _handle_custom_classification_update(
|
def _handle_custom_classification_update(
|
||||||
self, topic: str, model_config: Any
|
self, topic: str, model_config: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -339,23 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
model_name = topic.split("/")[-1]
|
model_name = topic.split("/")[-1]
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
remaining = []
|
self._remove_custom_classification_processor(model_name)
|
||||||
for processor in self.realtime_processors:
|
|
||||||
if (
|
|
||||||
isinstance(
|
|
||||||
processor,
|
|
||||||
(
|
|
||||||
CustomStateClassificationProcessor,
|
|
||||||
CustomObjectClassificationProcessor,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
and processor.model_config.name == model_name
|
|
||||||
):
|
|
||||||
processor.shutdown()
|
|
||||||
else:
|
|
||||||
remaining.append(processor)
|
|
||||||
self.realtime_processors = remaining
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully removed classification processor for model: {model_name}"
|
f"Successfully removed classification processor for model: {model_name}"
|
||||||
)
|
)
|
||||||
@@ -363,6 +369,13 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.config.classification.custom[model_name] = model_config
|
self.config.classification.custom[model_name] = model_config
|
||||||
|
|
||||||
|
# A disabled model must not run; tear down any existing processor and
|
||||||
|
# do not register a new one.
|
||||||
|
if not model_config.enabled:
|
||||||
|
self._remove_custom_classification_processor(model_name)
|
||||||
|
logger.info(f"Disabled classification processor for model: {model_name}")
|
||||||
|
return
|
||||||
|
|
||||||
# Check if processor already exists
|
# Check if processor already exists
|
||||||
for processor in self.realtime_processors:
|
for processor in self.realtime_processors:
|
||||||
if isinstance(
|
if isinstance(
|
||||||
@@ -702,7 +715,11 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
and "license_plate" not in camera_config.objects.track
|
and "license_plate" not in camera_config.objects.track
|
||||||
)
|
)
|
||||||
|
|
||||||
if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0:
|
has_enabled_custom = any(
|
||||||
|
c.enabled for c in self.config.classification.custom.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dedicated_lpr_enabled and not has_enabled_custom:
|
||||||
# no active features that use this data
|
# no active features that use this data
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+1
-27
@@ -72,11 +72,7 @@ class OnvifController:
|
|||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
self.config,
|
self.config,
|
||||||
self.config.cameras,
|
self.config.cameras,
|
||||||
[
|
[CameraConfigUpdateEnum.onvif],
|
||||||
CameraConfigUpdateEnum.onvif,
|
|
||||||
CameraConfigUpdateEnum.add,
|
|
||||||
CameraConfigUpdateEnum.remove,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
||||||
@@ -105,16 +101,6 @@ class OnvifController:
|
|||||||
if update_type == CameraConfigUpdateEnum.onvif.name:
|
if update_type == CameraConfigUpdateEnum.onvif.name:
|
||||||
for cam_name in cameras:
|
for cam_name in cameras:
|
||||||
await self._reinit_camera(cam_name)
|
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:
|
except Exception:
|
||||||
logger.error("Error checking for ONVIF config updates")
|
logger.error("Error checking for ONVIF config updates")
|
||||||
|
|
||||||
@@ -127,18 +113,6 @@ class OnvifController:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug(f"Error closing ONVIF session for {cam_name}")
|
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:
|
async def _reinit_camera(self, cam_name: str) -> None:
|
||||||
"""Re-initialize a camera after config change."""
|
"""Re-initialize a camera after config change."""
|
||||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Tests that disabled custom classification models are not registered or run."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Mock TFLite before importing the maintainer / classification modules
|
||||||
|
_MOCK_MODULES = [
|
||||||
|
"tflite_runtime",
|
||||||
|
"tflite_runtime.interpreter",
|
||||||
|
"ai_edge_litert",
|
||||||
|
"ai_edge_litert.interpreter",
|
||||||
|
]
|
||||||
|
for mod in _MOCK_MODULES:
|
||||||
|
if mod not in sys.modules:
|
||||||
|
sys.modules[mod] = MagicMock()
|
||||||
|
|
||||||
|
from frigate.data_processing.real_time.custom_classification import ( # noqa: E402
|
||||||
|
CustomObjectClassificationProcessor,
|
||||||
|
)
|
||||||
|
from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomClassificationEnabledGating(unittest.TestCase):
|
||||||
|
"""A model with enabled: false must not keep a processor registered."""
|
||||||
|
|
||||||
|
def _make_maintainer(self) -> EmbeddingMaintainer:
|
||||||
|
# Bypass the heavy __init__; only the attributes touched by the
|
||||||
|
# config update path are needed for these tests.
|
||||||
|
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
|
||||||
|
maintainer.realtime_processors = []
|
||||||
|
maintainer.config = MagicMock()
|
||||||
|
maintainer.config.classification.custom = {}
|
||||||
|
maintainer.requestor = MagicMock()
|
||||||
|
maintainer.metrics = MagicMock()
|
||||||
|
maintainer.event_metadata_publisher = MagicMock()
|
||||||
|
return maintainer
|
||||||
|
|
||||||
|
def _make_model_config(self, name: str, enabled: bool) -> MagicMock:
|
||||||
|
model_config = MagicMock()
|
||||||
|
model_config.name = name
|
||||||
|
model_config.enabled = enabled
|
||||||
|
model_config.state_config = None
|
||||||
|
return model_config
|
||||||
|
|
||||||
|
def _make_processor(self, name: str) -> MagicMock:
|
||||||
|
processor = MagicMock(spec=CustomObjectClassificationProcessor)
|
||||||
|
processor.model_config = MagicMock()
|
||||||
|
processor.model_config.name = name
|
||||||
|
return processor
|
||||||
|
|
||||||
|
def test_disabled_update_tears_down_existing_processor(self):
|
||||||
|
"""Toggling a running model to disabled shuts down and drops its processor."""
|
||||||
|
maintainer = self._make_maintainer()
|
||||||
|
processor = self._make_processor("atli")
|
||||||
|
maintainer.realtime_processors = [processor]
|
||||||
|
|
||||||
|
maintainer._handle_custom_classification_update(
|
||||||
|
"config/classification/custom/atli",
|
||||||
|
self._make_model_config("atli", enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
processor.shutdown.assert_called_once()
|
||||||
|
self.assertEqual(maintainer.realtime_processors, [])
|
||||||
|
|
||||||
|
def test_disabled_update_does_not_register_processor(self):
|
||||||
|
"""A disabled model that has no processor is never registered."""
|
||||||
|
maintainer = self._make_maintainer()
|
||||||
|
|
||||||
|
maintainer._handle_custom_classification_update(
|
||||||
|
"config/classification/custom/atli",
|
||||||
|
self._make_model_config("atli", enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(maintainer.realtime_processors, [])
|
||||||
|
|
||||||
|
def test_disabled_update_leaves_other_processors_untouched(self):
|
||||||
|
"""Disabling one model must not affect other running processors."""
|
||||||
|
maintainer = self._make_maintainer()
|
||||||
|
other = self._make_processor("simbi")
|
||||||
|
maintainer.realtime_processors = [other]
|
||||||
|
|
||||||
|
maintainer._handle_custom_classification_update(
|
||||||
|
"config/classification/custom/atli",
|
||||||
|
self._make_model_config("atli", enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
other.shutdown.assert_not_called()
|
||||||
|
self.assertEqual(maintainer.realtime_processors, [other])
|
||||||
|
|
||||||
|
def test_removed_model_tears_down_processor(self):
|
||||||
|
"""A None payload (model deleted) still shuts down its processor."""
|
||||||
|
maintainer = self._make_maintainer()
|
||||||
|
processor = self._make_processor("atli")
|
||||||
|
maintainer.realtime_processors = [processor]
|
||||||
|
|
||||||
|
maintainer._handle_custom_classification_update(
|
||||||
|
"config/classification/custom/atli", None
|
||||||
|
)
|
||||||
|
|
||||||
|
processor.shutdown.assert_called_once()
|
||||||
|
self.assertEqual(maintainer.realtime_processors, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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": {
|
"step3": {
|
||||||
"description": "Configure features, stream roles, and add additional streams for your camera.",
|
"description": "Configure stream roles and add additional streams for your camera.",
|
||||||
"streamsTitle": "Camera Streams",
|
"streamsTitle": "Camera Streams",
|
||||||
"addStream": "Add Stream",
|
"addStream": "Add Stream",
|
||||||
"addAnotherStream": "Add Another Stream",
|
"addAnotherStream": "Add Another Stream",
|
||||||
@@ -403,16 +403,6 @@
|
|||||||
"featuresPopover": {
|
"featuresPopover": {
|
||||||
"title": "Stream Features",
|
"title": "Stream Features",
|
||||||
"description": "Use go2rtc restreaming to reduce connections to your camera."
|
"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": {
|
"step4": {
|
||||||
|
|||||||
@@ -115,19 +115,13 @@ export default function CameraWizardDialog({
|
|||||||
case 1:
|
case 1:
|
||||||
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
||||||
return (state.wizardData.streams?.length ?? 0) > 0;
|
return (state.wizardData.streams?.length ?? 0) > 0;
|
||||||
case 2: {
|
case 2:
|
||||||
// Step 3: requires a detect stream; if PTZ is enabled, also require
|
// Step 3: Can proceed if at least one stream has 'detect' role
|
||||||
// ONVIF host + port (fields are pre-filled but the user may clear them)
|
return !!(
|
||||||
const hasDetect = !!(
|
|
||||||
state.wizardData.streams?.some((stream) =>
|
state.wizardData.streams?.some((stream) =>
|
||||||
stream.roles.includes("detect"),
|
stream.roles.includes("detect"),
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
const onvif = state.wizardData.onvif;
|
|
||||||
const onvifOk =
|
|
||||||
!onvif?.enabled || (!!onvif.host?.trim() && !!onvif.port);
|
|
||||||
return hasDetect && onvifOk;
|
|
||||||
}
|
|
||||||
case 3:
|
case 3:
|
||||||
// Step 4: Always can proceed from final step (save will be handled there)
|
// Step 4: Always can proceed from final step (save will be handled there)
|
||||||
return true;
|
return true;
|
||||||
@@ -247,20 +241,6 @@ 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 = {
|
const requestBody: ConfigSetBody = {
|
||||||
requires_restart: 1,
|
requires_restart: 1,
|
||||||
config_data: configData,
|
config_data: configData,
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
|
|
||||||
const update: Partial<WizardFormData> = {
|
const update: Partial<WizardFormData> = {
|
||||||
probeMode: true,
|
probeMode: true,
|
||||||
probeResult: response.data,
|
|
||||||
probeCandidates: candidateUris,
|
probeCandidates: candidateUris,
|
||||||
candidateTests: {},
|
candidateTests: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -32,10 +32,6 @@ import {
|
|||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuChevronsUpDown,
|
LuChevronsUpDown,
|
||||||
LuChevronDown,
|
|
||||||
LuChevronRight,
|
|
||||||
LuEye,
|
|
||||||
LuEyeOff,
|
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
@@ -48,11 +44,6 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
|
|
||||||
type Step3StreamConfigProps = {
|
type Step3StreamConfigProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@@ -73,53 +64,6 @@ export default function Step3StreamConfig({
|
|||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
|
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]);
|
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||||
|
|
||||||
@@ -781,136 +725,12 @@ export default function Step3StreamConfig({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{!hasDetectRole && (
|
||||||
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
|
||||||
{t("cameraWizard.step3.detectRoleWarning")}
|
{t("cameraWizard.step3.detectRoleWarning")}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||||
|
|||||||
@@ -268,7 +268,6 @@ export default function Step4Validation({
|
|||||||
customUrl: wizardData.customUrl,
|
customUrl: wizardData.customUrl,
|
||||||
streams: wizardData.streams,
|
streams: wizardData.streams,
|
||||||
hasBackchannel: wizardData.hasBackchannel,
|
hasBackchannel: wizardData.hasBackchannel,
|
||||||
onvif: wizardData.onvif,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onSave(configData);
|
onSave(configData);
|
||||||
|
|||||||
@@ -119,13 +119,6 @@ export type WizardFormData = {
|
|||||||
probeCandidates?: string[]; // candidate URLs from probe
|
probeCandidates?: string[]; // candidate URLs from probe
|
||||||
candidateTests?: CandidateTestMap; // test results for candidates
|
candidateTests?: CandidateTestMap; // test results for candidates
|
||||||
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
||||||
onvif?: {
|
|
||||||
enabled: boolean;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
user?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// API Response Types
|
// API Response Types
|
||||||
@@ -176,12 +169,6 @@ export type CameraConfigData = {
|
|||||||
live?: {
|
live?: {
|
||||||
streams: Record<string, string>;
|
streams: Record<string, string>;
|
||||||
};
|
};
|
||||||
onvif?: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
user?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
go2rtc?: {
|
go2rtc?: {
|
||||||
@@ -212,7 +199,6 @@ export type OnvifProbeResponse = {
|
|||||||
firmware_version?: string;
|
firmware_version?: string;
|
||||||
profiles_count?: number;
|
profiles_count?: number;
|
||||||
ptz_supported?: boolean;
|
ptz_supported?: boolean;
|
||||||
pan_tilt_supported?: boolean;
|
|
||||||
presets_count?: number;
|
presets_count?: number;
|
||||||
autotrack_supported?: boolean;
|
autotrack_supported?: boolean;
|
||||||
move_status_supported?: boolean;
|
move_status_supported?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user