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;