1use std::str::Split;
2use std::time::Duration;
3
4use crate::types::{Action, Argument, Container, Device, Item, Metadata, Service, TransportInfo};
5use anyhow::{anyhow, Result};
6use elementtree::Element;
7use surf::{http::Method, Client, Config, Url};
8use xml::reader::XmlEvent;
9use xml::EventReader;
10
11pub async fn parse_location(location: &str) -> Result<Device> {
12 let client: Client = Config::new()
13 .set_timeout(Some(Duration::from_secs(5)))
14 .try_into()?;
15 let req = surf::Request::new(Method::Get, location.parse()?);
16 let xml_root = client
17 .recv_string(req)
18 .await
19 .map_err(|e| anyhow!("Failed to retrieve xml from device endpoint: {}", e))?;
20
21 let mut device = Device {
22 location: location.to_string(),
23 ..Default::default()
24 };
25
26 device.device_type = parse_attribute(
27 &xml_root,
28 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}deviceType",
29 )?;
30
31 device.device_type = parse_attribute(
32 &xml_root,
33 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}deviceType",
34 )?;
35 device.friendly_name = parse_attribute(
36 &xml_root,
37 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}friendlyName",
38 )?;
39 device.manufacturer = parse_attribute(
40 &xml_root,
41 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}manufacturer",
42 )?;
43 device.manufacturer_url = match parse_attribute(
44 &xml_root,
45 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}manufacturerURL",
46 )? {
47 url if url.is_empty() => None,
48 url => Some(url),
49 };
50 device.model_description = match parse_attribute(
51 &xml_root,
52 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}modelDescription",
53 )? {
54 description if description.is_empty() => None,
55 description => Some(description),
56 };
57 device.model_name = parse_attribute(
58 &xml_root,
59 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}modelName",
60 )?;
61 device.model_number = match parse_attribute(
62 &xml_root,
63 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}modelNumber",
64 )? {
65 number if number.is_empty() => None,
66 number => Some(number),
67 };
68 device.udn = parse_attribute(
69 &xml_root,
70 "{urn:schemas-upnp-org:device-1-0}device/{urn:schemas-upnp-org:device-1-0}UDN",
71 )?;
72
73 let base_url = location.split('/').take(3).collect::<Vec<&str>>().join("/");
74 device.services = parse_services(&base_url, &xml_root).await?;
75
76 Ok(device)
77}
78
79fn parse_attribute(xml_root: &str, xml_name: &str) -> Result<String> {
80 let root = Element::from_reader(xml_root.as_bytes())?;
81 let mut xml_name = xml_name.split('/');
82 match root.find(
83 xml_name
84 .next()
85 .ok_or_else(|| anyhow!("xml_name ended unexpectedly"))?,
86 ) {
87 Some(element) => {
88 let element = element.find(
89 xml_name
90 .next()
91 .ok_or_else(|| anyhow!("xml_name ended unexpectedly"))?,
92 );
93 match element {
94 Some(element) => {
95 return Ok(element.text().to_string());
96 }
97 None => Ok("".to_string()),
98 }
99 }
100 None => Ok("".to_string()),
101 }
102}
103
104pub async fn parse_services(base_url: &str, xml_root: &str) -> Result<Vec<Service>> {
105 let root = Element::from_reader(xml_root.as_bytes())?;
106 let device = root
107 .find("{urn:schemas-upnp-org:device-1-0}device")
108 .ok_or_else(|| anyhow!("Invalid response from device"))?;
109
110 let mut services_with_actions: Vec<Service> = vec![];
111 if let Some(service_list) = device.find("{urn:schemas-upnp-org:device-1-0}serviceList") {
112 let xml_services = service_list.children();
113
114 let mut services = Vec::new();
115 for xml_service in xml_services {
116 let mut service = Service {
117 service_type: xml_service
118 .find("{urn:schemas-upnp-org:device-1-0}serviceType")
119 .ok_or_else(|| anyhow!("Service missing serviceType"))?
120 .text()
121 .to_string(),
122 service_id: xml_service
123 .find("{urn:schemas-upnp-org:device-1-0}serviceId")
124 .ok_or_else(|| anyhow!("Service missing serviceId"))?
125 .text()
126 .to_string(),
127 control_url: xml_service
128 .find("{urn:schemas-upnp-org:device-1-0}controlURL")
129 .ok_or_else(|| anyhow!("Service missing controlURL"))?
130 .text()
131 .to_string(),
132 event_sub_url: xml_service
133 .find("{urn:schemas-upnp-org:device-1-0}eventSubURL")
134 .ok_or_else(|| anyhow!("Service missing eventSubURL"))?
135 .text()
136 .to_string(),
137 scpd_url: xml_service
138 .find("{urn:schemas-upnp-org:device-1-0}SCPDURL")
139 .ok_or_else(|| anyhow!("Service missing SCPDURL"))?
140 .text()
141 .to_string(),
142 actions: vec![],
143 };
144
145 service.control_url = build_absolute_url(base_url, &service.control_url)?;
146 service.event_sub_url = build_absolute_url(base_url, &service.event_sub_url)?;
147 service.scpd_url = build_absolute_url(base_url, &service.scpd_url)?;
148
149 services.push(service);
150 }
151
152 for service in &services {
153 let mut service = service.clone();
154 service.actions = parse_service_description(&service.scpd_url).await?;
155 services_with_actions.push(service);
156 }
157 }
158
159 Ok(services_with_actions)
160}
161
162fn build_absolute_url(base_url: &str, relative_url: &str) -> Result<String> {
163 let base_url = Url::parse(base_url)?;
164 Ok(base_url.join(relative_url)?.to_string())
165}
166
167pub async fn parse_service_description(scpd_url: &str) -> Result<Vec<Action>> {
168 let client: Client = Config::new()
169 .set_timeout(Some(Duration::from_secs(5)))
170 .try_into()?;
171 let req = surf::Request::new(Method::Get, scpd_url.parse()?);
172
173 let xml_root = client
174 .recv_string(req)
175 .await
176 .map_err(|e| anyhow!("Failed to retrieve xml response from device: {}", e))?;
177 let root = Element::from_reader(xml_root.as_bytes())?;
178
179 let action_list = match root.find("{urn:schemas-upnp-org:service-1-0}actionList") {
180 Some(action_list) => action_list,
181 None => return Ok(vec![]),
182 };
183
184 let mut actions = Vec::new();
185 for xml_action in action_list.children() {
186 let mut action = Action {
187 name: xml_action
188 .find("{urn:schemas-upnp-org:service-1-0}name")
189 .ok_or_else(|| anyhow!("Service::Action missing name"))?
190 .text()
191 .to_string(),
192 arguments: vec![],
193 };
194
195 if let Some(arguments) = xml_action.find("{urn:schemas-upnp-org:service-1-0}argumentList") {
196 for xml_argument in arguments.children() {
197 let argument = Argument {
198 name: xml_argument
199 .find("{urn:schemas-upnp-org:service-1-0}name")
200 .ok_or_else(|| anyhow!("Service::Action::Argument missing name"))?
201 .text()
202 .to_string(),
203 direction: xml_argument
204 .find("{urn:schemas-upnp-org:service-1-0}direction")
205 .ok_or_else(|| anyhow!("Service::Action::Argument missing direction"))?
206 .text()
207 .to_string(),
208 related_state_variable: xml_argument
209 .find("{urn:schemas-upnp-org:service-1-0}relatedStateVariable")
210 .ok_or_else(|| {
211 anyhow!("Service::Action::Argument missing relatedStateVariable")
212 })?
213 .text()
214 .to_string(),
215 };
216 action.arguments.push(argument);
217 }
218 }
219 actions.push(action);
220 }
221 Ok(actions)
222}
223
224pub fn parse_volume(xml_root: &str) -> Result<u8> {
225 let parser = EventReader::from_str(xml_root);
226 let mut in_current_volume = false;
227 let mut current_volume: Option<u8> = None;
228 for e in parser {
229 match e {
230 Ok(XmlEvent::StartElement { name, .. }) => {
231 if name.local_name == "CurrentVolume" {
232 in_current_volume = true;
233 }
234 }
235 Ok(XmlEvent::EndElement { name }) => {
236 if name.local_name == "CurrentVolume" {
237 in_current_volume = false;
238 }
239 }
240 Ok(XmlEvent::Characters(volume)) => {
241 if in_current_volume {
242 current_volume = Some(volume.parse()?);
243 }
244 }
245 _ => {}
246 }
247 }
248 current_volume.ok_or_else(|| anyhow!("Invalid response from device"))
249}
250
251pub fn parse_duration(xml_root: &str) -> Result<u32> {
252 let parser = EventReader::from_str(xml_root);
253 let mut in_duration = false;
254 let mut duration: Option<String> = None;
255 for e in parser {
256 match e {
257 Ok(XmlEvent::StartElement { name, .. }) => {
258 if name.local_name == "MediaDuration" {
259 in_duration = true;
260 }
261 }
262 Ok(XmlEvent::EndElement { name }) => {
263 if name.local_name == "MediaDuration" {
264 in_duration = false;
265 }
266 }
267 Ok(XmlEvent::Characters(duration_str)) => {
268 if in_duration {
269 duration = Some(duration_str);
270 }
271 }
272 _ => {}
273 }
274 }
275
276 let duration = duration.ok_or_else(|| anyhow!("Invalid response from device"))?;
277 let mut duration_iter = duration.split(":");
278 let hours = duration_iter.next().unwrap_or("0").parse::<u32>()?;
279 let minutes = duration_iter.next().unwrap_or("0").parse::<u32>()?;
280 let seconds = duration_iter.next().unwrap_or("0").parse::<u32>()?;
281 Ok(hours * 3600 + minutes * 60 + seconds)
282}
283
284pub fn parse_position(xml_root: &str) -> Result<u32> {
285 let parser = EventReader::from_str(xml_root);
286 let mut in_position = false;
287 let mut position_iter: Split<'_, &str>;
288 let mut position: Option<String> = None;
289 for e in parser {
290 match e {
291 Ok(XmlEvent::StartElement { name, .. }) => {
292 if name.local_name == "RelTime" {
293 in_position = true;
294 }
295 }
296 Ok(XmlEvent::EndElement { name }) => {
297 if name.local_name == "RelTime" {
298 in_position = false;
299 }
300 }
301 Ok(XmlEvent::Characters(position_str)) => {
302 if in_position {
303 position = Some(position_str);
304 }
305 }
306 _ => {}
307 }
308 }
309
310 let position = position.ok_or_else(|| anyhow!("Invalid response from device"))?;
311 position_iter = position.split(":");
312 let hours = position_iter.next().unwrap_or("0").parse::<u32>()?;
313 let minutes = position_iter.next().unwrap_or("0").parse::<u32>()?;
314 let seconds = position_iter.next().unwrap_or("0").parse::<u32>()?;
315 Ok(hours * 3600 + minutes * 60 + seconds)
316}
317
318pub fn parse_supported_protocols(xml_root: &str) -> Result<Vec<String>> {
319 let parser = EventReader::from_str(xml_root);
320 let mut in_protocol = false;
321 let mut protocols: String = "".to_string();
322 for e in parser {
323 match e {
324 Ok(XmlEvent::StartElement { name, .. }) => {
325 if name.local_name == "Sink" {
326 in_protocol = true;
327 }
328 }
329 Ok(XmlEvent::EndElement { name }) => {
330 if name.local_name == "Sink" {
331 in_protocol = false;
332 }
333 }
334 Ok(XmlEvent::Characters(protocol)) => {
335 if in_protocol {
336 protocols = protocol;
337 }
338 }
339 _ => {}
340 }
341 }
342 Ok(protocols.split(',').map(|s| s.to_string()).collect())
343}
344
345pub fn parse_last_change(xml_root: &str) -> Result<Option<String>> {
346 let parser = EventReader::from_str(xml_root);
347 let mut result = None;
348 let mut in_last_change = false;
349 for e in parser {
350 match e {
351 Ok(XmlEvent::StartElement { name, .. }) => {
352 if name.local_name == "LastChange" {
353 in_last_change = true;
354 }
355 }
356 Ok(XmlEvent::EndElement { name }) => {
357 if name.local_name == "LastChange" {
358 in_last_change = false;
359 }
360 }
361 Ok(XmlEvent::Characters(last_change)) => {
362 if in_last_change {
363 result = Some(last_change);
364 }
365 }
366 _ => {}
367 }
368 }
369 Ok(result)
370}
371
372pub fn parse_current_play_mode(xml_root: &str) -> Result<Option<String>> {
373 let parser = EventReader::from_str(xml_root);
374 let mut current_play_mode: Option<String> = None;
375 for e in parser.into_iter().flatten() {
376 if let XmlEvent::StartElement {
377 name, attributes, ..
378 } = e
379 {
380 if name.local_name == "CurrentPlayMode" {
381 for attr in attributes {
382 if attr.name.local_name == "val" {
383 current_play_mode = Some(attr.value);
384 }
385 }
386 }
387 }
388 }
389 Ok(current_play_mode)
390}
391
392pub fn parse_transport_state(xml_root: &str) -> Result<Option<String>> {
393 let parser = EventReader::from_str(xml_root);
394 let mut transport_state: Option<String> = None;
395 for e in parser.into_iter().flatten() {
396 if let XmlEvent::StartElement {
397 name, attributes, ..
398 } = e
399 {
400 if name.local_name == "TransportState" {
401 for attr in attributes {
402 if attr.name.local_name == "val" {
403 transport_state = Some(attr.value);
404 }
405 }
406 }
407 }
408 }
409 Ok(transport_state)
410}
411
412pub fn parse_av_transport_uri_metadata(xml_root: &str) -> Result<Option<String>> {
413 let parser = EventReader::from_str(xml_root);
414 let mut av_transport_uri_metadata: Option<String> = None;
415 for e in parser.into_iter().flatten() {
416 if let XmlEvent::StartElement {
417 name, attributes, ..
418 } = e
419 {
420 if name.local_name == "AVTransportURIMetaData" {
421 for attr in attributes {
422 if attr.name.local_name == "val" {
423 av_transport_uri_metadata = Some(attr.value);
424 }
425 }
426 }
427 }
428 }
429 Ok(av_transport_uri_metadata)
430}
431
432pub fn parse_current_track_metadata(xml_root: &str) -> Result<Option<String>> {
433 let parser = EventReader::from_str(xml_root);
434 let mut current_track_metadata: Option<String> = None;
435 for e in parser.into_iter().flatten() {
436 if let XmlEvent::StartElement {
437 name, attributes, ..
438 } = e
439 {
440 if name.local_name == "CurrentTrackMetaData" {
441 for attr in attributes {
442 if attr.name.local_name == "val" {
443 current_track_metadata = Some(attr.value);
444 }
445 }
446 }
447 }
448 }
449 Ok(current_track_metadata)
450}
451
452pub fn deserialize_metadata(xml: &str) -> Result<Metadata> {
453 let parser = EventReader::from_str(xml);
454 let mut in_title = false;
455 let mut in_artist = false;
456 let mut in_album = false;
457 let mut in_album_art = false;
458 let mut title: Option<String> = None;
459 let mut artist: Option<String> = None;
460 let mut album: Option<String> = None;
461 let mut album_art: Option<String> = None;
462 let mut url: String = String::from("");
463
464 for e in parser {
465 match e {
466 Ok(XmlEvent::StartElement {
467 name, attributes, ..
468 }) => {
469 if name.local_name == "item" {
470 for attr in attributes {
471 if attr.name.local_name == "id" {
472 url = attr.value;
473 }
474 }
475 }
476 if name.local_name == "title" {
477 in_title = true;
478 }
479 if name.local_name == "artist" {
480 in_artist = true;
481 }
482 if name.local_name == "album" {
483 in_album = true;
484 }
485 if name.local_name == "albumArtURI" {
486 in_album_art = true;
487 }
488 }
489 Ok(XmlEvent::EndElement { name }) => {
490 if name.local_name == "title" {
491 in_title = false;
492 }
493 if name.local_name == "artist" {
494 in_artist = false;
495 }
496 if name.local_name == "album" {
497 in_album = false;
498 }
499 if name.local_name == "albumArtURI" {
500 in_album_art = false;
501 }
502 }
503 Ok(XmlEvent::Characters(value)) => {
504 if in_title {
505 title = Some(value.clone());
506 }
507 if in_artist {
508 artist = Some(value.clone());
509 }
510 if in_album {
511 album = Some(value.clone());
512 }
513 if in_album_art {
514 album_art = Some(value.clone());
515 }
516 }
517 _ => {}
518 }
519 }
520 Ok(Metadata {
521 title: title.unwrap_or_default(),
522 artist,
523 album,
524 album_art_uri: album_art,
525 url,
526 ..Default::default()
527 })
528}
529
530pub fn parse_browse_response(xml: &str, ip: &str) -> Result<(Vec<Container>, Vec<Item>)> {
531 let parser = EventReader::from_str(xml);
532 let mut in_result = false;
533 let mut result: (Vec<Container>, Vec<Item>) = (Vec::new(), Vec::new());
534
535 for e in parser {
536 match e {
537 Ok(XmlEvent::StartElement { name, .. }) => {
538 if name.local_name == "Result" {
539 in_result = true;
540 }
541 }
542 Ok(XmlEvent::EndElement { name }) => {
543 if name.local_name == "Result" {
544 in_result = false;
545 }
546 }
547 Ok(XmlEvent::Characters(value)) => {
548 if in_result {
549 result = deserialize_content_directory(&value, ip)?;
550 }
551 }
552 _ => {}
553 }
554 }
555 Ok(result)
556}
557
558pub fn deserialize_content_directory(xml: &str, ip: &str) -> Result<(Vec<Container>, Vec<Item>)> {
559 let parser = EventReader::from_str(xml);
560 let mut in_container = false;
561 let mut in_item = false;
562 let mut in_title = false;
563 let mut in_artist = false;
564 let mut in_album = false;
565 let mut in_album_art = false;
566 let mut in_genre = false;
567 let mut in_class = false;
568 let mut in_res = false;
569 let mut containers: Vec<Container> = Vec::new();
570 let mut items: Vec<Item> = Vec::new();
571
572 for e in parser {
573 match e {
574 Ok(XmlEvent::StartElement {
575 name, attributes, ..
576 }) => {
577 if name.local_name == "container" {
578 in_container = true;
579 let mut container = Container::default();
580 for attr in attributes.clone() {
581 if attr.name.local_name == "id" {
582 container.id = attr.value.clone();
583 }
584 if attr.name.local_name == "parentID" {
585 container.parent_id = attr.value.clone();
586 }
587 }
588 containers.push(container);
589 }
590 if name.local_name == "item" {
591 in_item = true;
592 let mut item = Item::default();
593 for attr in attributes.clone() {
594 if attr.name.local_name == "id" {
595 item.id = attr.value.clone();
596 }
597 if attr.name.local_name == "parentID" {
598 item.parent_id = attr.value.clone();
599 }
600 }
601 items.push(item);
602 }
603 if name.local_name == "title" {
604 in_title = true;
605 }
606 if name.local_name == "artist" {
607 in_artist = true;
608 }
609 if name.local_name == "album" {
610 in_album = true;
611 }
612 if name.local_name == "albumArtURI" {
613 in_album_art = true;
614 }
615 if name.local_name == "genre" {
616 in_genre = true;
617 }
618 if name.local_name == "class" {
619 in_class = true;
620 }
621 if name.local_name == "res" {
622 for attr in attributes {
623 if attr.name.local_name == "protocolInfo"
624 && (attr.value.clone().contains("audio")
625 || attr.value.clone().contains("video"))
626 {
627 items.last_mut().unwrap().protocol_info = attr.value.clone();
628 }
629 if attr.name.local_name == "size" {
630 items.last_mut().unwrap().size = Some(attr.value.parse::<u64>()?);
631 }
632 if attr.name.local_name == "duration" {
633 items.last_mut().unwrap().duration = Some(attr.value.clone());
634 }
635 }
636 in_res = true;
637 }
638 }
639 Ok(XmlEvent::EndElement { name }) => {
640 if name.local_name == "container" {
641 in_container = false;
642 }
643 if name.local_name == "item" {
644 in_item = false;
645 }
646 if name.local_name == "title" {
647 in_title = false;
648 }
649 if name.local_name == "artist" {
650 in_artist = false;
651 }
652 if name.local_name == "album" {
653 in_album = false;
654 }
655 if name.local_name == "albumArtURI" {
656 in_album_art = false;
657 }
658 if name.local_name == "genre" {
659 in_genre = false;
660 }
661 if name.local_name == "class" {
662 in_class = false;
663 }
664 if name.local_name == "res" {
665 in_res = false;
666 }
667 }
668 Ok(XmlEvent::Characters(value)) => {
669 if in_container {
670 if let Some(container) = containers.last_mut() {
671 if in_title {
672 container.title = value.clone();
673 }
674 if in_class {
675 container.object_class = Some(value.as_str().into());
676 }
677 }
678 }
679 if in_item {
680 if let Some(item) = items.last_mut() {
681 if in_title {
682 item.title = value.clone();
683 }
684 if in_artist {
685 item.artist = Some(value.clone());
686 }
687 if in_album {
688 item.album = Some(value.clone());
689 }
690 if in_album_art {
691 item.album_art_uri = Some(value.clone());
692 }
693 if in_genre {
694 item.genre = Some(value.clone());
695 }
696 if in_class {
697 item.object_class = Some(value.clone().as_str().into());
698 }
699 if in_res
700 && item.url.is_empty()
701 && value.contains(ip)
702 && (item.protocol_info.contains("audio")
703 || item.protocol_info.contains("video"))
704 {
705 item.url = value.clone();
706 }
707 }
708 }
709 }
710 _ => {}
711 }
712 }
713 Ok((containers, items))
714}
715
716pub fn parse_transport_info(xml: &str) -> Result<TransportInfo> {
717 let parser = EventReader::from_str(xml);
718 let mut in_transport_state = false;
719 let mut in_transport_status = false;
720 let mut in_transport_play_speed = false;
721 let mut transport_info = TransportInfo::default();
722
723 for e in parser {
724 match e {
725 Ok(XmlEvent::StartElement { name, .. }) => {
726 if name.local_name == "CurrentTransportState" {
727 in_transport_state = true;
728 }
729 if name.local_name == "CurrentTransportStatus" {
730 in_transport_status = true;
731 }
732 if name.local_name == "CurrentSpeed" {
733 in_transport_play_speed = true;
734 }
735 }
736 Ok(XmlEvent::EndElement { name }) => {
737 if name.local_name == "CurrentTransportState" {
738 in_transport_state = false;
739 }
740 if name.local_name == "CurrentTransportStatus" {
741 in_transport_status = false;
742 }
743 if name.local_name == "CurrentSpeed" {
744 in_transport_play_speed = false;
745 }
746 }
747 Ok(XmlEvent::Characters(value)) => {
748 if in_transport_state {
749 transport_info.current_transport_state = value.clone();
750 }
751 if in_transport_status {
752 transport_info.current_transport_status = value.clone();
753 }
754 if in_transport_play_speed {
755 transport_info.current_speed = value.clone();
756 }
757 }
758 _ => {}
759 }
760 }
761 Ok(transport_info)
762}
763
764#[cfg(test)]
765mod tests {
766 use crate::parser::parse_services;
767
768 #[tokio::test]
769 async fn test_parsing_device_without_service_list() {
770 const XML_ROOT: &'static str = r#"<?xml version="1.0" encoding="UTF-8"?>
771 <root xmlns="urn:schemas-upnp-org:device-1-0">
772 <specVersion>
773 <major>1</major>
774 <minor>0</minor>
775 </specVersion>
776 <device>
777 <deviceType>urn:schemas-upnp-org:device:WLANAccessPointDevice:1</deviceType>
778 <friendlyName>NETGEAR47B64C</friendlyName>
779 <manufacturer>NETGEAR</manufacturer>
780 <manufacturerURL>https://www.netgear.com</manufacturerURL>
781 <modelDescription>NETGEAR Dual Band Access Point</modelDescription>
782 <modelName>WAX214</modelName>
783 <modelNumber>WAX214</modelNumber>
784 <modelURL>https://www.netgear.com</modelURL>
785 <firmwareVersion>2.1.1.3</firmwareVersion>
786 <insightMode>0</insightMode>
787 <serialNumber>XXXXXXXXX</serialNumber>
788 <UDN>uuid:919ba4ec-ec93-490f-b0e3-80CC9C47B64C</UDN>
789 <presentationURL>http://xxxxxx:1337/</presentationURL>
790 </device>
791 </root>"#;
792
793 let result = parse_services("http://xxxxxx:1337/", XML_ROOT)
794 .await
795 .unwrap();
796 assert_eq!(result.len(), 0);
797 }
798}