Skip to main content

esphome_client/
discovery.rs

1use mdns_sd::{
2    Error as mdns_error, IfKind, Receiver, ResolvedService, ServiceDaemon, ServiceEvent,
3};
4use std::{
5    collections::HashMap,
6    fmt,
7    net::{IpAddr, SocketAddr},
8    time::Duration,
9};
10use tokio::{sync::mpsc, task::JoinHandle};
11
12const SERVICE_NAME: &str = "_esphomelib._tcp.local.";
13
14/// Information about a discovered ESPHome device.
15#[derive(Clone, Debug)]
16pub struct DeviceInfo {
17    record: ResolvedService,
18}
19
20impl Eq for DeviceInfo {}
21impl PartialEq for DeviceInfo {
22    fn eq(&self, other: &Self) -> bool {
23        self.record.get_fullname() == other.record.get_fullname()
24            && self.record.get_addresses() == other.record.get_addresses()
25            && self.record.get_port() == other.record.get_port()
26    }
27}
28
29impl DeviceInfo {
30    /// Gets the device's socket address.
31    #[must_use]
32    pub fn socket_address(&self) -> Option<SocketAddr> {
33        let addr = self.record.get_addresses().iter().next()?.to_owned();
34        Some(SocketAddr::new(addr.to_ip_addr(), self.record.get_port()))
35    }
36
37    /// Gets the device's hostname.
38    #[must_use]
39    pub fn hostname(&self) -> &str {
40        self.record.get_hostname()
41    }
42
43    /// Gets all the device's attributes as a `HashMap`.
44    #[must_use]
45    pub fn attributes(&self) -> HashMap<String, String> {
46        self.record.get_properties().clone().into_property_map_str()
47    }
48
49    /// Check if the device has the `api_encryption` attribute.
50    /// This indicates that the device requires a key for communication.
51    #[must_use]
52    pub fn has_encryption(&self) -> bool {
53        self.record.get_property("api_encryption").is_some()
54    }
55}
56
57pub use crate::error::DiscoveryError as Error;
58
59/// Client for discovering ESPHome devices using mDNS.
60/// This client allows you to discover devices on the local network
61/// that are running ESPHome and have the mDNS service enabled.
62///
63/// Requires the `discovery` feature to be enabled.
64///
65/// # Usage:
66/// ```rust,no_run
67/// use esphome_client::discovery::Client;
68///
69/// #[tokio::main]
70/// async fn main() {
71///     let mut discovery_results = Client::default().discover().expect("Results");
72///     while let Ok(device) = discovery_results.next().await {   
73///         println!("Discovered device: {device:?}");
74///     }
75/// }
76/// ```
77#[derive(Default, Debug)]
78pub struct Client {
79    interval: Option<Duration>,
80    interface: Option<IfKind>,
81    service_name: Option<String>,
82}
83
84impl Client {
85    /// Set the interface ip to discover devices on.
86    /// If not set, it will use the all interfaces.
87    #[must_use]
88    pub fn with_interface_ip(mut self, addr: IpAddr) -> Self {
89        self.interface = Some(IfKind::Addr(addr));
90        self
91    }
92
93    /// Set the interface name to discover devices on.
94    /// If not set, it will use the all interfaces.
95    #[must_use]
96    pub fn with_interface(mut self, interface: &str) -> Self {
97        self.interface = Some(IfKind::Name(interface.to_owned()));
98        self
99    }
100
101    /// Set the service name to filter devices by.
102    /// If not set, it will default to `_esphomelib._tcp.local`.
103    #[must_use]
104    pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
105        self.service_name = Some(service_name.into());
106        self
107    }
108
109    /// Set custom ip check interval.
110    /// Defaults to the default interval of the `mdns-sd` crate.
111    #[must_use]
112    pub const fn with_interval(mut self, interval: Duration) -> Self {
113        self.interval = Some(interval);
114        self
115    }
116
117    /// Initialize the discovery client and start discovering devices.
118    ///
119    /// # Errors
120    ///
121    /// Will return `Error` if discovery cannot be started due to initialization issues,
122    pub fn discover(self) -> Result<ResultStream, Error> {
123        let service_name = self
124            .service_name
125            .as_deref()
126            .unwrap_or(SERVICE_NAME)
127            .to_owned();
128
129        let mdns = ServiceDaemon::new().map_err(|e| Error::InitializationError {
130            reason: e.to_string(),
131        })?;
132        if let Some(interval) = self.interval {
133            if let Ok(interval) = interval.as_secs().try_into() {
134                mdns.set_ip_check_interval(interval)
135                    .map_err(|e| Error::InitializationError {
136                        reason: e.to_string(),
137                    })?;
138            }
139        }
140        if let Some(interface) = self.interface {
141            mdns.enable_interface(interface)
142                .map_err(|e| Error::InitializationError {
143                    reason: e.to_string(),
144                })?;
145        }
146        let receiver = mdns
147            .browse(&service_name)
148            .map_err(|e| Error::InitializationError {
149                reason: e.to_string(),
150            })?;
151
152        Ok(ResultStream::new(mdns, receiver))
153    }
154}
155
156/// Stream of discovered devices.
157/// This stream will yield `DeviceInfo` for each discovered device.
158/// Note that this stream will not yield unique devices, so you may receive the same device multiple times.
159pub struct ResultStream {
160    mdns: ServiceDaemon,
161    handle: JoinHandle<()>,
162    rx: mpsc::Receiver<DeviceInfo>,
163}
164
165impl fmt::Debug for ResultStream {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.debug_struct("Results")
168            .field("mdns", &"ServiceDaemon {}")
169            .field("handle", &self.handle)
170            .field("rx", &self.rx)
171            .finish()
172    }
173}
174
175impl ResultStream {
176    fn new(mdns: ServiceDaemon, receiver: Receiver<ServiceEvent>) -> Self {
177        let (tx, rx) = mpsc::channel(100);
178        let handle = tokio::spawn(async move {
179            while let Ok(event) = receiver.recv_async().await {
180                match event {
181                    ServiceEvent::ServiceResolved(info) => {
182                        tracing::debug!("Discovered device: {info:?}");
183                        if let Err(e) = tx.send(DeviceInfo { record: *info }).await {
184                            tracing::error!("Failed to send discovered device info: {e}");
185                        }
186                    }
187                    evt => tracing::debug!("Unhandled discovery event: {evt:?}"),
188                }
189            }
190        });
191        Self { mdns, handle, rx }
192    }
193
194    /// Get the next discovered device.
195    ///
196    /// Note that this will not return unique devices, so you may receive the same device multiple times.
197    ///
198    /// # Errors
199    ///
200    /// Will return `Error::Aborted` if the discovery was aborted.
201    pub async fn next(&mut self) -> Result<DeviceInfo, Error> {
202        self.rx.recv().await.ok_or(Error::Aborted)
203    }
204
205    /// Get the first discovered device.
206    ///
207    /// # Errors
208    ///
209    /// Will return `Error::Aborted` if the discovery was aborted.
210    pub async fn first(mut self) -> Result<DeviceInfo, Error> {
211        self.next().await
212    }
213}
214
215impl Drop for ResultStream {
216    fn drop(&mut self) {
217        self.handle.abort();
218        for _ in 0..5 {
219            if matches!(self.mdns.shutdown(), Err(mdns_error::Again)) {
220                // retry shutdown a few times
221            } else {
222                return;
223            }
224        }
225        tracing::error!("Failed to shutdown mDNS daemon after multiple attempts");
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use mdns_sd::ServiceInfo;
232
233    use super::*;
234
235    use std::net::{IpAddr, Ipv4Addr};
236    use std::time::Duration;
237
238    #[test]
239    fn test_device_info_hostname_and_attributes() {
240        // Mock ServiceInfo
241        let mut props: HashMap<String, String> = HashMap::new();
242        props.insert("api_encryption".into(), "true".into());
243        props.insert("foo".into(), "bar".into());
244        let info = ServiceInfo::new(
245            "_esphomelib._tcp.local",
246            "test-device",
247            "test.local",
248            "127.0.0.1",
249            6053,
250            props,
251        )
252        .unwrap()
253        .as_resolved_service();
254
255        let device = DeviceInfo { record: info };
256
257        assert_eq!(device.hostname(), "test.local");
258        let attrs = device.attributes();
259        assert_eq!(attrs.get("foo"), Some(&"bar".to_owned()));
260        assert!(device.has_encryption());
261    }
262
263    #[test]
264    fn test_device_info_socket_address() {
265        let info = ServiceInfo::new(
266            "_esphomelib._tcp.local",
267            "test-device",
268            "test.local",
269            "192.168.1.10",
270            6053,
271            HashMap::<String, String>::new(),
272        )
273        .unwrap()
274        .as_resolved_service();
275
276        let device = DeviceInfo { record: info };
277        let addr = device.socket_address().unwrap();
278        assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)));
279        assert_eq!(addr.port(), 6053);
280    }
281
282    #[test]
283    fn test_client_builder_methods() {
284        let client = Client::default()
285            .with_interface_ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
286            .with_interface("eth0")
287            .with_service_name("_custom._tcp.local")
288            .with_interval(Duration::from_secs(10));
289
290        assert!(client.interval.is_some());
291        assert!(client.interface.is_some());
292        assert_eq!(client.service_name.as_deref(), Some("_custom._tcp.local"));
293    }
294
295    #[test]
296    fn test_error_display() {
297        let init_err = Error::InitializationError {
298            reason: "fail".to_owned(),
299        };
300        let abort_err = Error::Aborted;
301        assert_eq!(format!("{init_err}"), "Initialization error: fail");
302        assert_eq!(format!("{abort_err}"), "Discovery aborted");
303    }
304}