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
545 downloads per month
Used in oxvif-device-manager
2.5MB
39K
SLoC
oxvif
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
UsernameTokenwithPasswordDigest(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-serverfeatures) — unit-test client code without a real camera - Metamorph (
metamorph/metamorph-serverfeatures) — 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 (
healthfeature), plus aconformanceexample 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_recordingsisf32—xs:floatin the schema, despite reading like a count.Media2ServiceCapabilities::webrtcisOption<u32>, a session count.Some(0)means WebRTC was described and no concurrent session is offered.RecordingServiceCapabilities::onboard_storagehas a schema default oftruewhich oxvif does not apply. Readonboard_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)
});
OnvifSessiondoes this for you for Profile G. Some cameras advertise the recording / search / replay services only viaGetServices, not theGetCapabilitiesextension.OnvifSession::buildfills any missing one fromGetServicesautomatically, soget_recordings/search_recordings/get_replay_uriwork 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::tokenisOption<String>.@tokenis optional ontt:PresetTourin the schema, unlikett: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.PtzPresetTourPresetDetailis an enum, not a struct. The schema member is anxs:choice—PresetToken,Homeor an explicitPosition, exactly one. ThreeOptionfields 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
HealthCheckfrom 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 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 optionalCheckErroron a failing check with aclass(ErrorClass::SoapFault/Precondition/Parse/Http/InvalidArgument), the ONVIFsubcode(e.g.ter:NotAuthorized),fault_code,reason, and verbatimdetail. 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 fromGetScopes. Compare against the assessedprofilesverdicts to flag "declares Profile G but replay/search fail".ProfileAssessment::profile_{s,t,g}— each aProfileState { verdict, missing, unverified }.verdictisconformant/partial/unsupported/inconclusive; the last means required checks couldn't be tested (auth blocked / skipped) with nothing verified to fail — kept distinct frompartialso "couldn't verify" is never read as "non-conformant".missinglists the ids that genuinely failed,unverifiedthe ones that couldn't be tested. Profile T additionally requiresmedia2(Media2 advertised) andevent_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-freeGetDeviceInformationprobe checks the device actually enforces authentication. Serving device info anonymously is a securityWarn; a rejection is aPass.
Per-service capabilities, and the device against itself (0.15+). Nine
service_caps_* checks ask each advertised service its own
GetServiceCapabilities — Pass 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 |
contradiction — Warn, 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
healthreport shape changed in a breaking way in 0.12.0 (profiles became an object, verdicts/status.kindlowercase,elapsed_msnullable). 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
Okso 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_1…VEC_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:
- Click the Manual tab in the device list
- Click Add and enter
127.0.0.1:18080(auto-completes to full ONVIF URL) - No credentials needed for the mock server — leave empty
- The device appears with snapshot thumbnails refreshing every 3 seconds
- 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_surface → MetamorphTransport |
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
Manufacturerstring 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.
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 MockServer — axum bound-port server (mock-server only)
│ ├── state.rs Stateful DeviceState — Set 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 surface — FixtureStore, MetamorphTransport, DeviceAdapter, QuirkReport
│ ├── fixture.rs FixtureStore — param-aware recorded SOAP exchanges (fixtures.json)
│ ├── record.rs RecordingTransport + record_standard_surface / drive_standard_surface
│ ├── replay.rs MetamorphTransport / ReplayResponder — Persona B replay (copy-on-write)
│ ├── adapter.rs DeviceAdapter / AdapterTransport — Persona C ONVIF skin
│ └── quirk.rs diff_against_synthetic / diff_details — structural 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