use super::{Device, Result};
use regex::Regex;
use serde_json::Value;
use tokio::{
net::UdpSocket,
time::{timeout, Duration},
};
use std::net::SocketAddr;
use std::str;
pub const SSDP_IP: &str = "239.255.255.250:1900";
pub const SSDP_URN: &str = "urn:dial-multiscreen-org:device:dial:1";
pub const DEFAULT_SSDP_MAXTIME: usize = 3;
pub(super) async fn uaudp_followup(location: &str) -> Result<Option<Device>> {
log::trace!("Device description followup");
let res = reqwest::get(location).await?.text().await?;
let mut items: Value = serde_xml_rs::from_str(&res).unwrap();
let friendly_name =
serde_json::from_value::<String>(items["device"]["friendlyName"]["$value"].take());
let manufacturer =
serde_json::from_value::<String>(items["device"]["manufacturer"]["$value"].take());
let model_name =
serde_json::from_value::<String>(items["device"]["modelName"]["$value"].take());
let uuid = serde_json::from_value::<String>(items["device"]["UDN"]["$value"].take());
match (friendly_name, manufacturer, model_name, uuid) {
(Ok(friendly_name), Ok(manufacturer), Ok(model_name), Ok(uuid))
if manufacturer == "Vizio" =>
{
let ip_addr = Regex::new(r"(?:http:////)?(\d+\.\d+\.\d+\.\d+)(?::\d+)?")
.unwrap()
.captures(location)
.unwrap()[1]
.into();
let uuid = Regex::new(r"^(?:(?:\s*\w+)\s*:\s*)?(.*)")
.unwrap()
.captures(&uuid)
.unwrap()[1]
.into();
Ok(Some(
Device::new(friendly_name, manufacturer, model_name, ip_addr, uuid).await?,
))
}
_ => {
log::warn!("Device is not compatible");
Ok(None)
}
}
}
pub(super) async fn ssdp(host: &str, st: &str, mx: usize) -> Result<Vec<Device>> {
log::info!("Starting SSDP query");
let body: &str = &[
"M-SEARCH * HTTP/1.1",
&format!("HOST: {}", host),
"MAN: \"ssdp:discover\"",
&format!("ST: {}", st),
&format!("MX: {}", mx),
"",
"",
]
.join("\r\n");
let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await?;
socket.send_to(body.as_bytes(), host).await?;
let mut rbuf = [0; 1024];
log::trace!("Wait for SSDP replies");
let mut devices: Vec<Device> = Vec::new();
while let Ok(Ok(len)) = timeout(Duration::from_secs(mx as u64), socket.recv(&mut rbuf)).await {
let mut headers = [httparse::EMPTY_HEADER; 16];
let mut res = httparse::Response::new(&mut headers);
res.parse(&rbuf).unwrap();
let location = str::from_utf8(
match headers.iter().find(|x| x.name.to_lowercase() == "location") {
Some(header) => header.value,
None => continue,
},
)
.unwrap();
log::info!("Received reply for location '{}'", location);
if let Some(device) = uaudp_followup(location).await? {
devices.push(device);
}
for b in rbuf[..len].iter_mut() {
*b = 0
}
}
log::info!("Found [{}] SmartCast Device(s)", devices.len());
Ok(devices)
}
#[cfg(test)]
mod tests {
use super::{ssdp, DEFAULT_SSDP_MAXTIME, SSDP_URN};
use crate::Device;
use chrono::prelude::*;
use http::Response;
use indoc::indoc;
use rand::{distributions::Alphanumeric, Rng};
use tokio::{
net::UdpSocket,
sync::watch::{self, Receiver},
};
use warp::{self, Filter};
use std::net::SocketAddr;
macro_rules! device_desc {
($ip:expr, $port:expr, $name:expr, $manufacturer:expr, $model:expr, $uuid:expr) => {
format!(
indoc! {
"<?xml version=\"1.0\"?>\r
<root xmlns=\"urn:schemas-upnp-org:device-1-0\">\r
<specVersion>\r
<major>1</major>\r
<minor>0</minor>\r
</specVersion>\r
<URLBase>http://{}:{}</URLBase>\r
<device>\r
<deviceType>urn:dial-multiscreen-org:device:dial:1</deviceType>\r
<friendlyName>{}</friendlyName>\r
<manufacturer>{}</manufacturer>\r
<modelName>{}</modelName>\r
<UDN>uuid:{}</UDN>\r
</device>\r
</root>\r
"
},
$ip, $port, $name, $manufacturer, $model, $uuid
)
};
}
async fn emulate_ssdp() -> (SocketAddr, Receiver<Option<SocketAddr>>) {
let (ssdp_tx, ssdp_rx) = watch::channel::<Option<SocketAddr>>(None);
let ssdp_socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
let ssdp_addr = ssdp_socket.local_addr().unwrap();
tokio::spawn(async move {
let mut rbuf = [0; 1024];
while let Ok((len, recv_addr)) = ssdp_socket.recv_from(&mut rbuf).await {
ssdp_tx.send(Some(recv_addr)).unwrap();
for b in rbuf[..len].iter_mut() {
*b = 0
}
}
});
(ssdp_addr, ssdp_rx)
}
async fn emulate_device(
smartcast_device: bool,
mut rx: Receiver<Option<SocketAddr>>,
) -> Device {
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
let device_addr = socket.local_addr().unwrap();
let desc_addr: SocketAddr = {
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
socket.local_addr().unwrap()
};
let rand_string: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.map(char::from)
.take(32)
.collect();
let device = Device::new(
format!("Fake Device-{}", &rand_string[0..4]), match smartcast_device {
true => "Vizio",
false => "Fake Company",
}
.into(),
format!("fake_model_{}", &rand_string[4..8]), device_addr .ip()
.to_string(),
format!(
"{}-{}-{}-{}-{}", &rand_string[0..8],
&rand_string[8..12],
&rand_string[12..16],
&rand_string[16..20],
&rand_string[20..32]
),
)
.await
.unwrap();
let desc_endpoint: String = "ssdp/device-desc.xml".into();
let descriptions = warp::path("ssdp")
.and(warp::path("device-desc.xml"))
.and(warp::path::end())
.and(warp::get())
.map({
let ip = device.ip();
let port = device.port();
let name = device.name();
let manufacturer = device.manufacturer();
let model_name = device.model_name();
let uuid = device.uuid();
move || {
let desc_xml = device_desc!(ip, port, name, manufacturer, model_name, uuid);
Response::builder()
.header("Application-URL", "http//127.0.0.1:8008/apps/")
.header("Content-Length", desc_xml.len())
.header("Content-Type", "application/xml")
.body(desc_xml)
.unwrap()
}
});
tokio::spawn(warp::serve(descriptions).run(desc_addr));
tokio::spawn({
let body = [
"HTTP/1.1 200 OK",
"CACHE-CONTROL: max-age=1800",
&format!("DATE: {}", Utc::now().format("%a, %d %b %Y %X GMT")),
"EXT:",
&format!(
"LOCATION: http://{}:{}/{}",
desc_addr.ip(),
desc_addr.port(),
desc_endpoint
),
"OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01",
"SERVER: Linux/4.19.71+, UPnP/1.0, Portable SDK for UPnP devices/1.6.18",
"X-User-Agent: redsonic",
"ST: urn:dial-multiscreen-org:device:dial:1",
&format!(
"USN: uuid:{}::urn:dial-multiscreen-org:device:dial:1",
device.uuid()
),
"BOOTID.UPNP.ORG: 0",
"CONFIGID.UPNP.ORG: 3",
"",
"",
]
.join("\r\n");
async move {
while rx.changed().await.is_ok() {
let msg = *rx.borrow();
if let Some(ip) = msg {
socket.send_to(body.as_bytes(), ip).await.unwrap();
}
}
}
});
device
}
#[tokio::test]
async fn ssdp_single_device() {
let (ssdp_addr, ssdp_rx) = emulate_ssdp().await;
let expected_device = emulate_device(true, ssdp_rx.clone()).await;
emulate_device(false, ssdp_rx.clone()).await;
let found_devices = ssdp(
&format!("{}:{}", ssdp_addr.ip(), ssdp_addr.port()),
SSDP_URN,
DEFAULT_SSDP_MAXTIME,
)
.await
.unwrap();
assert_eq!(found_devices.len(), 1);
assert_eq!(found_devices[0], expected_device);
}
#[tokio::test]
async fn ssdp_multi_device() {
let (ssdp_addr, ssdp_rx) = emulate_ssdp().await;
let mut expected_devices: Vec<Device> = Vec::new();
for _ in 0..10 {
expected_devices.push(emulate_device(true, ssdp_rx.clone()).await);
}
emulate_device(false, ssdp_rx).await;
let mut found_devices = ssdp(
&format!("{}:{}", ssdp_addr.ip(), ssdp_addr.port()),
SSDP_URN,
DEFAULT_SSDP_MAXTIME,
)
.await
.unwrap();
assert_eq!(found_devices.len(), 10);
found_devices.sort_by(|a, b| a.name().partial_cmp(&b.name()).unwrap());
expected_devices.sort_by(|a, b| a.name().partial_cmp(&b.name()).unwrap());
assert_eq!(found_devices, expected_devices);
}
#[tokio::test]
async fn ssdp_no_device() {
let (ssdp_addr, ssdp_rx) = emulate_ssdp().await;
for _ in 0..10 {
emulate_device(false, ssdp_rx.clone()).await;
}
let found_devices = ssdp(
&format!("{}:{}", ssdp_addr.ip(), ssdp_addr.port()),
SSDP_URN,
DEFAULT_SSDP_MAXTIME,
)
.await
.unwrap();
assert_eq!(found_devices.len(), 0);
}
}