Skip to main content

enpose_api/
devicediscovery.rs

1//! Client-side discovery of Enpose tracker devices on the local network.
2//!
3//! Use [`DeviceDiscovery::new`] followed by [`DeviceDiscovery::discover`]
4//! to find devices reachable on the current L2 segment.
5
6use std::io;
7use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
8use std::time::{Duration, Instant};
9
10use crate::protocol::{
11    BROADCAST_PORT, PACKET_SIZE, PKT_TYPE_PEER_INFO, PROTOCOL_VERSION,
12    encode_discovery_request, parse_packet,
13};
14
15/// Hard cap on how long [`DeviceDiscovery::discover`] is allowed to
16/// spend before returning, regardless of how many replies have arrived.
17const TOTAL_BUDGET: Duration = Duration::from_millis(500);
18
19/// Number of discovery-request bursts sent across the budget. Resending the
20/// request (rather than broadcasting once) keeps a dropped request or reply on
21/// a lossy segment from turning into a false "no devices found".
22const DISCOVERY_BURSTS: u32 = 3;
23
24/// Spacing between discovery-request bursts. Three bursts at this spacing fit
25/// inside [`TOTAL_BUDGET`] with room for the last one's replies to arrive.
26const BURST_INTERVAL: Duration = Duration::from_millis(150);
27
28/// Once at least one reply has arrived, return after this long passes with no
29/// further reply — additional replies from a cluster are already in flight and
30/// arrive close together, so this lets discovery finish promptly instead of
31/// always waiting out the full budget. With no reply yet, discovery keeps
32/// listening (and retransmitting) for the full [`TOTAL_BUDGET`].
33const QUIET_WINDOW: Duration = Duration::from_millis(50);
34
35/// Information about one tracker device discovered on the local network.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DeviceInfo {
38    /// Address the device replied from. This is the device's primary
39    /// IP on the cluster network and the address an Enpose API client
40    /// should use to connect to it.
41    pub ip: IpAddr,
42    /// Factory serial number of the device.
43    pub serial: u32,
44    /// `true` when the device's wire-protocol version matches the
45    /// version this API was built against.
46    ///
47    /// When `false`, the IP and serial are still populated so the
48    /// caller can surface a "found but incompatible" entry to the user
49    /// rather than silently hiding the device.
50    pub compatible: bool,
51}
52
53/// Discovers Enpose tracker devices on the local network.
54///
55/// On every call to [`Self::discover`] the API sends discovery
56/// requests to every directed-broadcast address of every up,
57/// non-loopback IPv4 interface on the host (e.g. `192.168.10.255` on
58/// a host with `enp1s0 192.168.10.10/24`), plus the limited-broadcast
59/// address `255.255.255.255`. This reaches the cluster network on
60/// multi-NIC hosts where the kernel's default route would otherwise
61/// send the limited broadcast out the wrong interface (typically the
62/// wifi default route, leaving the cluster ethernet unreachable).
63///
64/// A single instance can be reused for many discovery rounds; the API
65/// holds no persistent network state between calls.
66pub struct DeviceDiscovery {
67    /// When `Some`, [`Self::discover`] sends the request only to that
68    /// address — used by tests so a fake primary on loopback gets the
69    /// request without broadcasting on the LAN. When `None`,
70    /// [`Self::discover`] enumerates host interfaces and broadcasts to
71    /// every directed-broadcast plus `255.255.255.255`.
72    explicit_target: Option<SocketAddr>,
73}
74
75impl Default for DeviceDiscovery {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl DeviceDiscovery {
82    /// Create a new [`DeviceDiscovery`] that, on each [`Self::discover`]
83    /// call, broadcasts to every directed-broadcast on the host plus
84    /// `255.255.255.255` — all on [`BROADCAST_PORT`].
85    pub fn new() -> Self {
86        Self {
87            explicit_target: None,
88        }
89    }
90
91    /// Test constructor that targets a single specific socket address
92    /// instead of enumerating broadcasts. Lets unit tests run a fake
93    /// primary on loopback without depending on host interfaces.
94    #[cfg(test)]
95    pub(crate) fn with_target(target: SocketAddr) -> Self {
96        Self {
97            explicit_target: Some(target),
98        }
99    }
100
101    /// Broadcast discovery requests and collect replies.
102    ///
103    /// Sends the discovery request to [`BROADCAST_PORT`] up to three times,
104    /// 150 ms apart, and listens on the same socket for
105    /// [`PKT_TYPE_PEER_INFO`] replies. Resending guards against a dropped
106    /// request or reply on a lossy segment. Only the elected primary of a
107    /// cluster replies, so a cluster contributes one entry; multiple clusters
108    /// on the same L2 segment each contribute one. Replies are de-duplicated
109    /// by serial.
110    ///
111    /// Timing: returns 50 ms after the last reply (whichever cluster replies
112    /// last), or — if nothing replies — only at the hard 500 ms cap, having
113    /// retransmitted the request in the meantime.
114    ///
115    /// # Errors
116    ///
117    /// Returns an [`io::Error`] only for unrecoverable socket failures
118    /// (bind, send, or unexpected `recv` errors). A normal "no devices
119    /// found" outcome returns `Ok(vec![])`.
120    pub fn discover(&self) -> io::Result<Vec<DeviceInfo>> {
121        let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
122        socket.set_broadcast(true)?;
123
124        let request = encode_discovery_request();
125        let targets = self.discovery_targets();
126
127        let mut devices: Vec<DeviceInfo> = Vec::new();
128        let mut buf = [0u8; PACKET_SIZE];
129        let start = Instant::now();
130        let mut bursts_sent: u32 = 0;
131        let mut last_reply: Option<Instant> = None;
132
133        loop {
134            if start.elapsed() >= TOTAL_BUDGET {
135                break;
136            }
137            // Stop early once replies have stopped arriving — but only after
138            // hearing at least one. With no reply yet, keep waiting (and
139            // retransmitting) for the full budget so a dropped request or
140            // reply does not become a false "no devices found".
141            if last_reply.is_some_and(|t| t.elapsed() >= QUIET_WINDOW) {
142                break;
143            }
144
145            // Send the next request burst when it falls due.
146            let next_burst_at = BURST_INTERVAL * bursts_sent;
147            if bursts_sent < DISCOVERY_BURSTS && start.elapsed() >= next_burst_at {
148                for target in &targets {
149                    // A per-target send_to may fail (e.g. an interface went
150                    // down between enumeration and send); other targets still
151                    // get a chance, so swallow the error and keep going.
152                    let _ = socket.send_to(&request, target);
153                }
154                bursts_sent += 1;
155            }
156
157            // Wake at the soonest of: the budget cap, the next burst, or the
158            // quiet-window expiry once a reply has arrived. `max(1ms)` keeps
159            // the timeout non-zero (a zero read timeout means "block forever").
160            let now = start.elapsed();
161            let mut wait = TOTAL_BUDGET.saturating_sub(now);
162            if bursts_sent < DISCOVERY_BURSTS {
163                wait = wait.min((BURST_INTERVAL * bursts_sent).saturating_sub(now));
164            }
165            if let Some(t) = last_reply {
166                wait = wait.min(QUIET_WINDOW.saturating_sub(t.elapsed()));
167            }
168            socket.set_read_timeout(Some(wait.max(Duration::from_millis(1))))?;
169
170            match socket.recv_from(&mut buf) {
171                Ok((n, src)) => {
172                    let Some(parsed) = parse_packet(&buf[..n]) else {
173                        continue;
174                    };
175                    if parsed.pkt_type != PKT_TYPE_PEER_INFO {
176                        continue;
177                    }
178                    // Bridged networks — and our own retransmits — can echo a
179                    // reply more than once; drop duplicates by serial so the
180                    // returned list reflects unique devices.
181                    if devices.iter().any(|d| d.serial == parsed.serial) {
182                        continue;
183                    }
184                    devices.push(DeviceInfo {
185                        ip: src.ip(),
186                        serial: parsed.serial,
187                        compatible: parsed.version == PROTOCOL_VERSION,
188                    });
189                    last_reply = Some(Instant::now());
190                }
191                Err(e)
192                    if e.kind() == io::ErrorKind::WouldBlock
193                        || e.kind() == io::ErrorKind::TimedOut =>
194                {
195                    // Window elapsed with no packet; loop to retransmit or
196                    // finish. WouldBlock on Linux, TimedOut on Windows.
197                }
198                Err(e) => return Err(e),
199            }
200        }
201        Ok(devices)
202    }
203
204    /// Compute the set of addresses [`Self::discover`] should send the
205    /// request to. When the caller supplied an explicit target (tests),
206    /// that's the only entry. Otherwise: every directed broadcast for
207    /// every up, non-loopback IPv4 interface on the host, plus
208    /// `255.255.255.255` as a fallback for setups where directed
209    /// broadcasts are filtered or the enumeration finds nothing.
210    fn discovery_targets(&self) -> Vec<SocketAddr> {
211        if let Some(target) = self.explicit_target {
212            return vec![target];
213        }
214        let mut targets: Vec<SocketAddr> = enumerate_broadcast_addresses()
215            .into_iter()
216            .map(|ip| SocketAddr::from((ip, BROADCAST_PORT)))
217            .collect();
218        targets.push(SocketAddr::from((Ipv4Addr::BROADCAST, BROADCAST_PORT)));
219        targets
220    }
221}
222
223/// Enumerate every up, non-loopback, non-link-local IPv4 interface on
224/// the host and return its directed broadcast address — e.g. an
225/// interface configured as `192.168.10.10/24` yields `192.168.10.255`.
226///
227/// Using the *directed* broadcast — rather than the limited broadcast
228/// `255.255.255.255` — is what makes discovery reliable on multi-NIC
229/// hosts. The kernel's route table maps `192.168.10.0/24` to the
230/// interface that owns it, so a `send_to(192.168.10.255, ...)` always
231/// leaves via that interface. The limited broadcast follows the
232/// default route, which on a host with both ethernet and wifi
233/// typically points at wifi — the wrong network for a wired cluster.
234///
235/// Implemented via the `if-addrs` crate, which wraps `getifaddrs(3)`
236/// on Unix and `GetAdaptersAddresses` on Windows. Loopback
237/// (`127.0.0.0/8`) is skipped because it never reaches a remote
238/// device; link-local (`169.254.0.0/16`) is skipped because such
239/// addresses are auto-assigned when DHCP fails and broadcasting there
240/// reaches nothing useful.
241///
242/// Returns an empty `Vec` when enumeration itself fails; the caller
243/// still falls back to `255.255.255.255`.
244fn enumerate_broadcast_addresses() -> Vec<Ipv4Addr> {
245    let interfaces = match if_addrs::get_if_addrs() {
246        Ok(v) => v,
247        Err(_) => return Vec::new(),
248    };
249    interfaces
250        .into_iter()
251        .filter_map(|iface| match iface.addr {
252            if_addrs::IfAddr::V4(v4) => Some(v4),
253            _ => None,
254        })
255        .filter(|v4| !v4.ip.is_loopback() && !is_link_local(&v4.ip))
256        .filter_map(|v4| v4.broadcast)
257        .collect()
258}
259
260/// Returns `true` for IPv4 addresses in the link-local range
261/// `169.254.0.0/16`. `Ipv4Addr::is_link_local` is still unstable in
262/// the standard library, so we check the octets directly.
263fn is_link_local(ip: &Ipv4Addr) -> bool {
264    let o = ip.octets();
265    o[0] == 169 && o[1] == 254
266}
267
268#[cfg(test)]
269#[path = "devicediscovery_tests.rs"]
270mod tests;