Skip to main content

bacnet_client/
discovery.rs

1//! Device discovery table — collects IAm responses for WhoIs/WhoHas lookups.
2
3use std::collections::HashMap;
4use std::time::{Duration, Instant};
5
6use bacnet_types::MacAddr;
7use bacnet_types::enums::Segmentation;
8use bacnet_types::primitives::ObjectIdentifier;
9
10/// Information about a discovered BACnet device.
11#[derive(Debug, Clone)]
12pub struct DiscoveredDevice {
13    /// The device's object identifier (always ObjectType::DEVICE).
14    pub object_identifier: ObjectIdentifier,
15    /// The MAC address from which the IAm was received.
16    pub mac_address: MacAddr,
17    /// Maximum APDU length the device accepts.
18    pub max_apdu_length: u32,
19    /// Segmentation support level.
20    pub segmentation_supported: Segmentation,
21    /// Maximum segments the remote device accepts (None = unlimited/unspecified).
22    pub max_segments_accepted: Option<u32>,
23    /// Vendor identifier.
24    pub vendor_id: u16,
25    /// When this entry was last updated.
26    pub last_seen: Instant,
27    /// If this device is behind a router, the BACnet network number it resides on.
28    pub source_network: Option<u16>,
29    /// If this device is behind a router, its MAC address on the remote network.
30    pub source_address: Option<MacAddr>,
31}
32
33impl DiscoveredDevice {
34    /// True when this row represents a local peer: neither routing field is
35    /// set. See the [`DeviceTable`] address-space invariant.
36    pub fn is_local(&self) -> bool {
37        self.source_network.is_none() && self.source_address.is_none()
38    }
39}
40
41/// Manual registration details for a known routed peer, for use with
42/// [`crate::client::BACnetClient::add_routed_device`].
43///
44/// Everything a caller must supply so requests to the peer route correctly
45/// and are sized by the peer's advertised limits rather than local defaults.
46#[derive(Debug, Clone)]
47pub struct RoutedDeviceConfig {
48    /// Device instance number.
49    pub instance: u32,
50    /// MAC of the immediate-hop router (transport next hop, not peer identity).
51    pub router_mac: Vec<u8>,
52    /// Remote network number (SNET) the peer resides on.
53    pub remote_network: u16,
54    /// Peer's MAC on the remote network (SADR).
55    pub remote_mac: Vec<u8>,
56    /// The peer's advertised Max APDU Length Accepted.
57    pub max_apdu_length: u32,
58    /// The peer's advertised segmentation capability.
59    pub segmentation_supported: Segmentation,
60    /// The peer's Max Segments Accepted, if known (None = unspecified).
61    pub max_segments_accepted: Option<u32>,
62}
63
64/// Provenance of a row's `Segmentation_Supported` value (Clause 12.11).
65///
66/// Only authoritative capability may drive a preflight refusal: a legacy
67/// manual registration stores placeholder defaults that must not be mistaken
68/// for a peer advertising NO_SEGMENTATION (#371).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(crate) enum PeerSegmentation {
71    /// The value is authoritative: learned from the peer's I-Am or explicitly
72    /// supplied by a caller (`add_routed_device`, `DeviceTable::upsert`).
73    Authoritative(Segmentation),
74    /// The row stores manual placeholder defaults; capability is unknown.
75    Placeholder,
76}
77
78#[derive(Debug)]
79struct TableEntry {
80    device: DiscoveredDevice,
81    segmentation: PeerSegmentation,
82}
83
84/// Thread-safe device discovery table.
85///
86/// Keyed by device instance number (the instance part of the DEVICE object
87/// identifier). Ordinary I-Am refreshes update a row, while a conflicting
88/// complete endpoint claim leaves the first authoritative row unchanged.
89///
90/// # Address-space invariant
91///
92/// A row is *routed* when it carries routed-source identity (`source_network`
93/// and `source_address`, per Clause 6.2.2 the original source SNET/SADR); its
94/// `mac_address` is then only the immediate-hop router, shared by every peer
95/// behind that router. A row is *local* only when it is unambiguously local:
96/// both routing fields are `None`. A row with exactly one routing field set is
97/// malformed; it counts as neither local nor routable and matches no secondary
98/// lookup until a complete I-Am refreshes it.
99///
100/// # Capability provenance
101///
102/// Each row records whether its `segmentation_supported` is authoritative
103/// (I-Am ingestion via `upsert_with_result`, explicit
104/// [`upsert`][DeviceTable::upsert], or `add_routed_device`) or a legacy
105/// placeholder (`add_device`). Provenance lives in the same table entry as
106/// the device data, so it stays coherent across insert, update, purge, and
107/// clear: any replacement of an instance replaces its provenance with it.
108#[derive(Debug, Default)]
109pub struct DeviceTable {
110    devices: HashMap<u32, TableEntry>,
111}
112
113#[derive(Debug, Clone)]
114pub(crate) enum DeviceUpsertResult {
115    Inserted,
116    Updated,
117    Collision { retained: DiscoveredDevice },
118    Dropped,
119}
120
121enum DeviceEndpoint<'a> {
122    Local(&'a [u8]),
123    Routed(u16, &'a [u8]),
124    Incomplete,
125}
126
127impl DeviceTable {
128    /// Create an empty table.
129    pub fn new() -> Self {
130        Self {
131            devices: HashMap::new(),
132        }
133    }
134
135    /// Insert or update a discovered device.
136    ///
137    /// The row's `segmentation_supported` is treated as authoritative
138    /// capability (explicit configuration); see `PeerSegmentation`.
139    /// This explicit administrative path replaces any row with the same
140    /// instance regardless of endpoint identity.
141    ///
142    /// The table is capped at 4096 entries. If the table is full and the
143    /// device is not already present, the new entry is silently dropped.
144    pub fn upsert(&mut self, device: DiscoveredDevice) {
145        let segmentation = PeerSegmentation::Authoritative(device.segmentation_supported);
146        let _ = self.insert_entry(device, segmentation);
147    }
148
149    pub(crate) fn upsert_with_result(&mut self, device: DiscoveredDevice) -> DeviceUpsertResult {
150        let key = device.object_identifier.instance_number();
151        if let Some(existing) = self.devices.get(&key) {
152            let replace = existing.segmentation == PeerSegmentation::Placeholder
153                || match (Self::endpoint(&existing.device), Self::endpoint(&device)) {
154                    // A malformed retained row has no stable endpoint identity;
155                    // accepting the next I-Am keeps it repairable.
156                    (DeviceEndpoint::Incomplete, _) => true,
157                    // A malformed incoming row cannot displace stable routing.
158                    (_, DeviceEndpoint::Incomplete) => false,
159                    (DeviceEndpoint::Local(current), DeviceEndpoint::Local(incoming)) => {
160                        current == incoming
161                    }
162                    (
163                        DeviceEndpoint::Routed(current_network, current_address),
164                        DeviceEndpoint::Routed(incoming_network, incoming_address),
165                    ) => current_network == incoming_network && current_address == incoming_address,
166                    _ => false,
167                };
168            if !replace {
169                return DeviceUpsertResult::Collision {
170                    retained: existing.device.clone(),
171                };
172            }
173        }
174
175        let segmentation = PeerSegmentation::Authoritative(device.segmentation_supported);
176        self.insert_entry(device, segmentation)
177    }
178
179    fn endpoint(device: &DiscoveredDevice) -> DeviceEndpoint<'_> {
180        match (&device.source_network, &device.source_address) {
181            (None, None) => DeviceEndpoint::Local(device.mac_address.as_slice()),
182            (Some(network), Some(address)) => DeviceEndpoint::Routed(*network, address.as_slice()),
183            _ => DeviceEndpoint::Incomplete,
184        }
185    }
186
187    /// Insert or update a device whose capability fields are manual
188    /// placeholders rather than learned or explicitly supplied values
189    /// (legacy `add_device`). Its `segmentation_supported` must never drive
190    /// a capability decision (#371).
191    pub(crate) fn upsert_placeholder(&mut self, device: DiscoveredDevice) {
192        let _ = self.insert_entry(device, PeerSegmentation::Placeholder);
193    }
194
195    fn insert_entry(
196        &mut self,
197        device: DiscoveredDevice,
198        segmentation: PeerSegmentation,
199    ) -> DeviceUpsertResult {
200        const MAX_DEVICE_TABLE_ENTRIES: usize = 4096;
201        let key = device.object_identifier.instance_number();
202        let is_existing = self.devices.contains_key(&key);
203        if !is_existing && self.devices.len() >= MAX_DEVICE_TABLE_ENTRIES {
204            return DeviceUpsertResult::Dropped;
205        }
206        self.devices.insert(
207            key,
208            TableEntry {
209                device,
210                segmentation,
211            },
212        );
213        if is_existing {
214            DeviceUpsertResult::Updated
215        } else {
216            DeviceUpsertResult::Inserted
217        }
218    }
219
220    /// Get all discovered devices as a snapshot.
221    pub fn all(&self) -> Vec<DiscoveredDevice> {
222        self.devices.values().map(|e| e.device.clone()).collect()
223    }
224
225    /// Look up a device by instance number.
226    pub fn get(&self, instance: u32) -> Option<&DiscoveredDevice> {
227        self.devices.get(&instance).map(|e| &e.device)
228    }
229
230    /// Look up a local device by its MAC address.
231    ///
232    /// Only unambiguously local rows (no `source_network`/`source_address`)
233    /// are considered: a routed row's `mac_address` is the router it was
234    /// heard through, not the remote device's identity, so a lookup of the
235    /// router's own address must never select a peer behind it (Clause 6.2.2).
236    ///
237    /// The table is keyed by device instance, so two local rows can share one
238    /// MAC until the stale purge; the freshest `last_seen` wins, mirroring
239    /// [`DeviceTable::get_by_network_address`].
240    pub fn get_by_mac(&self, mac: &[u8]) -> Option<&DiscoveredDevice> {
241        self.devices
242            .values()
243            .filter(|e| e.device.is_local())
244            .filter(|e| e.device.mac_address.as_slice() == mac)
245            .max_by_key(|e| e.device.last_seen)
246            .map(|e| &e.device)
247    }
248
249    /// Look up a routed device by its remote network number and MAC address.
250    ///
251    /// A routed device's `mac_address` holds the router it was heard through,
252    /// which every device behind that router shares; its own identity is the
253    /// SNET/SADR of the NPDU that carried its I-Am, stored as
254    /// `source_network` and `source_address`.
255    ///
256    /// The table is keyed by device instance, so two rows can share one
257    /// SNET/SADR (a re-commissioned instance survives until the stale purge).
258    /// The freshest `last_seen` wins: it holds what the device most recently
259    /// advertised.
260    pub fn get_by_network_address(
261        &self,
262        network: u16,
263        address: &[u8],
264    ) -> Option<&DiscoveredDevice> {
265        self.devices
266            .values()
267            .filter(|e| {
268                e.device.source_network == Some(network)
269                    && e.device
270                        .source_address
271                        .as_ref()
272                        .is_some_and(|a| a.as_slice() == address)
273            })
274            .max_by_key(|e| e.device.last_seen)
275            .map(|e| &e.device)
276    }
277
278    /// Segmentation capability knowledge for the local row [`get_by_mac`](Self::get_by_mac)
279    /// selects, or `None` when no local row matches. Coherent with request
280    /// sizing: both consult the same row under one shared borrow.
281    pub(crate) fn local_peer_segmentation(&self, mac: &[u8]) -> Option<PeerSegmentation> {
282        let device = self.get_by_mac(mac)?;
283        self.devices
284            .get(&device.object_identifier.instance_number())
285            .map(|e| e.segmentation)
286    }
287
288    /// Segmentation capability knowledge for the routed row
289    /// [`get_by_network_address`] selects, or `None` when no row matches.
290    ///
291    /// [`get_by_network_address`]: Self::get_by_network_address
292    pub(crate) fn routed_peer_segmentation(
293        &self,
294        network: u16,
295        address: &[u8],
296    ) -> Option<PeerSegmentation> {
297        let device = self.get_by_network_address(network, address)?;
298        self.devices
299            .get(&device.object_identifier.instance_number())
300            .map(|e| e.segmentation)
301    }
302
303    /// Clear all entries.
304    pub fn clear(&mut self) {
305        self.devices.clear();
306    }
307
308    /// Number of discovered devices.
309    pub fn len(&self) -> usize {
310        self.devices.len()
311    }
312
313    /// Whether the table is empty.
314    pub fn is_empty(&self) -> bool {
315        self.devices.is_empty()
316    }
317
318    /// Remove entries whose `last_seen` is older than `max_age`.
319    pub fn purge_stale(&mut self, max_age: Duration) {
320        let _ = self.purge_stale_at(Instant::now(), max_age);
321    }
322
323    pub(crate) fn purge_stale_collect(&mut self, max_age: Duration) -> Vec<DiscoveredDevice> {
324        self.purge_stale_at(Instant::now(), max_age)
325    }
326
327    fn purge_stale_at(&mut self, now: Instant, max_age: Duration) -> Vec<DiscoveredDevice> {
328        let stale_keys: Vec<u32> = self
329            .devices
330            .iter()
331            .filter_map(|(key, entry)| {
332                let is_stale = now
333                    .checked_duration_since(entry.device.last_seen)
334                    .is_some_and(|age| age > max_age);
335                is_stale.then_some(*key)
336            })
337            .collect();
338
339        stale_keys
340            .into_iter()
341            .filter_map(|key| self.devices.remove(&key).map(|e| e.device))
342            .collect()
343    }
344}
345
346#[cfg(test)]
347#[path = "discovery_collision_tests.rs"]
348mod discovery_collision_tests;
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use bacnet_types::enums::ObjectType;
354
355    fn make_device(instance: u32) -> DiscoveredDevice {
356        DiscoveredDevice {
357            object_identifier: ObjectIdentifier::new(ObjectType::DEVICE, instance).unwrap(),
358            mac_address: MacAddr::from_slice(&[192, 168, 1, 100, 0xBA, 0xC0]),
359            max_apdu_length: 1476,
360            segmentation_supported: Segmentation::NONE,
361            max_segments_accepted: None,
362            vendor_id: 42,
363            last_seen: Instant::now(),
364            source_network: None,
365            source_address: None,
366        }
367    }
368
369    #[test]
370    fn upsert_and_get() {
371        let mut table = DeviceTable::new();
372        table.upsert(make_device(1234));
373        assert_eq!(table.len(), 1);
374        let dev = table.get(1234).unwrap();
375        assert_eq!(dev.vendor_id, 42);
376    }
377
378    #[test]
379    fn upsert_updates_existing() {
380        let mut table = DeviceTable::new();
381        table.upsert(make_device(1234));
382        let mut updated = make_device(1234);
383        updated.vendor_id = 99;
384        table.upsert(updated);
385        assert_eq!(table.len(), 1);
386        assert_eq!(table.get(1234).unwrap().vendor_id, 99);
387    }
388
389    #[test]
390    fn upsert_with_result_reports_insert_and_update() {
391        let mut table = DeviceTable::new();
392        assert!(matches!(
393            table.upsert_with_result(make_device(1234)),
394            DeviceUpsertResult::Inserted
395        ));
396        assert!(matches!(
397            table.upsert_with_result(make_device(1234)),
398            DeviceUpsertResult::Updated
399        ));
400    }
401
402    #[test]
403    fn all_returns_snapshot() {
404        let mut table = DeviceTable::new();
405        table.upsert(make_device(1));
406        table.upsert(make_device(2));
407        table.upsert(make_device(3));
408        assert_eq!(table.all().len(), 3);
409    }
410
411    #[test]
412    fn clear_empties_table() {
413        let mut table = DeviceTable::new();
414        table.upsert(make_device(1));
415        table.clear();
416        assert!(table.is_empty());
417    }
418
419    #[test]
420    fn get_by_mac_finds_device() {
421        let mut table = DeviceTable::new();
422        table.upsert(make_device(1234));
423        let mac = &[192, 168, 1, 100, 0xBA, 0xC0];
424        let dev = table.get_by_mac(mac).unwrap();
425        assert_eq!(dev.object_identifier.instance_number(), 1234);
426    }
427
428    #[test]
429    fn get_by_mac_not_found() {
430        let mut table = DeviceTable::new();
431        table.upsert(make_device(1234));
432        assert!(table.get_by_mac(&[10, 0, 0, 1, 0xBA, 0xC0]).is_none());
433    }
434
435    fn make_routed_device(instance: u32, network: u16, address: &[u8]) -> DiscoveredDevice {
436        let mut device = make_device(instance);
437        device.source_network = Some(network);
438        device.source_address = Some(MacAddr::from_slice(address));
439        device
440    }
441
442    #[test]
443    fn get_by_network_address_finds_routed_device() {
444        let mut table = DeviceTable::new();
445        table.upsert(make_routed_device(1, 100, &[0x03]));
446        table.upsert(make_routed_device(2, 200, &[0x03]));
447        let dev = table.get_by_network_address(200, &[0x03]).unwrap();
448        assert_eq!(dev.object_identifier.instance_number(), 2);
449    }
450
451    /// A local device whose MAC happens to equal the queried remote address
452    /// lives in a different address space and must not match.
453    #[test]
454    fn get_by_network_address_ignores_local_devices() {
455        let mut table = DeviceTable::new();
456        let mut local = make_device(1);
457        local.mac_address = MacAddr::from_slice(&[0x03]);
458        table.upsert(local);
459        assert!(table.get_by_network_address(100, &[0x03]).is_none());
460    }
461
462    #[test]
463    fn get_by_network_address_requires_both_terms() {
464        let mut table = DeviceTable::new();
465        table.upsert(make_routed_device(1, 100, &[0x03]));
466        assert!(table.get_by_network_address(100, &[0x04]).is_none());
467        assert!(table.get_by_network_address(101, &[0x03]).is_none());
468    }
469
470    /// A re-commissioned device instance leaves two rows at one SNET/SADR
471    /// until the stale purge; the freshest advertisement must win, not an
472    /// arbitrary hash-order pick.
473    #[test]
474    fn get_by_network_address_prefers_freshest_entry() {
475        let mut table = DeviceTable::new();
476        let now = Instant::now();
477        let mut stale = make_routed_device(1, 100, &[0x03]);
478        stale.max_apdu_length = 1476;
479        stale.last_seen = now;
480        let mut fresh = make_routed_device(2, 100, &[0x03]);
481        fresh.max_apdu_length = 128;
482        fresh.last_seen = now + Duration::from_secs(60);
483        table.upsert(stale);
484        table.upsert(fresh);
485
486        let dev = table.get_by_network_address(100, &[0x03]).unwrap();
487        assert_eq!(dev.object_identifier.instance_number(), 2);
488        assert_eq!(dev.max_apdu_length, 128);
489    }
490
491    /// A routed row's `mac_address` is the router it was heard through, not
492    /// the remote device's identity (Clause 6.2.2: SNET/SADR identify the
493    /// original source; the router MAC is only the local next hop). A local
494    /// MAC lookup for the router's own address must therefore never select a
495    /// routed peer behind that router (#372).
496    /// Provenance transitions (#371): a legacy placeholder row becomes
497    /// authoritative when refreshed through the I-Am insertion path, and
498    /// provenance never outlives its row across purge/clear.
499    #[test]
500    fn segmentation_provenance_tracks_row_lifecycle() {
501        let mut table = DeviceTable::new();
502        table.upsert_placeholder(make_device(1));
503        assert_eq!(
504            table.local_peer_segmentation(&[192, 168, 1, 100, 0xBA, 0xC0]),
505            Some(PeerSegmentation::Placeholder)
506        );
507
508        // I-Am refresh of the same instance (the dispatch path).
509        table.upsert_with_result(make_device(1));
510        assert_eq!(
511            table.local_peer_segmentation(&[192, 168, 1, 100, 0xBA, 0xC0]),
512            Some(PeerSegmentation::Authoritative(Segmentation::NONE))
513        );
514
515        // Purge removes the row and its provenance with it.
516        let mut stale = make_device(1);
517        stale.last_seen = Instant::now() - Duration::from_secs(120);
518        table.upsert_placeholder(stale);
519        table.purge_stale(Duration::from_secs(60));
520        assert_eq!(
521            table.local_peer_segmentation(&[192, 168, 1, 100, 0xBA, 0xC0]),
522            None
523        );
524
525        // Clear leaves no orphan provenance either.
526        table.upsert(make_device(2));
527        table.clear();
528        assert_eq!(
529            table.local_peer_segmentation(&[192, 168, 1, 100, 0xBA, 0xC0]),
530            None
531        );
532    }
533
534    #[test]
535    fn get_by_mac_ignores_routed_rows() {
536        let mut table = DeviceTable::new();
537        let router_mac = &[192, 168, 1, 1, 0xBA, 0xC0];
538        let routed = make_routed_device(3003, 100, &[0x03]);
539        let mut routed_with_router_mac = routed;
540        routed_with_router_mac.mac_address = MacAddr::from_slice(router_mac);
541        table.upsert(routed_with_router_mac);
542
543        assert!(table.get_by_mac(router_mac).is_none());
544    }
545
546    /// Several routed peers can share one router MAC; none of them may be
547    /// returned by a local lookup of that router's address.
548    #[test]
549    fn get_by_mac_ignores_multiple_routed_rows_behind_one_router() {
550        let mut table = DeviceTable::new();
551        let router_mac = &[192, 168, 1, 1, 0xBA, 0xC0];
552        for instance in [1u32, 2, 3] {
553            let mut peer = make_routed_device(instance, 100, &[instance as u8]);
554            peer.mac_address = MacAddr::from_slice(router_mac);
555            table.upsert(peer);
556        }
557        assert!(table.get_by_mac(router_mac).is_none());
558    }
559
560    fn make_local_device_at_mac(instance: u32, mac: &[u8]) -> DiscoveredDevice {
561        let mut device = make_device(instance);
562        device.mac_address = MacAddr::from_slice(mac);
563        device
564    }
565
566    /// A local device whose MAC byte-equals a routed peer's SADR lives in a
567    /// different address space; the local lookup must return the local row.
568    #[test]
569    fn get_by_mac_returns_local_row_not_routed_sadr_namesake() {
570        let mut table = DeviceTable::new();
571        table.upsert(make_local_device_at_mac(10, &[0x03]));
572        table.upsert(make_routed_device(3003, 100, &[0x03]));
573
574        let dev = table.get_by_mac(&[0x03]).unwrap();
575        assert_eq!(dev.object_identifier.instance_number(), 10);
576    }
577
578    /// Two local rows sharing one MAC (re-commissioned instance surviving
579    /// until the stale purge) must resolve deterministically to the freshest
580    /// advertisement, mirroring the routed lookup rule.
581    #[test]
582    fn get_by_mac_prefers_freshest_local_row() {
583        let mut table = DeviceTable::new();
584        let now = Instant::now();
585        let mut stale = make_device(1);
586        stale.last_seen = now;
587        let mut fresh = make_device(2);
588        fresh.last_seen = now + Duration::from_secs(60);
589        table.upsert(stale);
590        table.upsert(fresh);
591
592        let dev = table.get_by_mac(&[192, 168, 1, 100, 0xBA, 0xC0]).unwrap();
593        assert_eq!(dev.object_identifier.instance_number(), 2);
594    }
595
596    /// Partial routing metadata (one half of SNET/SADR set) is malformed:
597    /// such a row is not unambiguously local, so it is excluded from local
598    /// lookup; the routed lookup already requires both terms.
599    #[test]
600    fn partial_routing_metadata_is_excluded_from_local_lookup() {
601        let mut table = DeviceTable::new();
602        let mut partial = make_device(7);
603        partial.source_network = Some(100);
604        table.upsert(partial);
605
606        assert!(table.get_by_mac(&[192, 168, 1, 100, 0xBA, 0xC0]).is_none());
607        assert!(
608            table
609                .get_by_network_address(100, &[192, 168, 1, 100, 0xBA, 0xC0])
610                .is_none()
611        );
612    }
613
614    #[test]
615    fn purge_stale_removes_old_entries() {
616        let mut table = DeviceTable::new();
617        let now = Instant::now();
618        let mut old_device = make_device(1);
619        old_device.last_seen = now;
620        table.upsert(old_device);
621        let mut fresh_device = make_device(2);
622        fresh_device.last_seen = now + Duration::from_secs(120);
623        table.upsert(fresh_device);
624        assert_eq!(table.len(), 2);
625
626        let removed = table.purge_stale_at(now + Duration::from_secs(120), Duration::from_secs(60));
627        assert_eq!(table.len(), 1);
628        assert_eq!(removed.len(), 1);
629        assert_eq!(removed[0].object_identifier.instance_number(), 1);
630        assert!(table.get(1).is_none());
631        assert!(table.get(2).is_some());
632    }
633
634    #[test]
635    fn purge_stale_keeps_all_when_fresh() {
636        let mut table = DeviceTable::new();
637        table.upsert(make_device(1));
638        table.upsert(make_device(2));
639        table.purge_stale(Duration::from_secs(60));
640        assert_eq!(table.len(), 2);
641    }
642
643    #[test]
644    fn purge_stale_removes_all_when_expired() {
645        let mut table = DeviceTable::new();
646        let now = Instant::now();
647        let mut d1 = make_device(1);
648        d1.last_seen = now;
649        let mut d2 = make_device(2);
650        d2.last_seen = now;
651        table.upsert(d1);
652        table.upsert(d2);
653        let removed = table.purge_stale_at(now + Duration::from_secs(200), Duration::from_secs(60));
654        assert!(table.is_empty());
655        assert_eq!(removed.len(), 2);
656    }
657
658    #[test]
659    fn upsert_refreshes_last_seen() {
660        let mut table = DeviceTable::new();
661        let now = Instant::now();
662        let mut old_device = make_device(1);
663        old_device.last_seen = now;
664        table.upsert(old_device);
665
666        let mut refreshed = make_device(1);
667        refreshed.last_seen = now + Duration::from_secs(120);
668        table.upsert(refreshed);
669        let removed = table.purge_stale_at(now + Duration::from_secs(120), Duration::from_secs(60));
670        assert_eq!(table.len(), 1);
671        assert!(removed.is_empty());
672        assert!(table.get(1).is_some());
673    }
674
675    #[test]
676    fn purge_stale_handles_max_age_larger_than_instant_history() {
677        let mut table = DeviceTable::new();
678        table.upsert(make_device(1));
679
680        table.purge_stale(Duration::MAX);
681
682        assert_eq!(table.len(), 1);
683    }
684}