# oxvif
Async Rust client library for the [ONVIF](https://www.onvif.org/) IP camera protocol.
```
UDP multicast ──► discovery::probe() ──► Vec<DiscoveredDevice>
│
▼ XAddr
SOAP/HTTP ──────► OnvifClient ──► Device (capabilities, hostname, NTP, reboot)
──► Media1 (profiles, RTSP/snapshot URIs, video + audio configs)
──► Media2 (H.265 native, flat encoder config)
──► 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)
──► Recording (list stored recordings)
──► 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)
- WS-Discovery via UDP multicast (`239.255.255.250:3702`)
- Mockable transport — unit-test without a real camera
- No unsafe code; pure Rust XML parsing via `quick-xml`
- 228 unit tests + 9 doc tests
---
## Quick start
```rust
use oxvif::OnvifClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = OnvifClient::new("http://192.168.1.100/onvif/device_service")
.with_credentials("admin", "password");
let info = client.get_device_info().await?;
println!("Model: {} {}", info.manufacturer, info.model);
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(())
}
```
---
## Installation
```toml
[dependencies]
oxvif = "0.4.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
---
## `OnvifClient`
The main entry point. Stateless and cheaply cloneable — safe to wrap in `Arc` and share across threads.
### Constructors and builder methods
| `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) |
```rust
// 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.
```rust
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:**
| `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.**
```rust
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.username_token
caps.media.streaming.rtp_rtsp_tcp
caps.events.ws_pull_point
```
### `get_services() -> Result<Vec<OnvifService>, OnvifError>`
Use as a fallback when `caps.media2_url` is `None`:
```rust
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)
});
```
### `get_system_date_and_time() -> Result<SystemDateTime, OnvifError>`
Retrieves the device clock. Compute the offset to keep WS-Security timestamps in sync.
```rust
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>`
```rust
let info = client.get_device_info().await?;
// info.manufacturer, info.model, info.firmware_version, info.serial_number
```
### Hostname methods
| `get_hostname()` | Returns `Hostname { from_dhcp: bool, name: Option<String> }` |
| `set_hostname(name: &str)` | Set a static hostname |
### NTP methods
| `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.
```rust
let scopes = client.get_scopes().await?;
for s in &scopes {
println!("{s}");
}
```
---
## Media Service (Media1) methods
All Media1 methods use `media_url` from `caps.media.url`.
### Profile management
| `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
```rust
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
| `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 |
```rust
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
| H.265 | Via `Other(String)` | Native `VideoEncoding::H265` |
| Encoder config | Nested `H264`/`H265` sub-struct | Flat — `gov_length` and `profile` at top level |
| `GetStreamUri` response | `<MediaUri>` wrapper | Just `<Uri>` string |
| Write operations | Require `<ForcePersistence>true` | No `ForcePersistence` |
### Media2 method reference
| `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)` | `()` | |
---
## 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]`.
| `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_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 |
```rust
// 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"`).
---
## Audio Service methods
All audio methods use `media_url` from `caps.media.url`.
| `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 |
```rust
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).
**`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`.
| `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 |
```rust
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` (`Option<f32>`), `ir_cut_filter`, `white_balance_mode`, `exposure_mode` (`Option<String>`).
```rust
// 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`.
| `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 |
```rust
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),
}),
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>`).
**`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`.
```rust
// 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?;
```
**`PullPointSubscription` fields:**
| `reference_url` | `String` | Endpoint for `pull_messages`, `renew_subscription`, `unsubscribe` |
| `termination_time` | `String` | ISO-8601 timestamp when the subscription expires |
**`NotificationMessage` fields:**
| `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:**
| `topics` | `Vec<String>` | Flattened topic paths (e.g. `"VideoSource/MotionAlarm"`, `"RuleEngine/Cell/Motion"`) |
---
## Recording Service methods
Access recordings stored on the device (NVR/DVR). Obtain `recording_url` from
`get_services()` — look for the service with namespace
`http://www.onvif.org/ver10/recording/wsdl`.
| `get_recordings(recording_url)` | `Vec<RecordingItem>` | List all stored recordings |
**`RecordingItem` fields:** `token`, `source` (`RecordingSourceInformation`), `content`, `earliest_recording`, `latest_recording` (`Option<String>` ISO-8601), `recording_status` (`"Recording"`, `"Stopped"`, etc.), `tracks` (`Vec<RecordingTrack>`).
---
## Search Service methods
Search through stored recordings. Obtain `search_url` from `get_services()` —
namespace `http://www.onvif.org/ver10/search/wsdl`.
| `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 |
```rust
// 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`.
| `get_replay_uri(replay_url, recording_token, stream_type, protocol)` | `String` | RTSP URI for playback |
```rust
let uri = client.get_replay_uri(
&replay_url,
&rec.recording_token,
"RTP-Unicast",
"RTSP",
).await?;
println!("Playback: {uri}");
// Open in VLC: vlc "{uri}"
```
---
## Error handling
All API methods return `Result<T, OnvifError>`:
```rust
pub enum OnvifError {
Transport(TransportError), // network / TLS / unexpected HTTP status
Soap(SoapError), // parse failure, missing field, or SOAP Fault
}
```
```rust
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
Implement the `Transport` trait to inject any response:
```rust
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() }));
```
```sh
cargo test
```
---
## Running the built-in examples
```sh
cp .env.example .env # fill in ONVIF_URL, ONVIF_USERNAME, ONVIF_PASSWORD
```
```sh
cargo run --example camera -- full-workflow # end-to-end: all implemented operations
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 -- 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 -- discovery # WS-Discovery UDP multicast probe
cargo run --example camera -- error-handling # typed error variant matching demo
```
---
## Project structure
```
src/
├── lib.rs Public API surface and re-exports
├── client.rs OnvifClient — all ONVIF operations
├── discovery.rs WS-Discovery UDP multicast probe
├── error.rs OnvifError unified error type
├── transport.rs Transport trait + HttpTransport (reqwest + rustls)
├── 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
│ ├── capabilities.rs Capabilities, service sub-structs
│ ├── device.rs DeviceInfo, SystemDateTime, Hostname, NtpInfo
│ ├── events.rs PullPointSubscription, NotificationMessage, EventProperties
│ ├── imaging.rs ImagingSettings, ImagingOptions
│ ├── media.rs MediaProfile, StreamUri, SnapshotUri
│ ├── ptz.rs PtzPreset, PtzStatus
│ └── video.rs VideoSource, VideoEncoder configs and options
└── tests/
├── client_tests.rs 202 unit tests covering all client methods
└── types_tests.rs XML parsing unit tests
```
---
## Implemented ONVIF operations
### Device Service
| `GetCapabilities` | ✓ |
| `GetServices` | ✓ |
| `GetDeviceInformation` | ✓ |
| `GetSystemDateAndTime` | ✓ |
| `GetHostname` / `SetHostname` | ✓ |
| `GetNTP` / `SetNTP` | ✓ |
| `SystemReboot` | ✓ |
### Media Service (Media1)
| `GetProfiles` / `GetProfile` | ✓ |
| `CreateProfile` / `DeleteProfile` | ✓ |
| `AddVideoEncoderConfiguration` / `RemoveVideoEncoderConfiguration` | ✓ |
| `AddVideoSourceConfiguration` / `RemoveVideoSourceConfiguration` | ✓ |
| `GetStreamUri` | ✓ |
| `GetSnapshotUri` | ✓ |
| `GetVideoSources` | ✓ |
| `GetVideoSourceConfigurations` / `GetVideoSourceConfiguration` | ✓ |
| `SetVideoSourceConfiguration` | ✓ |
| `GetVideoSourceConfigurationOptions` | ✓ |
| `GetVideoEncoderConfigurations` / `GetVideoEncoderConfiguration` | ✓ |
| `SetVideoEncoderConfiguration` | ✓ |
| `GetVideoEncoderConfigurationOptions` | ✓ |
| `GetAudioSources` | ✓ |
| `GetAudioSourceConfigurations` | ✓ |
| `GetAudioEncoderConfigurations` / `GetAudioEncoderConfiguration` | ✓ |
| `SetAudioEncoderConfiguration` | ✓ |
| `GetAudioEncoderConfigurationOptions` | ✓ |
### Media2 Service
| `GetProfiles` | ✓ |
| `CreateProfile` / `DeleteProfile` | ✓ |
| `GetStreamUri` / `GetSnapshotUri` | ✓ |
| `GetVideoSourceConfigurations` / `SetVideoSourceConfiguration` | ✓ |
| `GetVideoSourceConfigurationOptions` | ✓ |
| `GetVideoEncoderConfigurations` / `GetVideoEncoderConfiguration` | ✓ |
| `SetVideoEncoderConfiguration` | ✓ |
| `GetVideoEncoderConfigurationOptions` | ✓ |
| `GetVideoEncoderInstances` | ✓ |
### PTZ Service
| `AbsoluteMove` / `RelativeMove` / `ContinuousMove` | ✓ |
| `Stop` | ✓ |
| `GetPresets` / `GotoPreset` | ✓ |
| `SetPreset` / `RemovePreset` | ✓ |
| `GetStatus` | ✓ |
| `GetConfigurations` / `GetConfiguration` | ✓ |
| `SetConfiguration` / `GetConfigurationOptions` | ✓ |
| `GetNodes` | ✓ |
### Imaging Service
| `GetImagingSettings` / `SetImagingSettings` | ✓ |
| `GetOptions` | ✓ |
| `Move` (focus/iris) | — |
### Events Service
| `GetEventProperties` | ✓ |
| `CreatePullPointSubscription` | ✓ |
| `PullMessages` | ✓ |
| `Renew` | ✓ |
| `Unsubscribe` | ✓ |
| WS-BaseNotification push (Subscribe) | — |
### WS-Discovery
| UDP multicast `Probe` | ✓ |
| `Hello` / `Bye` passive listening | — |
---
## License
MIT