1use quick_xml::events::Event;
2use quick_xml::Reader;
3use reqwest::Client;
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum PtzError {
8 #[error("HTTP error: {0}")]
9 Http(#[from] reqwest::Error),
10
11 #[error("XML parsing error: {0}")]
12 XmlParse(String),
13
14 #[error("Invalid response format: {0}")]
15 InvalidResponse(String),
16
17 #[error("Device error: {0}")]
18 Device(#[from] super::device::DeviceError),
19
20 #[error("PTZ not supported")]
21 NotSupported,
22
23 #[error("Invalid position: {0}")]
24 InvalidPosition(String),
25
26 #[error("Preset not found: {0}")]
27 PresetNotFound(String),
28}
29
30pub type Result<T> = std::result::Result<T, PtzError>;
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct PtzPosition {
34 pub pan: f64,
35 pub tilt: f64,
36 pub zoom: f64,
37}
38
39impl PtzPosition {
40 pub fn new(pan: f64, tilt: f64, zoom: f64) -> Self {
41 Self { pan, tilt, zoom }
42 }
43
44 pub fn validate(&self) -> Result<()> {
45 if !(-1.0..=1.0).contains(&self.pan) {
46 return Err(PtzError::InvalidPosition(format!(
47 "Pan {} out of range [-1.0, 1.0]",
48 self.pan
49 )));
50 }
51 if !(-1.0..=1.0).contains(&self.tilt) {
52 return Err(PtzError::InvalidPosition(format!(
53 "Tilt {} out of range [-1.0, 1.0]",
54 self.tilt
55 )));
56 }
57 if !(0.0..=1.0).contains(&self.zoom) {
58 return Err(PtzError::InvalidPosition(format!(
59 "Zoom {} out of range [0.0, 1.0]",
60 self.zoom
61 )));
62 }
63 Ok(())
64 }
65}
66
67#[derive(Debug, Clone, PartialEq)]
68pub struct PtzRange {
69 pub min: f64,
70 pub max: f64,
71}
72
73impl PtzRange {
74 pub fn new(min: f64, max: f64) -> Self {
75 Self { min, max }
76 }
77}
78
79impl Default for PtzRange {
80 fn default() -> Self {
81 Self {
82 min: -1.0,
83 max: 1.0,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct PtzCapabilities {
90 pub pan_range: PtzRange,
91 pub tilt_range: PtzRange,
92 pub zoom_range: PtzRange,
93 pub presets: bool,
94}
95
96impl Default for PtzCapabilities {
97 fn default() -> Self {
98 Self {
99 pan_range: PtzRange::default(),
100 tilt_range: PtzRange::default(),
101 zoom_range: PtzRange::new(0.0, 1.0),
102 presets: false,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq)]
108pub struct PtzPreset {
109 pub token: String,
110 pub name: String,
111 pub position: Option<PtzPosition>,
112}
113
114impl PtzPreset {
115 pub fn new(token: String, name: String) -> Self {
116 Self {
117 token,
118 name,
119 position: None,
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
125pub enum PtzCommand {
126 AbsoluteMove(PtzPosition),
127 RelativeMove(PtzPosition),
128 ContinuousMove(PtzPosition),
129 Stop,
130 GotoPreset(String),
131 SetPreset(String),
132 RemovePreset(String),
133 GotoHome,
134}
135
136#[derive(Debug, Clone)]
137pub struct PtzController {
138 client: Client,
139 endpoint: String,
140 profile_token: String,
141 auth: Option<(String, String)>,
142}
143
144impl PtzController {
145 pub fn new(endpoint: &str, profile_token: &str, auth: Option<(&str, &str)>) -> Self {
146 Self {
147 client: Client::builder()
148 .timeout(std::time::Duration::from_secs(10))
149 .build()
150 .unwrap_or_default(),
151 endpoint: endpoint.to_string(),
152 profile_token: profile_token.to_string(),
153 auth: auth.map(|(u, p)| (u.to_string(), p.to_string())),
154 }
155 }
156
157 pub async fn execute(&self, command: PtzCommand) -> Result<()> {
158 let soap_body = self.build_command_body(&command)?;
159 let auth_ref = self.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str()));
160 let envelope = super::device::build_soap_envelope(&soap_body, auth_ref)?;
161
162 let response = self
163 .client
164 .post(&self.endpoint)
165 .header("Content-Type", "application/soap+xml; charset=utf-8")
166 .body(envelope)
167 .send()
168 .await?;
169
170 if !response.status().is_success() {
171 return Err(PtzError::InvalidResponse(format!(
172 "HTTP status: {}",
173 response.status()
174 )));
175 }
176
177 Ok(())
178 }
179
180 pub async fn get_position(&self) -> Result<PtzPosition> {
181 let soap_body = format!(
182 r#"<tptz:GetStatus xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
183 <tptz:ProfileToken>{}</tptz:ProfileToken>
184 </tptz:GetStatus>"#,
185 self.profile_token
186 );
187
188 let auth_ref = self.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str()));
189 let envelope = super::device::build_soap_envelope(&soap_body, auth_ref)?;
190
191 let response = self
192 .client
193 .post(&self.endpoint)
194 .header("Content-Type", "application/soap+xml; charset=utf-8")
195 .body(envelope)
196 .send()
197 .await?;
198
199 if !response.status().is_success() {
200 return Err(PtzError::InvalidResponse(format!(
201 "HTTP status: {}",
202 response.status()
203 )));
204 }
205
206 let body = response.text().await?;
207 parse_position(&body)
208 }
209
210 pub async fn get_presets(&self) -> Result<Vec<PtzPreset>> {
211 let soap_body = format!(
212 r#"<tptz:GetPresets xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
213 <tptz:ProfileToken>{}</tptz:ProfileToken>
214 </tptz:GetPresets>"#,
215 self.profile_token
216 );
217
218 let auth_ref = self.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str()));
219 let envelope = super::device::build_soap_envelope(&soap_body, auth_ref)?;
220
221 let response = self
222 .client
223 .post(&self.endpoint)
224 .header("Content-Type", "application/soap+xml; charset=utf-8")
225 .body(envelope)
226 .send()
227 .await?;
228
229 if !response.status().is_success() {
230 return Err(PtzError::InvalidResponse(format!(
231 "HTTP status: {}",
232 response.status()
233 )));
234 }
235
236 let body = response.text().await?;
237 parse_presets(&body)
238 }
239
240 pub async fn get_capabilities(&self) -> Result<PtzCapabilities> {
241 let soap_body = format!(
242 r#"<tptz:GetConfigurationOptions xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
243 <tptz:ConfigurationToken>{}</tptz:ConfigurationToken>
244 </tptz:GetConfigurationOptions>"#,
245 self.profile_token
246 );
247
248 let auth_ref = self.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str()));
249 let envelope = super::device::build_soap_envelope(&soap_body, auth_ref)?;
250
251 let response = self
252 .client
253 .post(&self.endpoint)
254 .header("Content-Type", "application/soap+xml; charset=utf-8")
255 .body(envelope)
256 .send()
257 .await?;
258
259 if !response.status().is_success() {
260 return Err(PtzError::InvalidResponse(format!(
261 "HTTP status: {}",
262 response.status()
263 )));
264 }
265
266 let body = response.text().await?;
267 parse_capabilities(&body)
268 }
269
270 fn build_command_body(&self, command: &PtzCommand) -> Result<String> {
271 match command {
272 PtzCommand::AbsoluteMove(position) => {
273 position.validate()?;
274 Ok(format!(
275 r#"<tptz:AbsoluteMove xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
276 <tptz:ProfileToken>{}</tptz:ProfileToken>
277 <tptz:Position>
278 <tt:PanTilt x="{}" y="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
279 <tt:Zoom x="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
280 </tptz:Position>
281 </tptz:AbsoluteMove>"#,
282 self.profile_token, position.pan, position.tilt, position.zoom
283 ))
284 }
285 PtzCommand::RelativeMove(position) => {
286 position.validate()?;
287 Ok(format!(
288 r#"<tptz:RelativeMove xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
289 <tptz:ProfileToken>{}</tptz:ProfileToken>
290 <tptz:Translation>
291 <tt:PanTilt x="{}" y="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
292 <tt:Zoom x="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
293 </tptz:Translation>
294 </tptz:RelativeMove>"#,
295 self.profile_token, position.pan, position.tilt, position.zoom
296 ))
297 }
298 PtzCommand::ContinuousMove(velocity) => {
299 velocity.validate()?;
300 Ok(format!(
301 r#"<tptz:ContinuousMove xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
302 <tptz:ProfileToken>{}</tptz:ProfileToken>
303 <tptz:Velocity>
304 <tt:PanTilt x="{}" y="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
305 <tt:Zoom x="{}" xmlns:tt="http://www.onvif.org/ver10/schema"/>
306 </tptz:Velocity>
307 </tptz:ContinuousMove>"#,
308 self.profile_token, velocity.pan, velocity.tilt, velocity.zoom
309 ))
310 }
311 PtzCommand::Stop => Ok(format!(
312 r#"<tptz:Stop xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
313 <tptz:ProfileToken>{}</tptz:ProfileToken>
314 <tptz:PanTilt>true</tptz:PanTilt>
315 <tptz:Zoom>true</tptz:Zoom>
316 </tptz:Stop>"#,
317 self.profile_token
318 )),
319 PtzCommand::GotoPreset(preset_token) => Ok(format!(
320 r#"<tptz:GotoPreset xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
321 <tptz:ProfileToken>{}</tptz:ProfileToken>
322 <tptz:PresetToken>{}</tptz:PresetToken>
323 </tptz:GotoPreset>"#,
324 self.profile_token, preset_token
325 )),
326 PtzCommand::SetPreset(preset_name) => Ok(format!(
327 r#"<tptz:SetPreset xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
328 <tptz:ProfileToken>{}</tptz:ProfileToken>
329 <tptz:PresetName>{}</tptz:PresetName>
330 </tptz:SetPreset>"#,
331 self.profile_token, preset_name
332 )),
333 PtzCommand::RemovePreset(preset_token) => Ok(format!(
334 r#"<tptz:RemovePreset xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
335 <tptz:ProfileToken>{}</tptz:ProfileToken>
336 <tptz:PresetToken>{}</tptz:PresetToken>
337 </tptz:RemovePreset>"#,
338 self.profile_token, preset_token
339 )),
340 PtzCommand::GotoHome => Ok(format!(
341 r#"<tptz:GotoHomePosition xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
342 <tptz:ProfileToken>{}</tptz:ProfileToken>
343 </tptz:GotoHomePosition>"#,
344 self.profile_token
345 )),
346 }
347 }
348}
349
350fn parse_position(xml: &str) -> Result<PtzPosition> {
351 let mut reader = Reader::from_str(xml);
352 reader.config_mut().trim_text(true);
353
354 let mut pan = 0.0;
355 let mut tilt = 0.0;
356 let mut zoom = 0.0;
357
358 let mut buf = Vec::new();
359
360 loop {
361 match reader.read_event_into(&mut buf) {
362 Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
363 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
364 let local_name = name.split(':').last().unwrap_or(&name).to_string();
365
366 if local_name == "PanTilt" {
367 for attr in e.attributes().filter_map(|a| a.ok()) {
368 let key = String::from_utf8_lossy(attr.key.as_ref());
369 let value = String::from_utf8_lossy(&attr.value);
370
371 if key == "x" {
372 pan = value.parse().unwrap_or(0.0);
373 } else if key == "y" {
374 tilt = value.parse().unwrap_or(0.0);
375 }
376 }
377 } else if local_name == "Zoom" {
378 for attr in e.attributes().filter_map(|a| a.ok()) {
379 let key = String::from_utf8_lossy(attr.key.as_ref());
380 let value = String::from_utf8_lossy(&attr.value);
381
382 if key == "x" {
383 zoom = value.parse().unwrap_or(0.0);
384 }
385 }
386 }
387 }
388 Ok(Event::Eof) => break,
389 Err(e) => return Err(PtzError::XmlParse(e.to_string())),
390 _ => {}
391 }
392 buf.clear();
393 }
394
395 Ok(PtzPosition { pan, tilt, zoom })
396}
397
398fn parse_presets(xml: &str) -> Result<Vec<PtzPreset>> {
399 let mut reader = Reader::from_str(xml);
400 reader.config_mut().trim_text(true);
401
402 let mut presets = Vec::new();
403 let mut current_preset: Option<PtzPreset> = None;
404 let mut current_element = String::new();
405 let mut buf = Vec::new();
406
407 loop {
408 match reader.read_event_into(&mut buf) {
409 Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
410 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
411 let local_name = name.split(':').last().unwrap_or(&name).to_string();
412
413 if local_name == "Preset" {
414 if let Some(token_attr) = e.attributes().filter_map(|a| a.ok()).find(|attr| {
416 let key = String::from_utf8_lossy(attr.key.as_ref());
417 key == "token"
418 }) {
419 let token = String::from_utf8_lossy(&token_attr.value).to_string();
420 current_preset = Some(PtzPreset::new(token, String::new()));
421 }
422 }
423
424 current_element = local_name;
425 }
426 Ok(Event::Text(e)) => {
427 let text = e
428 .unescape()
429 .map_err(|e| PtzError::XmlParse(e.to_string()))?
430 .to_string();
431
432 if let Some(ref mut preset) = current_preset {
433 if current_element == "Name" {
434 preset.name = text;
435 }
436 }
437 }
438 Ok(Event::End(ref e)) => {
439 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
440 let local_name = name.split(':').last().unwrap_or(&name).to_string();
441
442 if local_name == "Preset" {
443 if let Some(preset) = current_preset.take() {
444 presets.push(preset);
445 }
446 }
447 }
448 Ok(Event::Eof) => break,
449 Err(e) => return Err(PtzError::XmlParse(e.to_string())),
450 _ => {}
451 }
452 buf.clear();
453 }
454
455 Ok(presets)
456}
457
458fn parse_capabilities(xml: &str) -> Result<PtzCapabilities> {
459 let mut reader = Reader::from_str(xml);
460 reader.config_mut().trim_text(true);
461
462 let mut capabilities = PtzCapabilities::default();
463 let mut buf = Vec::new();
464
465 loop {
466 match reader.read_event_into(&mut buf) {
467 Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
468 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
469 let local_name = name.split(':').last().unwrap_or(&name).to_string();
470
471 if local_name == "PanTiltLimits" || local_name == "ZoomLimits" {
472 for attr in e.attributes().filter_map(|a| a.ok()) {
473 let key = String::from_utf8_lossy(attr.key.as_ref());
474 let value = String::from_utf8_lossy(&attr.value);
475
476 if local_name == "PanTiltLimits" {
477 if key == "min" {
478 let min = value.parse().unwrap_or(-1.0);
479 capabilities.pan_range.min = min;
480 capabilities.tilt_range.min = min;
481 } else if key == "max" {
482 let max = value.parse().unwrap_or(1.0);
483 capabilities.pan_range.max = max;
484 capabilities.tilt_range.max = max;
485 }
486 } else if local_name == "ZoomLimits" {
487 if key == "min" {
488 capabilities.zoom_range.min = value.parse().unwrap_or(0.0);
489 } else if key == "max" {
490 capabilities.zoom_range.max = value.parse().unwrap_or(1.0);
491 }
492 }
493 }
494 }
495 }
496 Ok(Event::Eof) => break,
497 Err(e) => return Err(PtzError::XmlParse(e.to_string())),
498 _ => {}
499 }
500 buf.clear();
501 }
502
503 capabilities.presets = true;
505
506 Ok(capabilities)
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn test_ptz_position_validate() {
515 let valid_pos = PtzPosition::new(0.5, -0.3, 0.8);
516 assert!(valid_pos.validate().is_ok());
517
518 let invalid_pan = PtzPosition::new(1.5, 0.0, 0.5);
519 assert!(invalid_pan.validate().is_err());
520
521 let invalid_tilt = PtzPosition::new(0.0, -1.5, 0.5);
522 assert!(invalid_tilt.validate().is_err());
523
524 let invalid_zoom = PtzPosition::new(0.0, 0.0, 1.5);
525 assert!(invalid_zoom.validate().is_err());
526 }
527
528 #[test]
529 fn test_ptz_controller_build_absolute_move() {
530 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
531
532 let position = PtzPosition::new(0.5, -0.3, 0.8);
533 let command = PtzCommand::AbsoluteMove(position);
534 let result = controller.build_command_body(&command);
535
536 assert!(result.is_ok());
537 let body = result.unwrap();
538 assert!(body.contains("AbsoluteMove"));
539 assert!(body.contains("profile_1"));
540 assert!(body.contains("0.5"));
541 assert!(body.contains("-0.3"));
542 assert!(body.contains("0.8"));
543 }
544
545 #[test]
546 fn test_ptz_controller_build_relative_move() {
547 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
548
549 let position = PtzPosition::new(0.1, 0.2, 0.0);
550 let command = PtzCommand::RelativeMove(position);
551 let result = controller.build_command_body(&command);
552
553 assert!(result.is_ok());
554 let body = result.unwrap();
555 assert!(body.contains("RelativeMove"));
556 assert!(body.contains("Translation"));
557 }
558
559 #[test]
560 fn test_ptz_controller_build_continuous_move() {
561 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
562
563 let velocity = PtzPosition::new(0.5, 0.5, 0.0);
564 let command = PtzCommand::ContinuousMove(velocity);
565 let result = controller.build_command_body(&command);
566
567 assert!(result.is_ok());
568 let body = result.unwrap();
569 assert!(body.contains("ContinuousMove"));
570 assert!(body.contains("Velocity"));
571 }
572
573 #[test]
574 fn test_ptz_controller_build_stop() {
575 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
576
577 let command = PtzCommand::Stop;
578 let result = controller.build_command_body(&command);
579
580 assert!(result.is_ok());
581 let body = result.unwrap();
582 assert!(body.contains("Stop"));
583 assert!(body.contains("PanTilt"));
584 assert!(body.contains("Zoom"));
585 }
586
587 #[test]
588 fn test_ptz_controller_build_goto_preset() {
589 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
590
591 let command = PtzCommand::GotoPreset("preset_1".to_string());
592 let result = controller.build_command_body(&command);
593
594 assert!(result.is_ok());
595 let body = result.unwrap();
596 assert!(body.contains("GotoPreset"));
597 assert!(body.contains("preset_1"));
598 }
599
600 #[test]
601 fn test_ptz_controller_build_set_preset() {
602 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
603
604 let command = PtzCommand::SetPreset("MyPreset".to_string());
605 let result = controller.build_command_body(&command);
606
607 assert!(result.is_ok());
608 let body = result.unwrap();
609 assert!(body.contains("SetPreset"));
610 assert!(body.contains("MyPreset"));
611 }
612
613 #[test]
614 fn test_ptz_controller_build_goto_home() {
615 let controller = PtzController::new("http://192.168.1.100/onvif/ptz", "profile_1", None);
616
617 let command = PtzCommand::GotoHome;
618 let result = controller.build_command_body(&command);
619
620 assert!(result.is_ok());
621 let body = result.unwrap();
622 assert!(body.contains("GotoHomePosition"));
623 }
624
625 #[test]
626 fn test_parse_position() {
627 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
628<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
629 <SOAP-ENV:Body>
630 <tptz:GetStatusResponse>
631 <tptz:PTZStatus>
632 <tt:Position>
633 <tt:PanTilt x="0.5" y="-0.3"/>
634 <tt:Zoom x="0.8"/>
635 </tt:Position>
636 </tptz:PTZStatus>
637 </tptz:GetStatusResponse>
638 </SOAP-ENV:Body>
639</SOAP-ENV:Envelope>"#;
640
641 let result = parse_position(xml);
642 assert!(result.is_ok());
643
644 let position = result.unwrap();
645 assert_eq!(position.pan, 0.5);
646 assert_eq!(position.tilt, -0.3);
647 assert_eq!(position.zoom, 0.8);
648 }
649
650 #[test]
651 fn test_parse_presets() {
652 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
653<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
654 <SOAP-ENV:Body>
655 <tptz:GetPresetsResponse>
656 <tptz:Preset token="preset_1">
657 <tt:Name>Home</tt:Name>
658 </tptz:Preset>
659 <tptz:Preset token="preset_2">
660 <tt:Name>Entrance</tt:Name>
661 </tptz:Preset>
662 </tptz:GetPresetsResponse>
663 </SOAP-ENV:Body>
664</SOAP-ENV:Envelope>"#;
665
666 let result = parse_presets(xml);
667 assert!(result.is_ok());
668
669 let presets = result.unwrap();
670 assert_eq!(presets.len(), 2);
671 assert_eq!(presets[0].token, "preset_1");
672 assert_eq!(presets[0].name, "Home");
673 assert_eq!(presets[1].token, "preset_2");
674 assert_eq!(presets[1].name, "Entrance");
675 }
676
677 #[test]
678 fn test_ptz_range_default() {
679 let range = PtzRange::default();
680 assert_eq!(range.min, -1.0);
681 assert_eq!(range.max, 1.0);
682 }
683
684 #[test]
685 fn test_ptz_capabilities_default() {
686 let caps = PtzCapabilities::default();
687 assert_eq!(caps.pan_range.min, -1.0);
688 assert_eq!(caps.pan_range.max, 1.0);
689 assert_eq!(caps.zoom_range.min, 0.0);
690 assert_eq!(caps.zoom_range.max, 1.0);
691 assert!(!caps.presets);
692 }
693
694 #[test]
695 fn test_parse_capabilities() {
696 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
697<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
698 <SOAP-ENV:Body>
699 <tptz:GetConfigurationOptionsResponse>
700 <tptz:PTZConfigurationOptions>
701 <tt:Spaces>
702 <tt:PanTiltLimits min="-1.0" max="1.0"/>
703 <tt:ZoomLimits min="0.0" max="1.0"/>
704 </tt:Spaces>
705 </tptz:PTZConfigurationOptions>
706 </tptz:GetConfigurationOptionsResponse>
707 </SOAP-ENV:Body>
708</SOAP-ENV:Envelope>"#;
709
710 let result = parse_capabilities(xml);
711 assert!(result.is_ok());
712
713 let caps = result.unwrap();
714 assert!(caps.presets);
715 }
716}