upnp_client/
parser.rs

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}