Introduction
onvif-server is an ONVIF Profile S streaming-core device server library for
Rust, built on top of the soap-server crate. You implement the service traits for
your camera hardware to expose a device that standard ONVIF clients — VMS/NVR
software, Home Assistant, Frigate, python-onvif-zeep, ONVIF Device Manager — can
discover and stream from. It targets the Profile S streaming core, not every ONVIF
operation; see Operation Coverage for the exact support claims.
ONVIF Profile S coverage
| Service | Status |
|---|---|
| Device | Supported |
| Media | Supported |
| PTZ | Supported |
| Imaging | Supported |
| Events | Supported |
“Supported” means the service is routed and covers the Profile S streaming core — not every operation. See Operation Coverage for the exact per-operation breakdown (trait-backed / static / framework / absent, with default behaviour) and Capabilities & Limitations for the crate-level summary.
License
onvif-server is dual-licensed under MIT OR Apache-2.0 (your choice).
Installation
Adding the dependency
cargo add onvif-server
The discovery feature
WS-Discovery multicast support is gated behind the optional discovery feature,
which pulls in the socket2 crate for low-level UDP multicast socket control.
Enable it when you want the device to be auto-discoverable on the local network:
cargo add onvif-server --features discovery
See WS-Discovery for details on what this enables at runtime.
MSRV
The minimum supported Rust version is the rust-version declared in the crate’s
Cargo.toml (also shown on the crate’s crates.io page).
Quick Start
The entry point is OnvifServer::builder(), which returns an OnvifServerBuilder.
Chain builder methods to configure the server, call .build() to validate and
construct it, then call .run().await to bind the port and begin serving.
A minimal usable device
An empty impl DeviceService for MyCamera {} compiles, but a real client faults
immediately — GetDeviceInformation and GetStreamUri have no working default.
The smallest device a client can actually use implements those few operations and
lets the framework handle the rest:
use async_trait::async_trait;
use onvif_server::{DeviceInfo, DeviceService, MediaService, OnvifError, OnvifServer};
#[derive(Clone)]
struct MinimalCamera {
media_host: String, // the camera's routable IP, used in stream/snapshot URIs
}
#[async_trait]
impl DeviceService for MinimalCamera {
async fn get_device_information(&self) -> Result<DeviceInfo, OnvifError> {
Ok(DeviceInfo {
manufacturer: "Example Corp".into(),
model: "Minimal-1".into(),
firmware_version: "1.0.0".into(),
serial_number: "SN-0001".into(),
hardware_id: "minimal-hw-1".into(),
})
}
// get_scopes / get_hostname / get_system_date_and_time use working defaults.
}
#[async_trait]
impl MediaService for MinimalCamera {
// profiles() defaults to one 1920x1080 H264 "MainProfile".
async fn get_stream_uri(&self, _profile: &str) -> Result<String, OnvifError> {
Ok(format!("rtsp://{}:8554/stream", self.media_host))
}
async fn get_snapshot_uri(&self, _profile: &str) -> Result<String, OnvifError> {
Ok(format!("http://{}:8080/snapshot.jpg", self.media_host))
}
}
#[tokio::main]
async fn main() {
let host = "192.168.1.10"; // the address clients route to
let cam = MinimalCamera { media_host: host.into() };
OnvifServer::builder()
.port(8080)
.advertised_host(host)
.device_service(cam.clone())
.media_service(cam)
.auth("admin", "password")
.build()
.expect("build failed")
.run()
.await
.expect("server error");
}
This is the minimal_device
example — run it with cargo run --example minimal_device. For a fuller device
that implements all five service traits (PTZ, imaging, events) see
virtual_ptz. For what each operation does and what still
faults by default, see Operation Coverage.
Verify it is serving
GetSystemDateAndTime is auth-exempt (ONVIF requires it so clients can sync
clocks before authenticating), which makes it a perfect no-credentials smoke test:
curl -s http://192.168.1.10:8080/onvif/device_service \
-H 'Content-Type: application/soap+xml' \
-d '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<GetSystemDateAndTime xmlns="http://www.onvif.org/ver10/device/wsdl"/>
</s:Body>
</s:Envelope>'
You should get a GetSystemDateAndTimeResponse with the current UTC time. A
client then authenticates (see WS-Security) and calls
GetDeviceInformation, GetProfiles, and GetStreamUri to reach the stream.
Builder methods
.port(u16)
The TCP port the server listens on. Defaults to 8080.
.advertised_host(&str)
The host address embedded in XAddr URLs returned to ONVIF clients via
GetCapabilities, GetServices, and WS-Discovery responses.
ONVIF clients use these URLs to make follow-up requests, so this must be a
routable address from the client’s perspective — for example "192.168.1.10",
not "0.0.0.0". Defaults to "0.0.0.0" for backward compatibility.
.device_service(impl DeviceService)
Registers the Device Management Service implementation. This is the only
required service — .build() returns Err(BuildError::MissingRequiredService)
if it is omitted. The device service handles core ONVIF operations such as
GetSystemDateAndTime, GetCapabilities, GetDeviceInformation, and others.
.media_service(impl MediaService)
Registers the Media Service implementation. Optional — if omitted, the
/onvif/media_service route is not mounted and media capabilities are not
advertised in GetCapabilities.
.ptz_service(impl PTZService)
Registers the PTZ Service implementation. Optional — if omitted, the
/onvif/ptz_service route is not mounted.
.imaging_service(impl ImagingService)
Registers the Imaging Service implementation. Optional — if omitted, the
/onvif/imaging_service route is not mounted.
.event_service(impl EventService)
Registers the Event Service implementation. Optional — if omitted, the
/onvif/events_service route is not mounted.
.auth(&str, &str)
Enables WS-Security UsernameToken digest authentication with the given username and password. When this method is not called, the server runs unauthenticated and all operations are accessible without credentials. See WS-Security for details.
.discovery_uuid(uuid::Uuid)
Overrides the stable WS-Discovery EndpointReference UUID for this device. When not called, a random UUID-v4 is used at build time. Callers that need a deterministic identity across restarts should supply a stable UUID derived from hardware ID or stored configuration.
.build()
Validates configuration and constructs the OnvifServer. Returns
Err(BuildError::MissingRequiredService("device_service")) if no device service
was registered.
.run().await
Binds 0.0.0.0:<port> and starts serving SOAP requests. Does not return until
the server shuts down. Requires a tokio async runtime. Returns RunError::Io on
TCP bind failure.
Operation Coverage
This page lists every ONVIF operation onvif-server routes, how each is backed,
and what it does out of the box with an empty trait implementation. It is the
authoritative answer to “will my client work against this device?”
onvif-server targets the Profile S streaming core — enough for a client to
discover the device, enumerate a media profile, pull a stream/snapshot URI, drive
PTZ, and run a pull-point event subscription. It is not a full ONVIF device:
most management and configuration operations are absent.
Legend
| Backing | Meaning |
|---|---|
| Framework | Response built internally by onvif-server from builder config (e.g. advertised host, registered services). No trait method; nothing to implement. |
| Trait (default OK) | Dispatched to a service-trait method that ships a working default. Usable without overriding; override to customise. |
| Trait (override) | Dispatched to a service-trait method whose default returns a not_implemented SOAP fault. You must override it for the operation to work. |
| Static | Returns fixed, canned XML. Not overridable and not driven by your trait data. |
| Absent | Not routed. Returns a ter:ActionNotSupported SOAP fault. |
“Default behaviour” = what happens when the service is registered but the trait method is not overridden. ✅ = a usable response; ⚠️ = a response that may not match your device; ❌ = a SOAP fault.
Device — tds, route /onvif/device_service
Namespace http://www.onvif.org/ver10/device/wsdl. Always mounted (required).
| Operation | Backing | Default behaviour | Notes |
|---|---|---|---|
GetSystemDateAndTime | Trait (default OK) | ✅ Utc::now() | Auth-exempt (clock-sync before authenticating). Always reports TimeZone=UTC, DateTimeType=Manual. |
GetCapabilities | Framework | ✅ | Lists only the services you registered, with their XAddrs. |
GetServices | Framework | ✅ | Same set; version hardcoded to 2.42. |
GetScopes | Trait (default OK) | ✅ two fixed scopes | Defaults to video_encoder + Profile/Streaming. Note: these are not the scopes advertised over WS-Discovery (see below). |
GetHostname | Trait (default OK) | ✅ "onvif-device" | FromDHCP=false. |
GetDeviceInformation | Trait (override) | ❌ fault | Override to return manufacturer/model/firmware/serial/hardware-id. |
GetNetworkInterfaces | Trait (override) | ❌ fault | Override to advertise NICs. |
| all other Device ops | Absent | ❌ fault | SetHostname, SetScopes, GetUsers, SystemReboot, network/discovery config, etc. (~40 operations) are not routed. |
Media — trt, route /onvif/media_service
Namespace http://www.onvif.org/ver10/media/wsdl (Media1). Optional. Media2
(ver20) is not implemented.
| Operation | Backing | Default behaviour | Notes |
|---|---|---|---|
GetProfiles | Trait (default OK) | ✅ one static profile | Default is a single 1920×1080 H264 MainProfile. Override profiles() to expose real/multiple profiles. |
GetStreamUri | Trait (override) | ❌ fault | Override this — it is how clients learn the RTSP URL. |
GetSnapshotUri | Trait (override) | ❌ fault | Override for JPEG snapshot support. |
GetVideoSources | Static | ⚠️ fixed 1920×1080@30 | Canned; ignores your profile data. |
GetVideoSourceConfigurations | Static | ⚠️ fixed | Canned. |
GetVideoEncoderConfigurations | Static | ⚠️ fixed H264 1080p | Canned. May disagree with the profiles you advertise. |
| all other Media ops | Absent | ❌ fault | No Set*, GetAudio*, GetOSDs, CreateProfile, options, etc. |
PTZ — tptz, route /onvif/ptz_service
Namespace http://www.onvif.org/ver20/ptz/wsdl (the ver10 namespace is also
accepted on requests). Optional.
| Operation | Backing | Default behaviour | Notes |
|---|---|---|---|
GetNodes | Static | ✅ one node | FoV relative pan/tilt space, range −1..1, max 10 presets. |
GetNode | Static | ✅ / fault | Faults on unknown node token. |
GetConfigurations | Static | ✅ | Single PTZConfig. |
GetConfiguration | Static | ✅ / fault | Faults on unknown config token. |
GetConfigurationOptions | Static | ✅ | |
GetServiceCapabilities | Static | ✅ | MoveStatus=true (attribute form, required by Frigate). |
GetPresets | Trait (default OK) | ✅ empty list | Override to list saved presets. |
GetStatus | Trait (override) | ❌ fault | UtcTime filled from the server clock. |
RelativeMove | Trait (override) | ❌ fault | Coordinates parsed (absent → 0.0; malformed → fault). |
AbsoluteMove | Trait (override) | ❌ fault | As above. |
ContinuousMove | Trait (override) | ❌ fault | As above. |
Stop | Trait (override) | ❌ fault | PanTilt/Zoom absent → both true. |
GotoPreset | Trait (override) | ❌ fault | |
SetPreset | Trait (override) | ❌ fault | Returns the preset token. |
RemovePreset | Trait (override) | ❌ fault | |
| all other PTZ ops | Absent | ❌ fault | No GotoHomePosition/SetHomePosition, geo-move, presets-tours, etc. |
Imaging — timg, route /onvif/imaging_service
Namespace http://www.onvif.org/ver20/imaging/wsdl. Optional.
| Operation | Backing | Default behaviour | Notes |
|---|---|---|---|
GetImagingSettings | Trait (override) | ❌ fault | Only the fields you set are emitted; white balance is reported as a single MANUAL element. |
| all other Imaging ops | Absent | ❌ fault | No SetImagingSettings, GetOptions, GetMoveOptions, focus Move/Stop/GetStatus. |
Events — tev + WS-BaseNotification, route /onvif/events_service
Namespace http://www.onvif.org/ver10/events/wsdl. Optional. Implements the
pull-point subscription lifecycle only.
| Operation | Backing | Default behaviour | Notes |
|---|---|---|---|
GetEventProperties | Static | ✅ minimal | Fixed topic set with an empty TopicSet. The get_event_properties trait method is not consulted. |
CreatePullPointSubscription | Framework | ✅ | Creates an in-memory subscription (UUID id, default 60 s lifetime; honours InitialTerminationTime). |
PullMessages | Framework | ✅ empty | Validates the subscription and returns current/termination time but no NotificationMessages — there is no actual event delivery. |
Unsubscribe | Framework | ✅ | Removes the subscription. |
| all other Events ops | Absent | ❌ fault | No Subscribe (basic notify), renew, seek, etc. |
WS-Discovery (UDP multicast 239.255.255.250:3702)
Not an HTTP/SOAP service. Gated behind the non-default discovery Cargo
feature. When enabled, the server answers a Probe with ProbeMatches. It does
not send an unsolicited Hello on start.
| Behaviour | Backing | Notes |
|---|---|---|
Probe → ProbeMatches | Framework | Replies to multicast Probe with the device-service XAddr and a fixed Types/Scopes set (dn:NetworkVideoTransmitter). These scopes are hardcoded in discovery and independent of DeviceService::get_scopes. See WS-Discovery. |
What this is not
- Profile S core only. No Profile T/G/A/M/D/C operations.
- Media1 only (no Media2 /
ver20media). - No configuration writes. The only
Set*operation implemented is PTZSetPreset. Hostname, scopes, encoder config, imaging settings, users, network — none are settable. - No real event delivery. The pull-point lifecycle works, but
PullMessagesnever returns notifications. - Several responses are static. Video source/encoder configs and the PTZ node/config tree are canned and can disagree with the profiles your trait advertises — keep them consistent in your implementation if a strict client cross-checks them.
Capabilities & Limitations
What onvif-server is for, what a client can expect, and where it stops. For the
exact per-operation breakdown, see Operation Coverage; this page
is the crate-level summary.
What it is
A library for standing up a Profile S streaming-core ONVIF device server in
Rust. It bundles the official ONVIF WSDLs, runs them over the
soap-server transport, and exposes a
handler trait per service. You implement only the operations your device supports;
everything else is handled by the framework, returns a sensible default, or faults.
It exists to make a camera/encoder discoverable and consumable by ONVIF clients (Frigate, Home Assistant, ONVIF Device Manager, NVRs) — not to emulate a fully-featured commercial ONVIF device.
What a client gets
- Discovery of the device and its services (
GetCapabilities,GetServices, and optional WS-Discovery — the server answers aProbewithProbeMatches). - One or more media profiles with a stream URI and optional snapshot URI (you supply the URIs).
- PTZ control (moves, stop, status, presets) when you implement the PTZ trait.
- Imaging settings readout when implemented.
- A pull-point event subscription lifecycle (create / pull / unsubscribe).
Configuration surface
- Services are opt-in. Only the Device service is required; Media, PTZ,
Imaging, and Events are mounted (and advertised in
GetCapabilities/GetServices) only when you register them. - Auth is opt-in. With
.auth(user, pass), WS-Security UsernameToken is enforced on all non-bypassed operations; without it the server is unauthenticated.GetSystemDateAndTimeis always auth-exempt so clients can sync clocks before authenticating. See WS-Security. advertised_hostsets the host clients see in XAddrs (GetCapabilities/GetServices/discovery) — it must be an address the client can route to (not0.0.0.0). Stream/snapshot URIs are not derived from it; yourMediaServicereturns those (and must point them at a routable address too).- WS-Discovery is behind the non-default
discoveryCargo feature. Enable it to advertise on UDP multicast; see WS-Discovery.
Limitations
- Profile S core only — no Profile T/G/A/M/D/C operations.
- Media1 only — Media2 (
ver20/media) is not implemented. - Almost no configuration writes. The only
Set*operation is PTZSetPreset. Hostname, scopes, encoder config, imaging, users, and network are not settable. - No real event delivery. The subscription lifecycle works but
PullMessagesnever returns notifications — there is no event source. - Some responses are static. Video source/encoder configurations and the PTZ node/config tree are canned and not driven by your trait data; they can disagree with the profiles you advertise. Keep them consistent for strict clients.
- Discovery scopes are fixed (
NetworkVideoTransmitter) and independent ofDeviceService::get_scopes. GetSystemDateAndTimealways reports UTC withDateTimeType=Manual.
Conformance
Responses are differentially validated against an ONVIF schema oracle and a reference device in CI. See Conformance for what was checked and how to run the harness.
Services
onvif-server exposes five service traits. You implement the ones your hardware
needs; unimplemented methods return OnvifError::NotImplemented, which the SOAP
layer turns into a ter:ActionNotSupported fault (a well-formed fault, not a
dropped connection). This page is the how-to-implement guide; for the exact status
of every operation see Operation Coverage.
A note on the split between trait and framework: some responses are built by the framework from builder config (capabilities, service list, discovery), and a few are static canned XML (video source/encoder configs, the PTZ node tree). Those are not on the traits — you cannot override them. The sections below cover only what you implement.
DeviceService — required
Mounted at /onvif/device_service, registered with .device_service(impl).
| Method | Default | Implement when |
|---|---|---|
get_device_information | faults | Always — clients call it early. Return manufacturer, model, firmware, serial, hardware id. |
get_system_date_and_time | Utc::now() | Rarely. The default is correct for most devices. Always reported as UTC / Manual. |
get_scopes | two fixed scopes | To customise the scopes returned by GetScopes (video_encoder, Profile/Streaming by default). |
get_hostname | "onvif-device" | To report a real hostname. |
get_network_interfaces | faults | Only if a client needs NIC enumeration (most do not). |
GetCapabilities and GetServices are framework-built from the services you
register and your advertised_host — not trait methods.
Scopes gotcha:
get_scopessets theGetScopesresponse only. The scopes advertised over WS-Discovery are hardcoded (NetworkVideoTransmitter) and are not taken from this method. See WS-Discovery.
async fn get_device_information(&self) -> Result<DeviceInfo, OnvifError> {
Ok(DeviceInfo {
manufacturer: "Example Corp".into(),
model: "EX-1".into(),
firmware_version: "1.2.0".into(),
serial_number: "SN-0001".into(),
hardware_id: "ex-hw-1".into(),
})
}
MediaService
Mounted at /onvif/media_service, registered with .media_service(impl).
| Method | Default | Implement when |
|---|---|---|
profiles | one 1920×1080 H264 MainProfile (token profile_0) | To advertise real or multiple profiles. |
get_stream_uri | faults | Always for streaming — return the RTSP URL for the given profile token. |
get_snapshot_uri | faults | For JPEG snapshot support. |
The profile token you put in profiles() is the token clients pass back to
get_stream_uri/get_snapshot_uri — switch on it if you expose several profiles.
advertised_host vs stream URI:
advertised_hostcontrols the host clients see inGetCapabilities/GetServicesXAddrs. The stream/snapshot URIs are whatever you return here — they must independently point at a client-routable address (often the same host, RTSP port 554/8554).
GetVideoSources, GetVideoSourceConfigurations, and
GetVideoEncoderConfigurations are static (fixed 1920×1080 H264) and not
overridable; keep your advertised profile consistent with them for strict clients.
async fn get_stream_uri(&self, profile: &str) -> Result<String, OnvifError> {
Ok(format!("rtsp://{}:554/{}", self.host, profile))
}
PTZService
Mounted at /onvif/ptz_service, registered with .ptz_service(impl).
Discovery operations (GetNodes, GetNode, GetConfigurations,
GetConfiguration, GetConfigurationOptions, GetServiceCapabilities) are
static and not on the trait. You implement the control surface:
| Method | Default | Notes |
|---|---|---|
relative_move / absolute_move / continuous_move | faults | Coordinates are floats; missing → 0.0, malformed → fault before reaching you. |
stop | faults | pan_tilt / zoom booleans; if the client omits both, both are true. |
get_status | faults | Return PTZStatusResult { pan_tilt_moving, zoom_moving }; the response UtcTime is filled by the server. |
get_presets | empty list | Return your saved presets. |
goto_preset / set_preset / remove_preset | faults | set_preset returns the (new) preset token. |
Coordinate space: the advertised node uses the field-of-view relative pan/tilt translation space with
XRange/YRangeof −1..1 (andMaximumNumberOfPresets= 10). Interpret/clamp thepan/tilt/zoomarguments accordingly, and never move real hardware on a malformed coordinate — the framework already rejects unparseable values with a fault before calling you.
See the virtual_ptz example for a full in-memory implementation.
ImagingService
Mounted at /onvif/imaging_service, registered with .imaging_service(impl).
| Method | Default | Notes |
|---|---|---|
get_imaging_settings | faults | Return an ImagingSettings; only Some(_) fields are emitted. |
White balance is special: set white_balance_cr_gain and/or
white_balance_cb_gain and the response emits a single WhiteBalance element with
Mode=MANUAL and the gain children. SetImagingSettings and the imaging options /
focus operations are absent.
async fn get_imaging_settings(&self, _token: String) -> Result<ImagingSettings, OnvifError> {
Ok(ImagingSettings { brightness: Some(50.0), contrast: Some(50.0), ..Default::default() })
}
EventService
Mounted at /onvif/events_service, registered with .event_service(impl).
This service implements the WS-BaseNotification pull-point lifecycle
(CreatePullPointSubscription, PullMessages, Unsubscribe) entirely in the
framework, plus a static GetEventProperties. The single trait method,
get_event_properties, is currently not consulted by the handler.
Important limitation: there is no actual event delivery.
PullMessagesvalidates the subscription and returns the current/termination time but never returnsNotificationMessages. Registering anEventServicemakes the subscription handshake succeed (so clients like Frigate don’t error), but no motion/analytics events are pushed. Treat events as “subscribable but silent.”
Implementing incrementally
Start with device_service (get_device_information) + media_service
(get_stream_uri) — that is enough for most clients to enumerate the device and
open a stream. Add PTZ / imaging / events only as the client you target actually
calls them. Anything you skip faults cleanly with ter:ActionNotSupported.
WS-Security
Enabling authentication
Call .auth(username, password) on the builder to enable WS-Security
UsernameToken digest authentication:
#![allow(unused)]
fn main() {
OnvifServer::builder()
.port(8080)
.device_service(MyCamera)
.auth("admin", "password")
.build()
.expect("build failed")
.run()
.await
.expect("server error");
}
When .auth() is called, every SOAP request must include a valid WS-Security
UsernameToken header with a matching username and password digest. Requests
without a valid token receive a SOAP authentication fault.
When .auth() is not called, the server runs unauthenticated. All operations
are accessible without credentials.
Auth bypass: GetSystemDateAndTime
GetSystemDateAndTime is automatically exempt from authentication regardless of
whether .auth() is called. This is required by the ONVIF specification: clients
must be able to retrieve the device’s system time before they have valid credentials,
because the WS-Security digest is time-sensitive and requires clock synchronisation.
No additional configuration is needed — the exemption is pre-registered by the builder at construction time.
The clock-sync flow
ONVIF UsernameToken digest authentication is time-sensitive: the digest is
Base64(SHA-1(Nonce + Created + Password)), and the server rejects a Created
timestamp outside a ±300 s window (and replays of a Nonce within that window).
A client whose clock is skewed from the device by more than ~5 minutes cannot
authenticate. The standard handshake works around this:
- Unauthenticated
GetSystemDateAndTime— the client reads the device clock (this operation is auth-exempt, above). - The client computes its offset from the device and uses the device’s time as
the basis for the
Createdtimestamp in step 3. - Digest-authenticated calls — every subsequent request carries a
<wsse:Security>UsernameTokenwhoseCreated/Nonceare accepted because they fall inside the device’s freshness window.
This is why GetSystemDateAndTime must remain reachable without credentials, and
why a device with a badly wrong clock will appear to reject correct passwords.
A UsernameToken request
A digest-authenticated request carries this header (the client computes the
Password digest from a fresh Nonce and Created):
<s:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>admin</wsse:Username>
<wsse:Password Type=".../username-token-profile-1.0#PasswordDigest">
9kFw...base64-digest...=</wsse:Password>
<wsse:Nonce>LKqI...base64-nonce...=</wsse:Nonce>
<wsu:Created>2026-06-03T08:00:00Z</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>
</s:Header>
PasswordText (plaintext <wsse:Password>) is also accepted. The digest and
replay/freshness mechanics are implemented in the underlying soap-server crate —
see its WS-Security page
for the exact algorithm, the ±300 s windows, and multi-process caveats.
Authentication failure
A request to a protected operation with a missing or invalid <wsse:Security>
header gets a SOAP Sender (SOAP 1.1: Client) fault, in the same SOAP version
as the request:
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body>
<env:Fault>
<env:Code><env:Value>env:Sender</env:Value></env:Code>
<env:Reason><env:Text xml:lang="en">WS-Security header required but not provided</env:Text></env:Reason>
</env:Fault>
</env:Body>
</env:Envelope>
WS-Discovery
WS-Discovery enables ONVIF clients to find devices on the local network
automatically without knowing their IP addresses in advance. When a client
sends a multicast Probe message, discoverable devices respond with their
service addresses.
Enabling the feature
WS-Discovery support is behind the optional discovery Cargo feature. Add it
to your dependency:
cargo add onvif-server --features discovery
This feature pulls in the socket2 crate, which is required for the low-level
UDP multicast socket setup.
Runtime behaviour
When the discovery feature is enabled, OnvifServer::run() spawns a background
task that:
- Joins the IPv4 multicast group
239.255.255.250on port3702. - Listens for incoming UDP datagrams.
- Parses each datagram and ignores anything that is not a well-formed WS-Discovery
Probemessage (SOAP body first child =Probein namespacehttp://schemas.xmlsoap.org/ws/2005/04/discovery). - For genuine
Probemessages, sends aProbeMatchesresponse back to the sender’s address, embedding the device’s service address (XAddr) and its stable WS-Discovery EndpointReference UUID.
The XAddr in the ProbeMatches response is derived from the advertised_host and
port configured on the builder:
http://<advertised_host>:<port>/onvif/device_service.
EndpointReference UUID
ONVIF WS-Discovery requires the EndpointReference/Address to be a stable
per-device identity across discovery cycles. The configured UUID is fixed for the
lifetime of the server, so every ProbeMatches within one process run carries the
same identity.
When not set, the builder assigns a random UUID-v4 once at build time: stable
across discovery cycles, but not across restarts — a restarted device appears
as a new identity to clients that key on the EndpointReference. Use
.discovery_uuid(uuid::Uuid) to supply a fixed UUID derived from a hardware id or
stored config when restart-stable identity matters.
Deployment hazards
WS-Discovery is multicast UDP and fails quietly in common network topologies. Before relying on it:
advertised_hostmust be client-routable. TheProbeMatchesXAddr ishttp://<advertised_host>:<port>/onvif/device_service. Ifadvertised_hostis left at0.0.0.0(or a container-internal IP), clients discover the device but cannot then reach it. Set it to the device’s real LAN address.- UDP 3702 must be open. Host firewalls frequently block inbound UDP 3702 and
the multicast group
239.255.255.250. Discovery silently returns nothing if it is filtered — unlike the SOAP endpoint, there is no connection error to see. - Multicast rarely crosses subnets/VLANs. Probes are link-local; a client on a different VLAN or subnet (or across a router without an IGMP/mDNS reflector) will not discover the device. Cross-segment clients must be given the XAddr directly.
- Multiple NICs are ambiguous. On a multi-homed host the listener joins the
group, but the address clients should use is whatever you put in
advertised_host— pick the interface clients actually reach. - Discovery is optional. Most integrations (Frigate, Home Assistant) let you
enter the device URL directly; discovery is a convenience, not a requirement. If
it misbehaves, configure clients with the explicit
http://<host>:<port>/onvif/device_serviceURL and move on.
Low-level helpers
The probe-parsing and probe-response functions are always compiled (no feature gate) because they are pure XML and useful for testing:
onvif_server::discovery_is_probe(msg: &[u8]) -> bool— returnstrueif the bytes are a well-formed WS-DiscoveryProbemessage.onvif_server::discovery_build_probe_match(relates_to: &str, xaddr: &str, device_uuid: uuid::Uuid) -> String— builds aProbeMatchesXML response.
Only the UDP multicast listener (run_discovery) requires the discovery feature.
Client Setup
Notes for connecting common ONVIF clients to an onvif-server device: the URL to
use, auth, which operations the client leans on, and the caveats specific to this
crate’s coverage.
Connection basics
- Service URL:
http://<host>:<port>/onvif/device_service(default port 8080).<host>must match youradvertised_hostand be routable from the client. - Auth: if you called
.auth(user, pass), clients need those credentials; otherwise leave the client’s credentials blank.GetSystemDateAndTimeis always reachable unauthenticated for clock sync. - Streams: the RTSP/snapshot URLs are whatever your
MediaServicereturns — they are independent of the ONVIF port and must be reachable too.
Frigate
- Configure the camera with
onvif:host/port/user/password, plus the RTSP stream fromffmpeg:inputs (Frigate does not learn the stream URL from ONVIF — you set it directly). - Frigate calls
GetServiceCapabilitiesand the PTZ control surface foronvif-based PTZ. This crate returnsMoveStatusas an attribute (which Frigate requires) and implements the PTZ moves/stop/presets via yourPTZService. - Caveats: PTZ buttons do nothing unless you implement
PTZService. Frigate does not depend on this device for motion events (it does its own detection), so the lack of event delivery is not a problem for Frigate.
Home Assistant (ONVIF integration)
- Add the ONVIF integration and enter host, port, username, password. HA calls
GetDeviceInformation,GetCapabilities,GetProfiles, and the stream/snapshot URIs, and sets up PTZ services and an event subscription. - Caveats: HA subscribes to events (the pull-point handshake succeeds), but
this device never delivers events, so no ONVIF binary sensors / motion events
will fire — use HA-side motion detection instead. Implement
get_device_information,get_stream_uri, andget_snapshot_urior HA setup steps will fault.
ONVIF Device Manager (ODM)
- Windows diagnostic tool. Point it at the service URL (or discover on-LAN with the
discoveryfeature) and enter credentials. - Good for verifying enumeration: it exercises device info, profiles, video
configs, PTZ nodes, and the live stream. Operations this crate marks absent
(most
Set*, imaging options, etc.) show as errors/blank in ODM — that is expected, not a bug.
python-onvif-zeep
ONVIFCamera(host, port, user, pass, wsdl_dir). zeep builds its client from WSDLs, so pointwsdl_dirat a local ONVIF WSDL set (zeep does not fetch the device’s?wsdl).- Call only the operations in the coverage matrix; others return a
ter:ActionNotSupportedfault that zeep raises as aFault. Example:cam.create_devicemgmt_service().GetDeviceInformation().
VLC / RTSP players
- VLC is not an ONVIF client. It plays the RTSP stream directly — open the URL
your
MediaService::get_stream_urireturns (e.g.rtsp://<host>:554/stream). - Use VLC to confirm the underlying media path works independently of ONVIF signalling.
Example: Virtual PTZ Camera
The virtual_ptz example (examples/virtual_ptz.rs) is a minimal, fully functional
in-memory PTZ camera that implements all five ONVIF service traits. It demonstrates
how to share state across multiple service registrations using Arc<Mutex<_>>.
Running the example
cargo run --example virtual_ptz
The server binds on port 8080 and prints its service URLs:
Virtual PTZ ONVIF server running on :8080
Device service: http://0.0.0.0:8080/onvif/device_service
Media service: http://0.0.0.0:8080/onvif/media_service
PTZ service: http://0.0.0.0:8080/onvif/ptz_service
Imaging service: http://0.0.0.0:8080/onvif/imaging_service
Events service: http://0.0.0.0:8080/onvif/events_service
Credentials: admin / admin
Connect any ONVIF client (ONVIF Device Manager, VLC, Frigate, Home Assistant) to
http://<host>:8080/onvif/device_service with username admin and password admin.
What the example builds
Shared state: VirtualPTZ
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct VirtualPTZ {
presets: Arc<Mutex<HashMap<String, String>>>,
preset_counter: Arc<Mutex<u32>>,
}
}
VirtualPTZ stores PTZ presets in memory. It is Clone, so a single instance
can be registered for multiple service slots without wrapping in another Arc —
the internal Arcs are what actually share state between the clones.
DeviceService
Returns a static DeviceInfo with manufacturer "Virtual", model "VirtualPTZ",
firmware "1.0", serial number "0000", and hardware ID "virtual-hw-0".
MediaService
Returns fixed URIs:
- Stream URI:
rtsp://127.0.0.1:8554/stream - Snapshot URI:
http://127.0.0.1:8080/snapshot.jpg
PTZService
Implements the full PTZ surface:
| Method | Behaviour |
|---|---|
relative_move(profile, pan, tilt, zoom) | Logs the move; no hardware. |
absolute_move(profile, pan, tilt, zoom) | Logs the move; no hardware. |
continuous_move(profile, pan, tilt, zoom) | Logs the move; no hardware. |
stop(profile, pan_tilt, zoom) | Logs the stop; no hardware. |
get_status(profile) | Returns pan_tilt_moving: false, zoom_moving: false. |
get_presets(profile) | Returns all presets from the in-memory map. |
set_preset(profile, name, token) | Inserts into the map; auto-generates a token if none supplied. |
goto_preset(profile, token) | Logs the goto; no hardware. |
remove_preset(profile, token) | Removes from the map. |
PTZ presets are lost on restart.
ImagingService
Returns static ImagingSettings with brightness, contrast, and sharpness each
set to 50.0.
EventService
Uses the default (all methods return NotImplemented). ONVIF clients that
request event subscriptions receive a SOAP fault.
Server assembly
All five service slots are registered using clones of the same VirtualPTZ:
#![allow(unused)]
fn main() {
let cam = VirtualPTZ::new();
let server = onvif_server::OnvifServer::builder()
.port(8080)
.auth("admin", "admin")
.device_service(cam.clone())
.media_service(cam.clone())
.ptz_service(cam.clone())
.imaging_service(cam.clone())
.event_service(cam)
.build()?;
server.run().await?;
}
Because VirtualPTZ holds Arc<Mutex<_>> internally, all five registered clones
share the same preset storage.
What an ONVIF client sees
- A fully enumerable device with
GetCapabilitiesadvertising all five services. - A media profile with a stream URI pointing to a local RTSP address.
- Full PTZ control surfaces (move, stop, preset CRUD).
- Imaging settings query support.
- An events endpoint that responds with
ActionNotSupportedfor subscription requests (the defaultEventServiceimplementation).
Conformance
onvif-server responses are validated differentially against independent
authorities, not just self-checked. This page summarises what that means and how to
reproduce it.
What was validated
A differential conformance harness (crossref/, a non-published workspace member)
exercises 29 scenarios spanning the device, media, imaging, PTZ, events, and
discovery surfaces, plus auth variants. It runs in two layers:
- Layer 1 — in-process replay. Each scenario drives the full SOAP / auth /
routing stack through
OnvifServer::into_router()(no network) and diffs the response against a frozen snapshot. Volatile fields (timestamps, nonces, message IDs, host/port in URIs) are masked; named invariants assert structural facts the mask would otherwise hide (e.g.single_white_balance,ptz_move_status_attr, the discoveryRelatesToecho). - Layer 2 — schema oracle + reference device (Docker). Responses are validated
against an independent ONVIF XSD oracle (Java / Xerces) and compared against the
onvif-srvdreference device. This is where snapshots are promoted fromunverifiedtoverified. The host needs only Docker and the Rust toolchain.
The Layer-2 run is wired as a release-green gate: it must report all 29
scenarios verified, zero unverified, with an empty disagreement baseline.
This schema validation is what surfaced (and led to fixing) several real response
bugs before release — e.g. PTZ GetStatus UtcTime placement, white-balance
structure, capabilities element ordering, and the SOAP-1.2 fault subcode.
What a pass means — and doesn’t
A pass means the responses are schema-valid and structurally agree with the reference for the operations in the coverage matrix. It does not mean full ONVIF Profile S certification, nor that absent operations behave like a commercial device — see Capabilities & Limitations.
Reproducing it
# Layer 1 (no Docker): fast regression replay
cargo test -p onvif-crossref --test layer1_replay
# Layer 2 (Docker): the release-green conformance gate — must exit 0 (29/29)
cargo run -p onvif-crossref --bin layer2 -- --release-green
Full details, scenario contract, and mask/invariant definitions are in the harness README: https://github.com/NavistAu/onvif-server/blob/main/crossref/README.md.