use std::collections::HashMap;
use std::net::IpAddr;
use std::ops::Not;
use mdns_sd::{ServiceDaemon, ServiceInfo};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("mdns internal error {0}")]
Mdns(#[from] mdns_sd::Error),
#[error("I/O error {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Copy)]
pub enum ThingType {
#[default]
Thing,
Directory,
}
impl ThingType {
fn to_service_type(self) -> &'static str {
use ThingType::*;
match self {
Thing => "_wot",
Directory => "_directory._sub._wot",
}
}
fn to_dns_type(self) -> &'static str {
use ThingType::*;
match self {
Thing => "Thing",
Directory => "Directory",
}
}
}
pub struct Advertiser {
pub(crate) mdns: ServiceDaemon,
ips: Vec<IpAddr>,
hostname: String,
}
const WELL_KNOWN: &str = "/.well-known/wot";
pub struct ServiceBuilder<'a> {
mdns: &'a ServiceDaemon,
ips: Vec<IpAddr>,
hostname: String,
ty: ThingType,
port: u16,
path: String,
name: String,
}
fn normalize_hostname(mut hostname: String) -> String {
if !hostname.ends_with(".local") {
hostname.push_str(".local");
}
if !hostname.ends_with('.') {
hostname.push('.')
}
hostname
}
impl<'a> ServiceBuilder<'a> {
fn new(ad: &'a Advertiser, name: impl Into<String>) -> ServiceBuilder<'a> {
Self {
name: name.into(),
mdns: &ad.mdns,
ips: ad.ips.clone(),
hostname: ad.hostname.clone(),
ty: ThingType::Thing,
port: 8080,
path: WELL_KNOWN.to_string(),
}
}
pub fn thing_type(mut self, ty: ThingType) -> Self {
self.ty = ty;
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn path(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
pub fn hostname(mut self, host: impl Into<String>) -> Self {
self.hostname = normalize_hostname(host.into());
self
}
pub fn ips<I: Into<IpAddr>>(mut self, ips: impl IntoIterator<Item = I>) -> Self {
let ips = ips.into_iter();
self.ips = ips.map(|ip| ip.into()).collect();
self
}
pub fn build(self) -> Result<()> {
let Self {
mdns,
ips,
hostname,
ty,
path,
port,
name,
} = self;
let service_type = ty.to_service_type();
let domain = format!("{service_type}._tcp.local.");
let mut props = HashMap::new();
props.insert("td".to_string(), path);
props.insert("type".to_string(), ty.to_dns_type().to_string());
let service = ServiceInfo::new(
&domain,
name.as_ref(),
&hostname,
ips.as_slice(),
port,
Some(props),
)?;
mdns.register(service)?;
Ok(())
}
}
impl Advertiser {
pub fn new() -> Result<Self> {
let mdns = ServiceDaemon::new()?;
let hostname = normalize_hostname(hostname::get()?.to_string_lossy().to_string());
let ips = if_addrs::get_if_addrs()?
.iter()
.filter(|iface| iface.is_loopback().not())
.filter_map(|iface| {
let ip = iface.ip();
match ip {
IpAddr::V4(_) => Some(ip),
_ => None,
}
})
.collect();
let sa = Self {
mdns,
ips,
hostname,
};
Ok(sa)
}
pub fn add_service(&self, name: impl Into<String>) -> ServiceBuilder {
ServiceBuilder::new(self, name)
}
}
#[cfg(all(test, not(miri)))]
mod test {
use super::*;
use mdns_sd::{ServiceEvent::*, ServiceInfo};
use std::time::Duration;
#[test]
fn set_hostname() {
test_feature(
"TestLampHostname",
"_wot._tcp.local.",
|b| b.hostname("testhost"),
|info| {
let props = info.get_properties();
assert_eq!(props.get_property_val_str("td"), Some(WELL_KNOWN));
assert_eq!(props.get_property_val_str("type"), Some("Thing"));
assert_eq!(info.get_hostname(), "testhost.local.");
},
);
}
#[test]
fn set_path() {
test_feature(
"TestLampPath",
"_wot._tcp.local.",
|b| b.path("/test/path"),
|info| {
let props = info.get_properties();
assert_eq!(props.get_property_val_str("td"), Some("/test/path"));
assert_eq!(props.get_property_val_str("type"), Some("Thing"));
},
);
}
#[test]
fn set_type() {
test_feature(
"TestDirectory",
"_directory._sub._wot._tcp.local.",
|b| b.thing_type(ThingType::Directory),
|info| {
let props = info.get_properties();
assert_eq!(props.get_property_val_str("td"), Some(WELL_KNOWN));
assert_eq!(props.get_property_val_str("type"), Some("Directory"));
},
);
}
fn test_feature<F>(name: &str, browse: &str, build: F, check: fn(ServiceInfo))
where
F: for<'b> Fn(ServiceBuilder<'b>) -> ServiceBuilder<'b>,
{
let ad = Advertiser::new().unwrap();
build(ad.add_service(name)).build().unwrap();
let browser = ad.mdns.browse(browse).unwrap();
while let Ok(ev) = browser.recv_timeout(Duration::from_secs(1)) {
if let ServiceResolved(info) = ev {
if info.get_fullname().split_once('.').unwrap().0 == name {
check(info);
return;
}
}
}
panic!("Thing not found");
}
}