Compare commits

..

2 Commits

Author SHA1 Message Date
Josh Hawkins
ae60197cb0
Support onvif PasswordText cameras in the add camera wizard (#23365)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* try both onvif WS-Security password encodings when probing in the add camera wizard

* update onvif docs

* add tests
2026-05-31 08:20:09 -06:00
Josh Hawkins
407817a3b1
Motion search fixes (#23359)
* improve error parsing and increase skip default

* improve motion search  layout to match tracking details

* implement draw and move mode on mobile

* update motion search docs

* language tweaks

* improve tips

* note actions menu
2026-05-31 07:51:32 -06:00
9 changed files with 320 additions and 54 deletions

View File

@ -143,6 +143,11 @@ If your ONVIF camera does not require authentication credentials, you may still
:::
If a camera connects but fails to authenticate, two optional fields can help:
- `tls_insecure`: Skips TLS certificate verification and sends the ONVIF password as plaintext (`PasswordText`) instead of a hashed digest (`PasswordDigest`). Some cameras reject the digest token and only accept plaintext. This weakens connection security, so only enable it on a trusted local network.
- `ignore_time_mismatch`: ONVIF authentication tokens include a timestamp, and a camera will reject the token if its clock differs too much from Frigate's. Enabling this makes Frigate compensate for the time offset so authentication can still succeed. Running NTP on both the camera and the Frigate host is the recommended fix; only use this in a "safe" environment, as it slightly weakens token validation.
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.

View File

@ -153,7 +153,7 @@ Clicking a preview clip seeks the recording player to that timestamp so you can
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
To start a search, click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
2. Draw a polygon on the camera frame to define the region of interest.
@ -170,3 +170,21 @@ To start a search, click the kebab menu on a camera in the <NavPath path="Review
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
#### Common use cases
Frigate's main use case is to record and surface tracked objects, so Motion Search is most useful for the cases where object detection produced nothing — there is no object to find in Explore, but you suspect something happened.
- **Locating an unattributed change.** You know something appeared, disappeared, or moved in a window of footage — a package now gone, a gate left open — but no detection points to it. A search returns the candidate timestamps instead of scrubbing the timeline by hand.
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
#### Expected performance
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
To increase the speed of searches:
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.

View File

@ -529,6 +529,68 @@ def _extract_fps(r_frame_rate: str) -> float | None:
return None
def _build_digest_transport(username: str, password: str) -> AsyncTransport:
"""Build a zeep transport backed by an httpx client using HTTP digest auth."""
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
return AsyncTransport(client=client)
async def _connect_onvif_camera(
host: str,
port: int,
username: str,
password: str,
wsdl_base: str | None,
auth_type: str,
) -> ONVIFCamera:
"""Connect to an ONVIF device, trying both WS-Security password encodings.
Cameras disagree on whether the WS-Security UsernameToken should carry a
hashed PasswordDigest or a plaintext PasswordText. The wizard can't know
which a given camera expects, so we try PasswordDigest first (the common
case) and fall back to PasswordText when the device rejects the token. This
is independent of auth_type, which controls HTTP transport-level auth.
"""
first_error: Fault | None = None
# encrypt=True -> PasswordDigest, encrypt=False -> PasswordText
for encrypt in (True, False):
onvif_camera = ONVIFCamera(
host,
port,
username or "",
password or "",
wsdl_dir=wsdl_base,
encrypt=encrypt,
)
try:
await onvif_camera.update_xaddrs()
except Fault as e:
# A SOAP fault here is how a camera signals the wrong password
# encoding, so retry with the other encoding before giving up.
logger.debug(
"ONVIF connect with %s rejected, trying alternate encoding",
"PasswordDigest" if encrypt else "PasswordText",
)
if first_error is None:
first_error = e
continue
if auth_type == "digest" and username and password:
transport = _build_digest_transport(username, password)
for service in ("devicemgmt", "media", "ptz"):
if hasattr(onvif_camera, service):
getattr(onvif_camera, service).zeep_client.transport = transport
logger.debug("Configured digest authentication")
return onvif_camera
# Both encodings failed authentication; surface the original fault.
raise first_error
@router.get(
"/onvif/probe",
dependencies=[Depends(require_role(["admin"]))],
@ -605,34 +667,10 @@ async def onvif_probe(
except Exception:
wsdl_base = None
onvif_camera = ONVIFCamera(
host, port, username or "", password or "", wsdl_dir=wsdl_base
onvif_camera = await _connect_onvif_camera(
host, port, username, password, wsdl_base, auth_type
)
# Configure digest authentication if requested
if auth_type == "digest" and username and password:
# Create httpx client with digest auth
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
# Replace the transport in the zeep client
transport = AsyncTransport(client=client)
# Update the xaddr before setting transport
await onvif_camera.update_xaddrs()
# Replace transport in all services
if hasattr(onvif_camera, "devicemgmt"):
onvif_camera.devicemgmt.zeep_client.transport = transport
if hasattr(onvif_camera, "media"):
onvif_camera.media.zeep_client.transport = transport
if hasattr(onvif_camera, "ptz"):
onvif_camera.ptz.zeep_client.transport = transport
logger.debug("Configured digest authentication")
else:
await onvif_camera.update_xaddrs()
# Get device information
device_info = {
"manufacturer": "Unknown",
@ -644,10 +682,9 @@ async def onvif_probe(
# Update transport for device service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
device_service.zeep_client.transport = transport
device_service.zeep_client.transport = _build_digest_transport(
username, password
)
device_info_resp = await device_service.GetDeviceInformation()
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
@ -685,10 +722,9 @@ async def onvif_probe(
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
media_service.zeep_client.transport = _build_digest_transport(
username, password
)
profiles = await media_service.GetProfiles()
profiles_count = len(profiles) if profiles else 0
@ -720,10 +756,9 @@ async def onvif_probe(
# Update transport for PTZ service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
ptz_service.zeep_client.transport = transport
ptz_service.zeep_client.transport = _build_digest_transport(
username, password
)
# Check if PTZ service is available
try:
@ -876,10 +911,9 @@ async def onvif_probe(
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
media_service.zeep_client.transport = _build_digest_transport(
username, password
)
if profiles_count and media_service:
for p in profiles or []:

View File

@ -42,9 +42,9 @@ class MotionSearchRequest(BaseModel):
description="Minimum change area as a percentage of the ROI",
)
frame_skip: int = Field(
default=5,
default=30,
ge=1,
le=30,
le=120,
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
)
parallel: bool = Field(

View File

@ -0,0 +1,124 @@
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
from frigate.api.camera import _build_digest_transport, _connect_onvif_camera
def _make_camera(update_side_effect=None):
"""Build a mock ONVIFCamera whose update_xaddrs can raise or succeed."""
camera = MagicMock()
camera.update_xaddrs = AsyncMock(side_effect=update_side_effect)
return camera
class TestConnectOnvifCamera(unittest.IsolatedAsyncioTestCase):
async def test_password_digest_succeeds_first(self):
# Cameras that accept PasswordDigest authenticate on the first attempt
# and should never trigger the PasswordText fallback.
camera = _make_camera()
with patch("frigate.api.camera.ONVIFCamera", return_value=camera) as mock_cls:
result = await _connect_onvif_camera(
"cam.local", 80, "user", "pass", None, "basic"
)
self.assertIs(result, camera)
mock_cls.assert_called_once()
self.assertTrue(mock_cls.call_args.kwargs["encrypt"])
async def test_falls_back_to_password_text(self):
# A PasswordDigest rejection should retry once with PasswordText.
camera_digest = _make_camera(update_side_effect=Fault("token rejected"))
camera_text = _make_camera()
with patch(
"frigate.api.camera.ONVIFCamera",
side_effect=[camera_digest, camera_text],
) as mock_cls:
result = await _connect_onvif_camera(
"cam.local", 80, "user", "pass", None, "basic"
)
self.assertIs(result, camera_text)
self.assertEqual(mock_cls.call_count, 2)
self.assertTrue(mock_cls.call_args_list[0].kwargs["encrypt"])
self.assertFalse(mock_cls.call_args_list[1].kwargs["encrypt"])
async def test_both_encodings_fail_raises_first_fault(self):
# When both encodings fault, the original (PasswordDigest) fault is
# surfaced so the caller's existing Fault handler reports it.
first_fault = Fault("digest rejected")
camera_digest = _make_camera(update_side_effect=first_fault)
camera_text = _make_camera(update_side_effect=Fault("text rejected"))
with patch(
"frigate.api.camera.ONVIFCamera",
side_effect=[camera_digest, camera_text],
) as mock_cls:
with self.assertRaises(Fault) as ctx:
await _connect_onvif_camera(
"cam.local", 80, "user", "pass", None, "basic"
)
self.assertIs(ctx.exception, first_fault)
self.assertEqual(mock_cls.call_count, 2)
async def test_transport_error_is_not_retried(self):
# Connection-level errors (timeout, refused, unreachable) should
# propagate immediately without doubling latency on a second encoding.
camera = _make_camera(update_side_effect=TransportError("unreachable"))
with patch("frigate.api.camera.ONVIFCamera", side_effect=[camera]) as mock_cls:
with self.assertRaises(TransportError):
await _connect_onvif_camera(
"cam.local", 80, "user", "pass", None, "basic"
)
mock_cls.assert_called_once()
async def test_digest_auth_replaces_service_transports(self):
# auth_type "digest" wires an HTTP digest transport onto each service,
# independently of the WS-Security encoding.
camera = _make_camera()
with (
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
patch(
"frigate.api.camera._build_digest_transport",
return_value="TRANSPORT",
) as mock_transport,
):
result = await _connect_onvif_camera(
"cam.local", 80, "user", "pass", None, "digest"
)
self.assertIs(result, camera)
mock_transport.assert_called_once_with("user", "pass")
self.assertEqual(camera.devicemgmt.zeep_client.transport, "TRANSPORT")
self.assertEqual(camera.media.zeep_client.transport, "TRANSPORT")
self.assertEqual(camera.ptz.zeep_client.transport, "TRANSPORT")
async def test_basic_auth_does_not_replace_transports(self):
# Without digest auth, no transport override is built.
camera = _make_camera()
with (
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
patch("frigate.api.camera._build_digest_transport") as mock_transport,
):
await _connect_onvif_camera("cam.local", 80, "user", "pass", None, "basic")
mock_transport.assert_not_called()
class TestBuildDigestTransport(unittest.TestCase):
def test_returns_async_transport(self):
transport = _build_digest_transport("user", "pass")
self.assertIsInstance(transport, AsyncTransport)
if __name__ == "__main__":
unittest.main()

View File

@ -24,7 +24,9 @@
"points_one": "{{count}} point",
"points_other": "{{count}} points",
"undo": "Undo last point",
"reset": "Reset polygon"
"reset": "Reset polygon",
"drawMode": "Draw",
"moveMode": "Move"
},
"motionHeatmapLabel": "Motion Heatmap",
"dialog": {

View File

@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { LuHand, LuPencil } from "react-icons/lu";
import { FrigateConfig } from "@/types/frigateConfig";
import { TimeRange } from "@/types/timeline";
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Button } from "@/components/ui/button";
import {
@ -113,12 +115,34 @@ export default function MotionSearchDialog({
const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost();
const [imageLoaded, setImageLoaded] = useState(false);
const [panMode, setPanMode] = useState(false);
const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined;
return config.cameras[selectedCamera];
}, [config, selectedCamera]);
const aspectRatio = useMemo(() => {
if (!cameraConfig) {
return 16 / 9;
}
return cameraConfig.detect.width / cameraConfig.detect.height;
}, [cameraConfig]);
// Determine camera aspect ratio category
const cameraAspect = useMemo(() => {
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
return "wide";
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
return "tall";
} else {
return "normal";
}
}, [aspectRatio]);
const polygonClosed = useMemo(
() => !isDrawingROI && polygonPoints.length >= 3,
[isDrawingROI, polygonPoints.length],
@ -144,6 +168,7 @@ export default function MotionSearchDialog({
useEffect(() => {
setImageLoaded(false);
setPanMode(false);
}, [selectedCamera]);
const Overlay = isDesktop ? Dialog : Drawer;
@ -218,7 +243,13 @@ export default function MotionSearchDialog({
</div>
)}
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
panning={{ disabled: !isDesktop && !panMode }}
pinch={{ disabled: !isDesktop && !panMode }}
doubleClick={{ disabled: !isDesktop && !panMode }}
>
<div className="flex flex-col gap-2">
<TransformComponent
wrapperStyle={{
@ -231,7 +262,15 @@ export default function MotionSearchDialog({
height: "100%",
}}
>
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
<div
className={cn(
"relative mx-auto flex items-center justify-center overflow-hidden rounded-lg border bg-secondary",
cameraAspect === "tall"
? "max-h-[50dvh] lg:max-h-[60dvh]"
: "w-full",
)}
style={{ aspectRatio }}
>
{selectedCamera && cameraConfig ? (
<div className="relative h-full w-full">
<img
@ -261,6 +300,7 @@ export default function MotionSearchDialog({
isDrawing={isDrawingROI}
setIsDrawing={setIsDrawingROI}
isInteractive={true}
panMode={panMode}
/>
</div>
) : (
@ -282,11 +322,41 @@ export default function MotionSearchDialog({
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
</div>
<div className="flex flex-row justify-center gap-2">
{!isDesktop && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={panMode ? "select" : "default"}
className="size-8 rounded-md p-1.5"
aria-label={
panMode
? t("polygonControls.moveMode")
: t("polygonControls.drawMode")
}
onClick={() => setPanMode((prev) => !prev)}
>
{panMode ? (
<LuHand className="text-selected-foreground" />
) : (
<LuPencil className="text-secondary-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{panMode
? t("polygonControls.moveMode")
: t("polygonControls.drawMode")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className="size-6 rounded-md p-1"
className={cn(
"rounded-md",
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
)}
aria-label={t("polygonControls.undo")}
disabled={polygonPoints.length === 0 || isSearching}
onClick={undoPolygonPoint}
@ -302,7 +372,10 @@ export default function MotionSearchDialog({
<TooltipTrigger asChild>
<Button
variant="default"
className="size-6 rounded-md p-1"
className={cn(
"rounded-md",
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
)}
aria-label={t("polygonControls.reset")}
disabled={polygonPoints.length === 0 || isSearching}
onClick={resetPolygon}
@ -370,7 +443,7 @@ export default function MotionSearchDialog({
<Slider
id="frameSkip"
min={1}
max={60}
max={120}
step={1}
value={[frameSkip]}
onValueChange={([value]) => setFrameSkip(value)}

View File

@ -14,6 +14,7 @@ type MotionSearchROICanvasProps = {
isDrawing: boolean;
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
isInteractive?: boolean;
panMode?: boolean;
motionHeatmap?: Record<string, number> | null;
showMotionHeatmap?: boolean;
};
@ -26,6 +27,7 @@ export default function MotionSearchROICanvas({
isDrawing,
setIsDrawing,
isInteractive = true,
panMode = false,
motionHeatmap,
showMotionHeatmap = false,
}: MotionSearchROICanvasProps) {
@ -341,7 +343,9 @@ export default function MotionSearchROICanvas({
ref={setContainerNode}
className={cn(
"absolute inset-0 z-10",
isInteractive ? "pointer-events-auto" : "pointer-events-none",
isInteractive && !panMode
? "pointer-events-auto"
: "pointer-events-none",
)}
style={{ cursor: isDrawing ? "crosshair" : "default" }}
>

View File

@ -146,7 +146,7 @@ export default function MotionSearchView({
const [parallelMode, setParallelMode] = useState(false);
const [threshold, setThreshold] = useState(30);
const [minArea, setMinArea] = useState(20);
const [frameSkip, setFrameSkip] = useState(10);
const [frameSkip, setFrameSkip] = useState(30);
const [maxResults, setMaxResults] = useState(25);
// Job state
@ -846,7 +846,13 @@ export default function MotionSearchView({
responseData.errors;
if (Array.isArray(apiMessage)) {
errorMessage = apiMessage.join(", ");
errorMessage = apiMessage
.map((item) =>
typeof item === "string"
? item
: ((item as { msg?: string })?.msg ?? JSON.stringify(item)),
)
.join(", ");
} else if (typeof apiMessage === "string") {
errorMessage = apiMessage;
} else if (apiMessage) {