1use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use bacnet_types::MacAddr;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ReachabilityStatus {
15 Reachable,
17 Busy,
19 Unreachable,
21}
22
23#[derive(Debug, Clone)]
25pub struct RouteEntry {
26 pub port_index: usize,
28 pub directly_connected: bool,
30 pub next_hop_mac: MacAddr,
32 pub last_seen: Option<Instant>,
34 pub reachability: ReachabilityStatus,
35 pub busy_until: Option<Instant>,
37 pub flap_count: u8,
39 pub last_port_change: Option<Instant>,
41}
42
43#[derive(Debug, Clone)]
47pub struct RouterTable {
48 routes: HashMap<u16, RouteEntry>,
50}
51
52impl RouterTable {
53 pub fn new() -> Self {
55 Self {
56 routes: HashMap::new(),
57 }
58 }
59
60 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 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; }
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 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 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 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 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 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 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 pub fn lookup(&self, network: u16) -> Option<&RouteEntry> {
220 self.routes.get(&network)
221 }
222
223 pub fn lookup_mut(&mut self, network: u16) -> Option<&mut RouteEntry> {
225 self.routes.get_mut(&network)
226 }
227
228 pub fn remove(&mut self, network: u16) -> Option<RouteEntry> {
230 self.routes.remove(&network)
231 }
232
233 pub fn networks(&self) -> Vec<u16> {
235 self.routes.keys().copied().collect()
236 }
237
238 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 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 pub fn len(&self) -> usize {
258 self.routes.len()
259 }
260
261 pub fn is_empty(&self) -> bool {
263 self.routes.is_empty()
264 }
265
266 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 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 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 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}