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