Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ServiceStatus
DeviceSupported
MediaSupported
PTZSupported
ImagingSupported
EventsSupported

“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

BackingMeaning
FrameworkResponse 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.
StaticReturns fixed, canned XML. Not overridable and not driven by your trait data.
AbsentNot 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).

OperationBackingDefault behaviourNotes
GetSystemDateAndTimeTrait (default OK)Utc::now()Auth-exempt (clock-sync before authenticating). Always reports TimeZone=UTC, DateTimeType=Manual.
GetCapabilitiesFrameworkLists only the services you registered, with their XAddrs.
GetServicesFrameworkSame set; version hardcoded to 2.42.
GetScopesTrait (default OK)✅ two fixed scopesDefaults to video_encoder + Profile/Streaming. Note: these are not the scopes advertised over WS-Discovery (see below).
GetHostnameTrait (default OK)"onvif-device"FromDHCP=false.
GetDeviceInformationTrait (override)❌ faultOverride to return manufacturer/model/firmware/serial/hardware-id.
GetNetworkInterfacesTrait (override)❌ faultOverride to advertise NICs.
all other Device opsAbsent❌ faultSetHostname, 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.

OperationBackingDefault behaviourNotes
GetProfilesTrait (default OK)✅ one static profileDefault is a single 1920×1080 H264 MainProfile. Override profiles() to expose real/multiple profiles.
GetStreamUriTrait (override)❌ faultOverride this — it is how clients learn the RTSP URL.
GetSnapshotUriTrait (override)❌ faultOverride for JPEG snapshot support.
GetVideoSourcesStatic⚠️ fixed 1920×1080@30Canned; ignores your profile data.
GetVideoSourceConfigurationsStatic⚠️ fixedCanned.
GetVideoEncoderConfigurationsStatic⚠️ fixed H264 1080pCanned. May disagree with the profiles you advertise.
all other Media opsAbsent❌ faultNo 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.

OperationBackingDefault behaviourNotes
GetNodesStatic✅ one nodeFoV relative pan/tilt space, range −1..1, max 10 presets.
GetNodeStatic✅ / faultFaults on unknown node token.
GetConfigurationsStaticSingle PTZConfig.
GetConfigurationStatic✅ / faultFaults on unknown config token.
GetConfigurationOptionsStatic
GetServiceCapabilitiesStaticMoveStatus=true (attribute form, required by Frigate).
GetPresetsTrait (default OK)✅ empty listOverride to list saved presets.
GetStatusTrait (override)❌ faultUtcTime filled from the server clock.
RelativeMoveTrait (override)❌ faultCoordinates parsed (absent → 0.0; malformed → fault).
AbsoluteMoveTrait (override)❌ faultAs above.
ContinuousMoveTrait (override)❌ faultAs above.
StopTrait (override)❌ faultPanTilt/Zoom absent → both true.
GotoPresetTrait (override)❌ fault
SetPresetTrait (override)❌ faultReturns the preset token.
RemovePresetTrait (override)❌ fault
all other PTZ opsAbsent❌ faultNo GotoHomePosition/SetHomePosition, geo-move, presets-tours, etc.

Imaging — timg, route /onvif/imaging_service

Namespace http://www.onvif.org/ver20/imaging/wsdl. Optional.

OperationBackingDefault behaviourNotes
GetImagingSettingsTrait (override)❌ faultOnly the fields you set are emitted; white balance is reported as a single MANUAL element.
all other Imaging opsAbsent❌ faultNo 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.

OperationBackingDefault behaviourNotes
GetEventPropertiesStatic✅ minimalFixed topic set with an empty TopicSet. The get_event_properties trait method is not consulted.
CreatePullPointSubscriptionFrameworkCreates an in-memory subscription (UUID id, default 60 s lifetime; honours InitialTerminationTime).
PullMessagesFrameworkemptyValidates the subscription and returns current/termination time but no NotificationMessages — there is no actual event delivery.
UnsubscribeFrameworkRemoves the subscription.
all other Events opsAbsent❌ faultNo 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.

BehaviourBackingNotes
ProbeProbeMatchesFrameworkReplies 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 / ver20 media).
  • No configuration writes. The only Set* operation implemented is PTZ SetPreset. Hostname, scopes, encoder config, imaging settings, users, network — none are settable.
  • No real event delivery. The pull-point lifecycle works, but PullMessages never 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 a Probe with ProbeMatches).
  • 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. GetSystemDateAndTime is always auth-exempt so clients can sync clocks before authenticating. See WS-Security.
  • advertised_host sets the host clients see in XAddrs (GetCapabilities/ GetServices/discovery) — it must be an address the client can route to (not 0.0.0.0). Stream/snapshot URIs are not derived from it; your MediaService returns those (and must point them at a routable address too).
  • WS-Discovery is behind the non-default discovery Cargo 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 PTZ SetPreset. Hostname, scopes, encoder config, imaging, users, and network are not settable.
  • No real event delivery. The subscription lifecycle works but PullMessages never 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 of DeviceService::get_scopes.
  • GetSystemDateAndTime always reports UTC with DateTimeType=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).

MethodDefaultImplement when
get_device_informationfaultsAlways — clients call it early. Return manufacturer, model, firmware, serial, hardware id.
get_system_date_and_timeUtc::now()Rarely. The default is correct for most devices. Always reported as UTC / Manual.
get_scopestwo fixed scopesTo customise the scopes returned by GetScopes (video_encoder, Profile/Streaming by default).
get_hostname"onvif-device"To report a real hostname.
get_network_interfacesfaultsOnly 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_scopes sets the GetScopes response 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).

MethodDefaultImplement when
profilesone 1920×1080 H264 MainProfile (token profile_0)To advertise real or multiple profiles.
get_stream_urifaultsAlways for streaming — return the RTSP URL for the given profile token.
get_snapshot_urifaultsFor 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_host controls the host clients see in GetCapabilities/GetServices XAddrs. 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:

MethodDefaultNotes
relative_move / absolute_move / continuous_movefaultsCoordinates are floats; missing → 0.0, malformed → fault before reaching you.
stopfaultspan_tilt / zoom booleans; if the client omits both, both are true.
get_statusfaultsReturn PTZStatusResult { pan_tilt_moving, zoom_moving }; the response UtcTime is filled by the server.
get_presetsempty listReturn your saved presets.
goto_preset / set_preset / remove_presetfaultsset_preset returns the (new) preset token.

Coordinate space: the advertised node uses the field-of-view relative pan/tilt translation space with XRange/YRange of −1..1 (and MaximumNumberOfPresets = 10). Interpret/clamp the pan/tilt/zoom arguments 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).

MethodDefaultNotes
get_imaging_settingsfaultsReturn 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. PullMessages validates the subscription and returns the current/termination time but never returns NotificationMessages. Registering an EventService makes 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:

  1. Unauthenticated GetSystemDateAndTime — the client reads the device clock (this operation is auth-exempt, above).
  2. The client computes its offset from the device and uses the device’s time as the basis for the Created timestamp in step 3.
  3. Digest-authenticated calls — every subsequent request carries a <wsse:Security> UsernameToken whose Created/Nonce are 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:

  1. Joins the IPv4 multicast group 239.255.255.250 on port 3702.
  2. Listens for incoming UDP datagrams.
  3. Parses each datagram and ignores anything that is not a well-formed WS-Discovery Probe message (SOAP body first child = Probe in namespace http://schemas.xmlsoap.org/ws/2005/04/discovery).
  4. For genuine Probe messages, sends a ProbeMatches response 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_host must be client-routable. The ProbeMatches XAddr is http://<advertised_host>:<port>/onvif/device_service. If advertised_host is left at 0.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_service URL 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 — returns true if the bytes are a well-formed WS-Discovery Probe message.
  • onvif_server::discovery_build_probe_match(relates_to: &str, xaddr: &str, device_uuid: uuid::Uuid) -> String — builds a ProbeMatches XML 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 your advertised_host and be routable from the client.
  • Auth: if you called .auth(user, pass), clients need those credentials; otherwise leave the client’s credentials blank. GetSystemDateAndTime is always reachable unauthenticated for clock sync.
  • Streams: the RTSP/snapshot URLs are whatever your MediaService returns — 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 from ffmpeg: inputs (Frigate does not learn the stream URL from ONVIF — you set it directly).
  • Frigate calls GetServiceCapabilities and the PTZ control surface for onvif-based PTZ. This crate returns MoveStatus as an attribute (which Frigate requires) and implements the PTZ moves/stop/presets via your PTZService.
  • 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, and get_snapshot_uri or HA setup steps will fault.

ONVIF Device Manager (ODM)

  • Windows diagnostic tool. Point it at the service URL (or discover on-LAN with the discovery feature) 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 point wsdl_dir at 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:ActionNotSupported fault that zeep raises as a Fault. 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_uri returns (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:

MethodBehaviour
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 GetCapabilities advertising 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 ActionNotSupported for subscription requests (the default EventService implementation).

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 discovery RelatesTo echo).
  • Layer 2 — schema oracle + reference device (Docker). Responses are validated against an independent ONVIF XSD oracle (Java / Xerces) and compared against the onvif-srvd reference device. This is where snapshots are promoted from unverified to verified. 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.