mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-14 09:33:00 +03:00
Compare commits
10
Commits
f4a751d58a
...
7c86205904
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c86205904 | ||
|
|
f065cc8642 | ||
|
|
9ce80e7266 | ||
|
|
7a22963e6d | ||
|
|
3aadc88a29 | ||
|
|
470abbab9d | ||
|
|
50591cb2fa | ||
|
|
b4b946c624 | ||
|
|
c79d6cf2ea | ||
|
|
7b83b936ab |
@@ -59,6 +59,8 @@ Metrics are available at `/api/metrics` by default. No additional Frigate config
|
||||
- `frigate_storage_used_bytes{storage=""}` - Storage used bytes
|
||||
- `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info
|
||||
|
||||
These gauges report the operating system's figures for the whole filesystem (the same numbers as `df`), not Frigate's own recording footprint. For how this differs from the recordings usage shown in the UI, see [Understanding storage usage](/configuration/record#understanding-storage-usage).
|
||||
|
||||
### Service Metrics
|
||||
|
||||
- `frigate_service_uptime_seconds` - Uptime in seconds
|
||||
|
||||
@@ -199,10 +199,6 @@ Because recording segments are written in 10 second chunks, pre-capture timing d
|
||||
|
||||
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
Frigate supports both continuous and tracked object based recordings with separate retention modes and retention periods.
|
||||
@@ -355,3 +351,63 @@ Setting `verbose: true` writes a detailed report of every orphaned file and data
|
||||
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
|
||||
|
||||
:::
|
||||
|
||||
## Understanding storage usage
|
||||
|
||||
The storage usage Frigate reports will not exactly match what the operating system reports with `df` or `du`. This is expected, not a bug. The sections below explain how Frigate derives its storage figures and why they differ from the disk's own accounting.
|
||||
|
||||
### How Frigate measures recording usage
|
||||
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />) — and the per-camera **Camera Storage** breakdown — is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
|
||||
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_ — not the drive's real free space, which will be lower whenever anything else is stored on the disk.
|
||||
|
||||
### What counts toward usage — and why it won't match `df`
|
||||
|
||||
Only **recording segments** (`/media/frigate/recordings`) are included in the recordings storage total. Plenty of other things consume real disk space but are **not** part of that number:
|
||||
|
||||
- **Snapshots and thumbnails** (`/media/frigate/clips`) — see [Snapshots](/configuration/snapshots). These are retained independently of recordings.
|
||||
- **Preview videos** and **review thumbnails** (also under `/media/frigate/clips`).
|
||||
- **Exports** (`/media/frigate/exports`) — exports are never removed by retention.
|
||||
- **The database, downloaded detection models, and face / license plate training images** (stored under `/config`).
|
||||
- **Debug images from enrichments** (`/media/frigate/clips`) — when enabled, License Plate Recognition's `debug_save_plates` and GenAI's `debug_save_thumbnails` save plate crops and request images for troubleshooting.
|
||||
|
||||
These files are the usual explanation for an "other" or seemingly unaccounted bucket of space — it is real, it is Frigate's, and it simply isn't part of the _recordings_ total. They are also why comparing the **Recordings** figure to `df -h` always shows a gap: `df` additionally counts any non-Frigate data on the disk, filesystem overhead and reserved blocks (ext4 reserves ~5% for root by default, so a disk can read "full" before recordings approach the total), and recently deleted recordings whose space has not yet been reclaimed.
|
||||
|
||||
:::tip
|
||||
|
||||
The Storage page is not intended to be a system-wide disk monitor — it shows how much space _Frigate's recordings_ use. To see true disk usage, use `df -h` (free space) and `du -sh` (per-directory usage) on the host.
|
||||
|
||||
:::
|
||||
|
||||
### Free space and the `/media/frigate` mount
|
||||
|
||||
Frigate reports the capacity and free space of whatever filesystem is actually mounted at `/media/frigate` **inside the container**. If an external drive or network share isn't truly mounted there — a missing `/etc/fstab` entry, a share that was offline when the container started, or a host that doesn't pass the path through — the container falls back to the host's OS disk, and Frigate will correctly report that smaller disk instead of the drive you intended.
|
||||
|
||||
If the reported capacity doesn't match your drive, the mount is the place to look, not Frigate. Verify what is actually mounted from inside the container:
|
||||
|
||||
```bash
|
||||
docker exec -it frigate df -h /media/frigate
|
||||
docker exec -it frigate mount | grep media
|
||||
```
|
||||
|
||||
See the [storage mount layout](/frigate/installation#storage) for how the volumes are expected to be configured.
|
||||
|
||||
### The `/tmp/cache` area is separate
|
||||
|
||||
Recording segments are first written to `/tmp/cache` — a small, in-memory (`tmpfs`) area — before being checked and moved to `/media/frigate/recordings`. Because it is separate and small, `/tmp/cache` can fill up and produce `No space left on device` errors even when the recordings disk has plenty of room — they are different storage areas. See [Recordings troubleshooting](/troubleshooting/recordings) for diagnosing cache and slow-storage issues.
|
||||
|
||||
### When the metrics don't match what's on disk
|
||||
|
||||
Because usage is tracked in the database, deleting recording files directly on disk — or files left behind after an upgrade — will not update the reported usage, and can even push it above 100%. Frigate is unaware of files it didn't record and won't count or remove them automatically. Use [Syncing Media Files With Disk](#syncing-media-files-with-disk) to reconcile the database with what is actually on disk.
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
Yes. Frigate continuously checks the **free space of the disk** holding `/media/frigate/recordings`. This is different from adding up the size of every recording: free space is a single number the operating system already tracks, so Frigate can ask for it instantly without reading through your files or spinning up the disk — which is exactly why it relies on this check rather than scanning the drive. When less than roughly one hour of recording space remains — estimated from the current recording bitrate, **not** a fixed percentage — Frigate deletes the oldest recordings to reclaim space and logs a message. This emergency cleanup removes the oldest recordings first **regardless of retention settings**.
|
||||
|
||||
Two consequences follow from this being based on whole-disk free space:
|
||||
|
||||
- Because the check uses the disk's real free space, **anything** filling the drive — including non-Frigate files — can trigger deletion of your oldest recordings.
|
||||
- Cleanup can run while a meaningful percentage of the disk is still free (for example, with high bitrates or many cameras), because the threshold is "less than ~1 hour of recording headroom," not "X% full."
|
||||
|
||||
Frequent emergency cleanups usually mean your configured retention exceeds what the disk can hold. Reduce your retention days so the normal retention cleanup keeps up and the emergency path rarely triggers.
|
||||
|
||||
@@ -42,6 +42,8 @@ Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011
|
||||
|
||||
Storage is an important consideration when planning a new installation. To get a more precise estimate of your storage requirements, you can use an IP camera storage calculator. Websites like [IPConfigure Storage Calculator](https://calculator.ipconfigure.com/) can help you determine the necessary disk space based on your camera settings.
|
||||
|
||||
Once running, see [Understanding storage usage](/configuration/record#understanding-storage-usage) for how Frigate measures and reports disk usage — and why its numbers won't exactly match `df` or `du`.
|
||||
|
||||
#### SSDs (Solid State Drives)
|
||||
|
||||
SSDs are an excellent choice for Frigate, offering high speed and responsiveness. The older concern that SSDs would quickly "wear out" from constant video recording is largely no longer valid for modern consumer and enterprise-grade SSDs.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+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")
|
||||
|
||||
@@ -42,6 +42,8 @@ from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2
|
||||
|
||||
|
||||
class SegmentInfo:
|
||||
def __init__(
|
||||
@@ -301,6 +303,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.saved.value,
|
||||
)
|
||||
|
||||
self._expire_stale_recordings_info(grouped_recordings)
|
||||
|
||||
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
|
||||
*tasks
|
||||
)
|
||||
@@ -311,6 +315,21 @@ class RecordingMaintainer(threading.Thread):
|
||||
[r for r in recordings_to_insert if r is not None],
|
||||
)
|
||||
|
||||
def _expire_stale_recordings_info(
|
||||
self, grouped_recordings: defaultdict[str, list[dict[str, Any]]]
|
||||
) -> None:
|
||||
expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL
|
||||
for recordings_info in (
|
||||
self.object_recordings_info,
|
||||
self.audio_recordings_info,
|
||||
):
|
||||
for camera in list(recordings_info.keys()):
|
||||
if camera in grouped_recordings:
|
||||
continue
|
||||
info = recordings_info[camera]
|
||||
while info and info[0][0] < expire_before:
|
||||
info.pop(0)
|
||||
|
||||
def drop_segment(self, cache_path: str) -> None:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
@@ -115,6 +115,46 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_called_once_with(cache_path)
|
||||
|
||||
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
config.cameras = {}
|
||||
stop_event = MagicMock()
|
||||
maintainer = RecordingMaintainer(config, stop_event)
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
ancient = now - 86400
|
||||
recent = now - 1
|
||||
|
||||
maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])]
|
||||
maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])]
|
||||
|
||||
maintainer.object_recordings_info["absent_cam"] = [
|
||||
(ancient, [], [], []),
|
||||
(recent, [], [], []),
|
||||
]
|
||||
maintainer.audio_recordings_info["absent_cam"] = [
|
||||
(ancient, 0, []),
|
||||
(recent, 0, []),
|
||||
]
|
||||
|
||||
grouped_recordings = {"present_cam": [{"start_time": ancient}]}
|
||||
|
||||
maintainer._expire_stale_recordings_info(grouped_recordings)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])]
|
||||
)
|
||||
self.assertEqual(
|
||||
maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])]
|
||||
)
|
||||
self.assertEqual(
|
||||
maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user