M yooper/src/discovery.rs => yooper/src/discovery.rs +68 -49
@@ 1,24 1,20 @@
-use std::net::{Ipv4Addr, SocketAddr};
use std::collections::HashMap;
+use std::net::{Ipv4Addr, SocketAddr};
+use futures::sink::SinkExt;
+use mac_address::{get_mac_address, MacAddressError};
+use std::time::{SystemTime, UNIX_EPOCH};
use tokio::{
- select,
net::UdpSocket,
- time::{self, Duration},
+ select,
stream::StreamExt,
+ time::{self, Duration},
};
use tokio_util::udp::UdpFramed;
-use uuid::{Uuid, self};
-use mac_address::{get_mac_address, MacAddressError};
-use os_info;
-use std::time::{SystemTime, UNIX_EPOCH};
-use futures::sink::SinkExt;
+use uuid::{self, Uuid};
use crate::{
- ssdp::message::{
- Codec, Message, MSearch,
-
- },
+ ssdp::message::{Codec, MSearch, Message, SearchTarget},
Error,
};
@@ 27,24 23,33 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
const SSDP_ADDRESS: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
const SSDP_PORT: u16 = 1900;
+/// Discover services on your network
pub struct Discovery {
uuid: Uuid,
user_agent: String,
socket: UdpFramed<Codec>,
}
+/// A Device that's responded to a search
pub struct Device {
+ /// version information for the server that responded to the search
pub server: String,
+ /// The address the device responded from
pub address: SocketAddr,
+ /// A list of discovered services
pub services: Vec<Service>,
}
+/// A Service represents a running service on a device
pub struct Service {
+ /// Unique Service Name identifies a unique instance of a device or service.
pub service_name: String,
- pub target: String,
+ /// the search target you would use to describe this service
+ pub target: SearchTarget,
}
impl Discovery {
+ /// Create a new Discovery struct, including creating a new socket
pub async fn new() -> Result<Self, Error> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, SSDP_PORT)).await?;
socket.join_multicast_v4(SSDP_ADDRESS, Ipv4Addr::UNSPECIFIED)?;
@@ 53,6 58,7 @@ impl Discovery {
Self::from_socket(socket)
}
+ /// Create a new Discovery struct based on an existing Tokio socket
pub fn from_socket(socket: UdpSocket) -> Result<Self, Error> {
Ok(Self {
socket: UdpFramed::new(socket, Codec::new()),
@@ 61,27 67,31 @@ impl Discovery {
})
}
+ /// Send out an MSearch packet to discover services
pub async fn start_search(&mut self, secs: u8) -> Result<(), Error> {
// TODO: secs should be between 1 and 5
- let msg = Message::MSearch(
- MSearch {
- max_wait: Some(secs),
- target: "ssdp:all".into(),
- user_agent: Some(self.user_agent.clone()),
- host: format!("{}:{}", SSDP_ADDRESS, SSDP_PORT),
+ let msg = Message::MSearch(MSearch {
+ max_wait: Some(secs),
+ target: SearchTarget::All,
+ user_agent: Some(self.user_agent.clone()),
+ host: format!("{}:{}", SSDP_ADDRESS, SSDP_PORT),
- friendly_name: Some("yooper".into()),
- uuid: Some(self.uuid.to_string()),
+ friendly_name: Some("yooper".into()),
+ uuid: Some(self.uuid.to_string()),
- ..Default::default()
- }
- );
+ ..Default::default()
+ });
- self.socket.send((msg, (SSDP_ADDRESS, SSDP_PORT).into())).await?;
+ self.socket
+ .send((msg, (SSDP_ADDRESS, SSDP_PORT).into()))
+ .await?;
Ok(())
}
+ /// Find all SSDP services on the network.
+ /// Will block for n secs then return a list of discovered devices
+ /// secs should be between 1 and 5 to comply with
pub async fn find(&mut self, secs: u8) -> Result<Vec<Device>, Error> {
let mut map: HashMap<SocketAddr, Device> = HashMap::new();
self.start_search(secs).await?;
@@ 89,39 99,44 @@ impl Discovery {
let mut delay = time::delay_for(Duration::from_secs(secs.into()));
loop {
- select!{
- msg = self.socket.next() => {
- match msg {
- Some(Err(e)) => eprintln!("Error receiving: {:?}", e),
- Some(Ok((Message::SearchResponse(sr), address))) => {
- let device = map.entry(address).or_insert(Device {
- address,
- server: sr.server,
- services: Vec::new(),
-
- });
- device.services.push(Service{
- target: sr.target,
- service_name: sr.unique_service_name
- })
+ select! {
+ msg = self.socket.next() => {
+ match msg {
+ Some(Err(e)) => eprintln!("Error receiving: {:?}", e),
+ Some(Ok((Message::SearchResponse(sr), address))) => {
+ let device = map.entry(address).or_insert(Device {
+ address,
+ server: sr.server,
+ services: Vec::new(),
+
+ });
+ device.services.push(Service{
+ target: sr.target,
+ service_name: sr.unique_service_name
+ })
+ }
+ _ => (),
}
- _ => (),
}
- }
- _ = &mut delay => {
- break
- }
- };
+ _ = &mut delay => {
+ break
+ }
+ };
}
- Ok(map.into_iter().map(|(_k, v)| v ).collect())
+ Ok(map.into_iter().map(|(_k, v)| v).collect())
}
}
fn user_agent() -> String {
let info = os_info::get();
- format!("{}/{}.1 upnp/2.0 yooper/{}", info.os_type(), info.version(), VERSION)
+ format!(
+ "{}/{}.1 upnp/2.0 yooper/{}",
+ info.os_type(),
+ info.version(),
+ VERSION
+ )
}
fn get_uuid() -> Result<Uuid, Error> {
@@ 132,7 147,11 @@ fn get_uuid() -> Result<Uuid, Error> {
let since_the_epoch = start
.duration_since(UNIX_EPOCH)
.expect("Time went backwards");
- let ts = uuid::v1::Timestamp::from_unix(&ctx, since_the_epoch.as_secs(), since_the_epoch.subsec_nanos());
+ let ts = uuid::v1::Timestamp::from_unix(
+ &ctx,
+ since_the_epoch.as_secs(),
+ since_the_epoch.subsec_nanos(),
+ );
Ok(uuid::Uuid::new_v1(ts, &mac.bytes())?)
}
M yooper/src/errors.rs => yooper/src/errors.rs +6 -4
@@ 1,9 1,8 @@
use thiserror::Error;
+use mac_address::MacAddressError;
use std::convert::Infallible;
use std::num::ParseIntError;
-use mac_address::MacAddressError;
-use uuid;
#[derive(Error, Debug)]
pub enum Error {
@@ 16,14 15,17 @@ pub enum Error {
#[error("missing required header {0}")]
MissingHeader(&'static str),
- #[error("Required constant header had incorrect value {0}")]
+ #[error("Required header {0} had incorrect value")]
IncorrectHeader(&'static str),
+ #[error("Header {0} had a value we couldn't parse ({1})")]
+ MalformedHeader(&'static str, String),
+
#[error("IO Error {0}")]
IO(#[from] std::io::Error),
#[error("Format Error")]
- Fmt(#[from]std::fmt::Error),
+ Fmt(#[from] std::fmt::Error),
#[error("Received a packet we don't understand")]
UnknownPacket, // TODO EKF more descriptive
M yooper/src/main.rs => yooper/src/main.rs +2 -6
@@ 1,7 1,4 @@
-use yooper::{
- Error,
- discovery::Discovery
-};
+use yooper::{discovery::Discovery, Error};
// const VERSION: &'static str = env!("CARGO_PKG_VERSION");
// const OS: &'static str = "linux"; //TODO
@@ 22,9 19,8 @@ async fn main() -> Result<(), Error> {
for result in discovery.find(5).await? {
println!("{} at {}", result.server, result.address);
for service in result.services {
- println!("∟ {}", service.target)
+ println!("∟ {:?}", service.target)
}
-
}
Ok(())
}
M yooper/src/ssdp/message.rs => yooper/src/ssdp/message.rs +31 -15
@@ 1,44 1,44 @@
+//! A set of symbolic representations of SSDP packets
+
mod codec;
pub(self) mod types;
use crate::ssdp::packet::{FromHeaders, FromPacket, ToHeaders, ToPacket};
pub use codec::Codec;
+pub use types::SearchTarget;
+
#[derive(ToHeaders, FromHeaders, Debug, PartialEq, Default)]
pub struct MSearch {
pub host: String,
- /// Maximum wait time in seconds. shall be greater than or equal to 1 and should
- /// be less than 5 inclusive.
+ pub man: types::ManDiscover,
+
#[header("cache-control")]
pub cache_control: Option<String>,
+ /// Maximum wait time in seconds. shall be greater than or equal to 1 and should
+ /// be less than 5 inclusive.
#[header("mx")]
pub max_wait: Option<u8>,
-
- pub man: types::ManDiscover,
-
// TODO: enum
/// Field value contains Search Target.
#[header("st")]
- pub target: String,
+ pub target: types::SearchTarget,
/// Field value shall begin with the following “product tokens” (defined
/// by HTTP/1.1). The first product token identifes the operating system in the form OS name/OS version, the
/// second token represents the UPnP version and shall be UPnP/2.0, and the third token identifes the product
/// using the form product name/product version. For example, “USER-AGENT: unix/5.1 UPnP/2.0
/// MyProduct/1.0”.
pub user_agent: Option<String>,
- /// control point can request that a device replies to a TCP port on the control point. When this header
- /// is present it identifies the TCP port on which the device can reply to the search.
+ /// if set, this TCP port can be used for any follow up requests
#[header("tcpport.upnp.org")]
pub tcp_port: Option<u16>,
/// Specifies the friendly name of the control point. The friendly name is vendor specific.
#[header("cpfn.upnp.org")]
pub friendly_name: Option<String>,
- /// uuid of the control point. When the control point is implemented in a UPnP device it is recommended
- /// to use the UDN of the co-located UPnP device. When implemented, all specified requirements for uuid usage
- /// in devices also apply for control points.
+ /// uuid of the control point.
#[header("cpuuid.upnp.org")]
pub uuid: Option<String>,
}
@@ 60,7 60,6 @@ pub struct Available {
/// Specified by UPnP vendor. Single absolute URL (see RFC 3986)
pub location: String,
-
#[header("securelocation.upnp.org")]
pub secure_location: Option<String>,
// TODO: Enum
@@ 81,30 80,40 @@ pub struct Available {
#[header("bootid.upnp.org")]
pub boot_id: Option<i32>,
+ /// A number identifying this particular configuration.
+ /// if configuration changes, this should change as well
#[header("configid.upnp.org")]
pub config_id: Option<i32>,
+ /// A port other than 1900 than can be used for queries
#[header("searchport.upnp.org")]
pub search_port: Option<u16>,
}
#[derive(ToHeaders, FromHeaders, Debug, PartialEq)]
pub struct SearchResponse {
+ /// Specifies how long this response is valid
#[header("cache-control")]
pub max_age: String,
+ /// When the responce was generated
pub date: Option<String>,
+
+ /// The URL for the UPNP description of the root device
pub location: String,
ext: types::Ext,
+ /// A server string like "unix/5.1 UPnP/2.0 MyProduct/1.0"
+ pub server: String,
+
+ /// If set, a base url with https:// that can be used instead of location
#[header("securelocation.upnp.org")]
pub secure_location: Option<String>,
- pub server: String,
- // TODO: enum
#[header("st")]
- pub target: String,
+ pub target: types::SearchTarget,
+ /// A unique service name for this particular service
// TODO: Enum
#[header("usn")]
pub unique_service_name: String,
@@ 113,18 122,25 @@ pub struct SearchResponse {
#[header("bootid.upnp.org")]
pub boot_id: Option<i32>,
+ /// A number identifying this particular configuration.
+ /// if configuration changes, this should change as well
#[header("configid.upnp.org")]
pub config_id: Option<i32>,
+ /// A port other than 1900 than can be used for queries
#[header("searchport.upnp.org")]
pub search_port: Option<u16>,
}
+/// Any SSDP message
#[derive(Debug, PartialEq, FromPacket, ToPacket)]
pub enum Message {
+ /// Search the network for other devices
#[message(reqline = "MSearch")]
MSearch(MSearch),
+ /// Notification that a device has been added to the network
#[message(reqline = "Notify", nts = "ssdp:alive")]
Available(Available),
+ /// A response to a search query
#[message(reqline = "Ok")]
SearchResponse(SearchResponse),
}
M yooper/src/ssdp/message/codec.rs => yooper/src/ssdp/message/codec.rs +2 -1
@@ 1,10 1,11 @@
use super::Message;
+use crate::ssdp::packet::{self, FromPacket, ToPacket};
use crate::Error;
-use crate::ssdp::packet::{FromPacket, self, ToPacket};
use bytes::BytesMut;
use tokio_util::codec::{Decoder, Encoder};
+/// A codec for turning udp packets into decoded Messages
#[derive(Default)]
pub struct Codec {
encoder: packet::Encoder,
M yooper/src/ssdp/message/types.rs => yooper/src/ssdp/message/types.rs +110 -2
@@ 23,7 23,7 @@ impl FromStr for Ext {
#[derive(PartialEq, Debug, Default)]
pub struct ManDiscover;
-impl ToString for ManDiscover{
+impl ToString for ManDiscover {
fn to_string(&self) -> String {
String::from("\"ssdp:discover\"")
}
@@ 33,8 33,116 @@ impl FromStr for ManDiscover {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
- "ssdp:discover" | "\"ssdp:discover\""=> Ok(Self {}),
+ "ssdp:discover" | "\"ssdp:discover\"" => Ok(Self {}),
_ => Err(Error::IncorrectHeader("man")),
}
}
}
+
+/// What kind of control point to search for
+#[derive(PartialEq, Debug)]
+pub enum SearchTarget {
+ /// Search for all devices and services
+ All,
+ /// Search for root devices only
+ RootDevice,
+ /// Search for a particular device
+ UUID(uuid::Uuid),
+
+ /// Search for any device of this type, where device_type is defined by the UPnP forum
+ Device {
+ device_type: String,
+ version: String,
+ },
+ /// Search for any service of this type, where service_type is defined by the UPnP forum
+ Service {
+ service_type: String,
+ version: String,
+ },
+
+ /// Search for for any device of this type, where device_type is defined by a vendor
+ VendorDevice {
+ domain_name: String,
+ device_type: String,
+ version: String,
+ },
+ /// Search for for any service of this type, where service_type is defined by a vendor
+ VendorService {
+ domain_name: String,
+ service_type: String,
+ version: String,
+ },
+
+ /// Not everyone plays by the rules. A catch-all for non-standard search types
+ Other(String),
+}
+
+impl ToString for SearchTarget {
+ fn to_string(&self) -> std::string::String {
+ use SearchTarget::*;
+
+ match self {
+ All => "ssdp:all".to_string(),
+ RootDevice => "upnp:rootdevice".to_string(),
+ UUID(uuid) => format!("uuid:{}", uuid.to_string()),
+ Device {
+ device_type,
+ version,
+ } => format!("urn:schemas-upnp-org:device:{}:{}", device_type, version),
+ Service {
+ service_type,
+ version,
+ } => format!("urn:schemas-upnp-org:service:{}:{}", service_type, version),
+ VendorDevice {
+ domain_name,
+ device_type,
+ version,
+ } => format!("urn:{}:device:{}:{}", domain_name, device_type, version),
+ VendorService {
+ domain_name,
+ service_type,
+ version,
+ } => format!("urn:{}:sercvice:{}:{}", domain_name, service_type, version),
+ Other(s) => s.to_string(),
+ }
+ }
+}
+
+impl FromStr for SearchTarget {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ use SearchTarget::*;
+
+ Ok(match s.split(':').collect::<Vec<&str>>().as_slice() {
+ ["ssdp", "all"] => All,
+ ["upnp", "rootdevice"] => RootDevice,
+ ["uuid", uuid] => UUID(uuid::Uuid::parse_str(uuid)?),
+ ["urn", "schemas-upnp-org", "device", dt, v] => Device {
+ device_type: dt.to_string(),
+ version: v.to_string(),
+ },
+ ["urn", "schemas-upnp-org", "service", st, v] => Service {
+ service_type: st.to_string(),
+ version: v.to_string(),
+ },
+ ["urn", dn, "device", dt, v] => VendorDevice {
+ domain_name: dn.to_string(),
+ device_type: dt.to_string(),
+ version: v.to_string(),
+ },
+ ["urn", dn, "service", st, v] => VendorService {
+ domain_name: dn.to_string(),
+ service_type: st.to_string(),
+ version: v.to_string(),
+ },
+ _ => Other(s.to_owned()),
+ })
+ }
+}
+
+impl Default for SearchTarget {
+ fn default() -> Self {
+ Self::All
+ }
+}
M yooper/src/ssdp/mod.rs => yooper/src/ssdp/mod.rs +1 -0
@@ 1,3 1,4 @@
+//! A set of libraries for working with SSDP, the service discovery portion of UPnP
pub mod message;
pub mod packet;
M yooper/src/ssdp/packet.rs => yooper/src/ssdp/packet.rs +12 -9
@@ 1,24 1,21 @@
+//! Packet is an unstructured intermediate representation of an SSDP UDP packet
mod decoder;
mod encoder;
use indexmap::IndexMap;
-use std::net::Ipv4Addr;
use std::str::FromStr;
use crate::Error;
pub use decoder::Decoder;
pub use encoder::Encoder;
-pub use yooper_derive::{FromPacket, ToPacket, FromHeaders, ToHeaders};
+pub use yooper_derive::{FromHeaders, FromPacket, ToHeaders, ToPacket};
-pub(crate) const REQUEST_LINE_NOTIFY: &str = "NOTIFY * HTTP/1.1";
-pub(crate) const REQUEST_LINE_M_SEARCH: &str = "M-SEARCH * HTTP/1.1";
-pub(crate) const REQUEST_LINE_OK: &str = "HTTP/1.1 200 OK";
-#[allow(dead_code)]
-pub(crate) const SSDP_ADDRESS: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
-#[allow(dead_code)]
-pub(crate) const SSDP_PORT: u16 = 1900;
+const REQUEST_LINE_NOTIFY: &str = "NOTIFY * HTTP/1.1";
+const REQUEST_LINE_M_SEARCH: &str = "M-SEARCH * HTTP/1.1";
+const REQUEST_LINE_OK: &str = "HTTP/1.1 200 OK";
+/// The Request line of the packet
#[derive(PartialEq, Debug)]
pub enum PacketType {
MSearch,
@@ 49,11 46,15 @@ impl FromStr for PacketType {
}
}
+/// records, in order, the headers for the packet
pub type Headers = IndexMap<String, String>;
+/// A single SSDP packet
#[derive(PartialEq, Debug)]
pub struct Packet {
+ /// The request line of a packet
pub typ: PacketType,
+ /// The headers from the packet
pub headers: Headers,
}
@@ 68,10 69,12 @@ impl Packet {
}
}
+/// Deserialize a packet into something more structured
pub trait FromPacket: std::marker::Sized {
fn from_packet(msg: &Packet) -> Result<Self, crate::errors::Error>;
}
+/// Serialize a structured representation into a packet
pub trait ToPacket {
fn to_packet(&self) -> Packet;
}
M yooper/src/ssdp/packet/decoder.rs => yooper/src/ssdp/packet/decoder.rs +8 -5
@@ 2,8 2,9 @@ use crate::errors::Error;
use bytes::BytesMut;
use tokio_util::codec;
-use super::{Packet, Headers};
+use super::{Headers, Packet};
+/// Turn a UDP packet into an unstructured Packet
#[derive(Default)]
pub struct Decoder {}
@@ 26,8 27,7 @@ impl codec::Decoder for Decoder {
let typ = reqline.parse()?;
- let headers: Headers =
- iter.map(split_header).collect::<Result<_, Error>>()?;
+ let headers: Headers = iter.map(split_header).collect::<Result<_, Error>>()?;
Ok(Some(Packet { typ, headers }))
}
@@ 55,9 55,12 @@ fn split_header(line: &str) -> Result<(String, String), Error> {
#[cfg(test)]
mod tests {
- use tokio_util::codec::Decoder;
+ use crate::ssdp::{
+ packet::{Packet, PacketType},
+ tests::constants::*,
+ };
use bytes::BytesMut;
- use crate::ssdp::{packet::{Packet, PacketType}, tests::constants::*};
+ use tokio_util::codec::Decoder;
#[test]
fn test_parse_notify() {
M yooper/src/ssdp/packet/encoder.rs => yooper/src/ssdp/packet/encoder.rs +1 -0
@@ 8,6 8,7 @@ use std::fmt::Write;
#[derive(Default)]
pub struct Encoder {}
+/// Turn a an unstructured Packet into a UDP bytestream
impl codec::Encoder<Packet> for Encoder {
type Error = Error;