esphome_client/
discovery.rs1use 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#[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 #[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 #[must_use]
39 pub fn hostname(&self) -> &str {
40 self.record.get_hostname()
41 }
42
43 #[must_use]
45 pub fn attributes(&self) -> HashMap<String, String> {
46 self.record.get_properties().clone().into_property_map_str()
47 }
48
49 #[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#[derive(Default, Debug)]
78pub struct Client {
79 interval: Option<Duration>,
80 interface: Option<IfKind>,
81 service_name: Option<String>,
82}
83
84impl Client {
85 #[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 #[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 #[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 #[must_use]
112 pub const fn with_interval(mut self, interval: Duration) -> Self {
113 self.interval = Some(interval);
114 self
115 }
116
117 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
156pub 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 pub async fn next(&mut self) -> Result<DeviceInfo, Error> {
202 self.rx.recv().await.ok_or(Error::Aborted)
203 }
204
205 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 } 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 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}