Design
Scope
The deck is one client for IP cameras and NVRs of every vendor that implements the ONVIF profiles (S streaming, T advanced streaming, G recording), over SOAP 1.2 web services:
- WS-Discovery: find devices on the local network;
- Device: information, services, scopes, time, reboot;
- Media and Media2: profiles, stream and snapshot URIs, snapshots;
- PTZ: continuous, absolute and relative moves, stop, status, presets;
- Imaging: settings, brightness, focus;
- Events through a pull-point subscription, so no callback server is needed;
callfor every other operation.
It needs the net capability.
Out of scope:
- RTSP and video decoding. The deck returns stream URLs; a player,
ffmpegor a recorder consumes them. - Base-notification push events. They need an HTTP endpoint the camera can reach; pull-point subscriptions cover events instead.
- Profile M analytics, and helpers for device configuration (users, network, OSD), which stay reachable through
call.
Decisions
- SOAP 1.2 over
httpdirectly. Requests are built with the bundledsoapmodule's envelope helpers and parsed with its parser, but posted throughhttpin one place.soap.sendwould drop the reply headers, and the deck needs theWWW-Authenticateheader of a401to choose HTTP Digest. Reading the reply followssoap.send(same messages, the generic kinds passed on). Ifsoapever exposes reply headers, the deck can go back to it. - WS-Security
UsernameTokenwithPasswordDigest. The digest isbase64(sha1(nonce + created + password))with a fresh 16-byte nonce andCreatedtime per request, so the password is never sent;passwordDigestexposes the computation. The Security block carries nomustUnderstand: a camera without WS-Security must ignore it and answer with its HTTP Digest challenge, wheremustUnderstand="1"would get a MustUnderstand fault instead. - The camera's clock decides.
connectreadsGetSystemDateAndTimeunsigned, as ONVIF requires, and keeps the offset (taken at the midpoint of the round trip) to correctCreated. A camera that refuses the unsigned read is asked again signed with the local clock. A camera that sends no UTC time gets offset 0 andclockSyncedfalse; the POSIX time zone is not interpreted. There is no automatic re-sync onter:NotAuthorized, because a retry would double failed logins and some cameras lock accounts;syncClockis the caller's call. - One failed login at most per call.
Device.authis""(not known yet),"wsse"or"digest", set bywithAuthor settled byconnectandsyncClockwhen it is still"". With"", a signed call answered401with a Digest challenge is resent once withhttp.digestAuthorization; a401without one fails at once. With"wsse"or"digest"there is one attempt.Deviceis a value, so a call cannot record what worked; a device used withoutconnectfinds out per call. Earlier every401was retried with Digest, two to three failed logins per call on a wrong password. - Snapshot auth. The snapshot GET answers a
401with HTTP Digest, and with Basic only overhttps(refused over plainhttp). The deck never offers Basic on SOAP. User info in a snapshot URI is stripped and never used, sent or shown; the device's own credentials answer the challenge. - Plain
httponly on the local network. Plainhttpis allowed to IP literals that are private, link-local or loopback; host names and public addresses needwithPlainHttp. Cameras servehttpon the LAN, and the digest never sends the password, but credentials should not cross the internet in the clear. - Addresses are rebased onto the device's host. Service addresses, subscription addresses and snapshot URLs from the camera that name the device URL's host are kept as reported, port and scheme included. One that names another host takes the device URL's scheme, host and port, so credentials only go to the host the caller named and NAT setups work. A device reached over
httpsthat reports plainhttpon the same host is moved too, so there is no TLS downgrade. Paths and queries are kept verbatim.streamUrireturnsStream{address, rebased}: the address as given, and the same with only the host swapped. - Explicit services win.
withServicerecords its override, andconnectputs it back after reading the services. - Replies are read by local name. Vendors differ in prefixes (Media2 is sometimes bound to
trt2), so elements are matched by local name. - Strict input. Tokens are 1 to 64 characters without control characters; PTZ values are in the generic normalized spaces (velocity and translation -1 to 1, absolute pan and tilt -1 to 1, zoom 0 to 1), and every move names its generic space; brightness and focus speed are checked against the device's own option ranges (one extra request); subscriptions last 10 to 86400 s and a pull waits 1 to 60 s for 1 to 1024 events. Numbers in replies must be
xs:floattext. - Camera text returned as data is kept whole. Event items, scopes, names, tokens and the like keep every script; only control characters become
?. Text quoted in error messages is cut and made printable ASCII. - Discovery. Two Probes (
NetworkVideoTransmitter,Device) go toDISCOVERY_ADDRESSfrom one unicast UDP socket; no multicast group is joined. Answers are matched onRelatesToand action, deduplicated by endpoint, capped atMAX_DEVICES, and junk is skipped, since any host can send to the socket. - Events. Requests carry WS-Addressing
Action,ToandMessageID; reference parameters (such as Axis's subscription id) are sent back as given.pullreturnsPulled{events, subscription}with the updated termination time.
Errors
A request the camera refused, or that failed, throws OnvifError (kind "onvif", ERROR_KIND) with status (the HTTP status, 0 for a local check or an unreadable reply), the SOAP fault code, the first subcode, every level in subcodes (read from the fault element, since soap keeps only the first) and the camera's reason. Local checks throw OnvifError with status 0 before anything is sent. Callers compare subcode or subcodes, never the message.
Generic kinds pass through:
"limit"(a reply overmaxBytes, a snapshot overmaxMediaBytes, a decoding cap),"timeout"and"closed"are rethrown with the same kind and a message that names the operation;"net", a camera that cannot be reached, is thenet.NetErrorhttpraised, passed on withreason,op,sentandrepliedintact. Discovery passes on thenet.NetErrorof a socket that cannot be bound or send;"cancelled"goes on as it is.
The deck repeats no request after a transport failure.
Security and limits
- The password is never sent on SOAP (only its digest) and never appears in a message. Messages never show a URL's user info or query, and a
net.NetErrorwhose message would name the URL gets the shortened URL instead. - Replies are capped at
DEFAULT_MAX_BYTES(8 MiB) and snapshots atDEFAULT_MAX_MEDIA_BYTES(16 MiB), adjustable withwithMaxBytesandwithMaxMediaBytes; the idle timeout isDEFAULT_TIMEOUT_MS(10 s), adjustable withwithTimeout. - Discovery answers are capped by count and datagram size; anything that is not a matching ProbeMatches is ignored.
- Text a camera sends is treated as hostile: cut and made printable in messages, control characters replaced in data.
- TLS certificates are checked unless the caller opts out with
withInsecure;withCaCertadds a private CA.
Testing and validation
- The overlays run against a loopback fake camera that answers each request by path and operation and records what it received, so tests check what the deck sends (the signed header, the digest, the body) as well as how it reads replies. Fixtures are constants under
src/topics/fixtures/, so the overlays run from a bare copy ofsrc/. - Replies were recorded on 2026-11-14 from onvif_simple_server (a C ONVIF server used in camera firmware, run in a container, which checks the PasswordDigest), and taken from published captures of EZVIZ (Hikvision firmware), Dahua and gSOAP cameras and the ONVIF Application Programmer's Guide (Axis). The PasswordDigest matches the Guide's vector.
- Validated on 2026-11-14 against the simulator: every export and the examples, including a port-mapped setup that showed the rebase rules working. Raw captures from Axis, Bosch and Reolink cameras parse without error.
- Transport failures are tested against a closed loopback port.
- The examples need a camera and are only parse-checked in the gate. There is no automated test against a real camera.
Not covered: a successful Renew from a real camera, a SOAP 1.1 fault, a Media2 snapshot over Digest on a real camera, TLS to a camera, and a multicast Probe answered on a real LAN (none answered during validation; unicast Probes work).
Known limitations and open questions
absoluteMoveon the simulator answerster:InvalidPosition, since it reads degrees whatever space the request names.- HTTP Digest on SOAP (
"digest") costs two round trips per call:httpkeeps no nonce cache. - The posted request is built by the deck rather than
soap.send, as long assoapdoes not expose reply headers.
Dependencies on the interpreter
- The bundled
soapmodule and HTTP Digest inhttp(0.26.0, the floor). - The bundled modules passing
"limit","timeout"and"closed"on. net.NetErrorand the"net"kind (jennifer's M28.65, transport errors have their own kind), with the decoder caps of M28.68 as"limit".- Thrown error structs and lint's L104 for
OnvifError.
History
- 2026-11-14: first version; validated against the simulator and vendor captures. Fixed then: camera text returned as data was cut at 120 characters and lost non-ASCII characters. Decided after it: rebasing only for another host,
Stream{address, rebased}, dropping snapshot user info, generic PTZ spaces,Device.authwith Digest only on a Digest challenge, nomustUnderstand. - 2026-11-15: a camera that cannot be reached throws the transport's
net.NetErrorinstead of anOnvifErrorwith status 0.