oxvif 0.9.4

Async Rust client library for the ONVIF IP camera protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
# oxvif

Async Rust client library for the [ONVIF](https://www.onvif.org/) IP camera protocol.

```
UDP multicast ──► discovery::probe() ──► Vec<DiscoveredDevice>
                                                  ▼ XAddr
                      OnvifSession ─── caches service URLs, delegates every call
SOAP/HTTP ──────►  OnvifClient ──► Device    (capabilities, hostname, NTP, reboot)
                           ──► Media1    (profiles, RTSP/snapshot URIs, video + audio configs)
                           ──► Media2    (H.265, metadata, audio, video source modes)
                           ──► PTZ       (move, stop, presets, home, status, configurations, nodes)
                           ──► Imaging   (brightness, contrast, exposure, IR cut, focus move/stop)
                           ──► OSD       (create, read, update, delete on-screen display elements)
                           ──► Events    (subscribe, pull, renew, unsubscribe, continuous stream)
                           ──► Recording (list, create/delete recordings and recording jobs)
                           ──► Search    (find recordings by time/scope)
                           ──► Replay    (RTSP URI for playback)
```

- Async-first (`tokio` + `reqwest`)
- WS-Security `UsernameToken` with `PasswordDigest` (ONVIF Profile S §5.12)
- HTTP Digest Authentication (RFC 7616, ONVIF Profile T §7.1)
- WS-Discovery via UDP multicast (`239.255.255.250:3702`)
- Mockable transport — unit-test without a real camera
- No unsafe code; pure Rust XML parsing via `quick-xml`
- 375 unit tests + 19 doc tests + 26 mock server tests

---

## Quick start

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

### `OnvifSession` — URL caching handled for you

```rust
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

```rust
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.

---

## Installation

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

---

## `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

```rust
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) |

```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:**

| 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.**

```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

| 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.

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

### 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` |
| `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) |
| `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_storage_configurations()` | List SD/NAS storage locations → `Vec<StorageConfiguration>` |
| `set_storage_configuration(token, ...)` | Create or update a storage configuration entry |

---

## 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

```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

| 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 |

```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

| Feature | Media1 | Media2 |
|---------|--------|--------|
| 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

| 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)` | `()` | |

---

## 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_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"`), `utc_time` (`Option<String>`), `error` (`Option<String>` — device fault description if any).

---

## 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 |

```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), `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 |

```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`, `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>`).

```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`.

| 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 |

```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),
        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`.

```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?;
```

### 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.

```rust
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)` | `RecordingJob` | Create a new recording job |
| `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 |

```rust
// Create a recording and start a job
let rec_token = client.create_recording(
    &recording_url, "src1", "Camera1", "Front door camera", "192.168.1.50"
).await?;

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

let config = RecordingJobConfiguration {
    recording_token: rec_token.clone(),
    mode: "Active".into(),
    priority: 1,
    source_token: "VideoSourceToken_0".into(),
};
let job = client.create_recording_job(&recording_url, &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 |

```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`.

| Method | Returns | Description |
|--------|---------|-------------|
| `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

### Mock server

`examples/mock_server/` is a **stateful** ONVIF mock server. Set operations
(SetHostname, CreateUsers, SetDNS, etc.) persist in memory, so you can verify
Get-after-Set roundtrips without a real camera.

Features:
- **Stateful device service** — hostname, users, scopes, DNS, NTP, timezone
  all persist across requests (Set → Get returns updated values)
- **Snapshot endpoint**`GET /mock/snapshot.jpg` serves a dynamically
  generated test-pattern BMP image that changes color every second
- **All ONVIF services** — Device, Media1, Media2, PTZ, Imaging, Events,
  Recording, Search, Replay (84 response handlers)
- **26 unit tests** — stateful roundtrip verification, XML parser tests, WS-Security auth

```sh
# Terminal 1 — start the mock server (default port 18080)
cargo run --example mock_server

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

#### Using with OxDM

The mock server is designed to work with [OxDM](https://github.com/smiti1642/oxdm),
the Dioxus-based ONVIF Device Manager:

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

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

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

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

### Unit test transport mock

Implement the `Transport` trait to inject any response:

```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 -- 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:

```sh
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](#testing-without-a-real-camera).

### Mock server

```sh
# Default port 18080; pass a port number to override
cargo run --example mock_server
cargo run --example mock_server -- 19090

# Run mock server tests (26 tests: stateful roundtrips, XML parser, auth)
cargo test --example mock_server
```

---

## 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)
├── 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, 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
└── tests/
    ├── client_tests.rs  unit tests covering all client methods
    ├── 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/         Stateful ONVIF mock server for offline development
│   ├── main.rs          Entry point, Axum router
│   ├── dispatch.rs      SOAP action routing to service modules
│   ├── helpers.rs       SOAP envelope builder, action extraction
│   ├── snapshot.rs      Test-pattern BMP image generation
│   ├── state.rs         Mutable device state + 11 stateful unit tests
│   ├── xml_parse.rs     Request body XML tag extraction + 5 unit tests
│   └── services/
│       ├── device.rs    Device service (20 Get + 8 Set handlers, stateful)
│       ├── media.rs     Media1 service (21 handlers)
│       ├── media2.rs    Media2 service (17 handlers)
│       ├── ptz.rs       PTZ service (8 handlers)
│       ├── imaging.rs   Imaging service (4 handlers)
│       ├── events.rs    Events service (5 handlers)
│       └── recording.rs Recording/Search/Replay (9 handlers)
├── odm_compat.rs        ODM compatibility integration test
└── write_workflow.rs    Write-operation workflow with embedded mock server
```

---

## Implemented ONVIF operations

### Device Service

| Operation | Status |
|-----------|--------|
| `GetCapabilities` ||
| `GetServices` ||
| `GetDeviceInformation` ||
| `GetSystemDateAndTime` ||
| `GetHostname` / `SetHostname` ||
| `GetNTP` / `SetNTP` ||
| `SystemReboot` ||
| `GetScopes` ||
| `GetUsers` / `CreateUsers` / `DeleteUsers` / `SetUser` ||
| `GetNetworkInterfaces` / `SetNetworkInterfaces` ||
| `GetNetworkProtocols` / `SetNetworkProtocols` ||
| `GetDNS` / `SetDNS` ||
| `GetNetworkDefaultGateway` / `SetNetworkDefaultGateway` ||
| `GetDiscoveryMode` / `SetDiscoveryMode` ||
| `SendAuxiliaryCommand` ||
| `GetSystemLog` ||
| `GetSystemUris` ||
| `SetSystemFactoryDefault` ||
| `GetRelayOutputs` / `SetRelayOutputState` / `SetRelayOutputSettings` ||
| `GetStorageConfigurations` / `SetStorageConfiguration` ||

### Media Service (Media1)

| Operation | Status |
|-----------|--------|
| `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

| Operation | Status |
|-----------|--------|
| `GetProfiles` ||
| `CreateProfile` / `DeleteProfile` ||
| `GetStreamUri` / `GetSnapshotUri` ||
| `GetVideoSourceConfigurations` / `SetVideoSourceConfiguration` ||
| `GetVideoSourceConfigurationOptions` ||
| `GetVideoEncoderConfigurations` / `GetVideoEncoderConfiguration` ||
| `SetVideoEncoderConfiguration` ||
| `GetVideoEncoderConfigurationOptions` ||
| `GetVideoEncoderInstances` ||
| `AddConfiguration` / `RemoveConfiguration` ||
| `GetMetadataConfigurations` / `SetMetadataConfiguration` ||
| `GetMetadataConfigurationOptions` ||
| `GetAudioSourceConfigurations` ||
| `GetAudioEncoderConfigurations` / `SetAudioEncoderConfiguration` ||
| `GetAudioEncoderConfigurationOptions` ||
| `GetAudioOutputConfigurations` ||
| `GetAudioDecoderConfigurations` ||
| `GetVideoSourceModes` / `SetVideoSourceMode` ||

### PTZ Service

| Operation | Status |
|-----------|--------|
| `AbsoluteMove` / `RelativeMove` / `ContinuousMove` ||
| `Stop` ||
| `GetPresets` / `GotoPreset` ||
| `SetPreset` / `RemovePreset` ||
| `GetStatus` ||
| `GetConfigurations` / `GetConfiguration` ||
| `SetConfiguration` / `GetConfigurationOptions` ||
| `GetNodes` / `GetNode` ||
| `GetCompatibleConfigurations` ||

### Imaging Service

| Operation | Status |
|-----------|--------|
| `GetImagingSettings` / `SetImagingSettings` ||
| `GetOptions` ||
| `Move` / `Stop` / `GetMoveOptions` / `GetStatus` ||

### Events Service

| Operation | Status |
|-----------|--------|
| `GetEventProperties` ||
| `CreatePullPointSubscription` ||
| `PullMessages` ||
| `Renew` ||
| `Unsubscribe` ||
| `event_stream` (continuous poll stream) ||
| WS-BaseNotification push (`subscribe` + `notification_listener`) ||
| `SetSynchronizationPoint` ||

### Recording Service

| Operation | Status |
|-----------|--------|
| `GetRecordings` ||
| `CreateRecording` / `DeleteRecording` ||
| `CreateTrack` / `DeleteTrack` ||
| `GetRecordingJobs` ||
| `CreateRecordingJob` / `SetRecordingJobMode` / `DeleteRecordingJob` ||
| `GetRecordingJobState` ||

### Search Service

| Operation | Status |
|-----------|--------|
| `FindRecordings` ||
| `GetRecordingSearchResults` ||
| `EndSearch` ||

### Replay Service

| Operation | Status |
|-----------|--------|
| `GetReplayUri` ||

### WS-Discovery

| Operation | Status |
|-----------|--------|
| UDP multicast `Probe` ||
| `Hello` / `Bye` passive listening (`listen`) ||

---

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for version history.

---

## License

MIT