use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::time::Instant;
use bacnet_types::enums::Segmentation;
use crate::tui::message::DeviceRow;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SortKey {
Instance,
Address,
Network,
Vendor,
LastSeen,
}
impl SortKey {
fn next(self) -> Self {
match self {
Self::Instance => Self::Address,
Self::Address => Self::Network,
Self::Network => Self::Vendor,
Self::Vendor => Self::LastSeen,
Self::LastSeen => Self::Instance,
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Instance => "instance",
Self::Address => "address",
Self::Network => "network",
Self::Vendor => "vendor",
Self::LastSeen => "last seen",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Movement {
Up,
Down,
PageUp,
PageDown,
Home,
End,
}
pub(crate) struct DeviceTable {
rows: HashMap<u32, DeviceRow>,
sort: SortKey,
descending: bool,
filter: String,
visible: Vec<u32>,
selected: Option<u32>,
selected_index: Option<usize>,
stale: bool,
pub(crate) offset: Cell<usize>,
pub(crate) page: Cell<usize>,
}
impl Default for DeviceTable {
fn default() -> Self {
Self {
rows: HashMap::new(),
sort: SortKey::Instance,
descending: false,
filter: String::new(),
visible: Vec::new(),
selected: None,
selected_index: None,
stale: false,
offset: Cell::new(0),
page: Cell::new(10),
}
}
}
impl DeviceTable {
pub(crate) fn upsert(&mut self, row: DeviceRow) {
let reorders = match self.rows.get(&row.instance) {
Some(old) => {
self.sort == SortKey::LastSeen
|| old.address != row.address
|| old.network != row.network
|| old.vendor_id != row.vendor_id
}
None => true,
};
self.rows.insert(row.instance, row);
self.stale |= reorders;
}
pub(crate) fn remove(&mut self, instance: u32) {
if self.rows.remove(&instance).is_some() {
self.stale = true;
}
}
pub(crate) fn replace_all(&mut self, rows: Vec<DeviceRow>) {
self.rows = rows.into_iter().map(|r| (r.instance, r)).collect();
self.stale = true;
}
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.rows.is_empty()
}
#[cfg(test)]
pub(crate) fn get(&self, instance: u32) -> Option<&DeviceRow> {
self.rows.get(&instance)
}
pub(crate) fn sort(&self) -> (SortKey, bool) {
(self.sort, self.descending)
}
pub(crate) fn cycle_sort(&mut self) {
self.sort = self.sort.next();
self.descending = false;
self.stale = true;
}
pub(crate) fn reverse(&mut self) {
self.descending = !self.descending;
self.stale = true;
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
pub(crate) fn push_filter(&mut self, ch: char) {
self.filter.push(ch);
self.stale = true;
}
pub(crate) fn pop_filter(&mut self) {
self.filter.pop();
self.stale = true;
}
pub(crate) fn clear_filter(&mut self) {
if !self.filter.is_empty() {
self.filter.clear();
self.stale = true;
}
}
pub(crate) fn settle(&mut self) {
if !self.stale {
return;
}
self.stale = false;
let needle = self.filter.to_lowercase();
let mut visible: Vec<&DeviceRow> = self
.rows
.values()
.filter(|row| needle.is_empty() || matches(row, &needle))
.collect();
let key = self.sort;
visible.sort_by(|a, b| {
let order = match key {
SortKey::Instance => a.instance.cmp(&b.instance),
SortKey::Address => a.address.cmp(&b.address),
SortKey::Network => a.network.cmp(&b.network),
SortKey::Vendor => a.vendor_id.cmp(&b.vendor_id),
SortKey::LastSeen => b.last_seen.cmp(&a.last_seen),
};
order.then(a.instance.cmp(&b.instance))
});
if self.descending {
visible.reverse();
}
self.visible = visible.into_iter().map(|r| r.instance).collect();
self.fix_selection();
}
fn fix_selection(&mut self) {
self.selected_index = self
.selected
.and_then(|sel| self.visible.iter().position(|&i| i == sel));
if self.selected_index.is_none() {
self.selected_index = (!self.visible.is_empty()).then_some(0);
}
self.selected = self.selected_index.map(|i| self.visible[i]);
}
#[cfg(test)]
pub(crate) fn visible(&self) -> impl Iterator<Item = &DeviceRow> + '_ {
self.visible.iter().filter_map(|i| self.rows.get(i))
}
pub(crate) fn visible_len(&self) -> usize {
self.visible.len()
}
pub(crate) fn visible_window(
&self,
start: usize,
len: usize,
) -> impl Iterator<Item = &DeviceRow> + '_ {
self.visible
.iter()
.skip(start)
.take(len)
.filter_map(|i| self.rows.get(i))
}
pub(crate) fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub(crate) fn move_selection(&mut self, movement: Movement) {
self.settle();
let Some(last) = self.visible.len().checked_sub(1) else {
return;
};
let current = self.selected_index.unwrap_or(0);
let page = self.page.get().max(1);
let next = match movement {
Movement::Up => current.saturating_sub(1),
Movement::Down => (current + 1).min(last),
Movement::PageUp => current.saturating_sub(page),
Movement::PageDown => (current + page).min(last),
Movement::Home => 0,
Movement::End => last,
};
self.selected_index = Some(next);
self.selected = Some(self.visible[next]);
}
}
fn matches(row: &DeviceRow, needle: &str) -> bool {
row.instance.to_string().contains(needle)
|| row.address.to_lowercase().contains(needle)
|| row.network.is_some_and(|n| n.to_string().contains(needle))
|| row.vendor_id.to_string().contains(needle)
}
pub(crate) fn segmentation_label(segmentation: Segmentation) -> String {
match segmentation {
Segmentation::BOTH => "both".into(),
Segmentation::TRANSMIT => "transmit".into(),
Segmentation::RECEIVE => "receive".into(),
Segmentation::NONE => "none".into(),
other => other.to_raw().to_string(),
}
}
pub(crate) fn age_label(now: Instant, then: Instant) -> String {
let secs = now.saturating_duration_since(then).as_secs();
match secs {
0..=59 => format!("{secs}s"),
60..=3_599 => format!("{}m", secs / 60),
_ => format!("{}h", secs / 3_600),
}
}
const MAX_ADDRESSES: usize = 4;
const MAX_EXTRA: usize = 64;
const MAX_INSTANCES: usize = 4_096;
pub(crate) const MAX_BANNER_LINES: usize = 3;
#[derive(Default)]
struct Claims {
addresses: BTreeSet<String>,
extra: BTreeSet<u64>,
}
impl Claims {
fn add(&mut self, address: &str) -> bool {
if self.addresses.contains(address) {
return false;
}
if self.addresses.len() < MAX_ADDRESSES {
return self.addresses.insert(address.to_string());
}
if self.extra.len() >= MAX_EXTRA {
return false;
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(address, &mut hasher);
self.extra.insert(std::hash::Hasher::finish(&hasher))
}
fn line(&self, instance: u32) -> String {
let list: Vec<&str> = self.addresses.iter().map(String::as_str).collect();
let mut joined = match list.as_slice() {
[one] => (*one).to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
match self.extra.len() {
0 => {}
MAX_EXTRA => joined.push_str(&format!(" (+{MAX_EXTRA} or more)")),
n => joined.push_str(&format!(" (+{n} more)")),
}
format!("instance {instance} claimed by {joined}")
}
}
#[derive(Default)]
pub(crate) struct Duplicates {
by_instance: BTreeMap<u32, Claims>,
banner: Vec<String>,
}
impl Duplicates {
pub(crate) fn record(&mut self, retained: &DeviceRow, incoming: &DeviceRow) -> bool {
let instance = retained.instance;
if !self.by_instance.contains_key(&instance) && self.by_instance.len() >= MAX_INSTANCES {
return false;
}
let claims = self.by_instance.entry(instance).or_default();
let added = claims.add(&retained.address) | claims.add(&incoming.address);
if added {
self.rebuild_banner();
}
added
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.by_instance.is_empty()
}
#[cfg(test)]
pub(crate) fn lines(&self) -> Vec<String> {
self.by_instance
.iter()
.map(|(instance, claims)| claims.line(*instance))
.collect()
}
pub(crate) fn banner(&self) -> &[String] {
&self.banner
}
fn rebuild_banner(&mut self) {
let total = self.by_instance.len();
let shown = if total > MAX_BANNER_LINES {
MAX_BANNER_LINES - 1
} else {
total
};
self.banner = self
.by_instance
.iter()
.take(shown)
.map(|(instance, claims)| format!(" DUPLICATE {}", claims.line(*instance)))
.collect();
if total > shown {
self.banner.push(format!(
" DUPLICATE and {} more duplicated instances",
total - shown
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claim(instance: u32, address: &str) -> DeviceRow {
DeviceRow {
instance,
address: address.into(),
network: None,
vendor_id: 1,
max_apdu: 1476,
segmentation: Segmentation::NONE,
last_seen: Instant::now(),
}
}
#[test]
fn duplicates_keep_four_addresses_and_count_the_rest_once() {
let mut duplicates = Duplicates::default();
let retained = claim(7, "10.0.0.1:47808");
for i in 2..=9 {
duplicates.record(&retained, &claim(7, &format!("10.0.0.{i}:47808")));
}
assert!(!duplicates.record(&retained, &claim(7, "10.0.0.9:47808")));
assert_eq!(
duplicates.lines(),
[
"instance 7 claimed by 10.0.0.1:47808, 10.0.0.2:47808, 10.0.0.3:47808 \
and 10.0.0.4:47808 (+5 more)"
]
);
for i in 0..200 {
duplicates.record(&retained, &claim(7, &format!("10.0.1.{i}:47808")));
}
assert!(duplicates.lines()[0].ends_with("(+64 or more)"));
}
#[test]
fn the_banner_is_capped_and_summarises_the_rest() {
let mut duplicates = Duplicates::default();
for instance in 1..=5 {
duplicates.record(
&claim(instance, "10.0.0.1:47808"),
&claim(instance, "10.0.0.2:47808"),
);
}
let banner = duplicates.banner();
assert_eq!(banner.len(), MAX_BANNER_LINES);
assert_eq!(banner[2], " DUPLICATE and 3 more duplicated instances");
assert!(banner[0].starts_with(" DUPLICATE instance 1 claimed by"));
}
}