Skip to main content

camgrab_core/onvif/
ptz.rs

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                    // Extract token attribute
415                    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    // Assume presets are supported if no error occurred
504    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}