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