use crate::error::{DiscoveryError, Result};
use crate::Device;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Root {
pub device: DeviceDescription,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDescription {
pub device_type: String,
pub friendly_name: String,
pub manufacturer: String,
pub manufacturer_url: Option<String>,
pub model_description: Option<String>,
pub model_name: String,
pub model_number: Option<String>,
pub model_url: Option<String>,
pub serial_number: Option<String>,
#[serde(rename = "UDN")]
pub udn: String,
pub room_name: Option<String>,
pub display_name: Option<String>,
}
impl DeviceDescription {
pub fn from_xml(xml: &str) -> Result<Self> {
let root: Root = quick_xml::de::from_str(xml)
.map_err(|e| DiscoveryError::ParseError(format!("Failed to parse device XML: {e}")))?;
Ok(root.device)
}
pub fn to_device(&self, ip_address: String) -> Device {
Device {
id: self.udn.clone(),
name: self.friendly_name.clone(),
room_name: self
.room_name
.clone()
.unwrap_or_else(|| "Unknown".to_string()),
ip_address,
port: 1400,
model_name: self.model_name.clone(),
}
}
pub fn is_sonos_device(&self) -> bool {
self.manufacturer.to_lowercase().contains("sonos")
|| self.device_type.contains("ZonePlayer")
|| self.device_type.contains("MediaRenderer")
}
}
pub fn extract_ip_from_url(url: &str) -> Option<String> {
url.split("//")
.nth(1)?
.split(':')
.next()
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_ip_from_url() {
assert_eq!(
extract_ip_from_url("http://192.168.1.100:1400/xml/device_description.xml"),
Some("192.168.1.100".to_string())
);
assert_eq!(
extract_ip_from_url("https://10.0.0.5:8080/path"),
Some("10.0.0.5".to_string())
);
assert_eq!(extract_ip_from_url("invalid-url"), None);
}
#[test]
fn test_device_from_xml() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:ZonePlayer:1</deviceType>
<friendlyName>Living Room</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<UDN>uuid:RINCON_000E58A0123456</UDN>
<roomName>Living Room</roomName>
</device>
</root>"#;
let device = DeviceDescription::from_xml(xml).unwrap();
assert_eq!(device.friendly_name, "Living Room");
assert_eq!(device.manufacturer, "Sonos, Inc.");
assert_eq!(device.model_name, "Sonos One");
assert_eq!(device.udn, "uuid:RINCON_000E58A0123456");
assert_eq!(device.room_name, Some("Living Room".to_string()));
assert!(device.is_sonos_device());
}
#[test]
fn test_is_sonos_device_by_manufacturer() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Test Device</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Test Model</modelName>
<UDN>uuid:TEST123</UDN>
</device>
</root>"#;
let device = DeviceDescription::from_xml(xml).unwrap();
assert!(device.is_sonos_device());
}
#[test]
fn test_is_sonos_device_by_device_type() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:ZonePlayer:1</deviceType>
<friendlyName>Test Device</friendlyName>
<manufacturer>Other Manufacturer</manufacturer>
<modelName>Test Model</modelName>
<UDN>uuid:TEST123</UDN>
</device>
</root>"#;
let device = DeviceDescription::from_xml(xml).unwrap();
assert!(device.is_sonos_device());
}
#[test]
fn test_not_sonos_device() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
<friendlyName>Router</friendlyName>
<manufacturer>Other Company</manufacturer>
<modelName>Router Model</modelName>
<UDN>uuid:ROUTER123</UDN>
</device>
</root>"#;
let device = DeviceDescription::from_xml(xml).unwrap();
assert!(!device.is_sonos_device());
}
#[test]
fn test_to_device_conversion() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:ZonePlayer:1</deviceType>
<friendlyName>Kitchen</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos Play:1</modelName>
<UDN>uuid:RINCON_ABCDEF123456</UDN>
<roomName>Kitchen</roomName>
</device>
</root>"#;
let device_desc = DeviceDescription::from_xml(xml).unwrap();
let device = device_desc.to_device("192.168.1.50".to_string());
assert_eq!(device.id, "uuid:RINCON_ABCDEF123456");
assert_eq!(device.name, "Kitchen");
assert_eq!(device.room_name, "Kitchen");
assert_eq!(device.ip_address, "192.168.1.50");
assert_eq!(device.port, 1400);
assert_eq!(device.model_name, "Sonos Play:1");
}
#[test]
fn test_to_device_with_missing_room_name() {
let xml = r#"<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:ZonePlayer:1</deviceType>
<friendlyName>Bedroom</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<UDN>uuid:RINCON_XYZ789</UDN>
</device>
</root>"#;
let device_desc = DeviceDescription::from_xml(xml).unwrap();
let device = device_desc.to_device("192.168.1.100".to_string());
assert_eq!(device.room_name, "Unknown");
}
}