PTZ ONVIF
Context: $ARGUMENTS
Speak ONVIF (SOAP 1.2 / XML / WS-Security) to IP cameras — the industry-standard way to discover devices, pull RTSP URIs, and drive PTZ without vendor SDKs. For low-level Sony VISCA (non-ONVIF PTZ) use ptz-visca. For spec lookup see ptz-docs.
Quick start
- Discover all cameras on the LAN: → Step 1 (
discover) - Fetch basic camera info: → Step 2 (
info --host 192.168.1.64 --user admin --password x) - Get the RTSP stream URL: → Step 3 (
streams) - Grab a single snapshot: → Step 4 (
snapshot) - PTZ continuous pan-right: → Step 5 (
ptz continuous --pan 0.5 --tilt 0 --timeout 1.5) - PTZ absolute position or preset: → Step 5 (
ptz absolute/ptz preset-goto)
When to use
- Turn up a new NVR/VMS deployment; inventory cameras on the subnet.
- Pull RTSP URIs programmatically for ffmpeg ingest without vendor apps.
- Build a control UI that speaks to any ONVIF-compliant camera (Hikvision, Dahua, Axis, Bosch, PTZOptics, Amcrest, Reolink, Uniview, ACTi, Vivotek, etc.).
- Script snapshots / presets / PTZ movement across heterogeneous brands.
- Subscribe to motion-detection events.
Step 1 — Discover
WS-Discovery is a UDP multicast probe to 239.255.255.250:3702. The helper sends a SOAP <Probe> envelope and parses ProbeMatch responses to extract each camera's Device service URL (XAddrs).
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py discover --timeout 3
Output (JSON):
[
{"xaddr": "http://192.168.1.64:80/onvif/device_service",
"types": "dn:NetworkVideoTransmitter tds:Device",
"scopes": ["onvif://www.onvif.org/hardware/DS-2CD2", ...]}
]
WS-Discovery is link-local — it does NOT cross subnets or VLANs. For a camera you already know, skip to Step 2 with the Device service URL (usually http://HOST/onvif/device_service).
Step 2 — Get device info
Requires WS-Security UsernameToken with digest:
Digest = BASE64( SHA1( nonce + created + password ) )
nonce is raw random bytes (NOT base64) used inside the SHA1. The header also carries the base64 of the nonce and the ISO-8601 Created timestamp.
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py info \
--host 192.168.1.64 --user admin --password 'S3cret' \
--format json
Returns Manufacturer, Model, FirmwareVersion, SerialNumber, HardwareId.
Step 3 — Fetch stream URIs (Media service)
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py streams \
--host 192.168.1.64 --user admin --password 'S3cret'
Enumerates media profiles and returns RTSP URIs suitable for ffmpeg:
ffmpeg -rtsp_transport tcp -i rtsp://admin:S3cret@192.168.1.64:554/... out.mp4
Pass --rtsp-transport rtp-unicast / rtp-multicast / http to tweak SetStreamUri negotiation.
Step 4 — Snapshot
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py snapshot \
--host 192.168.1.64 --user admin --password 'S3cret' \
--output shot.jpg
Calls GetSnapshotUri then fetches the JPEG via HTTP Digest (most cameras) or Basic auth.
Step 5 — PTZ
# Continuous move (ContinuousMove, auto-stop after --timeout)
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz continuous \
--host 192.168.1.64 --user admin --password 'S3cret' \
--pan 0.5 --tilt 0 --zoom 0 --timeout 1.5
# Absolute (AbsoluteMove)
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz absolute \
--host ... --pan-x 0.5 --tilt-y -0.3 --zoom 0.2
# Goto preset
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz preset-goto \
--host ... --preset-token 2
# Save current position as preset
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz preset-set \
--host ... --preset-name "Cam1-Wide"
# Stop
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz stop --host ...
Pan/tilt velocities are normalized to [-1.0, 1.0]. Absolute positions also [-1.0, 1.0]; zoom [0.0, 1.0]. The helper auto-discovers the first ProfileToken if none is provided.
Gotchas
- WS-Discovery does NOT cross subnets. It's link-local multicast only. For remote cameras, plug in the Device service URL directly or run a proxy on the camera's subnet.
- WS-Security PasswordText vs PasswordDigest. Spec requires digest. Some old cameras accept PasswordText, but sending PasswordText to a modern camera fails with
Sender not authorized. Always compute the digest. - Nonce is raw bytes into SHA1, base64 into XML. The digest is
SHA1(raw_nonce + created_str + password); the XML element'swsse:Noncetag carries the BASE64-encoded nonce. Mixing these up produces silent 401 rejections. - Clock skew > 5 min breaks auth. Many cameras reject digests with a
Createdtimestamp too far from their own clock. If auth fails on a known-good password, checkntpon the camera — or add--time-offset <sec>when using the helper. - Profile S is deprecated 2027-03-31. Profile T replaces it. When both exist, prefer T's Media2 service (
GetStreamUrion Media2 vs Media v1). onvif-util!=onvif-cli. Both are real (libonvif'sonvif-utilfrom github.com/sr99622/libonvif, and the PyPI/Goonvif-cli). Don't confuse them.camobabyis NOT a real tool — ignore any hallucinated reference.- Vendor-specific paths. Most cameras expose
/onvif/device_servicebut Axis uses/onvif/servicesand some OEM devices use/onvif-http/snapshot. Always resolve XAddrs viaGetCapabilitiesorGetServicesrather than assuming the path. - RTSP often wants a separate credential. Cameras can have an "ONVIF user" (for SOAP) and a separate "RTSP user"; if the returned RTSP URL 401s, try injecting the same user/password or check the camera's own user table.
- Port 80 for SOAP is common but not universal. Hikvision NVRs often host SOAP on the camera at
:80but on the NVR at:8000.GetServicesreturns authoritative URLs. AbsoluteMovefails on cameras that don't reportAbsolutePanTiltPositionSpace. Fall back toRelativeMoveorContinuousMove. The helper raises a clear error.GetSnapshotUrireturns ONE URI but cameras often rate-limit it. Don't poll at >1 Hz without testing; some units will refuse connections after burst requests.- High-ping cameras can exceed the default timeout. Use
--timeout Nto override (default 8 s). - Python-onvif-zeep (PyPI
onvif-zeep) is optional. The helper ships a pure-stdlib SOAP client. Ifonvif-zeepis importable, the helper can fall back to it for complex flows (Events, Analytics) — but everything shown above works without it.
Examples
Example 1 — Discover and grab stream URIs in one shot
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py discover | \
jq -r '.[].xaddr' | while read url; do
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py streams \
--xaddr "$url" --user admin --password 'S3cret'
done
Example 2 — Point camera at preset 2 with a 1 s pre-move freeze
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz stop --host ... --user admin --password x
sleep 1
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py ptz preset-goto \
--host ... --user admin --password x --preset-token 2
Example 3 — Pipe the RTSP URI straight to ffmpeg
URL=$(uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py streams \
--host ... --user admin --password x --format json \
| jq -r '.[0].rtsp_uri')
ffmpeg -rtsp_transport tcp -i "$URL" -c copy out.mp4
Example 4 — Dry-run the SOAP envelope
uv run ${CLAUDE_SKILL_DIR}/scripts/onvifctl.py info \
--host ... --user admin --password x --dry-run --verbose
Prints the SOAP envelope and the computed WS-Security digest without hitting the network.
Reference docs
references/services.md— per-profile ONVIF service matrix, XAddr conventions, WS-Security digest recipe, canonical SOAP operation names.
CLI alternatives
onvif-util— libonvif (github.com/sr99622/libonvif), C,brew install sr99622/libonvif/libonvifor build from source.onvif-cli(Python) —pipx install onvif-cli(fromgithub.com/gardere/onvif-cli).onvif_control(Bash) —github.com/camelcamro/onvif_control, minimal WS-Security + PTZ.gonvif(Go) — performant alternative for large fleets.- PyPI
onvif-zeepfor Python dev; pure Python, full coverage incl. Events/Analytics.
Troubleshooting
WS-Discovery returns nothing
Cause: Not on the same L2 broadcast domain, or firewall drops multicast.
Solution: Confirm cameras on same subnet/VLAN. Disable firewall UDP blocks on port 3702. Fall back to unicast GetCapabilities with a known IP.
HTTP 401 from SOAP endpoint
Cause: Digest mismatch — typically PasswordText vs PasswordDigest, or clock skew > 5 min.
Solution: Use --verbose to see the SOAP envelope. Verify the Created timestamp is current. Check the camera's NTP.
Sender not authorized or 401 from digest
Cause: Wrong hashing order (nonce + created + password is required — in that order), or passing the base64-encoded nonce into the SHA1 instead of raw bytes.
Solution: Read references/services.md WS-Security pseudocode. Let --dry-run --verbose print the digest inputs.
ActionNotSupported on AbsoluteMove
Cause: Camera lacks AbsolutePanTiltPositionSpace.
Solution: Use ContinuousMove with a short --timeout or RelativeMove.
RTSP stream URL returns 401
Cause: Different user table for RTSP vs SOAP.
Solution: Embed user/password in the URL (rtsp://user:pass@host/...) or check the RTSP user in the camera UI.
Snapshot URL 404s
Cause: Vendor's snapshot handler lives elsewhere despite what GetSnapshotUri returns.
Solution: Try the vendor's documented URL (e.g. http://host/cgi-bin/snapshot.cgi for Amcrest/Dahua), or use an ffmpeg -frames:v 1 grab from the RTSP stream.