use std::collections::VecDeque;
use std::time::Instant;
use bacnet_types::enums::PropertyIdentifier;
use super::devices::Movement;
use super::objects::object_label;
use super::{App, Link, FLASH_FOR};
use crate::tui::message::Command;
use crate::tui::point::Health;
use crate::tui::watched::{Point, RowId, Source, WatchCommand, WatchEvent, WatchStats};
use crate::tui::worker::watch::MAX_ROWS;
pub(crate) const HISTORY: usize = 512;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum Screen {
#[default]
Main,
Browse,
Watch,
Network,
Sc,
Mstp,
Capture,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct WatchRow {
pub(crate) id: RowId,
pub(crate) point: Point,
pub(crate) name: Option<String>,
pub(crate) source: Source,
pub(crate) value: Option<Result<String, String>>,
pub(crate) units: Option<String>,
pub(crate) flags: Option<Health>,
pub(crate) updated: Option<Instant>,
pub(crate) history: VecDeque<f64>,
}
#[derive(Debug, Default)]
pub(crate) struct WatchList {
rows: Vec<WatchRow>,
selected: usize,
next_id: RowId,
pub(crate) stats: WatchStats,
}
impl WatchList {
pub(crate) fn rows(&self) -> &[WatchRow] {
&self.rows
}
pub(crate) fn selected(&self) -> usize {
self.selected
}
fn add(&mut self, point: Point) -> Result<RowId, String> {
if self.rows.iter().any(|row| row.point == point) {
return Err("Already watched.".into());
}
if self.rows.len() >= MAX_ROWS {
return Err(format!("The watch list holds at most {MAX_ROWS} rows."));
}
self.next_id += 1;
self.rows.push(WatchRow {
id: self.next_id,
point,
name: None,
source: Source::Starting,
value: None,
units: None,
flags: None,
updated: None,
history: VecDeque::new(),
});
Ok(self.next_id)
}
fn remove_selected(&mut self) -> Option<RowId> {
if self.selected >= self.rows.len() {
return None;
}
let row = self.rows.remove(self.selected);
self.selected = self.selected.min(self.rows.len().saturating_sub(1));
Some(row.id)
}
fn row_mut(&mut self, id: RowId) -> Option<&mut WatchRow> {
self.rows.iter_mut().find(|row| row.id == id)
}
pub(crate) fn apply(&mut self, event: WatchEvent) -> Option<String> {
match event {
WatchEvent::Source { row, source } => {
if let Source::Failed(error) = &source {
let text =
self.rows.iter().find(|r| r.id == row).map(|r| {
format!("Can't watch {}: {error}", object_label(r.point.object))
});
self.rows.retain(|r| r.id != row);
self.selected = self.selected.min(self.rows.len().saturating_sub(1));
return text;
}
if let Some(row) = self.row_mut(row) {
row.source = source;
}
}
WatchEvent::Details { row, name, units } => {
if let Some(row) = self.row_mut(row) {
row.name = name.or(row.name.take());
row.units = units.or(row.units.take());
}
}
WatchEvent::Samples(samples) => {
for (id, sample) in samples {
let Some(row) = self.row_mut(id) else {
continue;
};
if let Some(number) = sample.number {
if row.history.len() >= HISTORY {
row.history.pop_front();
}
row.history.push_back(number);
}
row.flags = sample.flags.or(row.flags);
row.value = Some(sample.value);
row.updated = Some(sample.at);
}
}
WatchEvent::Stats(stats) => self.stats = stats,
WatchEvent::Notice(text) => return Some(text),
}
None
}
pub(crate) fn move_selection(&mut self, movement: Movement) {
let last = self.rows.len().saturating_sub(1);
self.selected = match movement {
Movement::Up => self.selected.saturating_sub(1),
Movement::Down => (self.selected + 1).min(last),
Movement::PageUp => self.selected.saturating_sub(10),
Movement::PageDown => (self.selected + 10).min(last),
Movement::Home => 0,
Movement::End => last,
};
}
}
impl App {
pub(super) fn watch_point(&mut self, mut point: Point, out: &mut Vec<Command>) {
if !self.may_transmit() {
return;
}
if !matches!(self.link, Link::Up { .. }) {
self.flash("Not connected.", FLASH_FOR);
return;
}
let mut moved = None;
if let Some(device) = self.devices.get(point.device) {
if let Some(typed) = point.network.filter(|&n| device.network != Some(n)) {
let actual = match device.network {
Some(network) => format!("network {network}"),
None => "this network".into(),
};
moved = Some(format!("{} is on {actual}, not {typed}. ", point.device));
}
point.network = device.network;
}
let notice = match self.watch.add(point) {
Ok(row) => {
out.push(Command::Watch(WatchCommand::Add { row, point }));
format!(
"Watching {} on device {}. Press 3 for the watch list.",
object_label(point.object),
point.device_label()
)
}
Err(error) => error,
};
self.flash(moved.unwrap_or_default() + ¬ice, FLASH_FOR);
}
pub(super) fn watch_selected(&mut self, out: &mut Vec<Command>) {
let Some((device, object)) = self.shown_browser().and_then(|b| b.selected_point()) else {
self.flash("Open a device and select an object to watch.", FLASH_FOR);
return;
};
let point = Point {
device,
network: None,
object,
property: PropertyIdentifier::PRESENT_VALUE,
};
self.watch_point(point, out);
}
pub(super) fn unwatch_selected(&mut self, out: &mut Vec<Command>) {
if let Some(row) = self.watch.remove_selected() {
out.push(Command::Watch(WatchCommand::Remove { row }));
}
}
}
#[cfg(test)]
mod tests {
use bacnet_types::enums::ObjectType;
use bacnet_types::primitives::ObjectIdentifier;
use super::*;
use crate::tui::watched::Sample;
fn point(instance: u32) -> Point {
Point {
device: 7,
network: None,
object: ObjectIdentifier::new(ObjectType::ANALOG_INPUT, instance).unwrap(),
property: PropertyIdentifier::PRESENT_VALUE,
}
}
#[test]
fn rows_are_unique_and_the_history_is_bounded() {
let mut list = WatchList::default();
let id = list.add(point(1)).unwrap();
assert!(list.add(point(1)).is_err(), "a duplicate");
let now = Instant::now();
let samples = (0..HISTORY + 5)
.map(|n| {
(
id,
Sample {
value: Ok(n.to_string()),
number: Some(n as f64),
flags: None,
at: now,
},
)
})
.collect();
list.apply(WatchEvent::Samples(samples));
let row = &list.rows()[0];
assert_eq!(row.history.len(), HISTORY);
assert_eq!(row.history.front(), Some(&5.0));
let newest = (HISTORY + 4).to_string();
assert_eq!(row.value.as_ref().unwrap().as_deref(), Ok(newest.as_str()));
let notice = list.apply(WatchEvent::Source {
row: id,
source: Source::Failed("no such device".into()),
});
assert!(notice.unwrap().contains("no such device"));
assert!(list.rows().is_empty());
}
#[test]
fn a_later_start_without_a_name_or_units_keeps_the_first_ones() {
let mut list = WatchList::default();
let id = list.add(point(1)).unwrap();
let details = |name: Option<&str>, units: Option<&str>| WatchEvent::Details {
row: id,
name: name.map(Into::into),
units: units.map(Into::into),
};
list.apply(details(Some("Zone Temp"), Some("degrees-fahrenheit")));
list.apply(details(None, None));
let row = &list.rows()[0];
assert_eq!(row.name.as_deref(), Some("Zone Temp"));
assert_eq!(row.units.as_deref(), Some("degrees-fahrenheit"));
list.apply(details(Some("Zone 2"), Some("percent")));
let row = &list.rows()[0];
assert_eq!(row.name.as_deref(), Some("Zone 2"));
assert_eq!(row.units.as_deref(), Some("percent"));
}
}