~liz/yooper

b58cb4ca2aa007df7290232219883076abe438f0 — Ellie Frost 2 years ago 90a87fd
Lots of documentation
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;