Skip to main content

camgrab_core/onvif/
device.rs

1use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
2use chrono::Utc;
3use quick_xml::events::{BytesStart, Event};
4use quick_xml::{Reader, Writer};
5use reqwest::Client;
6use sha1::{Digest, Sha1};
7use thiserror::Error;
8use uuid::Uuid;
9
10#[derive(Debug, Error)]
11pub enum DeviceError {
12    #[error("HTTP error: {0}")]
13    Http(#[from] reqwest::Error),
14
15    #[error("XML parsing error: {0}")]
16    XmlParse(String),
17
18    #[error("Invalid response format: {0}")]
19    InvalidResponse(String),
20
21    #[error("Authentication failed")]
22    AuthenticationFailed,
23
24    #[error("Endpoint not found")]
25    EndpointNotFound,
26
27    #[error("Profile not found")]
28    ProfileNotFound,
29}
30
31pub type Result<T> = std::result::Result<T, DeviceError>;
32
33#[derive(Debug, Clone, PartialEq)]
34pub struct DeviceInfo {
35    pub manufacturer: String,
36    pub model: String,
37    pub firmware_version: String,
38    pub serial_number: String,
39    pub hardware_id: String,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct MediaProfile {
44    pub token: String,
45    pub name: String,
46    pub video_source: Option<String>,
47    pub video_encoder: Option<String>,
48}
49
50#[derive(Debug, Clone)]
51pub struct DeviceService {
52    client: Client,
53}
54
55impl DeviceService {
56    pub fn new() -> Self {
57        Self {
58            client: Client::builder()
59                .timeout(std::time::Duration::from_secs(10))
60                .build()
61                .unwrap_or_default(),
62        }
63    }
64
65    pub async fn get_device_information(
66        &self,
67        endpoint: &str,
68        auth: Option<(&str, &str)>,
69    ) -> Result<DeviceInfo> {
70        let soap_body =
71            r#"<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>"#;
72        let envelope = build_soap_envelope(soap_body, auth)?;
73
74        let response = self
75            .client
76            .post(endpoint)
77            .header("Content-Type", "application/soap+xml; charset=utf-8")
78            .body(envelope)
79            .send()
80            .await?;
81
82        if !response.status().is_success() {
83            return Err(DeviceError::InvalidResponse(format!(
84                "HTTP status: {}",
85                response.status()
86            )));
87        }
88
89        let body = response.text().await?;
90        parse_device_information(&body)
91    }
92
93    pub async fn get_profiles(
94        &self,
95        endpoint: &str,
96        auth: Option<(&str, &str)>,
97    ) -> Result<Vec<MediaProfile>> {
98        let soap_body = r#"<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>"#;
99        let envelope = build_soap_envelope(soap_body, auth)?;
100
101        let response = self
102            .client
103            .post(endpoint)
104            .header("Content-Type", "application/soap+xml; charset=utf-8")
105            .body(envelope)
106            .send()
107            .await?;
108
109        if !response.status().is_success() {
110            return Err(DeviceError::InvalidResponse(format!(
111                "HTTP status: {}",
112                response.status()
113            )));
114        }
115
116        let body = response.text().await?;
117        parse_profiles(&body)
118    }
119
120    pub async fn get_stream_uri(
121        &self,
122        endpoint: &str,
123        profile_token: &str,
124        auth: Option<(&str, &str)>,
125    ) -> Result<String> {
126        let soap_body = format!(
127            r#"<trt:GetStreamUri xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
128                <trt:StreamSetup>
129                    <tt:Stream xmlns:tt="http://www.onvif.org/ver10/schema">RTP-Unicast</tt:Stream>
130                    <tt:Transport xmlns:tt="http://www.onvif.org/ver10/schema">
131                        <tt:Protocol>RTSP</tt:Protocol>
132                    </tt:Transport>
133                </trt:StreamSetup>
134                <trt:ProfileToken>{profile_token}</trt:ProfileToken>
135            </trt:GetStreamUri>"#
136        );
137
138        let envelope = build_soap_envelope(&soap_body, auth)?;
139
140        let response = self
141            .client
142            .post(endpoint)
143            .header("Content-Type", "application/soap+xml; charset=utf-8")
144            .body(envelope)
145            .send()
146            .await?;
147
148        if !response.status().is_success() {
149            return Err(DeviceError::InvalidResponse(format!(
150                "HTTP status: {}",
151                response.status()
152            )));
153        }
154
155        let body = response.text().await?;
156        parse_stream_uri(&body)
157    }
158}
159
160impl Default for DeviceService {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166pub(super) fn build_soap_envelope(body: &str, auth: Option<(&str, &str)>) -> Result<String> {
167    let mut writer = Writer::new(Vec::new());
168
169    // XML declaration
170    writer
171        .write_event(Event::Decl(quick_xml::events::BytesDecl::new(
172            "1.0",
173            Some("UTF-8"),
174            None,
175        )))
176        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
177
178    // Envelope
179    let mut envelope = BytesStart::new("s:Envelope");
180    envelope.push_attribute(("xmlns:s", "http://www.w3.org/2003/05/soap-envelope"));
181    envelope.push_attribute(("xmlns:tds", "http://www.onvif.org/ver10/device/wsdl"));
182    envelope.push_attribute(("xmlns:trt", "http://www.onvif.org/ver10/media/wsdl"));
183    envelope.push_attribute(("xmlns:tt", "http://www.onvif.org/ver10/schema"));
184    writer
185        .write_event(Event::Start(envelope))
186        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
187
188    // Header
189    writer
190        .write_event(Event::Start(BytesStart::new("s:Header")))
191        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
192
193    // Add WS-Security if authentication is provided
194    if let Some((username, password)) = auth {
195        write_security_header(&mut writer, username, password)?;
196    }
197
198    writer
199        .write_event(Event::End(BytesStart::new("s:Header").to_end()))
200        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
201
202    // Body
203    writer
204        .write_event(Event::Start(BytesStart::new("s:Body")))
205        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
206
207    // Write the actual SOAP body content
208    writer
209        .write_event(Event::Text(quick_xml::events::BytesText::new(body)))
210        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
211
212    writer
213        .write_event(Event::End(BytesStart::new("s:Body").to_end()))
214        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
215
216    writer
217        .write_event(Event::End(BytesStart::new("s:Envelope").to_end()))
218        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
219
220    String::from_utf8(writer.into_inner()).map_err(|e| DeviceError::XmlParse(e.to_string()))
221}
222
223fn write_security_header(
224    writer: &mut Writer<Vec<u8>>,
225    username: &str,
226    password: &str,
227) -> Result<()> {
228    // Generate nonce and timestamp
229    let nonce_bytes = Uuid::new_v4().as_bytes().to_vec();
230    let nonce_b64 = BASE64.encode(&nonce_bytes);
231    let created = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
232
233    // Calculate password digest: Base64(SHA1(nonce + created + password))
234    let mut hasher = Sha1::new();
235    hasher.update(&nonce_bytes);
236    hasher.update(created.as_bytes());
237    hasher.update(password.as_bytes());
238    let digest = hasher.finalize();
239    let digest_b64 = BASE64.encode(digest);
240
241    // Security header
242    let mut security = BytesStart::new("wsse:Security");
243    security.push_attribute((
244        "xmlns:wsse",
245        "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd",
246    ));
247    security.push_attribute((
248        "xmlns:wsu",
249        "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd",
250    ));
251    writer
252        .write_event(Event::Start(security))
253        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
254
255    // UsernameToken
256    writer
257        .write_event(Event::Start(BytesStart::new("wsse:UsernameToken")))
258        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
259
260    // Username
261    writer
262        .write_event(Event::Start(BytesStart::new("wsse:Username")))
263        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
264    writer
265        .write_event(Event::Text(quick_xml::events::BytesText::new(username)))
266        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
267    writer
268        .write_event(Event::End(BytesStart::new("wsse:Username").to_end()))
269        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
270
271    // Password
272    let mut password_elem = BytesStart::new("wsse:Password");
273    password_elem.push_attribute(("Type", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest"));
274    writer
275        .write_event(Event::Start(password_elem))
276        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
277    writer
278        .write_event(Event::Text(quick_xml::events::BytesText::new(&digest_b64)))
279        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
280    writer
281        .write_event(Event::End(BytesStart::new("wsse:Password").to_end()))
282        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
283
284    // Nonce
285    let mut nonce_elem = BytesStart::new("wsse:Nonce");
286    nonce_elem.push_attribute(("EncodingType", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary"));
287    writer
288        .write_event(Event::Start(nonce_elem))
289        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
290    writer
291        .write_event(Event::Text(quick_xml::events::BytesText::new(&nonce_b64)))
292        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
293    writer
294        .write_event(Event::End(BytesStart::new("wsse:Nonce").to_end()))
295        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
296
297    // Created
298    writer
299        .write_event(Event::Start(BytesStart::new("wsu:Created")))
300        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
301    writer
302        .write_event(Event::Text(quick_xml::events::BytesText::new(&created)))
303        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
304    writer
305        .write_event(Event::End(BytesStart::new("wsu:Created").to_end()))
306        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
307
308    writer
309        .write_event(Event::End(BytesStart::new("wsse:UsernameToken").to_end()))
310        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
311
312    writer
313        .write_event(Event::End(BytesStart::new("wsse:Security").to_end()))
314        .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
315
316    Ok(())
317}
318
319fn parse_device_information(xml: &str) -> Result<DeviceInfo> {
320    let mut reader = Reader::from_str(xml);
321    reader.config_mut().trim_text(true);
322
323    let mut manufacturer = String::new();
324    let mut model = String::new();
325    let mut firmware_version = String::new();
326    let mut serial_number = String::new();
327    let mut hardware_id = String::new();
328
329    let mut buf = Vec::new();
330    let mut current_element = String::new();
331
332    loop {
333        match reader.read_event_into(&mut buf) {
334            Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
335                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
336                current_element = name.split(':').last().unwrap_or(&name).to_string();
337            }
338            Ok(Event::Text(e)) => {
339                let text = e
340                    .unescape()
341                    .map_err(|e| DeviceError::XmlParse(e.to_string()))?
342                    .to_string();
343
344                match current_element.as_str() {
345                    "Manufacturer" => manufacturer = text,
346                    "Model" => model = text,
347                    "FirmwareVersion" => firmware_version = text,
348                    "SerialNumber" => serial_number = text,
349                    "HardwareId" => hardware_id = text,
350                    _ => {}
351                }
352            }
353            Ok(Event::Eof) => break,
354            Err(e) => return Err(DeviceError::XmlParse(e.to_string())),
355            _ => {}
356        }
357        buf.clear();
358    }
359
360    Ok(DeviceInfo {
361        manufacturer,
362        model,
363        firmware_version,
364        serial_number,
365        hardware_id,
366    })
367}
368
369fn parse_profiles(xml: &str) -> Result<Vec<MediaProfile>> {
370    let mut reader = Reader::from_str(xml);
371    reader.config_mut().trim_text(true);
372
373    let mut profiles = Vec::new();
374    let mut current_profile: Option<MediaProfile> = None;
375    let mut current_element = String::new();
376    let mut buf = Vec::new();
377
378    loop {
379        match reader.read_event_into(&mut buf) {
380            Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
381                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
382                let local_name = name.split(':').last().unwrap_or(&name).to_string();
383
384                if local_name == "Profiles" {
385                    // Extract token attribute
386                    if let Some(token_attr) = e.attributes().filter_map(|a| a.ok()).find(|attr| {
387                        let key = String::from_utf8_lossy(attr.key.as_ref());
388                        key.ends_with("token") || key == "token"
389                    }) {
390                        let token = String::from_utf8_lossy(&token_attr.value).to_string();
391                        current_profile = Some(MediaProfile {
392                            token,
393                            name: String::new(),
394                            video_source: None,
395                            video_encoder: None,
396                        });
397                    }
398                }
399
400                current_element = local_name;
401            }
402            Ok(Event::Text(e)) => {
403                let text = e
404                    .unescape()
405                    .map_err(|e| DeviceError::XmlParse(e.to_string()))?
406                    .to_string();
407
408                if let Some(ref mut profile) = current_profile {
409                    match current_element.as_str() {
410                        "Name" => profile.name = text,
411                        "SourceToken" => profile.video_source = Some(text),
412                        "Encoding" => profile.video_encoder = Some(text),
413                        _ => {}
414                    }
415                }
416            }
417            Ok(Event::End(ref e)) => {
418                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
419                let local_name = name.split(':').last().unwrap_or(&name).to_string();
420
421                if local_name == "Profiles" {
422                    if let Some(profile) = current_profile.take() {
423                        profiles.push(profile);
424                    }
425                }
426            }
427            Ok(Event::Eof) => break,
428            Err(e) => return Err(DeviceError::XmlParse(e.to_string())),
429            _ => {}
430        }
431        buf.clear();
432    }
433
434    Ok(profiles)
435}
436
437fn parse_stream_uri(xml: &str) -> Result<String> {
438    let mut reader = Reader::from_str(xml);
439    reader.config_mut().trim_text(true);
440
441    let mut uri = String::new();
442    let mut buf = Vec::new();
443    let mut current_element = String::new();
444
445    loop {
446        match reader.read_event_into(&mut buf) {
447            Ok(Event::Start(ref e) | Event::Empty(ref e)) => {
448                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
449                current_element = name.split(':').last().unwrap_or(&name).to_string();
450            }
451            Ok(Event::Text(e)) => {
452                if current_element == "Uri" {
453                    uri = e
454                        .unescape()
455                        .map_err(|e| DeviceError::XmlParse(e.to_string()))?
456                        .to_string();
457                    break;
458                }
459            }
460            Ok(Event::Eof) => break,
461            Err(e) => return Err(DeviceError::XmlParse(e.to_string())),
462            _ => {}
463        }
464        buf.clear();
465    }
466
467    if uri.is_empty() {
468        return Err(DeviceError::InvalidResponse(
469            "No URI found in response".to_string(),
470        ));
471    }
472
473    Ok(uri)
474}
475
476// Convenience functions for backward compatibility
477pub async fn get_device_information(
478    endpoint: &str,
479    auth: Option<(&str, &str)>,
480) -> Result<DeviceInfo> {
481    DeviceService::new()
482        .get_device_information(endpoint, auth)
483        .await
484}
485
486pub async fn get_profiles(endpoint: &str, auth: Option<(&str, &str)>) -> Result<Vec<MediaProfile>> {
487    DeviceService::new().get_profiles(endpoint, auth).await
488}
489
490pub async fn get_stream_uri(
491    endpoint: &str,
492    profile_token: &str,
493    auth: Option<(&str, &str)>,
494) -> Result<String> {
495    DeviceService::new()
496        .get_stream_uri(endpoint, profile_token, auth)
497        .await
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn test_build_soap_envelope_no_auth() {
506        let body =
507            r#"<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>"#;
508        let result = build_soap_envelope(body, None);
509        assert!(result.is_ok());
510
511        let envelope = result.unwrap();
512        assert!(envelope.contains("s:Envelope"));
513        assert!(envelope.contains("s:Header"));
514        assert!(envelope.contains("s:Body"));
515        assert!(envelope.contains("GetDeviceInformation"));
516        assert!(!envelope.contains("wsse:Security"));
517    }
518
519    #[test]
520    fn test_build_soap_envelope_with_auth() {
521        let body =
522            r#"<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>"#;
523        let result = build_soap_envelope(body, Some(("admin", "password123")));
524        assert!(result.is_ok());
525
526        let envelope = result.unwrap();
527        assert!(envelope.contains("wsse:Security"));
528        assert!(envelope.contains("wsse:UsernameToken"));
529        assert!(envelope.contains("wsse:Username"));
530        assert!(envelope.contains("wsse:Password"));
531        assert!(envelope.contains("wsse:Nonce"));
532        assert!(envelope.contains("wsu:Created"));
533        assert!(envelope.contains("admin"));
534    }
535
536    #[test]
537    fn test_parse_device_information() {
538        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
539<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
540    <SOAP-ENV:Body>
541        <tds:GetDeviceInformationResponse>
542            <tds:Manufacturer>Acme Corp</tds:Manufacturer>
543            <tds:Model>Camera 3000</tds:Model>
544            <tds:FirmwareVersion>1.2.3</tds:FirmwareVersion>
545            <tds:SerialNumber>SN123456</tds:SerialNumber>
546            <tds:HardwareId>HW-001</tds:HardwareId>
547        </tds:GetDeviceInformationResponse>
548    </SOAP-ENV:Body>
549</SOAP-ENV:Envelope>"#;
550
551        let result = parse_device_information(xml);
552        assert!(result.is_ok());
553
554        let info = result.unwrap();
555        assert_eq!(info.manufacturer, "Acme Corp");
556        assert_eq!(info.model, "Camera 3000");
557        assert_eq!(info.firmware_version, "1.2.3");
558        assert_eq!(info.serial_number, "SN123456");
559        assert_eq!(info.hardware_id, "HW-001");
560    }
561
562    #[test]
563    fn test_parse_profiles() {
564        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
565<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
566    <SOAP-ENV:Body>
567        <trt:GetProfilesResponse>
568            <trt:Profiles token="profile_1">
569                <tt:Name>MainStream</tt:Name>
570                <tt:VideoSourceConfiguration>
571                    <tt:SourceToken>video_source_1</tt:SourceToken>
572                </tt:VideoSourceConfiguration>
573                <tt:VideoEncoderConfiguration>
574                    <tt:Encoding>H264</tt:Encoding>
575                </tt:VideoEncoderConfiguration>
576            </trt:Profiles>
577            <trt:Profiles token="profile_2">
578                <tt:Name>SubStream</tt:Name>
579            </trt:Profiles>
580        </trt:GetProfilesResponse>
581    </SOAP-ENV:Body>
582</SOAP-ENV:Envelope>"#;
583
584        let result = parse_profiles(xml);
585        assert!(result.is_ok());
586
587        let profiles = result.unwrap();
588        assert_eq!(profiles.len(), 2);
589
590        assert_eq!(profiles[0].token, "profile_1");
591        assert_eq!(profiles[0].name, "MainStream");
592
593        assert_eq!(profiles[1].token, "profile_2");
594        assert_eq!(profiles[1].name, "SubStream");
595    }
596
597    #[test]
598    fn test_parse_stream_uri() {
599        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
600<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
601    <SOAP-ENV:Body>
602        <trt:GetStreamUriResponse>
603            <trt:MediaUri>
604                <tt:Uri>rtsp://192.168.1.100:554/stream1</tt:Uri>
605            </trt:MediaUri>
606        </trt:GetStreamUriResponse>
607    </SOAP-ENV:Body>
608</SOAP-ENV:Envelope>"#;
609
610        let result = parse_stream_uri(xml);
611        assert!(result.is_ok());
612
613        let uri = result.unwrap();
614        assert_eq!(uri, "rtsp://192.168.1.100:554/stream1");
615    }
616
617    #[test]
618    fn test_parse_stream_uri_missing() {
619        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
620<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
621    <SOAP-ENV:Body>
622        <trt:GetStreamUriResponse>
623        </trt:GetStreamUriResponse>
624    </SOAP-ENV:Body>
625</SOAP-ENV:Envelope>"#;
626
627        let result = parse_stream_uri(xml);
628        assert!(result.is_err());
629    }
630}