#onvif #camera #soap #rtsp #ip-camera

oxvif

Async Rust client library for the ONVIF IP camera protocol

32 releases (14 breaking)

Uses new Rust 2024

new 0.15.0 Aug 4, 2026
0.14.0 Jul 27, 2026
0.13.0 Jul 24, 2026

#360 in Network programming

Download history 43/week @ 2026-05-03 42/week @ 2026-05-10 90/week @ 2026-05-17 67/week @ 2026-05-24 107/week @ 2026-05-31 141/week @ 2026-06-07 94/week @ 2026-06-14 132/week @ 2026-06-21 73/week @ 2026-06-28 104/week @ 2026-07-05 65/week @ 2026-07-12 118/week @ 2026-07-19 190/week @ 2026-07-26 166/week @ 2026-08-02

545 downloads per month
Used in oxvif-device-manager

MIT license

2.5MB
39K SLoC

oxvif

crates.io docs.rs downloads license

Async ONVIF client for IP cameras (Profile S/T/G) in Rust — discovery, PTZ, media, imaging & events.

UDP multicast ──► discovery::probe() ──► Vec<DiscoveredDevice>
                                                  │
                                                  ▼ XAddr
                      OnvifSession ─── caches service URLs, delegates every call
                           │
SOAP/HTTP ──────►  OnvifClient ──► Device    (capabilities, hostname, NTP, reboot)
                               ──► Media1    (profiles, RTSP/snapshot URIs, video + audio configs)
                               ──► Media2    (H.265, metadata, audio, video source modes)
                               ──► PTZ       (move, stop, presets, home, status, configurations, nodes)
                               ──► Imaging   (brightness, contrast, exposure, IR cut, focus move/stop)
                               ──► OSD       (create, read, update, delete on-screen display elements)
                               ──► Events    (subscribe, pull, renew, unsubscribe, continuous stream)
                               ──► Recording (list, create/delete recordings and recording jobs)
                               ──► Search    (find recordings by time/scope)
                               ──► Replay    (RTSP URI for playback)
  • Async-first (tokio + reqwest)
  • WS-Security UsernameToken with PasswordDigest (ONVIF Profile S §5.12)
  • HTTP Digest Authentication (RFC 7616, ONVIF Profile T §7.1)
  • WS-Discovery via UDP multicast (239.255.255.250:3702)
  • Mockable transport, plus a built-in mock ONVIF device (mock / mock-server features) — unit-test client code without a real camera
  • Metamorph (metamorph / metamorph-server features) — clone a real camera and replay it verbatim offline, serve the clone from a bound port, diff its response shapes, or skin a non-ONVIF device as ONVIF
  • No unsafe code; pure Rust XML parsing via quick-xml
  • Optional, scriptable device health check with parse-coverage detection (health feature), plus a conformance example that validates the parsers against real cameras
  • Hundreds of unit + doc tests, including the in-process mock device, the health checks, and scrubbed real-camera regression captures

Quick start

Two ways to use oxvif — pick whichever suits your workflow.

OnvifSession — URL caching handled for you

use oxvif::{OnvifSession, OnvifError};

#[tokio::main]
async fn main() -> Result<(), OnvifError> {
    let session = OnvifSession::builder("http://192.168.1.100/onvif/device_service")
        .with_credentials("admin", "password")
        .with_clock_sync()   // syncs WS-Security timestamp with device clock
        .build()
        .await?;

    let profiles = session.get_profiles().await?;
    let uri = session.get_stream_uri(&profiles[0].token).await?;
    println!("RTSP: {}", uri.uri);
    Ok(())
}

OnvifClient — direct control, you manage service URLs

use oxvif::{OnvifClient, OnvifError};

#[tokio::main]
async fn main() -> Result<(), OnvifError> {
    let client = OnvifClient::new("http://192.168.1.100/onvif/device_service")
        .with_credentials("admin", "password");

    let caps = client.get_capabilities().await?;
    let media_url = caps.media.url.unwrap();

    let profiles = client.get_profiles(&media_url).await?;
    let uri = client.get_stream_uri(&media_url, &profiles[0].token).await?;
    println!("RTSP: {}", uri.uri);
    Ok(())
}

OnvifSession calls GetCapabilities once on build() and caches all service URLs — no URL arguments needed for individual methods. OnvifClient is stateless; you forward the URL yourself for full routing control.

Testing — drive a mock device, no camera needed

Enable the mock feature and point a client at a built-in, stateful mock ONVIF device — no network, no hardware. Ideal for unit tests.

[dev-dependencies]
oxvif = { version = "0.15", features = ["mock"] }
use std::sync::Arc;
use oxvif::{OnvifClient, mock::MockTransport};

#[tokio::test]
async fn talks_to_a_mock_camera() {
    let client = OnvifClient::new("http://mock")
        .with_transport(Arc::new(MockTransport::new()));

    client.set_hostname("lab-cam").await.unwrap();
    let h = client.get_hostname().await.unwrap();   // Set → Get round-trips
    assert_eq!(h.name.as_deref(), Some("lab-cam"));
}

Need a real bound port instead? The mock-server feature adds MockServer::start(). See Testing without a real camera for details.


Installation

[dependencies]
oxvif = "0.15"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Serde support (serde feature)

Enable the serde feature to derive Serialize + Deserialize on every response type in oxvif::types, plus the WS-Discovery result types (DiscoveredDevice, DiscoveryEvent) returned by discovery::probe and friends. This lets you expose them directly over a REST API (or persist them as JSON) without hand-cloning parallel structs.

[dependencies]
oxvif = { version = "0.15", features = ["serde"] }
// Any response type, straight to JSON — no shadow structs.
let profiles = session.get_profiles().await?;
println!("{}", serde_json::to_string_pretty(&profiles)?);

// …and back again, so they work as request bodies too.
let round_tripped: Vec<oxvif::MediaProfile> = serde_json::from_str(&json)?;

In a web handler that means the response type is the API type:

use axum::{Json, http::StatusCode};

async fn presets() -> Result<Json<Vec<oxvif::PtzPreset>>, StatusCode> {
    session.ptz_get_presets("Profile_1").await
        .map(Json)
        .map_err(|_| StatusCode::BAD_GATEWAY)
}

Field names are the Rust-native snake_case identifiers (no rename_all), and Deserialize is derived too, so the types also work as request bodies. The feature is opt-in: it pulls no new dependency and costs nothing when disabled.


OnvifSession

OnvifSession calls GetCapabilities once at construction, caches all service URLs internally, and exposes every operation as a one-liner — no URL parameters needed anywhere.

Building a session

use oxvif::{OnvifSession, OnvifError};

#[tokio::main]
async fn main() -> Result<(), OnvifError> {
    let session = OnvifSession::builder("http://192.168.1.100/onvif/device_service")
        .with_credentials("admin", "password")
        .with_clock_sync()   // syncs WS-Security timestamp with device clock
        .build()
        .await?;

    // Capabilities are already cached — no extra round-trip
    let caps = session.capabilities();

    let profiles = session.get_profiles().await?;
    let uri      = session.get_stream_uri(&profiles[0].token).await?;
    println!("RTSP: {}", uri.uri);

    let status = session.ptz_get_status(&profiles[0].token).await?;
    println!("Pan: {:?}  Tilt: {:?}", status.pan, status.tilt);

    Ok(())
}

Builder methods

Method Description
OnvifSession::builder(device_url) Start building a session
.with_credentials(username, password) Enable WS-Security UsernameToken authentication
.with_clock_sync() Call GetSystemDateAndTime first and apply UTC offset — prevents auth failures on devices with clock skew
.with_transport(transport) Replace HTTP transport (for unit testing)
.build().await Connect, sync clock (if set), call GetCapabilities, return OnvifSession

Session accessors

Method Description
session.capabilities() Returns the cached &Capabilities — no network call
session.client() Access the underlying &OnvifClient directly (e.g. for custom transport or fine-grained URL routing)

OnvifSession delegates every OnvifClient method — the full method list is in the sections below (Device, Media, PTZ, Imaging, OSD, Events, Recording, Search, Replay).


OnvifClient

Stateless and cheaply cloneable — safe to wrap in Arc and share across threads. You manage the service URLs (obtained from get_capabilities() or get_services()), which gives you full control over per-call routing.

Constructors and builder methods

Method Description
OnvifClient::new(device_url) Connect to device at device_url (e.g. http://192.168.1.100/onvif/device_service)
.with_credentials(username, password) Enable WS-Security UsernameToken authentication
.with_utc_offset(offset_secs: i64) Adjust WS-Security timestamp if device clock differs from local UTC
.with_transport(Arc<dyn Transport>) Replace the default HTTP transport (used for unit testing)
// Sync device clock before sending authenticated requests
let client = OnvifClient::new("http://192.168.1.100/onvif/device_service");
let dt = client.get_system_date_and_time().await?;
let client = client
    .with_credentials("admin", "secret")
    .with_utc_offset(dt.utc_offset_secs());

WS-Discovery

Find ONVIF cameras on your local network without knowing their IP addresses.

use std::time::Duration;
use oxvif::discovery;

let devices = discovery::probe(Duration::from_secs(3)).await;

for d in &devices {
    println!("Found: {}", d.endpoint);
    for addr in &d.xaddrs {
        println!("  XAddr: {addr}");          // use this as device_url
    }
    for scope in &d.scopes {
        println!("  Scope: {scope}");         // e.g. "onvif://www.onvif.org/name/Camera1"
    }
}

DiscoveredDevice fields:

Field Type Description
endpoint String Unique endpoint URN (e.g. uuid:...)
types Vec<String> WS-Discovery types (e.g. NetworkVideoTransmitter)
scopes Vec<String> ONVIF scopes (name, location, hardware, etc.)
xaddrs Vec<String> Device service URLs — pass the first to OnvifClient::new

probe returns an empty Vec on I/O errors; it never panics.


Device Service methods

get_capabilities() -> Result<Capabilities, OnvifError>

Retrieves all service endpoint URLs and feature flags. Always call this first.

let caps = client.get_capabilities().await?;

caps.device.url        // Device management service
caps.media.url         // Media service (profiles / stream URIs)
caps.ptz_url           // PTZ service
caps.events.url        // Events service
caps.imaging_url       // Imaging service
caps.analytics.url     // Analytics service
caps.media2_url        // Media2 service (None on many cameras — use GetServices)

caps.device.system.firmware_upgrade
caps.device.security.tls_1_2
caps.device.security.dot1x          // from Security/Extension/Extension
caps.media.streaming.rtp_rtsp_tcp
caps.events.ws_pull_point

SecurityCapabilities models all twelve members tt:SecurityCapabilities declares across its three levels. username_token is not among them and was removed in 0.15.0 — ONVIF declares that name only on the service-level type, so read it from device_get_service_capabilities().security.

Per-service capabilities — *_get_service_capabilities()

get_capabilities() answers which services exist and at what URL. Each service has its own GetServiceCapabilities answering what that service can do, and all nine are implemented:

Method Returns
device_get_service_capabilities() DeviceServiceCapabilities
media_get_service_capabilities(media_url) MediaServiceCapabilities
media2_get_service_capabilities(media2_url) Media2ServiceCapabilities
ptz_get_service_capabilities(ptz_url) PtzServiceCapabilities
imaging_get_service_capabilities(imaging_url) ImagingServiceCapabilities
events_get_service_capabilities(events_url) EventsServiceCapabilities
recording_get_service_capabilities(recording_url) RecordingServiceCapabilities
search_get_service_capabilities(search_url) SearchServiceCapabilities
replay_get_service_capabilities(replay_url) ReplayServiceCapabilities
let caps = client.device_get_service_capabilities().await?;

caps.security.tls1_2                 // Option<bool>
caps.security.max_users              // Option<u32>
caps.system.user_config_not_supported
caps.misc.map(|m| m.auxiliary_commands);  // what SendAuxiliaryCommand accepts

Every flag is Option<bool>, not bool. None means the device did not mention the attribute; Some(false) means it said no. The two are different answers, and collapsing them would defeat the reason for asking — a feature the firmware never described is not the same as one it declined. The device-level Capabilities from get_capabilities() uses bare bool and cannot make that distinction, which is why the two families are separate types.

List-valued attributes are the deliberate exception: they are Vec<_>, empty when absent, because for a list "absent" and "present but empty" both mean no items.

A few field types read wrong and are right:

  • RecordingServiceCapabilities::max_recordings is f32xs:float in the schema, despite reading like a count.
  • Media2ServiceCapabilities::webrtc is Option<u32>, a session count. Some(0) means WebRTC was described and no concurrent session is offered.
  • RecordingServiceCapabilities::onboard_storage has a schema default of true which oxvif does not apply. Read onboard_storage.unwrap_or(true) if you want the schema's behaviour rather than the device's silence.

get_services() -> Result<Vec<OnvifService>, OnvifError>

Use as a fallback when caps.media2_url is None:

let caps = client.get_capabilities().await?;
let media2_url = caps.media2_url.clone().or_else(|| {
    client.get_services().await.ok()?
        .into_iter()
        .find(|s| s.is_media2())
        .map(|s| s.url)
});

OnvifSession does this for you for Profile G. Some cameras advertise the recording / search / replay services only via GetServices, not the GetCapabilities extension. OnvifSession::build fills any missing one from GetServices automatically, so get_recordings / search_recordings / get_replay_uri work on those devices without a manual fallback.

get_system_date_and_time() -> Result<SystemDateTime, OnvifError>

Retrieves the device clock. Compute the offset to keep WS-Security timestamps in sync.

let dt = client.get_system_date_and_time().await?;
let offset = dt.utc_offset_secs();   // device_utc − local_utc

get_device_info() -> Result<DeviceInfo, OnvifError>

let info = client.get_device_info().await?;
// info.manufacturer, info.model, info.firmware_version, info.serial_number

Hostname methods

Method Description
get_hostname() Returns Hostname { from_dhcp: bool, name: Option<String> }
set_hostname(name: &str) Set a static hostname

NTP methods

Method Description
get_ntp() Returns NtpInfo { from_dhcp: bool, servers: Vec<String> }
set_ntp(from_dhcp: bool, servers: &[&str]) Configure NTP servers

system_reboot() -> Result<String, OnvifError>

Initiates a device reboot. Returns the device's informational message.

get_scopes() -> Result<Vec<String>, OnvifError>

Returns the device's scope URIs — strings that describe the device's name, location, hardware model, and capabilities (e.g. "onvif://www.onvif.org/name/Camera1"). Completes Profile S coverage.

let scopes = client.get_scopes().await?;
for s in &scopes {
    println!("{s}");
}

set_scopes(scopes) -> Result<(), OnvifError>

Replaces the device's configurable scopes with scopes: &[&str]. Fixed scopes the device reports as non-configurable are untouched.

set_system_date_and_time(req) -> Result<(), OnvifError>

Sets the clock — manual or NTP, with timezone and DST. Takes a SetDateTimeRequest; pair it with get_system_date_and_time().

User management

Method Description
get_users() List all configured user accounts (usernames + access levels)
create_users(users) Create accounts — users is &[(&str, &str, &str)] (username, password, level)
delete_users(usernames) Delete accounts by username
set_user(username, password, level) Modify an existing account; password = None leaves it unchanged

Network configuration

Method Description
get_network_interfaces() List interfaces with IP/MAC/MTU info → Vec<NetworkInterface>
set_network_interfaces(token, enabled, addr, prefix, from_dhcp) Update IPv4 config; returns RebootNeeded: bool
get_network_protocols() List enabled protocols (HTTP/HTTPS/RTSP, ports) → Vec<NetworkProtocol>
set_network_protocols(protocols) Enable/disable protocols — protocols is &[(&str, bool, &[u32])]
get_dns() DNS servers + DHCP flag → DnsInformation
set_dns(from_dhcp, servers) Set DNS servers
get_network_default_gateway() Default gateway addresses → NetworkGateway
set_network_default_gateway(ipv4_addresses) Replace the IPv4 default gateway list
get_discovery_mode() Current WS-Discovery mode ("Discoverable" / "NonDiscoverable")
set_discovery_mode(mode) Change WS-Discovery mode

System & I/O

Method Description
get_system_log(log_type) Retrieve device log ("System" or "Access") → SystemLog
get_system_uris() Syslog / support-info / system-backup download URIs → SystemUris
set_system_factory_default(default_type) Factory reset — "Hard" (full) or "Soft" (keep network)
start_firmware_upgrade() Begin firmware upgrade (upload-URI flow) → FirmwareUpgradeStart (upload URI + timing)
start_system_restore() Begin system restore (upload-URI flow) → SystemRestoreStart
get_relay_outputs() List relay output ports → Vec<RelayOutput>
set_relay_output_state(token, state) Set relay electrical state ("active" / "inactive")
set_relay_output_settings(token, mode, delay, idle) Configure relay mode/delay/idle-state
get_digital_inputs(deviceio_url) List digital input ports → Vec<DigitalInput>DeviceIO endpoint, see below
get_storage_configurations() List SD/NAS storage locations → Vec<StorageConfiguration>
set_storage_configuration(token, ...) Create or update a storage configuration entry

get_digital_inputs is the one method in this table that does not go to the device service. Digital inputs live on DeviceIO, so it takes that endpoint:

let caps = client.get_capabilities().await?;
let deviceio = caps.device_io.url.as_deref().unwrap();
for input in client.get_digital_inputs(deviceio).await? {
    println!("{} idle={}", input.token, input.idle_state);
}

A device that advertises no DeviceIO endpoint does not implement the operation. If caps.device_io.url is None, fall back to get_services() and look for OnvifService::is_device_io() — some firmware lists DeviceIO there and not in GetCapabilities. OnvifSession::get_digital_inputs() does both for you.

The three relay-output methods above genuinely are device-service operations, even though DeviceIO offers them too: deviceio.wsdl types those messages with the device service's own elements.


Media Service (Media1) methods

All Media1 methods use media_url from caps.media.url.

Profile management

Method Returns Description
get_profiles(media_url) Vec<MediaProfile> List all profiles
get_profile(media_url, token) MediaProfile Get a single profile
create_profile(media_url, name, token) MediaProfile Create a new empty profile
delete_profile(media_url, token) () Delete a non-fixed profile
add_video_encoder_configuration(media_url, profile_token, config_token) () Bind encoder config to profile
remove_video_encoder_configuration(media_url, profile_token) () Unbind encoder config
add_video_source_configuration(media_url, profile_token, config_token) () Bind video source to profile
remove_video_source_configuration(media_url, profile_token) () Unbind video source

Streaming

let profiles = client.get_profiles(&media_url).await?;

let rtsp = client.get_stream_uri(&media_url, &profiles[0].token).await?;
println!("RTSP: {}", rtsp.uri);

let snap = client.get_snapshot_uri(&media_url, &profiles[0].token).await?;
println!("Snapshot: {}", snap.uri);

Video source and encoder configurations

Method Description
get_video_sources(media_url) Physical video inputs
get_video_source_configurations(media_url) Crop/position window configs
get_video_source_configuration(media_url, token) Single VSC by token
set_video_source_configuration(media_url, config) Write VSC back to device
get_video_source_configuration_options(media_url, token) Valid bounds ranges
get_video_encoder_configurations(media_url) Codec / resolution / bitrate configs
get_video_encoder_configuration(media_url, token) Single VEC by token
set_video_encoder_configuration(media_url, config) Write VEC back to device
get_video_encoder_configuration_options(media_url, token) Valid resolution/bitrate/fps ranges
let mut enc = client.get_video_encoder_configuration(media_url, &token).await?;
if let Some(rc) = enc.rate_control.as_mut() {
    rc.bitrate_limit = 2048;   // 2 Mbps
}
client.set_video_encoder_configuration(media_url, &enc).await?;

Media2 methods

Media2 (ver20/media/wsdl) is the successor to Media1, with native H.265 support and a simplified encoder config structure. All Media2 methods use media2_url.

Media1 vs Media2 key differences

Feature Media1 Media2
H.265 Via Other(String) Native VideoEncoding::H265
Encoder config Nested H264/H265 sub-struct Flat — gov_length and profile are top-level fields, and XML attributes on the wire
GetStreamUri response <MediaUri> wrapper Just <Uri> string
Write operations Require <ForcePersistence>true No ForcePersistence

Media2 method reference

Method Returns Description
get_profiles_media2(url) Vec<MediaProfile2> List profiles
get_stream_uri_media2(url, token) String RTSP URI
get_snapshot_uri_media2(url, token) String HTTP snapshot URI
get_video_source_configurations_media2(url) Vec<VideoSourceConfiguration>
set_video_source_configuration_media2(url, config) ()
get_video_source_configuration_options_media2(url, token) VideoSourceConfigurationOptions
get_video_encoder_configurations_media2(url) Vec<VideoEncoderConfiguration2> Flat H.265-capable config
get_video_encoder_configuration_media2(url, token) VideoEncoderConfiguration2
set_video_encoder_configuration_media2(url, config) ()
get_video_encoder_configuration_options_media2(url, token) VideoEncoderConfigurationOptions2
get_video_encoder_instances_media2(url, config_token) VideoEncoderInstances Encoder capacity
create_profile_media2(url, name) String Create profile, returns new token
delete_profile_media2(url, token) ()
add_configuration_media2(url, profile, kind, token) () Bind a configuration to a profile
remove_configuration_media2(url, profile, kind, token) () Unbind it
get_metadata_configurations_media2(url, config_token, profile_token) Vec<MetadataConfiguration>
set_metadata_configuration_media2(url, config) ()
get_metadata_configuration_options_media2(url, config_token, profile_token) MetadataConfigurationOptions
get_audio_source_configurations_media2(url) Vec<AudioSourceConfiguration>
get_audio_encoder_configurations_media2(url) Vec<AudioEncoderConfiguration>
get_audio_encoder_configuration_options_media2(url, config_token) AudioEncoderConfigurationOptions
set_audio_encoder_configuration_media2(url, config) ()
get_audio_output_configurations_media2(url) Vec<AudioOutputConfiguration>
get_audio_decoder_configurations_media2(url) Vec<AudioDecoderConfiguration>
get_video_source_modes_media2(url, video_source_token) Vec<VideoSourceMode> Sensor modes
set_video_source_mode_media2(url, source_token, mode_token) bool true if the device needs a reboot

PTZ methods

All PTZ methods use ptz_url from caps.ptz_url. Coordinates use the ONVIF normalised range: pan/tilt [-1.0, 1.0], zoom [0.0, 1.0].

Method Description
ptz_absolute_move(ptz_url, profile_token, pan, tilt, zoom) Move to an absolute position
ptz_relative_move(ptz_url, profile_token, pan, tilt, zoom) Move by an offset
ptz_continuous_move(ptz_url, profile_token, pan, tilt, zoom) Start continuous movement
ptz_stop(ptz_url, profile_token) Stop all movement
ptz_get_presets(ptz_url, profile_token) List all saved preset positions
ptz_goto_preset(ptz_url, profile_token, preset_token) Move to a saved preset
ptz_set_preset(ptz_url, profile_token, name, token) Save current position as preset
ptz_remove_preset(ptz_url, profile_token, preset_token) Delete a preset
ptz_get_status(ptz_url, profile_token) Current pan/tilt/zoom position and move state
ptz_get_configurations(ptz_url) List all PTZ configurations
ptz_get_configuration(ptz_url, token) Single PTZ configuration by token
ptz_set_configuration(ptz_url, config, force_persist) Write PTZ configuration back to device
ptz_get_configuration_options(ptz_url, token) Valid timeout ranges for a PTZ configuration
ptz_get_nodes(ptz_url) List PTZ nodes (capabilities, preset count, home support)
ptz_get_compatible_configurations(ptz_url, profile_token) Configurations the device will accept for this profile
ptz_goto_home_position(ptz_url, profile_token, speed) Move to the configured home position
ptz_set_home_position(ptz_url, profile_token) Save current position as home
ptz_get_service_capabilities(ptz_url) What the PTZ service supports
ptz_get_preset_tours(ptz_url, profile_token) List stored guard tours
ptz_get_preset_tour(ptz_url, profile_token, tour_token) Single tour by token
ptz_get_preset_tour_options(ptz_url, profile_token, tour_token) What tours the device accepts
ptz_create_preset_tour(ptz_url, profile_token) Create an empty tour, returns its token
ptz_modify_preset_tour(ptz_url, profile_token, tour) Write a tour back to the device
ptz_operate_preset_tour(ptz_url, profile_token, tour_token, op) Start / stop / pause a tour
ptz_remove_preset_tour(ptz_url, profile_token, tour_token) Delete a tour
ptz_send_auxiliary_command(ptz_url, profile_token, data) Wiper / washer / IR lamp, per profile
// Save current position
let token = client.ptz_set_preset(ptz_url, &profile, Some("Entrance"), None).await?;

// Query position
let status = client.ptz_get_status(ptz_url, &profile).await?;
println!("pan={:?} tilt={:?} zoom={:?} state={}",
    status.pan, status.tilt, status.zoom, status.pan_tilt_status);

PtzStatus fields: pan, tilt, zoom (Option<f32>), pan_tilt_status, zoom_status (String"IDLE" or "MOVING"), utc_time (Option<String>), error (Option<String> — device fault description if any).

Preset tours

A preset tour is a stored guard tour: a named sequence of stops the camera walks unattended. Create an empty tour, fill it in, then start it.

use oxvif::{
    PtzPresetTour, PtzPresetTourDirection, PtzPresetTourOperation,
    PtzPresetTourPresetDetail, PtzPresetTourSpot, PtzPresetTourStartingCondition,
    PtzPresetTourState, PtzPresetTourStatus,
};

// What this device will accept — a stop outside these bounds comes back as a
// fault rather than being clamped.
let opts = client.ptz_get_preset_tour_options(ptz_url, &profile, None).await?;
println!("directions: {:?}", opts.starting_condition.directions);
println!("stay time:  {:?}", opts.tour_spot.stay_time);

let token = client.ptz_create_preset_tour(ptz_url, &profile).await?;

let tour = PtzPresetTour {
    token: Some(token.clone()),
    name: Some("Night sweep".into()),
    status: PtzPresetTourStatus {
        state: PtzPresetTourState::Idle,
        current_tour_spot: None,
    },
    auto_start: true,
    starting_condition: PtzPresetTourStartingCondition {
        random_preset_order: Some(false),
        recurring_time: Some(2),
        recurring_duration: None,
        direction: Some(PtzPresetTourDirection::Forward),
    },
    tour_spots: vec![PtzPresetTourSpot {
        preset_detail: PtzPresetTourPresetDetail::PresetToken("Preset_1".into()),
        speed: None,
        stay_time: Some("PT15S".into()),
    }],
};
client.ptz_modify_preset_tour(ptz_url, &profile, &tour).await?;

client
    .ptz_operate_preset_tour(ptz_url, &profile, &token, PtzPresetTourOperation::Start)
    .await?;

Two things about the types are worth knowing before you build one:

  • PtzPresetTour::token is Option<String>. @token is optional on tt:PresetTour in the schema, unlike tt:PTZPreset/@token, so a device may return a tour without one and oxvif does not treat that as an error. You still need a token on the way in to every operation that names an existing tour.
  • PtzPresetTourPresetDetail is an enum, not a struct. The schema member is an xs:choicePresetToken, Home or an explicit Position, exactly one. Three Option fields would let you send two at once and get an unhelpful fault back.

PtzPresetTourState and PtzPresetTourDirection both carry an Unknown(String) variant. Both schema types end in Extended, so vendors do extend them, and an unrecognised value is carried rather than turning ptz_get_preset_tours into an Err.

Auxiliary commands — there are two of them

ptz_send_auxiliary_command(ptz_url, profile_token, data) and send_auxiliary_command(command) are different ONVIF operations that happen to share a name. The first is the PTZ service's, is scoped to a media profile, and returns the device's answer; the second is the Device service's and is not. Cameras that implement a wiper generally implement the PTZ one, so try that first.

The values a given camera accepts are vendor-namespaced, so oxvif does not model them as an enum — they are discoverable instead:

let advertised = client
    .device_get_service_capabilities()
    .await?
    .misc
    .map(|m| m.auxiliary_commands)
    .unwrap_or_default();
// e.g. ["tt:Wiper|On", "tt:Wiper|Off", "tt:IRLamp|On", "tt:IRLamp|Off", "tt:IRLamp|Auto"]

let answer = client
    .ptz_send_auxiliary_command(ptz_url, &profile, "tt:Wiper|On")
    .await?;

tt:AuxiliaryData has a schema maxLength of 128, which oxvif does not enforce: the device rejects an over-long value with a fault, and a client-side check would be a second source of truth that drifts from the firmware.


Audio Service methods

All audio methods use media_url from caps.media.url.

Method Returns Description
get_audio_sources(media_url) Vec<AudioSource> Physical audio inputs (microphones)
get_audio_source_configurations(media_url) Vec<AudioSourceConfiguration> Audio source configs
get_audio_encoder_configurations(media_url) Vec<AudioEncoderConfiguration> Codec / bitrate / sample rate configs
get_audio_encoder_configuration(media_url, token) AudioEncoderConfiguration Single config by token
set_audio_encoder_configuration(media_url, config) () Write config back to device
get_audio_encoder_configuration_options(media_url, token) AudioEncoderConfigurationOptions Valid encoding / bitrate / sample rate options
let sources = client.get_audio_sources(&media_url).await?;
println!("Audio inputs: {}", sources.len());

let mut enc = client.get_audio_encoder_configuration(&media_url, &token).await?;
enc.bitrate = 128;
client.set_audio_encoder_configuration(&media_url, &enc).await?;

AudioEncoderConfiguration fields: token, name, use_count, encoding (AudioEncoding), bitrate (kbps), sample_rate (kHz), channels (Option<u32>).

AudioEncoding variants: G711, G726, Aac, Other(String).


Imaging Service methods

All imaging methods use imaging_url from caps.imaging_url and require a video_source_token.

Method Description
get_imaging_settings(imaging_url, source_token) Current brightness, contrast, IR cut, white balance, exposure
set_imaging_settings(imaging_url, source_token, settings) Write modified settings back
get_imaging_options(imaging_url, source_token) Valid ranges for each setting
imaging_get_status(imaging_url, source_token) Current focus position and move state
imaging_get_move_options(imaging_url, source_token) Valid focus movement ranges
imaging_move(imaging_url, source_token, focus) Move focus: FocusMove::Absolute, Relative, or Continuous
imaging_stop(imaging_url, source_token) Stop ongoing focus movement
let mut s = client.get_imaging_settings(&imaging_url, &source_token).await?;
s.brightness = Some(70.0);
s.ir_cut_filter = Some("AUTO".into());
client.set_imaging_settings(&imaging_url, &source_token, &s).await?;

ImagingSettings fields: brightness, color_saturation, contrast, sharpness, focus_default_speed, wide_dynamic_range_level (Option<f32>); ir_cut_filter, white_balance_mode, exposure_mode, backlight_compensation, focus_mode, wide_dynamic_range_mode, image_stabilization_mode, tone_compensation_mode (Option<String>).

// Move focus to an absolute position
client.imaging_move(&imaging_url, &source_token,
    &FocusMove::Absolute { position: 0.5, speed: None }).await?;

// Start continuous autofocus sweep
client.imaging_move(&imaging_url, &source_token,
    &FocusMove::Continuous { speed: 0.3 }).await?;
client.imaging_stop(&imaging_url, &source_token).await?;

// Query focus state
let status = client.imaging_get_status(&imaging_url, &source_token).await?;
println!("focus={:?}  state={}", status.focus_position, status.focus_move_status);

OSD Service methods

On-screen display (OSD) elements overlay text or images on the video stream. All OSD methods use media_url from caps.media.url.

Method Returns Description
get_osds(media_url, config_token) Vec<OsdConfiguration> List all OSD elements (pass None for all)
get_osd(media_url, osd_token) OsdConfiguration Get a single OSD by token
set_osd(media_url, osd) () Update an existing OSD
create_osd(media_url, osd) String Create a new OSD, returns its token
delete_osd(media_url, osd_token) () Delete an OSD element
get_osd_options(media_url, config_token) OsdOptions Valid OSD types and position options
use oxvif::{OsdConfiguration, OsdPosition, OsdTextString};

// Create a date/time overlay in the upper-left corner
let osd = OsdConfiguration {
    token: String::new(),                           // empty = device assigns token
    video_source_config_token: vsc_token.clone(),
    type_: "Text".into(),
    position: OsdPosition { type_: "UpperLeft".into(), x: None, y: None },
    text_string: Some(OsdTextString {
        type_: "DateAndTime".into(),
        date_format: Some("MM/DD/YYYY".into()),
        time_format: Some("HH:mm:ss".into()),
        plain_text: None,
        font_size: Some(28),
        font_color: None,
        background_color: None,
        is_persistent_text: None,
    }),
    image_path: None,
};
let token = client.create_osd(&media_url, &osd).await?;
println!("Created OSD token: {token}");

// List all OSDs
let osds = client.get_osds(&media_url, None).await?;
for o in &osds {
    println!("[{}] type={} position={}", o.token, o.type_, o.position.type_);
}

OsdConfiguration fields: token, video_source_config_token, type_ ("Text" or "Image"), position (OsdPosition), text_string (Option<OsdTextString>), image_path (Option<String>).

OsdTextString fields: type_, plain_text, date_format, time_format (Option<String>), font_size (Option<u32>), font_color, background_color (Option<OsdColor>), is_persistent_text (Option<bool>). OsdColor carries x/y/z channel values, optional colorspace URI, and transparent level.

OsdOptions fields: max_osd (u32), types (Vec<String>), position_types (Vec<String>), text_types (Vec<String>).


Events Service methods

ONVIF Events use a pull-point subscription model. All operations start with events_url from caps.events.url.

// 1. Discover available topics
let props = client.get_event_properties(&events_url).await?;
for topic in &props.topics {
    println!("Topic: {topic}");    // e.g. "VideoSource/MotionAlarm"
}

// 2. Subscribe
let sub = client.create_pull_point_subscription(
    &events_url,
    None,           // filter: None = all topics
    Some("PT60S"),  // expire after 60 seconds
).await?;
println!("Subscription URL: {}", sub.reference_url);

// 3. Poll for events
let msgs = client.pull_messages(&sub.reference_url, "PT5S", 50).await?;
for m in &msgs {
    println!("[{}] {} — data={:?}", m.utc_time, m.topic, m.data);
}

// 4. Extend subscription
let new_time = client.renew_subscription(&sub.reference_url, "PT60S").await?;

// 5. Cancel
client.unsubscribe(&sub.reference_url).await?;

set_synchronization_point(subscription_url) asks the device to re-send the current state of every property topic on the subscription, so a client that just connected does not have to wait for the next change to learn it.

Continuous event stream

event_stream wraps the polling loop into an infinite async Stream — each item is one NotificationMessage. Use futures::StreamExt::take or a select! block to bound it, and call unsubscribe when done.

use futures::StreamExt as _;

let sub = client.create_pull_point_subscription(&events_url, None, Some("PT60S")).await?;
let mut stream = client.event_stream(&sub.reference_url, "PT5S", 10);

while let Some(Ok(msg)) = stream.next().await {
    println!("[{}] {} {:?}", msg.utc_time, msg.topic, msg.data);
}

PullPointSubscription fields:

Field Type Description
reference_url String Endpoint for pull_messages, renew_subscription, unsubscribe
termination_time String ISO-8601 timestamp when the subscription expires

NotificationMessage fields:

Field Type Description
topic String Event topic path (e.g. tns1:VideoSource/MotionAlarm)
utc_time String Event timestamp from Message/@UtcTime
source HashMap<String, String> Source SimpleItem pairs (e.g. VideoSourceToken = "VideoSource_1")
data HashMap<String, String> Data SimpleItem pairs (e.g. IsMotion = "true")

EventProperties fields:

Field Type Description
topics Vec<String> Flattened topic paths (e.g. "VideoSource/MotionAlarm", "RuleEngine/Cell/Motion")

Recording Service methods

Access and manage recordings stored on the device (NVR/DVR). Obtain recording_url from get_services() — namespace http://www.onvif.org/ver10/recording/wsdl.

Read operations

Method Returns Description
get_recordings(recording_url) Vec<RecordingItem> List all stored recordings
get_recording_jobs(recording_url) Vec<RecordingJob> List all recording jobs
get_recording_job_state(recording_url, job_token) RecordingJobState Current active state of a job

Write operations

Method Returns Description
create_recording(recording_url, config) String Create a new recording entry (config: &RecordingConfiguration), returns token
delete_recording(recording_url, recording_token) () Delete a recording and all its tracks
create_track(recording_url, recording_token, track_type, description) String Add a track to a recording, returns track token
delete_track(recording_url, recording_token, track_token) () Remove a track from a recording
create_recording_job(recording_url, config) String Create a new recording job (config: &RecordingJobConfiguration), returns job token
set_recording_job_mode(recording_url, job_token, mode) () Set job mode ("Active" or "Idle")
delete_recording_job(recording_url, job_token) () Delete a recording job
// Create a recording and start a job
let config = RecordingConfiguration {
    source_name: "Camera1".into(),
    source_id: "src1".into(),
    location: "Front door".into(),
    description: "Front door camera".into(),
    content: String::new(),
    maximum_retention_time: "PT0S".into(),
};
let rec_token = client.create_recording(&recording_url, &config).await?;

let track_token = client.create_track(
    &recording_url, &rec_token, "Video", "Main stream"
).await?;

let job_config = RecordingJobConfiguration {
    recording_token: rec_token.clone(),
    mode: "Active".into(),
    priority: 1,
    source_token: "VideoSourceToken_0".into(),
};
let job_token = client.create_recording_job(&recording_url, &job_config).await?;
println!("Job token: {job_token}");

// Check job state
let state = client.get_recording_job_state(&recording_url, &job_token).await?;
println!("Active state: {}", state.active_state);

RecordingItem fields: token, source (RecordingSourceInformation), content, tracks (Vec<RecordingTrack>).

RecordingSourceInformation fields: source_id, name, location, description (String), address (Option<String> — network address of the source device).

RecordingTrack fields: token, track_type ("Video", "Audio", "Metadata"), description (String), data_from, data_to (Option<String> ISO-8601 — time bounds of recorded data in this track).

RecordingJob fields: token, recording_token, mode, priority (u32), source_token.

RecordingJobState fields: recording_token, active_state ("Active", "Idle", or device-specific string).


Search Service methods

Search through stored recordings. Obtain search_url from get_services() — namespace http://www.onvif.org/ver10/search/wsdl.

Method Returns Description
find_recordings(search_url, max_matches, keep_alive) String (search token) Start an async recording search
get_recording_search_results(search_url, token, max_results, wait_time) FindRecordingResults Poll results (call until search_state == "Completed")
end_search(search_url, token) () Release search session on device
// Find all recordings, collect results, play back the first one
let search_url   = /* from get_services() */;
let replay_url   = /* from get_services() */;

let token = client.find_recordings(&search_url, None, "PT60S").await?;

let results = loop {
    let r = client.get_recording_search_results(&search_url, &token, 100, "PT5S").await?;
    if r.search_state == "Completed" { break r; }
};
client.end_search(&search_url, &token).await?;

for rec in &results.recording_information {
    println!("[{}] {}{} to {}",
        rec.recording_token, rec.source_name,
        rec.earliest_recording.as_deref().unwrap_or("?"),
        rec.latest_recording.as_deref().unwrap_or("?"));
}

FindRecordingResults fields: search_state ("Queued", "Searching", "Completed"), recording_information (Vec<RecordingInformation>).

RecordingInformation fields: recording_token, source_name, earliest_recording, latest_recording, content, recording_status.


Replay Service methods

Stream a stored recording over RTSP. Obtain replay_url from get_services() — namespace http://www.onvif.org/ver10/replay/wsdl.

Method Returns Description
get_replay_uri(replay_url, recording_token, stream_type, protocol) String RTSP URI for playback
let uri = client.get_replay_uri(
    &replay_url,
    &rec.recording_token,
    "RTP-Unicast",
    "RTSP",
).await?;
println!("Playback: {uri}");
// Open in VLC: vlc "{uri}"

Health check (health feature)

Point it at a camera, get a Pass/Warn/Fail/Skip report with a Profile S/T/G verdict. A readable alternative to the official ONVIF Device Test Tool — pure library code over OnvifSession, no extra dependencies.

Try it now — no camera

cargo run --example healthcheck --features health,mock-server -- --mock

That starts a throwaway mock camera in-process and checks it. You get the real report format, and you can try --json and --baseline the same way:

ONVIF health check — http://127.0.0.1:27365/onvif/device
  31 pass · 0 warn · 0 fail · 1 skip   (20 ms total)

  [Connectivity]
    PASS connect                          5ms  GetCapabilities ok
    PASS get_device_info                  3ms  oxvif-mock MockCam-1080p fw 1.0.0

  [Time]
    PASS system_date_time                 3ms  skew 0s

  [Services]
    PASS get_services                     5ms  8 service(s)
    PASS service_caps_self_consistent     0ms  24 fact(s) cross-checked, no contradiction
    …

Then point it at your own camera:

cargo run --example healthcheck --features health -- \
    http://192.168.1.100/onvif/device_service admin password

In your own code

[dependencies]
oxvif = { version = "0.15", features = ["health"] }
use oxvif::health::HealthCheck;

#[tokio::main]
async fn main() {
    let report = HealthCheck::new("http://192.168.1.100/onvif/device_service")
        .with_credentials("admin", "password")
        .run()
        .await;

    // Human-readable: per-check status, timings, profile verdict.
    println!("{report}");

    // Or inspect it programmatically.
    for c in &report.checks {
        println!("{:?} {}{}", c.status, c.id, c.detail);
    }
    println!("Profile S: {:?}", report.profiles.profile_s.verdict);

    if !report.ok() {
        std::process::exit(1);   // something actually failed
    }
}

HealthCheck::run() never returns an error — an unreachable device is a failing connect check, not an Err, so a batch run over a fleet cannot be derailed by one bad camera. Checks run concurrently and are read-only unless you opt in below.

Common options

Builder call What it adds
.with_credentials(user, pass) WS-Security + HTTP Digest
.with_clock_sync(true) Sync to the device clock first — fixes spurious auth failures
.with_liveness_probes(true) Actually fetch the snapshot / reach the RTSP port / exercise Profile G
.with_write_checks(true) One non-destructive write round-trip
.with_force_unsupported(true) Hunt for services the device failed to advertise
.with_capture(true) Keep the raw SOAP of every failing call, credentials blanked

report.to_json() / to_json_pretty() for CI; report.diff(&previous) for "what changed since the last run".

Want this with a GUI? OxDM — the ONVIF device manager built on this crate — drives the same HealthCheck from a Diagnostics tab: per-service Pass/Warn/Fail/Skip, the Profile S/T/G verdict, "save as baseline" plus an automatic diff on the next run, and batch runs across a fleet exportable as JSON or JUnit XML for CI. Everything below is the library underneath it, so the two report the same thing.

A fleet health run in OxDM: one row per camera with pass/warn/fail/skip counts, Profile S/T/G badges, and live stream and recording probe results

A batch run over a fleet — demonstrated in OxDM, which drives this crate's HealthCheck. The snapshot 291 KB / RTSP OK / replay URI OK badges are the liveness probes below: real bytes fetched, not an advertised URL echoed back.


Reference — what each option actually does

Everything from here down is detail. The block above is enough to use it.

Active liveness probing (opt-in). By default the check only confirms the device answered each SOAP call. Enable with_liveness_probes(true) to also verify the results actually work:

let report = HealthCheck::new(url)
    .with_credentials("admin", "password")
    .with_liveness_probes(true)   // RTSP OPTIONS + snapshot bytes + real Profile G
    .run()
    .await;

With it on, get_stream_uri follows the RTSP URI with a non-destructive OPTIONS reachability probe, get_snapshot_uri fetches the bytes and validates them as a real image (rejecting a 0-byte body or an HTML error page returned with a 200), and the recording / search / replay checks genuinely exercise Profile G (recording search + replay-URI resolution) instead of reporting advertised-only presence. Off by default because these open extra RTSP/HTTP connections the read-only SOAP checks never touch.

Force-verifying undeclared services (opt-in). with_force_unsupported(true) goes after the opposite problem — a device that under-declares. For each profile-gating service the device doesn't advertise (Media2, recording / search / replay), it tries a few conventional service URLs (and the device endpoint itself) and calls the operation; one that answers is flagged as under-declared rather than counted as unsupported. Best-effort — vendors use non-standard paths, so a miss isn't proof of absence.

Raw-SOAP capture (opt-in, 0.13+). with_capture(true) records the raw request/response of every SOAP call that fails (a transport error or a SOAP Fault) into HealthReport::captured — the raw evidence for why a brand rejected a call, to pair with the structured CheckError. Off by default; successful (credential-bearing) requests are never stored, and stored requests have their WS-Security Password/Nonce blanked, so a capture carries no credential-derivation material. Each CapturedExchange keys on the SOAP action.

Structured facts (0.11+). For building a cross-brand conformance corpus, the report carries machine-readable facts alongside the human-readable strings:

  • CheckResult::error — an optional CheckError on a failing check with a class (ErrorClass::SoapFault / Precondition / Parse / Http / InvalidArgument), the ONVIF subcode (e.g. ter:NotAuthorized), fault_code, reason, and verbatim detail. This lets you group the same fault across vendors by subcode instead of re-parsing free-text reasons, and separate genuine device faults from client-side preconditions.
  • HealthReport::clock_skew_s — the numeric device-vs-local clock skew, the usual cause of spurious WS-Security auth failures.
  • HealthReport::declared_profiles — the profiles the device self-declares via its scopes (e.g. ["S", "T", "G"]), read from GetScopes. Compare against the assessed profiles verdicts to flag "declares Profile G but replay/search fail".
  • ProfileAssessment::profile_{s,t,g} — each a ProfileState { verdict, missing, unverified }. verdict is conformant / partial / unsupported / inconclusive; the last means required checks couldn't be tested (auth blocked / skipped) with nothing verified to fail — kept distinct from partial so "couldn't verify" is never read as "non-conformant". missing lists the ids that genuinely failed, unverified the ones that couldn't be tested. Profile T additionally requires media2 (Media2 advertised) and event_motion_topic (a motion-alarm topic), so a Profile-S-only device is not read as near-T.
  • Category::Security / auth_enforcement — when credentials are supplied, a credential-free GetDeviceInformation probe checks the device actually enforces authentication. Serving device info anonymously is a security Warn; a rejection is a Pass.

Per-service capabilities, and the device against itself (0.15+). Nine service_caps_* checks ask each advertised service its own GetServiceCapabilitiesPass when it answers, Skip when the service is not advertised, Fail when it is advertised and refuses.

Then service_caps_self_consistent does the part no single call can: twenty-four attributes are stated twice by the device, once in the device-level GetCapabilities and again in a service's GetServiceCapabilities (twenty on Device — four network, five system, eleven security — three on Media streaming, one on Events). Everything else in a capability report is a claim with nothing to contradict it; these can be wrong rather than merely unknown, and a client trusting either source is guessing when they differ.

Only one direction is reported, and the asymmetry is the point:

GetCapabilities GetServiceCapabilities verdict
true false contradictionWarn, naming the attribute
false true counted, never warned
anything absent not compared

The device-level Capabilities uses bare bool and so cannot distinguish "said no" from "did not say" — an omitted <tt:Network> element, which is legal and common, parses as four falses. true cannot come from absence, so only the first row is a certainty. The second is counted and shown in the detail (N stated only by the service) without a warning. Reading it as a finding would flag every terse but conformant camera; oxvif's own mock tripped it six times before its GetCapabilities was taught to state those blocks.

A contradiction is a Warn, not a Fail: the device works, and which source is right is not knowable from here — but it has to be visible.

Note: the health report shape changed in a breaking way in 0.12.0 (profiles became an object, verdicts/status.kind lowercase, elapsed_ms nullable). 0.11 output was provisional. See the CHANGELOG.

Parse coverage. The report also includes a Category::Coverage dimension: for a curated set of list operations it compares how many items the parser returned against how many item elements the device actually sent, and warns when the parser silently dropped data (the bug class where a wrong element name yields an empty result with no error). It catches list-emptying; it does not catch scalar field-defaulting — for that, validate against real hardware with the conformance example (the mirror of mock_server):

cargo run --example conformance --features mock -- devices.txt

It points oxvif at a list of real cameras, dumps each raw SOAP response, and prints a parsed summary so silent-parse mismatches stand out for review.


Error handling

All API methods return Result<T, OnvifError>:

pub enum OnvifError {
    Transport(TransportError),  // network / TLS / unexpected HTTP status
    Soap(SoapError),            // parse failure, missing field, or SOAP Fault
}
use oxvif::error::OnvifError;
use oxvif::soap::SoapError;
use oxvif::transport::TransportError;

match client.get_capabilities().await {
    Ok(caps) => { /* use caps */ }
    Err(OnvifError::Transport(TransportError::Http(e))) => eprintln!("Network: {e}"),
    Err(OnvifError::Transport(TransportError::HttpStatus { status, body })) => {
        eprintln!("HTTP {status}: {body}");
    }
    Err(OnvifError::Soap(SoapError::Fault { code, reason })) => {
        eprintln!("SOAP Fault [{code}]: {reason}");
    }
    Err(e) => eprintln!("Other: {e}"),
}

HTTP 500 is treated as Ok so the SOAP layer can parse the <s:Fault> detail.


Testing without a real camera

A built-in, stateful mock ONVIF device — Set persists, Get reflects it — covering every operation oxvif implements (157 SOAP actions, and a test asserts none is missing). Two lenses, one behind the other.

Try it now — a test that needs nothing

[dev-dependencies]
oxvif = { version = "0.15", features = ["mock"] }
use std::sync::Arc;
use oxvif::{OnvifClient, mock::MockTransport};

#[tokio::test]
async fn my_code_handles_a_camera() {
    let client = OnvifClient::new("http://mock")
        .with_transport(Arc::new(MockTransport::new()));

    // A real Set → Get round-trip. No network, no sockets, no hardware.
    client.set_hostname("lab-cam").await.unwrap();
    assert_eq!(
        client.get_hostname().await.unwrap().name.as_deref(),
        Some("lab-cam"),
    );
}

cargo test — that's it. Nothing to start, nothing to clean up.

Which one do I want?

MockTransport (mock) MockServer (mock-server)
Wiring Injected into the client A real bound port you connect to
Exercises Your code + the parsers plus the HTTP transport and WS-Security
Needs nothing axum, an ephemeral port
Use for unit tests integration tests, and driving oxvif from other tools

Start with MockTransport. Reach for MockServer when you want the real transport in the loop, or when something outside your test — OxDM, Frigate, ONVIF Device Manager — needs an ONVIF device to talk to.

use oxvif::{OnvifSession, mock::MockServer};

#[tokio::test]
async fn over_real_http() -> Result<(), oxvif::OnvifError> {
    let server = MockServer::start().await.unwrap();   // ephemeral 127.0.0.1 port

    // An ordinary session — nothing injected, the real HTTP stack runs.
    let session = OnvifSession::builder(server.device_url()).build().await?;
    assert_eq!(session.get_device_info().await?.manufacturer, "oxvif-mock");
    Ok(())
}   // the server shuts down when dropped

Making it misbehave

The point of a mock is the cases a real camera won't reproduce on demand:

let mock = MockTransport::new();

// Seed the device into the state you need:
mock.device().modify(|s| s.hostname = "seeded-cam".into());

// Arm a single-shot fault for the next matching call:
mock.inject_fault("GetProfiles", "ter:NotAuthorized", "denied");

let client = OnvifClient::new("http://mock").with_transport(Arc::new(mock.clone()));
assert!(client.get_profiles("http://mock/media").await.is_err());  // consumes it

// Then assert what your code did to the device:
client.set_hostname("after").await.unwrap();
assert_eq!(mock.device().read().hostname, "after");

MockTransport is Clone and every clone shares one device state, so the handle you keep and the one the client holds are the same device. MockServer has the same three methods (.device(), .inject_fault(), .clear_faults()).

Both default to no authentication so tests stay frictionless — call .with_auth() (MockTransport) or .enforce_auth(true) (MockServer::builder()) to exercise WS-Security.


Reference

Everything from here down is detail. The blocks above are enough to use it.

1. MockTransport — embedded in the client (in-process, no sockets, no axum):

use std::sync::Arc;
use oxvif::{OnvifClient, mock::MockTransport};

#[tokio::test]
async fn embedded_mock() {
    let client = OnvifClient::new("http://mock")
        .with_transport(Arc::new(MockTransport::new()));

    client.set_hostname("lab-cam").await.unwrap();
    let h = client.get_hostname().await.unwrap();   // Set → Get round-trips
    assert_eq!(h.name.as_deref(), Some("lab-cam"));
}

Or keep the MockTransport as its own named handle instead of constructing it inline — then you can seed/inspect its state and arm faults directly on it. It's Clone, and every clone shares one device state, so hand a clone to the client:

use std::sync::Arc;
use oxvif::OnvifClient;
use oxvif::mock::MockTransport;

#[tokio::test]
async fn standalone_transport_handle() {
    let mock = MockTransport::new();                 // an independent object

    // Drive the mock directly — no client needed:
    mock.device().modify(|s| s.hostname = "seeded-cam".into());      // seed state
    mock.inject_fault("GetProfiles", "ter:NotAuthorized", "denied"); // arm one error

    // Share it with a client (clone — both sides see the same state):
    let client = OnvifClient::new("http://mock").with_transport(Arc::new(mock.clone()));

    assert_eq!(
        client.get_hostname().await.unwrap().name.as_deref(),
        Some("seeded-cam"),
    );
    assert!(client.get_profiles("http://mock/media").await.is_err()); // consumes the fault

    // Inspect, off the handle, what the client changed:
    client.set_hostname("after").await.unwrap();
    assert_eq!(mock.device().read().hostname, "after");
}

2. MockServer — a standalone server you connect to over real HTTP (needs the mock-server feature). Start it on its own port and point an ordinary OnvifClient / OnvifSession at it — nothing is injected into the client, so the real HTTP transport (and, optionally, WS-Security) is exercised end-to-end:

use oxvif::{OnvifSession, mock::MockServer};

#[tokio::test]
async fn standalone_server() -> Result<(), oxvif::OnvifError> {
    let server = MockServer::start().await.unwrap();   // ephemeral 127.0.0.1 port

    // A normal session talking to the mock over HTTP — no transport swap.
    let session = OnvifSession::builder(server.device_url()).build().await?;
    assert_eq!(session.get_device_info().await?.manufacturer, "oxvif-mock");

    // Arm an error for the next GetProfiles to test your error handling.
    server.inject_fault("GetProfiles", "ter:NotAuthorized", "denied");
    assert!(session.get_profiles().await.is_err());
    Ok(())
}   // server shuts down when dropped

Both default to no authentication (frictionless tests) — call .with_auth() (MockTransport) / .enforce_auth(true) (MockServer::builder()) to exercise WS-Security. State is in-memory; opt into persistence via MockState::set_on_change.

Full reference: docs/mock-server.md. What the mock answers for all 157 operations, which are state-backed and which are static, the complete seeded fixture, worked request/response examples, the fault catalogue, and what it deliberately does not model. Essential reading if you drive it from a non-Rust ONVIF client.

oxvif::mock API reference

MockTransport (mock feature) — in-process Transport; Clone + Default; pass via OnvifClient::with_transport(Arc::new(..)):

Method Description
MockTransport::new() Default device, auth off
MockTransport::with_state(MockState) Build from a seeded state
.with_auth() Enforce WS-Security (builder-style, consumes self)
.device() -> &MockState Seed / inspect device state
.inject_fault(suffix, code, reason) Arm a single-shot SOAP Fault for the next matching action
.clear_faults() Drop all queued faults

MockServer (mock-server feature) — bound-port HTTP server; shuts down on drop:

Method Description
MockServer::start().await Start on an ephemeral port (auth off) → io::Result<MockServer>
MockServer::builder() .port(u16) · .initial_state(DeviceState) · .on_change(hook) · .enforce_auth(bool) · .start().await
.device_url() / .base_url() / .port() Connection info for OnvifClient / OnvifSession
.device() / .inject_fault(..) / .clear_faults() Same as MockTransport

HTTP extras: GET /mock/snapshot.jpg, POST /admin/inject_fault?action=&code=&reason=, POST /admin/clear_faults.

MockState — shared device state (seed / assert / persist):

Method Description
MockState::new() / ::with_state(DeviceState) Create
.read() Read guard over DeviceState, for assertions
.modify(|s| ..) / .modify_returning(|s| ..) Mutate the state
.set_on_change(hook) Fire a callback after each mutation — the persistence seam

DeviceState is serde-serializable for snapshot/restore; the library itself never writes to disk.

The mock is a two-sensor camera

Since 0.15 the mock presents two lenses, not one — VS_1 and VS_2, with source configs VSC_1/VSC_2, encoder configs VEC_1VEC_4 (main + sub per sensor), and four profiles. The numbers come from a real dual-sensor device.

The two sensors deliberately disagree: VS_1 reaches 2592x1944 and offers H.265 on Media2; VS_2 stops at 1280x720 and offers H.264 only. That is what makes the fixture worth having — a single-sensor mock answers every channel identically, so it cannot tell a client that passes the configuration token from one that drops it. Both look correct.

For the same reason the mock faults on a per-channel Get…Options with no ConfigurationToken rather than answering for a default channel. Real devices usually answer; this one refuses, because a token-less call is a client bug that a permissive device hides.

The same applies to Imaging, where every operation is per-VideoSourceToken. VS_1 is a motorised lens reporting levels on 0–100; VS_2 is fixed-focus on 0–255 and faults on GetMoveOptions / Move / Stop. So a client can be tested against "this channel has no focus" rather than only "this device has none".

Standalone mock server (cargo run)

The examples/mock_server binary wraps oxvif::mock::MockServer with TOML file persistence (state survives restarts) — handy for manual testing and OxDM:

# Terminal 1 — start the mock server (default port 18080); needs the feature
cargo run --example mock_server --features mock-server

# Terminal 2 — run any example against it (no credentials required)
ONVIF_URL=http://127.0.0.1:18080/onvif/device \
cargo run --example camera -- full-workflow

It serves GET /mock/snapshot.jpg (a test-pattern image) and persists Set operations to ~/.oxvif/mock_device.toml. The mock engine's unit tests run with cargo test --features mock-server.

Using with OxDM

The mock server is designed to work with OxDM, the Dioxus-based ONVIF Device Manager:

# Terminal 1 — start mock server
cd oxvif && cargo run --example mock_server --features mock-server

# Terminal 2 — start OxDM
cd oxdm && dx serve --platform desktop

In OxDM:

  1. Click the Manual tab in the device list
  2. Click Add and enter 127.0.0.1:18080 (auto-completes to full ONVIF URL)
  3. No credentials needed for the mock server — leave empty
  4. The device appears with snapshot thumbnails refreshing every 3 seconds
  5. Settings tabs (Identification, Network, Time, Users, Maintenance) show live data from the mock server's stateful device service

Or copy .env.example to .env and set ONVIF_URL=http://127.0.0.1:18080/onvif/device so examples pick it up automatically via dotenvy.

Unit test transport mock

Implement the Transport trait to inject any response:

use oxvif::transport::{Transport, TransportError};
use async_trait::async_trait;
use std::sync::Arc;

struct MockTransport { xml: String }

#[async_trait]
impl Transport for MockTransport {
    async fn soap_post(&self, _url: &str, _action: &str, _body: String)
        -> Result<String, TransportError>
    {
        Ok(self.xml.clone())
    }
}

let client = OnvifClient::new("http://ignored")
    .with_transport(Arc::new(MockTransport { xml: MY_FIXTURE_XML.into() }));
cargo test

Metamorph (metamorph / metamorph-server features)

Metamorph turns oxvif's mock device into a shape-shifter: clone a real camera and replay it verbatim, put an ONVIF skin on a non-ONVIF device, or diff a clone's response shapes against oxvif's own reference mock — all offline, no hardware. metamorph is a superset of mock (it builds on the same responder chain and DeviceState); everything here is opt-in and feature-gated, and metamorph-server just adds mock-server on top to serve a clone over HTTP.

[dev-dependencies]
oxvif = { version = "0.15", features = ["metamorph"] }         # record / replay in-process
# oxvif = { version = "0.15", features = ["metamorph-server"] } # + serve the clone over real HTTP

The shortest useful thing

Borrow a camera once, keep it forever. Three steps:

use std::sync::Arc;
use oxvif::OnvifClient;
use oxvif::metamorph::{FixtureStore, MetamorphTransport, record_standard_surface};

// 1. Clone it. This is the ONLY step that needs the camera.
let clone = record_standard_surface(
    "http://192.168.1.100/onvif/device_service",
    Some(("admin", "password")),
    "hikvision-ds2cd",
).await?;
clone.save("clones/hikvision-ds2cd")?;

// 2. Later, on any machine, with no camera anywhere:
let store  = FixtureStore::load("clones/hikvision-ds2cd")?;
let client = OnvifClient::new("http://replay")
    .with_transport(Arc::new(MetamorphTransport::new(store)));

// 3. The real camera's recorded answers, byte for byte.
let info = client.get_device_info().await?;

// And: will oxvif choke on this device?
let report = FixtureStore::load("clones/hikvision-ds2cd")?.verify_parsing().await;
for v in report.failures() {
    println!("cannot parse {}: {}", v.action, v.error.as_deref().unwrap_or(""));
}

Saved clones carry no secrets — WS-Security Password/Nonce and any user:pass@ in a URL are scrubbed before anything is written.

Or from the command line, no code at all:

# Clone a camera you have
cargo run --example metamorph_record --features metamorph -- \
    http://192.168.1.100/onvif/device_service admin password clones/mycam

# Serve the clone on a real port + print how it deviates from oxvif's reference mock
cargo run --example metamorph_serve --features metamorph-server -- clones/mycam

# No camera at all: put an ONVIF skin on one fixed RTSP stream
cargo run --example metamorph_adapter --features metamorph

Which persona do I want?

I want to… Use Needs a camera?
Test against my camera's real quirks, offline record_standard_surfaceMetamorphTransport once, to clone
Let other tools drive the clone (OxDM, Frigate, ODM) MockServer::builder().replay(store) once, to clone
Know whether oxvif parses a device correctly store.verify_parsing() once, to clone
See how a device deviates from the spec-ideal store.diff_against_synthetic() once, to clone
Make a non-ONVIF device (RTSP-only) look like ONVIF DeviceAdapter + AdapterTransport no

Reference

Everything from here down is detail. The block above is enough to use it.

Clone & replay (Persona B)

record_standard_surface drives a camera's standard read surface once and returns a FixtureStore — the recorded SOAP exchanges keyed by the canonical, ephemera-masked request, so GetProfile(token=A) and (token=B) stay distinct while per-request nonce / MessageID / timestamps never fragment the key. Recorded envelopes have their WS-Security Password/Nonce and any user:pass@ URL credential (e.g. an RTSP stream URI) scrubbed, so a saved fixtures.json carries no secret.

use oxvif::metamorph::record_standard_surface;

// Clone the camera once — the device is needed only here.
let clone = record_standard_surface(
    "http://192.168.1.100/onvif/device_service",
    Some(("admin", "password")),   // None for an open device
    "hikvision-ds2cd",             // label for the store
).await?;
clone.save("clones/hikvision-ds2cd")?;   // → clones/hikvision-ds2cd/fixtures.json

record_standard_surface sweeps the full non-destructive Get* surface (per-profile stream/snapshot URIs, encoder/source configs, OSD, audio, PTZ, imaging, events, and Media2 when advertised). To capture only part of it — a whole zone, or a single command to reproduce a model-specific quirk — use record_surface with a SurfaceSelection; it returns a SweepReport telling you which operations were recorded, which the device errored on, and which were skipped for a missing prerequisite. Per-token operations declare their token source via SurfaceOp::requires(), so a UI can render the surface as a dependency tree and the driver auto-includes prerequisites:

use oxvif::metamorph::{SurfaceGroup, SurfaceSelection, record_surface};

// Just the media zone plus the single GetStreamUri command.
let selection = SurfaceSelection::from_groups(&[SurfaceGroup::Media]);
let (clone, report) = record_surface(
    "http://192.168.1.100/onvif/device_service",
    Some(("admin", "password")),
    "hikvision-ds2cd",
    &selection,
).await?;
for op in report.skipped() {
    eprintln!("skipped {}: {:?}", op.action_name(), report.outcome(op));
}

Replay it in-process by pointing an ordinary OnvifClient at a MetamorphTransport — the client-drivable counterpart of MockTransport. Reads reproduce the recorded device verbatim; writes fall through to synthetic DeviceState and invalidate that operation family (coarse copy-on-write), so Set → Get still round-trips:

use std::sync::Arc;
use oxvif::OnvifClient;
use oxvif::metamorph::{FixtureStore, MetamorphTransport};

let store  = FixtureStore::load("clones/hikvision-ds2cd")?;
let client = OnvifClient::new("http://replay")
    .with_transport(Arc::new(MetamorphTransport::new(store)));

let info = client.get_device_info().await?;   // the real camera's recorded response

Or serve the clone from a real bound port — the "container" (needs the metamorph-server feature). MockServer::builder().replay(store) splices the replay responder into the HTTP mock server, so any ONVIF client — oxdm, ONVIF Device Manager, Frigate — or oxvif's own HealthCheck can drive the cloned camera over the network:

use oxvif::metamorph::FixtureStore;
use oxvif::mock::MockServer;

let store  = FixtureStore::load("clones/hikvision-ds2cd")?;
let server = MockServer::builder().replay(store).start().await?;
println!("cloned camera at {}", server.device_url());
// point any HTTP ONVIF client here; the server shuts down when dropped.

To record with a session you already own instead of the one-shot helper, tap a live transport with RecordingTransport and call drive_standard_surface(&session), which exposes just the standard read-surface op list.

Quirk diff — how a clone deviates from the reference mock

FixtureStore::diff_against_synthetic() replays each recorded request through oxvif's synthetic mock and diffs the two responses' element-path sets, returning a serde-serialisable QuirkReport. Per drifting operation, an OperationQuirk lists the element paths only_in_clone (an extra vendor element oxvif's mock doesn't emit) and only_in_synthetic (a block the clone omits).

let store  = FixtureStore::load("clones/hikvision-ds2cd")?;
let report = store.diff_against_synthetic();
if report.is_empty() {
    println!("no structural drift vs the reference mock");
}
for q in &report.quirks {
    println!("{}: +{:?} -{:?}", q.action, q.only_in_clone, q.only_in_synthetic);
}

Scope. This is a structural diff — which element paths exist, not their values (a different Manufacturer string is expected, not a quirk) — and the baseline is oxvif's own reference mock, not the ONVIF WSDL/XSD schema. A deviation means "the clone's shape differs from what oxvif emits/expects" (a useful proxy for whether oxvif will parse the device correctly), not a schema-conformance verdict. For a side-by-side view, diff_details() -> Vec<OperationDiff> renders each operation's baseline and clone responses as aligned, pretty-printed XML (instance values like IPs and tokens normalised) ready for a git-style line diff.

A quirk report grouped by service area, each operation showing added and removed element counts, above a note that operations the device declined with a SOAP Fault are correct device behaviour rather than a client problem

A git-style side-by-side diff of one operation: oxvif's reference response on the left, the cloned camera's on the right, with word-level highlighting on the values that differ and extra vendor blocks the reference does not emit

What a QuirkReport looks like rendered — demonstrated in OxDM. Top: the report grouped by service area. Bottom: diff_details() as a git-style side-by-side, oxvif's reference on the left and the cloned camera on the right. Note the __MASKED__ tokens — the canonicaliser normalises instance values so the diff shows shape drift, and a saved clone carries no credential.

Parse verification — will oxvif choke on this device

FixtureStore::verify_parsing().await runs oxvif's own typed parser over each recorded response and returns a ParseReport of ParseVerdicts: Parsed (with the extracted value as JSON), Failed (with the parser error), Faulted (the device returned a SOAP Fault — it declined, which is correct behaviour, not an oxvif problem), or Unverified (no parser wired — a write/event op the sweep never records). This is the value / type-level check — it catches quirks the structural diff is blind to, e.g. a device returning <Width>1080p</Width> where oxvif expects an integer: same element path (no structural drift), but the parser rejects it.

failures() means "oxvif choked" and deliberately excludes Faulted, as does all_parsed(); use faulted() to list the operations the device declined. This matters when sweeping with a restricted account, where otherwise every denied operation would masquerade as an interop bug.

let store  = FixtureStore::load("clones/hikvision-ds2cd")?;
let report = store.verify_parsing().await;
for v in report.failures() {
    println!("oxvif cannot parse {}: {}", v.action, v.error.as_deref().unwrap_or(""));
}

The two diffs are complementary, not either/or. Parse verification is oxvif-opinionated (the verdict: does it work?); the structural SOAP diff is oxvif-independent wire truth (the evidence: what does the device actually send?). Both are keyed by (action, key_canon), so a UI can join them per operation — the parse verdict as the status badge, the side-by-side SOAP diff as the drill-down.

Both reports serialise: to_json() / to_json_pretty() on either one. Save a QuirkReport as a baseline and report.diff(&baseline) returns a QuirkDiff of just what moved — appeared, resolved, and changed (still quirky, but the deviating paths shifted). With a full sweep covering 52 operations, comparing two reports by hand is not viable; "three things changed since the firmware update" is. Output is order-deterministic, so a saved JSON baseline also diffs cleanly with ordinary text tools.

Progress — driving a real camera takes a while

A full sweep is 52 operations and more HTTP requests than that (per-token reads run once per token), so a UI that just awaits it appears frozen. Each long call has a _with_progress variant taking an Fn(..) + Send + Sync callback — usable from an async UI by feeding a channel. The plain versions are unchanged.

use oxvif::metamorph::{SurfaceSelection, record_surface_with_progress};

let (clone, sweep) = record_surface_with_progress(
    "http://192.168.1.100/onvif/device_service",
    Some(("admin", "password")),
    "hikvision-ds2cd",
    &SurfaceSelection::all(),
    |p| println!("{}/{}  {}", p.done, p.total, p.op.action_name()),
)
.await?;

SweepProgress::total counts the selected operations after prerequisite expansion, not HTTP requests — a per-token operation ticks once no matter how many tokens the camera returns, and the request count cannot be known until the list read answers. Every selected operation ticks exactly once, whether it ran or was resolved as skipped. verify_parsing_with_progress and diff_against_synthetic_with_progress report FixtureProgress over the recorded fixtures the same way.

Adapter / skin (Persona C)

Implement the DeviceAdapter trait to put an ONVIF skin on a non-ONVIF device (e.g. an RTSP-only camera). Only identity and stream_uri are required — enough for a standard NVR / Frigate to ingest it as an ONVIF camera; everything else (profiles, capabilities, services) falls through to the synthetic mock. continuous_move and snapshot are optional hooks that default to unsupported.

use std::sync::Arc;
use oxvif::OnvifClient;
use oxvif::metamorph::{AdapterTransport, DeviceAdapter, DeviceIdentity};

struct RtspCam { rtsp: String }

#[async_trait::async_trait]
impl DeviceAdapter for RtspCam {
    fn identity(&self) -> DeviceIdentity {
        DeviceIdentity {
            manufacturer: "Acme".into(),
            model: "RTSP-Skin".into(),
            firmware_version: "1.0".into(),
            serial_number: "SN-0001".into(),
            hardware_id: "HW-0001".into(),
        }
    }
    fn stream_uri(&self, _profile: &str) -> Option<String> {
        Some(self.rtsp.clone())
    }
}

let adapter = Arc::new(RtspCam {
    rtsp: "rtsp://192.168.1.77:554/Streaming/Channels/101".into(),
});
let client = OnvifClient::new("http://adapter")
    .with_transport(Arc::new(AdapterTransport::new(adapter)));

let info = client.get_device_info().await?;   // identity from the adapter
// GetStreamUri returns the real RTSP URL; profiles come from the synthetic scaffolding.

See examples/metamorph_record.rs, metamorph_serve.rs, and metamorph_adapter.rs for runnable versions of each.


Running the built-in examples

cp .env.example .env   # fill in ONVIF_URL, ONVIF_USERNAME, ONVIF_PASSWORD
cargo run --example camera -- full-workflow          # end-to-end: all implemented operations
cargo run --example camera -- session                # same workflow via OnvifSession API
cargo run --example camera -- device-info            # manufacturer, model, firmware
cargo run --example camera -- device-management      # hostname, NTP, GetServices
cargo run --example camera -- stream-uris            # tabular RTSP URI listing
cargo run --example camera -- snapshot-uris          # tabular HTTP snapshot URI listing
cargo run --example camera -- system-datetime        # device clock and UTC offset
cargo run --example camera -- ptz-presets            # list all PTZ presets
cargo run --example camera -- ptz-status             # current pan/tilt/zoom position
cargo run --example camera -- ptz-config             # PTZ configurations and nodes
cargo run --example camera -- ptz-home               # go to / set PTZ home position
cargo run --example camera -- audio                  # audio sources and encoder configs
cargo run --example camera -- imaging-focus          # focus status, move options, move/stop
cargo run --example camera -- osd                    # on-screen display elements (list, create, delete)
cargo run --example camera -- video-config           # video sources, encoder configs (Media1)
cargo run --example camera -- video-config-media2    # H.265 encoder configs (Media2)
cargo run --example camera -- imaging                # brightness, contrast, exposure settings
cargo run --example camera -- events                 # subscribe, pull, renew, unsubscribe
cargo run --example camera -- event-stream           # continuous event stream via event_stream()
cargo run --example camera -- recording              # list recordings, search, get replay URI
cargo run --example camera -- recording-jobs         # recording jobs: list, create, set mode, delete
cargo run --example camera -- users                  # list, create, delete device user accounts
cargo run --example camera -- network-config         # interfaces, protocols, DNS, gateway
cargo run --example camera -- relay-outputs          # list relay outputs and trigger state change
cargo run --example camera -- storage                # list storage configurations (SD/NAS)
cargo run --example camera -- discovery-mode         # show and toggle WS-Discovery mode
cargo run --example camera -- discovery              # WS-Discovery UDP multicast probe
cargo run --example camera -- error-handling         # typed error variant matching demo
cargo run --example camera -- healthcheck            # quick reachability + auth check

Direct device targeting with --ip and --auth

Instead of using a .env file, you can pass the device address and credentials directly on the command line:

cargo run --example camera -- --ip 192.168.1.100 --auth admin:password device-info
cargo run --example camera -- --ip 192.168.1.100 healthcheck

To run without a real camera, start the mock server first — see Testing without a real camera.

Mock server

# Default port 18080; pass a port number to override (needs the feature)
cargo run --example mock_server --features mock-server
cargo run --example mock_server --features mock-server -- 19090

# Run the mock engine's unit tests
cargo test --features mock-server

Conformance check (real devices)

# Validate the parsers against a list of real cameras; flags silent-parse gaps.
# The device-list file (pipe-delimited: name | url | user | pass) holds
# credentials — keep it out of version control.
cargo run --example conformance --features mock -- devices.txt

Metamorph (clone / serve / skin)

# Clone a real camera's read surface into clones/<vendor-model>/fixtures.json
cargo run --example metamorph_record --features metamorph -- \
    http://192.168.1.100/onvif/device_service admin password clones/hikvision-ds2cd

# Serve the recorded clone from a bound port + print a structural quirk diff
cargo run --example metamorph_serve --features metamorph-server -- clones/hikvision-ds2cd

# Skin one fixed RTSP stream as an ONVIF device (Persona C template)
cargo run --example metamorph_adapter --features metamorph

Project structure

src/
├── lib.rs               Public API surface and re-exports
├── client/
│   ├── mod.rs           OnvifClient — constructor and builder methods
│   ├── device.rs        Device service methods
│   ├── events.rs        Events service methods (incl. event_stream)
│   ├── imaging.rs       Imaging service methods
│   ├── media.rs         Media1 service methods
│   ├── media2.rs        Media2 service methods
│   ├── ptz.rs           PTZ service methods
│   └── recording.rs     Recording / Search / Replay service methods
├── session.rs           OnvifSession — convenience wrapper with cached service URLs
├── discovery.rs         WS-Discovery UDP multicast probe
├── error.rs             OnvifError unified error type
├── transport.rs         Transport trait + HttpTransport (reqwest + rustls)
├── fixtures.rs          CapturingTransport / FixtureTransport — record-and-replay test seam
├── redact.rs            Strip WS-Security digests and URL credentials before a recorder writes to disk
├── soap/
│   ├── mod.rs
│   ├── envelope.rs      SOAP 1.2 envelope builder
│   ├── security.rs      WS-Security UsernameToken / PasswordDigest
│   ├── xml.rs           Namespace-stripping XML parser (XmlNode)
│   └── error.rs         SoapError
├── types/
│   ├── mod.rs           XML helper functions (xml_escape, xml_str,)
│   ├── audio.rs         AudioSource, AudioEncoderConfiguration, AudioEncoding
│   ├── capabilities.rs  Capabilities, service sub-structs
│   ├── device.rs        DeviceInfo, NetworkInterfaceConfig, SystemDateTime, Hostname, NtpInfo, StorageConfiguration
│   ├── events.rs        PullPointSubscription, NotificationMessage, EventProperties
│   ├── imaging.rs       ImagingSettings, ImagingOptions, ImagingStatus
│   ├── media.rs         MediaProfile, MediaProfile2, StreamUri, SnapshotUri
│   ├── osd.rs           OsdConfiguration, OsdTextString, OsdColor, OsdOptions
│   ├── ptz.rs           PtzPreset, PtzStatus
│   ├── ptz_config.rs    PtzConfiguration, PtzConfigurationOptions, PtzNode, PtzSpeed
│   ├── recording.rs     RecordingItem, RecordingJob, RecordingJobConfiguration, RecordingJobState
│   └── video.rs         VideoSource, VideoEncoder configs and options
├── mock/                In-process mock ONVIF device (mock / mock-server features)
│   ├── mod.rs           Public surface — MockTransport, MockServer, MockState, DeviceState
│   ├── transport.rs     MockTransport — Transport impl, zero-network
│   ├── server.rs        MockServeraxum bound-port server (mock-server only)
│   ├── state.rs         Stateful DeviceStateSet persists, Get reflects
│   ├── dispatch.rs      SOAP action routing to per-service handlers
│   ├── services/        device / media / media2 / ptz / imaging / events / recording handlers
│   ├── auth.rs          Optional WS-Security enforcement
│   ├── fault_injection.rs  Single-shot SOAP Fault queue per action
│   ├── helpers.rs       SOAP envelope helpers
│   ├── snapshot.rs      Test-pattern JPEG generator (GET /mock/snapshot.jpg)
│   ├── font.rs          5×7 bitmap font used by the snapshot generator
│   └── xml_parse.rs     Request body tag extraction
├── health/              Read-only ONVIF health / conformance check (health feature)
│   ├── mod.rs           HealthCheck builder + HealthReport public surface
│   ├── checks.rs        Individual check implementations (connectivity, services, …)
│   └── report.rs        HealthReport / CheckResult / ReportDiff types (Serde + diff)
├── metamorph/           Metamorph personas (metamorph / metamorph-server features)
│   ├── mod.rs           Public surfaceFixtureStore, MetamorphTransport, DeviceAdapter, QuirkReport
│   ├── fixture.rs       FixtureStoreparam-aware recorded SOAP exchanges (fixtures.json)
│   ├── record.rs        RecordingTransport + record_standard_surface / drive_standard_surface
│   ├── replay.rs        MetamorphTransport / ReplayResponderPersona B replay (copy-on-write)
│   ├── adapter.rs       DeviceAdapter / AdapterTransportPersona C ONVIF skin
│   └── quirk.rs         diff_against_synthetic / diff_detailsstructural quirk diff
└── tests/
    ├── common.rs        shared test transports + SOAP fault / empty-body fixtures
    ├── client/          unit tests, one file per client service module
    │   ├── device_tests.rs, media_tests.rs, media2_tests.rs, ptz_tests.rs
    │   └── imaging_tests.rs, events_tests.rs, recording_tests.rs
    ├── session_tests.rs unit tests for OnvifSession builder and delegates
    └── types_tests.rs   XML parsing unit tests
examples/
├── camera.rs            Live camera integration examples (all commands)
├── mock_server/         Thin wrapper over oxvif::mock::MockServer with TOML persistence
│   └── main.rs          Entry point (--features mock-server)
├── healthcheck.rs       Scriptable health/conformance check + --json + --baseline (--features health)
├── record_fixtures.rs   Capture every SOAP exchange against a live device for replay (--features mock,health)
├── conformance.rs       Validate the parsers against a fleet of real cameras; flags silent-parse gaps (--features mock)
├── metamorph_record.rs  Clone a real camera into a param-aware fixtures.json (--features metamorph)
├── metamorph_serve.rs   Serve a recorded clone from a bound port + quirk diff (--features metamorph-server)
├── metamorph_adapter.rs Skin one fixed RTSP stream as an ONVIF device (--features metamorph)
├── probe_unicast.rs     One-shot unicast WS-Discovery probe to a specific host
├── odm_compat.rs        ODM compatibility integration test
└── write_workflow.rs    Write-operation workflow with embedded mock server

Implemented ONVIF operations

OPERATIONS.md — per-service coverage tables for all nine services plus WS-Discovery. Split out of this file to keep it navigable; the method signatures and usage examples for each operation are in the sections above.


Changelog

See CHANGELOG.md for version history.


License

MIT — covers oxvif's own source code.

Trademark & ONVIF

ONVIF® is a trademark of ONVIF, Inc. oxvif is an independent, community project and is not affiliated with, endorsed by, or certified by ONVIF. The name "ONVIF" is used here only descriptively, to identify the protocol oxvif speaks. oxvif has not been through the ONVIF conformance program and makes no ONVIF Profile conformance claim; the health / conformance features are unofficial self-diagnostics, not the official ONVIF Device Test Tool. Interface references under docs/ are derived from the publicly published ONVIF WSDL/XSD schemas for interoperability — see docs/README.md.

Dependencies

~22–39MB
~595K SLoC