Skip to main content

bacnet_network/
router_table.rs

1//! Router table — maps BACnet network numbers to transport ports.
2//!
3//! Per ASHRAE 135-2020 Clause 6.4, a BACnet router maintains a routing table
4//! that records which directly-connected or learned networks can be reached
5//! via which port.
6
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use bacnet_types::MacAddr;
11
12/// Reachability status of a route entry.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ReachabilityStatus {
15    /// Route is available for traffic.
16    Reachable,
17    /// Route is temporarily unreachable due to congestion (Router-Busy).
18    Busy,
19    /// Route has permanently failed.
20    Unreachable,
21}
22
23/// A route entry in the router table.
24#[derive(Debug, Clone)]
25pub struct RouteEntry {
26    /// Index of the port this network is reachable through.
27    pub port_index: usize,
28    /// Whether this is a directly-connected network (vs learned via another router).
29    pub directly_connected: bool,
30    /// MAC address of the next-hop router (empty for directly-connected networks).
31    pub next_hop_mac: MacAddr,
32    /// When this learned route was last confirmed. `None` for direct routes.
33    pub last_seen: Option<Instant>,
34    pub reachability: ReachabilityStatus,
35    /// Deadline after which a `Busy` status auto-clears (spec 6.6.3.6).
36    pub busy_until: Option<Instant>,
37    /// Number of times this route changed ports within the flap detection window.
38    pub flap_count: u8,
39    /// When the route last changed ports.
40    pub last_port_change: Option<Instant>,
41}
42
43/// BACnet routing table.
44///
45/// Maps network numbers to the port through which they can be reached.
46#[derive(Debug, Clone)]
47pub struct RouterTable {
48    /// Network number → route entry.
49    routes: HashMap<u16, RouteEntry>,
50}
51
52impl RouterTable {
53    /// Create an empty routing table.
54    pub fn new() -> Self {
55        Self {
56            routes: HashMap::new(),
57        }
58    }
59
60    /// Add a directly-connected network on the given port.
61    /// Network 0 and 0xFFFF are reserved and will be silently ignored.
62    pub fn add_direct(&mut self, network: u16, port_index: usize) {
63        if network == 0 || network == 0xFFFF {
64            return;
65        }
66        self.routes.insert(
67            network,
68            RouteEntry {
69                port_index,
70                directly_connected: true,
71                next_hop_mac: MacAddr::new(),
72                last_seen: None,
73                reachability: ReachabilityStatus::Reachable,
74                busy_until: None,
75                flap_count: 0,
76                last_port_change: None,
77            },
78        );
79    }
80
81    /// Add a learned route (network reachable via a next-hop router on the given port).
82    /// Network 0 and 0xFFFF are reserved and will be silently ignored.
83    /// Does not overwrite direct routes.
84    pub fn add_learned(&mut self, network: u16, port_index: usize, next_hop_mac: MacAddr) {
85        if network == 0 || network == 0xFFFF {
86            return;
87        }
88        if let Some(existing) = self.routes.get(&network) {
89            if existing.directly_connected {
90                return; // never overwrite direct routes
91            }
92        }
93        self.routes.insert(
94            network,
95            RouteEntry {
96                port_index,
97                directly_connected: false,
98                next_hop_mac,
99                last_seen: Some(Instant::now()),
100                reachability: ReachabilityStatus::Reachable,
101                busy_until: None,
102                flap_count: 0,
103                last_port_change: None,
104            },
105        );
106    }
107
108    /// Add a learned route, always accepting (spec 6.6.3.2: last I-Am-Router wins).
109    /// Detects rapid port changes for operator visibility but never suppresses updates.
110    ///
111    /// Returns `true` if the route was inserted/updated.
112    pub fn add_learned_with_flap_detection(
113        &mut self,
114        network: u16,
115        port_index: usize,
116        next_hop_mac: MacAddr,
117    ) -> bool {
118        if network == 0 || network == 0xFFFF {
119            return false;
120        }
121        if let Some(existing) = self.routes.get(&network) {
122            if existing.directly_connected {
123                return false;
124            }
125            if existing.port_index != port_index {
126                let now = Instant::now();
127                let flap_count = match existing.last_port_change {
128                    Some(changed) if now.duration_since(changed) < Duration::from_secs(60) => {
129                        existing.flap_count.saturating_add(1)
130                    }
131                    _ => 1,
132                };
133                if flap_count >= 3 {
134                    tracing::warn!(
135                        network,
136                        old_port = existing.port_index,
137                        new_port = port_index,
138                        flap_count,
139                        "Route flapping detected — network changed ports {} times in 60s",
140                        flap_count
141                    );
142                }
143                self.routes.insert(
144                    network,
145                    RouteEntry {
146                        port_index,
147                        directly_connected: false,
148                        next_hop_mac,
149                        last_seen: Some(now),
150                        reachability: ReachabilityStatus::Reachable,
151                        busy_until: None,
152                        flap_count,
153                        last_port_change: Some(now),
154                    },
155                );
156                return true;
157            }
158        }
159        self.add_learned(network, port_index, next_hop_mac);
160        true
161    }
162
163    /// Mark a network as busy with a deadline for auto-clear (spec 6.6.3.6).
164    pub fn mark_busy(&mut self, network: u16, deadline: Instant) {
165        if let Some(entry) = self.routes.get_mut(&network) {
166            entry.reachability = ReachabilityStatus::Busy;
167            entry.busy_until = Some(deadline);
168        }
169    }
170
171    /// Mark a network as available, clearing any busy state (spec 6.6.3.7).
172    pub fn mark_available(&mut self, network: u16) {
173        if let Some(entry) = self.routes.get_mut(&network) {
174            entry.reachability = ReachabilityStatus::Reachable;
175            entry.busy_until = None;
176        }
177    }
178
179    /// Mark a network as permanently unreachable (spec 6.6.3.5, reject reason 1).
180    /// Keeps the entry in the table (unlike `remove`).
181    pub fn mark_unreachable(&mut self, network: u16) {
182        if let Some(entry) = self.routes.get_mut(&network) {
183            if !entry.directly_connected {
184                entry.reachability = ReachabilityStatus::Unreachable;
185                entry.busy_until = None;
186            }
187        }
188    }
189
190    /// Clear busy state for entries whose `busy_until` deadline has elapsed.
191    pub fn clear_expired_busy(&mut self) {
192        let now = Instant::now();
193        for entry in self.routes.values_mut() {
194            if let Some(deadline) = entry.busy_until {
195                if now >= deadline {
196                    entry.reachability = ReachabilityStatus::Reachable;
197                    entry.busy_until = None;
198                }
199            }
200        }
201    }
202
203    /// Get effective reachability, checking busy_until inline for immediate accuracy.
204    /// This avoids up to 90s worst-case from the 60s aging sweep granularity.
205    pub fn effective_reachability(&self, network: u16) -> Option<ReachabilityStatus> {
206        self.routes.get(&network).map(|entry| {
207            if entry.reachability == ReachabilityStatus::Busy {
208                if let Some(deadline) = entry.busy_until {
209                    if Instant::now() >= deadline {
210                        return ReachabilityStatus::Reachable;
211                    }
212                }
213            }
214            entry.reachability
215        })
216    }
217
218    /// Look up the route for a network number.
219    pub fn lookup(&self, network: u16) -> Option<&RouteEntry> {
220        self.routes.get(&network)
221    }
222
223    /// Lookup a mutable route entry by network number.
224    pub fn lookup_mut(&mut self, network: u16) -> Option<&mut RouteEntry> {
225        self.routes.get_mut(&network)
226    }
227
228    /// Remove a route.
229    pub fn remove(&mut self, network: u16) -> Option<RouteEntry> {
230        self.routes.remove(&network)
231    }
232
233    /// List all known network numbers.
234    pub fn networks(&self) -> Vec<u16> {
235        self.routes.keys().copied().collect()
236    }
237
238    /// List networks reachable via ports OTHER than `exclude_port`.
239    pub fn networks_not_on_port(&self, exclude_port: usize) -> Vec<u16> {
240        self.routes
241            .iter()
242            .filter(|(_, entry)| entry.port_index != exclude_port)
243            .map(|(net, _)| *net)
244            .collect()
245    }
246
247    /// List networks reachable on a given port.
248    pub fn networks_on_port(&self, port_index: usize) -> Vec<u16> {
249        self.routes
250            .iter()
251            .filter(|(_, entry)| entry.port_index == port_index)
252            .map(|(net, _)| *net)
253            .collect()
254    }
255
256    /// Number of routes.
257    pub fn len(&self) -> usize {
258        self.routes.len()
259    }
260
261    /// Whether the table is empty.
262    pub fn is_empty(&self) -> bool {
263        self.routes.is_empty()
264    }
265
266    /// Refresh the `last_seen` timestamp for a learned route.
267    ///
268    /// Direct routes are unaffected since they never expire.
269    pub fn touch(&mut self, network: u16) {
270        if let Some(entry) = self.routes.get_mut(&network) {
271            if !entry.directly_connected {
272                entry.last_seen = Some(Instant::now());
273            }
274        }
275    }
276
277    /// Remove learned routes that have not been refreshed within `max_age`.
278    ///
279    /// Returns the network numbers that were purged.
280    pub fn purge_stale(&mut self, max_age: Duration) -> Vec<u16> {
281        let now = Instant::now();
282        let stale: Vec<u16> = self
283            .routes
284            .iter()
285            .filter(|(_, entry)| {
286                if let Some(seen) = entry.last_seen {
287                    !entry.directly_connected && now.duration_since(seen) > max_age
288                } else {
289                    false
290                }
291            })
292            .map(|(net, _)| *net)
293            .collect();
294        for net in &stale {
295            self.routes.remove(net);
296        }
297        stale
298    }
299}
300
301impl Default for RouterTable {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn add_direct_and_lookup() {
313        let mut table = RouterTable::new();
314        table.add_direct(1000, 0);
315
316        let entry = table.lookup(1000).unwrap();
317        assert!(entry.directly_connected);
318        assert_eq!(entry.port_index, 0);
319        assert!(entry.next_hop_mac.is_empty());
320    }
321
322    #[test]
323    fn add_learned_route() {
324        let mut table = RouterTable::new();
325        let next_hop = MacAddr::from_slice(&[192, 168, 1, 100, 0xBA, 0xC0]);
326        table.add_learned(2000, 0, next_hop.clone());
327
328        let entry = table.lookup(2000).unwrap();
329        assert!(!entry.directly_connected);
330        assert_eq!(entry.port_index, 0);
331        assert_eq!(entry.next_hop_mac, next_hop);
332    }
333
334    #[test]
335    fn lookup_unknown_returns_none() {
336        let table = RouterTable::new();
337        assert!(table.lookup(9999).is_none());
338    }
339
340    #[test]
341    fn remove_route() {
342        let mut table = RouterTable::new();
343        table.add_direct(1000, 0);
344        assert_eq!(table.len(), 1);
345
346        let removed = table.remove(1000);
347        assert!(removed.is_some());
348        assert!(table.is_empty());
349    }
350
351    #[test]
352    fn networks_on_port() {
353        let mut table = RouterTable::new();
354        table.add_direct(1000, 0);
355        table.add_direct(2000, 1);
356        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
357
358        let port0 = table.networks_on_port(0);
359        assert_eq!(port0.len(), 2);
360        assert!(port0.contains(&1000));
361        assert!(port0.contains(&3000));
362
363        let port1 = table.networks_on_port(1);
364        assert_eq!(port1.len(), 1);
365        assert!(port1.contains(&2000));
366    }
367
368    #[test]
369    fn list_all_networks() {
370        let mut table = RouterTable::new();
371        table.add_direct(100, 0);
372        table.add_direct(200, 1);
373        table.add_direct(300, 2);
374
375        let nets = table.networks();
376        assert_eq!(nets.len(), 3);
377    }
378
379    #[test]
380    fn learned_route_does_not_override_direct() {
381        let mut table = RouterTable::new();
382        table.add_direct(1000, 0);
383
384        let entry = table.lookup(1000).unwrap();
385        assert!(entry.directly_connected);
386        assert_eq!(entry.port_index, 0);
387
388        // add_learned should not overwrite a direct route
389        table.add_learned(1000, 1, MacAddr::from_slice(&[10, 0, 1, 1]));
390
391        let entry = table.lookup(1000).unwrap();
392        assert!(entry.directly_connected);
393        assert_eq!(entry.port_index, 0);
394        assert!(entry.next_hop_mac.is_empty());
395    }
396
397    #[test]
398    fn add_learned_overwrites_existing_learned() {
399        let mut table = RouterTable::new();
400        table.add_learned(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
401
402        let entry = table.lookup(3000).unwrap();
403        assert!(!entry.directly_connected);
404        assert_eq!(entry.next_hop_mac.as_slice(), &[10, 0, 1, 1]);
405
406        table.add_learned(3000, 1, MacAddr::from_slice(&[10, 0, 2, 1]));
407
408        let entry = table.lookup(3000).unwrap();
409        assert!(!entry.directly_connected);
410        assert_eq!(entry.port_index, 1);
411        assert_eq!(entry.next_hop_mac.as_slice(), &[10, 0, 2, 1]);
412    }
413
414    #[test]
415    fn lookup_unknown_network_returns_none() {
416        let mut table = RouterTable::new();
417        table.add_direct(1000, 0);
418        table.add_direct(2000, 1);
419
420        assert!(table.lookup(9999).is_none());
421    }
422
423    #[test]
424    fn purge_stale_routes() {
425        let mut table = RouterTable::new();
426        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
427        let purged = table.purge_stale(Duration::from_secs(0));
428        assert_eq!(purged, vec![3000]);
429        assert!(table.lookup(3000).is_none());
430    }
431
432    #[test]
433    fn direct_routes_never_expire() {
434        let mut table = RouterTable::new();
435        table.add_direct(1000, 0);
436        let purged = table.purge_stale(Duration::from_secs(0));
437        assert!(purged.is_empty());
438        assert!(table.lookup(1000).is_some());
439    }
440
441    #[test]
442    fn touch_refreshes_timestamp() {
443        let mut table = RouterTable::new();
444        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
445        table.touch(3000);
446        let purged = table.purge_stale(Duration::from_secs(3600));
447        assert!(purged.is_empty());
448        assert!(table.lookup(3000).is_some());
449    }
450
451    #[test]
452    fn learned_route_has_last_seen() {
453        let mut table = RouterTable::new();
454        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
455        let entry = table.lookup(3000).unwrap();
456        assert!(entry.last_seen.is_some());
457    }
458
459    #[test]
460    fn direct_route_has_no_last_seen() {
461        let mut table = RouterTable::new();
462        table.add_direct(1000, 0);
463        let entry = table.lookup(1000).unwrap();
464        assert!(entry.last_seen.is_none());
465    }
466
467    #[test]
468    fn networks_not_on_port_excludes_requesting_port() {
469        let mut table = RouterTable::new();
470        table.add_direct(1000, 0);
471        table.add_direct(2000, 1);
472        table.add_learned(3000, 1, MacAddr::from_slice(&[10, 0, 1, 1]));
473        table.add_learned(4000, 0, MacAddr::from_slice(&[10, 0, 2, 1]));
474
475        let nets = table.networks_not_on_port(0);
476        assert!(nets.contains(&2000));
477        assert!(nets.contains(&3000));
478        assert!(!nets.contains(&1000));
479        assert!(!nets.contains(&4000));
480        assert_eq!(nets.len(), 2);
481
482        let nets = table.networks_not_on_port(1);
483        assert!(nets.contains(&1000));
484        assert!(nets.contains(&4000));
485        assert!(!nets.contains(&2000));
486        assert!(!nets.contains(&3000));
487        assert_eq!(nets.len(), 2);
488    }
489
490    #[test]
491    fn add_learned_flap_inserts_new_route() {
492        let mut table = RouterTable::new();
493        let result =
494            table.add_learned_with_flap_detection(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
495        assert!(result);
496        let entry = table.lookup(3000).unwrap();
497        assert_eq!(entry.port_index, 0);
498    }
499
500    #[test]
501    fn add_learned_flap_refreshes_same_port() {
502        let mut table = RouterTable::new();
503        table.add_learned(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
504        let result =
505            table.add_learned_with_flap_detection(3000, 0, MacAddr::from_slice(&[10, 0, 1, 2]));
506        assert!(result);
507        let entry = table.lookup(3000).unwrap();
508        assert_eq!(entry.next_hop_mac.as_slice(), &[10, 0, 1, 2]);
509    }
510
511    #[test]
512    fn add_learned_flap_always_updates_different_port() {
513        let mut table = RouterTable::new();
514        table.add_learned(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
515        // Spec 6.6.3.2: last I-Am-Router wins — always accept even from different port
516        let result =
517            table.add_learned_with_flap_detection(3000, 1, MacAddr::from_slice(&[10, 0, 2, 1]));
518        assert!(result);
519        let entry = table.lookup(3000).unwrap();
520        assert_eq!(entry.port_index, 1);
521        assert_eq!(entry.next_hop_mac.as_slice(), &[10, 0, 2, 1]);
522    }
523
524    #[test]
525    fn add_learned_flap_increments_flap_count() {
526        let mut table = RouterTable::new();
527        table.add_learned_with_flap_detection(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
528        table.add_learned_with_flap_detection(3000, 1, MacAddr::from_slice(&[10, 0, 2, 1]));
529        let entry = table.lookup(3000).unwrap();
530        assert_eq!(entry.flap_count, 1);
531        table.add_learned_with_flap_detection(3000, 0, MacAddr::from_slice(&[10, 0, 1, 1]));
532        let entry = table.lookup(3000).unwrap();
533        assert_eq!(entry.flap_count, 2);
534    }
535
536    #[test]
537    fn add_learned_flap_rejects_direct_route() {
538        let mut table = RouterTable::new();
539        table.add_direct(1000, 0);
540        let result =
541            table.add_learned_with_flap_detection(1000, 1, MacAddr::from_slice(&[10, 0, 2, 1]));
542        assert!(!result);
543        assert!(table.lookup(1000).unwrap().directly_connected);
544    }
545
546    #[test]
547    fn mark_busy_sets_reachability_and_deadline() {
548        let mut table = RouterTable::new();
549        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
550        let deadline = Instant::now() + Duration::from_secs(30);
551        table.mark_busy(3000, deadline);
552        let entry = table.lookup(3000).unwrap();
553        assert_eq!(entry.reachability, ReachabilityStatus::Busy);
554        assert_eq!(entry.busy_until, Some(deadline));
555    }
556
557    #[test]
558    fn mark_available_clears_busy() {
559        let mut table = RouterTable::new();
560        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
561        table.mark_busy(3000, Instant::now() + Duration::from_secs(30));
562        table.mark_available(3000);
563        let entry = table.lookup(3000).unwrap();
564        assert_eq!(entry.reachability, ReachabilityStatus::Reachable);
565        assert!(entry.busy_until.is_none());
566    }
567
568    #[test]
569    fn mark_unreachable_keeps_entry() {
570        let mut table = RouterTable::new();
571        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
572        table.mark_unreachable(3000);
573        let entry = table.lookup(3000).unwrap();
574        assert_eq!(entry.reachability, ReachabilityStatus::Unreachable);
575        assert!(table.lookup(3000).is_some());
576    }
577
578    #[test]
579    fn mark_unreachable_does_not_affect_direct_routes() {
580        let mut table = RouterTable::new();
581        table.add_direct(1000, 0);
582        table.mark_unreachable(1000);
583        let entry = table.lookup(1000).unwrap();
584        assert_eq!(entry.reachability, ReachabilityStatus::Reachable);
585    }
586
587    #[test]
588    fn clear_expired_busy_clears_elapsed_deadlines() {
589        let mut table = RouterTable::new();
590        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
591        table.mark_busy(3000, Instant::now() - Duration::from_secs(1));
592        table.clear_expired_busy();
593        let entry = table.lookup(3000).unwrap();
594        assert_eq!(entry.reachability, ReachabilityStatus::Reachable);
595        assert!(entry.busy_until.is_none());
596    }
597
598    #[test]
599    fn effective_reachability_checks_deadline_inline() {
600        let mut table = RouterTable::new();
601        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
602        table.mark_busy(3000, Instant::now() - Duration::from_secs(1));
603        assert_eq!(
604            table.effective_reachability(3000),
605            Some(ReachabilityStatus::Reachable)
606        );
607    }
608
609    #[test]
610    fn effective_reachability_returns_busy_when_deadline_not_elapsed() {
611        let mut table = RouterTable::new();
612        table.add_learned(3000, 0, MacAddr::from_slice(&[1, 2, 3]));
613        table.mark_busy(3000, Instant::now() + Duration::from_secs(30));
614        assert_eq!(
615            table.effective_reachability(3000),
616            Some(ReachabilityStatus::Busy)
617        );
618    }
619}