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}
28
29/// Thread-safe device discovery table.
30///
31/// Keyed by device instance number (the instance part of the DEVICE object
32/// identifier). Updated whenever an IAm is received.
33#[derive(Debug, Default)]
34pub struct DeviceTable {
35    devices: HashMap<u32, DiscoveredDevice>,
36}
37
38impl DeviceTable {
39    pub fn new() -> Self {
40        Self {
41            devices: HashMap::new(),
42        }
43    }
44
45    /// Insert or update a discovered device.
46    ///
47    /// The table is capped at 4096 entries. If the table is full and the
48    /// device is not already present, the new entry is silently dropped.
49    pub fn upsert(&mut self, device: DiscoveredDevice) {
50        const MAX_DEVICE_TABLE_ENTRIES: usize = 4096;
51        let key = device.object_identifier.instance_number();
52        if !self.devices.contains_key(&key) && self.devices.len() >= MAX_DEVICE_TABLE_ENTRIES {
53            return; // table full, drop new entry
54        }
55        self.devices.insert(key, device);
56    }
57
58    /// Get all discovered devices as a snapshot.
59    pub fn all(&self) -> Vec<DiscoveredDevice> {
60        self.devices.values().cloned().collect()
61    }
62
63    /// Look up a device by instance number.
64    pub fn get(&self, instance: u32) -> Option<&DiscoveredDevice> {
65        self.devices.get(&instance)
66    }
67
68    /// Look up a device by its MAC address.
69    pub fn get_by_mac(&self, mac: &[u8]) -> Option<&DiscoveredDevice> {
70        self.devices
71            .values()
72            .find(|d| d.mac_address.as_slice() == mac)
73    }
74
75    /// Clear all entries.
76    pub fn clear(&mut self) {
77        self.devices.clear();
78    }
79
80    /// Number of discovered devices.
81    pub fn len(&self) -> usize {
82        self.devices.len()
83    }
84
85    /// Whether the table is empty.
86    pub fn is_empty(&self) -> bool {
87        self.devices.is_empty()
88    }
89
90    /// Remove entries whose `last_seen` is older than `max_age`.
91    pub fn purge_stale(&mut self, max_age: Duration) {
92        let cutoff = Instant::now() - max_age;
93        self.devices.retain(|_, d| d.last_seen >= cutoff);
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use bacnet_types::enums::ObjectType;
101
102    fn make_device(instance: u32) -> DiscoveredDevice {
103        DiscoveredDevice {
104            object_identifier: ObjectIdentifier::new(ObjectType::DEVICE, instance).unwrap(),
105            mac_address: MacAddr::from_slice(&[192, 168, 1, 100, 0xBA, 0xC0]),
106            max_apdu_length: 1476,
107            segmentation_supported: Segmentation::NONE,
108            max_segments_accepted: None,
109            vendor_id: 42,
110            last_seen: Instant::now(),
111        }
112    }
113
114    #[test]
115    fn upsert_and_get() {
116        let mut table = DeviceTable::new();
117        table.upsert(make_device(1234));
118        assert_eq!(table.len(), 1);
119        let dev = table.get(1234).unwrap();
120        assert_eq!(dev.vendor_id, 42);
121    }
122
123    #[test]
124    fn upsert_updates_existing() {
125        let mut table = DeviceTable::new();
126        table.upsert(make_device(1234));
127        let mut updated = make_device(1234);
128        updated.vendor_id = 99;
129        table.upsert(updated);
130        assert_eq!(table.len(), 1);
131        assert_eq!(table.get(1234).unwrap().vendor_id, 99);
132    }
133
134    #[test]
135    fn all_returns_snapshot() {
136        let mut table = DeviceTable::new();
137        table.upsert(make_device(1));
138        table.upsert(make_device(2));
139        table.upsert(make_device(3));
140        assert_eq!(table.all().len(), 3);
141    }
142
143    #[test]
144    fn clear_empties_table() {
145        let mut table = DeviceTable::new();
146        table.upsert(make_device(1));
147        table.clear();
148        assert!(table.is_empty());
149    }
150
151    #[test]
152    fn get_by_mac_finds_device() {
153        let mut table = DeviceTable::new();
154        table.upsert(make_device(1234));
155        let mac = &[192, 168, 1, 100, 0xBA, 0xC0];
156        let dev = table.get_by_mac(mac).unwrap();
157        assert_eq!(dev.object_identifier.instance_number(), 1234);
158    }
159
160    #[test]
161    fn get_by_mac_not_found() {
162        let mut table = DeviceTable::new();
163        table.upsert(make_device(1234));
164        assert!(table.get_by_mac(&[10, 0, 0, 1, 0xBA, 0xC0]).is_none());
165    }
166
167    #[test]
168    fn purge_stale_removes_old_entries() {
169        let mut table = DeviceTable::new();
170        // Insert a device with a last_seen in the past
171        let mut old_device = make_device(1);
172        old_device.last_seen = Instant::now() - Duration::from_secs(120);
173        table.upsert(old_device);
174        // Insert a fresh device
175        table.upsert(make_device(2));
176        assert_eq!(table.len(), 2);
177
178        table.purge_stale(Duration::from_secs(60));
179        assert_eq!(table.len(), 1);
180        assert!(table.get(1).is_none());
181        assert!(table.get(2).is_some());
182    }
183
184    #[test]
185    fn purge_stale_keeps_all_when_fresh() {
186        let mut table = DeviceTable::new();
187        table.upsert(make_device(1));
188        table.upsert(make_device(2));
189        table.purge_stale(Duration::from_secs(60));
190        assert_eq!(table.len(), 2);
191    }
192
193    #[test]
194    fn purge_stale_removes_all_when_expired() {
195        let mut table = DeviceTable::new();
196        let mut d1 = make_device(1);
197        d1.last_seen = Instant::now() - Duration::from_secs(200);
198        let mut d2 = make_device(2);
199        d2.last_seen = Instant::now() - Duration::from_secs(200);
200        table.upsert(d1);
201        table.upsert(d2);
202        table.purge_stale(Duration::from_secs(60));
203        assert!(table.is_empty());
204    }
205
206    #[test]
207    fn upsert_refreshes_last_seen() {
208        let mut table = DeviceTable::new();
209        let mut old_device = make_device(1);
210        old_device.last_seen = Instant::now() - Duration::from_secs(120);
211        table.upsert(old_device);
212
213        // Re-discover the same device (fresh timestamp)
214        table.upsert(make_device(1));
215
216        // Should survive purge since last_seen was refreshed
217        table.purge_stale(Duration::from_secs(60));
218        assert_eq!(table.len(), 1);
219        assert!(table.get(1).is_some());
220    }
221}