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 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 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 writer
190 .write_event(Event::Start(BytesStart::new("s:Header")))
191 .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
192
193 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 writer
204 .write_event(Event::Start(BytesStart::new("s:Body")))
205 .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
206
207 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 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 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 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 writer
257 .write_event(Event::Start(BytesStart::new("wsse:UsernameToken")))
258 .map_err(|e| DeviceError::XmlParse(e.to_string()))?;
259
260 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 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 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 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 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
476pub 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}