pub(crate) mod browser;
pub(crate) mod capture;
pub(crate) mod command_bar;
pub(crate) mod connection;
pub(crate) mod devices;
mod filter;
pub(crate) mod monitor;
pub(crate) mod mstp;
pub(crate) mod names;
pub(crate) mod network;
pub(crate) mod objects;
pub(crate) mod picker;
pub(crate) mod safety;
pub(crate) mod sc;
pub(crate) mod watch;
pub(crate) mod who_has;
pub(crate) mod whois;
mod worker_events;
use std::time::{Duration, Instant};
use self::browser::Browser;
use self::devices::{DeviceTable, Duplicates, Movement};
use self::picker::{Picker, PickerKey};
use self::watch::{Screen, WatchList};
use self::whois::{FormKey, FormOutcome, WhoIsForm};
use super::log_layer::LogRing;
use super::message::{Command, Failure, OpId, OpOutcome, WhoIsSpec, WorkerEvent};
use crate::core::target::AddressStyle;
pub(crate) const CTRL_C_WINDOW: Duration = Duration::from_secs(2);
pub(crate) const FLASH_FOR: Duration = Duration::from_secs(4);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Link {
Down,
Disconnecting,
Picking,
Connecting,
Up {
local: String,
},
Failed(Failure),
Passive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Overlay {
Connection,
None,
Help,
WhoIs(WhoIsForm),
Picker(Picker),
Command(String),
Safety(Box<safety::Modal>),
Join(Box<mstp::JoinDialog>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OpState {
Sending,
Listening {
until: Instant,
},
Done,
Cancelled,
CancelFailed(String),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Operation {
pub(crate) id: OpId,
pub(crate) spec: WhoIsSpec,
pub(crate) replies: usize,
pub(crate) state: OpState,
}
impl Operation {
pub(crate) fn running(&self) -> bool {
matches!(self.state, OpState::Sending | OpState::Listening { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Flash {
pub(crate) text: String,
pub(crate) until: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NoticeContext {
screen: Screen,
dialog: Option<(
std::mem::Discriminant<Overlay>,
Option<std::mem::Discriminant<safety::Modal>>,
)>,
flashes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FilterKey {
Char(char),
Backspace,
Commit,
Clear,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
Tick {
now: Instant,
dropped: u64,
log_generation: u64,
},
Resize,
Quit,
CtrlC,
Escape,
ToggleHelp,
ScrollHelp(Movement),
ToggleLog,
OpenConnection,
Connection(connection::Key),
Reconnect,
Disconnect,
OpenWhoIs,
Move(Movement),
Open,
CyclePane {
back: bool,
},
Refresh,
CycleSort,
ReverseSort,
StartFilter,
Filter(FilterKey),
Form(FormKey),
Picker(PickerKey),
ShowScreen(Screen),
AddWatch,
RemoveWatch,
OpenCommandLine,
CommandLine(FilterKey),
ToggleArm,
OpenLedger,
OpenWrite {
relinquish: bool,
},
Modal(safety::ModalKey),
Network(network::NetworkKey),
Sc(sc::ScKey),
Join(mstp::JoinKey),
Mstp(mstp::MstpKey),
Capture(capture::CaptureKey),
Worker(WorkerEvent),
}
pub(crate) struct AppConfig {
pub(crate) transport: &'static str,
pub(crate) style: AddressStyle,
pub(crate) color: bool,
pub(crate) picker: Option<Picker>,
pub(crate) join: Option<mstp::JoinDialog>,
pub(crate) passive: bool,
pub(crate) now: Instant,
}
pub(crate) struct App {
pub(crate) transport: &'static str,
pub(crate) style: AddressStyle,
pub(crate) color: bool,
pub(crate) link: Link,
pub(crate) connection: Option<connection::Connection>,
pub(crate) generation: u64,
pub(crate) devices: DeviceTable,
pub(crate) browser: Option<Box<Browser>>,
pub(crate) watch: WatchList,
pub(crate) safety: safety::SafetyState,
pub(crate) network: network::NetworkState,
pub(crate) sc: sc::ScState,
pub(crate) mstp: mstp::MstpState,
pub(crate) join_again: Option<Box<mstp::JoinDialog>>,
pub(crate) mstp_thread: String,
pub(crate) monitor: Option<Box<monitor::MonitorState>>,
pub(crate) capture: capture::CaptureState,
pub(crate) who_has: Option<who_has::WhoHasSearch>,
pub(crate) names: names::NameCache,
pub(crate) screen: Screen,
next_req: super::point::ReqId,
pub(crate) duplicates: Duplicates,
pub(crate) overlay: Overlay,
pub(crate) filter_editing: bool,
pub(crate) show_log: bool,
pub(crate) op: Option<Operation>,
last_spec: Option<WhoIsSpec>,
next_op: OpId,
pub(crate) flash: Option<Flash>,
flashes: u64,
pub(crate) help_scroll: std::cell::Cell<usize>,
ctrl_c_at: Option<Instant>,
epoch: Instant,
pub(crate) now: Instant,
pub(crate) dropped: u64,
pub(crate) log: LogRing,
log_generation: u64,
pub(crate) quit: bool,
pub(crate) exit_error: Option<String>,
pub(crate) exit_code: Option<i32>,
pub(crate) dirty: bool,
}
impl App {
pub(crate) fn new(config: AppConfig, log: LogRing) -> Self {
let join_again = config.join.clone().map(Box::new);
let (link, overlay) = match (config.picker, config.join) {
(Some(picker), _) => (Link::Picking, Overlay::Picker(picker)),
(None, Some(join)) => (Link::Picking, Overlay::Join(Box::new(join))),
(None, None) => (Link::Connecting, Overlay::None),
};
let log_generation = log.generation();
Self {
transport: config.transport,
style: config.style,
color: config.color,
link,
connection: None,
generation: 0,
devices: DeviceTable::default(),
browser: None,
watch: WatchList::default(),
safety: safety::SafetyState::new(config.passive),
network: network::NetworkState::default(),
sc: sc::ScState::default(),
mstp: mstp::MstpState::default(),
join_again,
mstp_thread: "normal".into(),
monitor: None,
capture: capture::CaptureState::default(),
who_has: None,
names: names::NameCache::default(),
screen: Screen::Main,
next_req: 0,
duplicates: Duplicates::default(),
overlay,
filter_editing: false,
show_log: false,
op: None,
last_spec: None,
next_op: 0,
flash: None,
flashes: 0,
help_scroll: std::cell::Cell::new(0),
ctrl_c_at: None,
epoch: config.now,
now: config.now,
dropped: 0,
log,
log_generation,
quit: false,
exit_error: None,
exit_code: None,
dirty: true,
}
}
pub(crate) fn fail(&mut self, error: impl Into<String>, code: i32) {
if self.exit_error.is_none() {
self.exit_error = Some(error.into());
self.exit_code = Some(code);
}
self.quit = true;
}
pub(crate) fn op_running(&self) -> bool {
self.op.as_ref().is_some_and(Operation::running)
}
pub(crate) fn shown_browser(&self) -> Option<&Browser> {
self.browser
.as_deref()
.filter(|_| self.screen == Screen::Browse)
}
pub(super) fn shown_browser_mut(&mut self) -> Option<&mut Browser> {
let shown = self.screen == Screen::Browse;
self.browser.as_deref_mut().filter(|_| shown)
}
fn show_browser(&mut self) {
if self.browser.is_some() {
self.screen = Screen::Browse;
} else {
self.flash("Open a device first (Enter on a row).", FLASH_FOR);
}
}
fn flash(&mut self, text: impl Into<String>, lasts: Duration) {
self.flashes += 1;
self.flash = Some(Flash {
text: text.into(),
until: self.now + lasts,
});
}
fn notice_context(&self) -> NoticeContext {
let dialog = match &self.overlay {
Overlay::None => None,
Overlay::Safety(modal) => Some((
std::mem::discriminant(&self.overlay),
Some(std::mem::discriminant(modal.as_ref())),
)),
other => Some((std::mem::discriminant(other), None)),
};
NoticeContext {
screen: self.screen,
dialog,
flashes: self.flashes,
}
}
fn drop_stale_notice(&mut self, before: &NoticeContext) {
let after = self.notice_context();
let moved = after.screen != before.screen
|| (after.dialog.is_some() && after.dialog != before.dialog);
if moved && after.flashes == before.flashes && self.flash.take().is_some() {
self.dirty = true;
}
}
fn running_op_mut(&mut self, id: OpId) -> Option<&mut Operation> {
self.op.as_mut().filter(|op| op.id == id && op.running())
}
fn tick(&mut self, now: Instant, dropped: u64, log_generation: u64) {
let second = |t: Instant| t.saturating_duration_since(self.epoch).as_secs();
let ticking = !self.devices.is_empty()
|| !self.watch.rows().is_empty()
|| matches!(self.screen, Screen::Network | Screen::Sc);
if second(now) != second(self.now) && (ticking || self.op_running()) {
self.dirty = true;
}
self.now = now;
if dropped != self.dropped {
self.dropped = dropped;
self.dirty = true;
}
if log_generation != self.log_generation {
self.log_generation = log_generation;
self.dirty |= self.show_log;
}
if self.flash.as_ref().is_some_and(|f| now >= f.until) {
self.flash = None;
self.dirty = true;
}
if self
.ctrl_c_at
.is_some_and(|t| now.saturating_duration_since(t) >= CTRL_C_WINDOW)
{
self.ctrl_c_at = None;
}
}
fn ctrl_c(&mut self, out: &mut Vec<Command>) {
let now = self.now;
if self
.ctrl_c_at
.is_some_and(|t| now.saturating_duration_since(t) < CTRL_C_WINDOW)
{
self.request_quit();
return;
}
if self.link == Link::Picking {
self.quit = true;
return;
}
if self.link == Link::Connecting {
self.ctrl_c_at = Some(now);
self.disconnect(out);
return;
}
self.ctrl_c_at = Some(now);
let cancelled = if let Overlay::Safety(_) = self.overlay {
self.modal_key(safety::ModalKey::Esc, out);
Some("Closed")
} else if self.overlay != Overlay::None {
self.overlay = Overlay::None;
Some("Closed")
} else if self.filter_editing {
self.filter_editing = false;
Some("Closed the filter")
} else if let Some(op) = self
.op
.as_mut()
.filter(|op| op.running() || matches!(op.state, OpState::CancelFailed(_)))
{
op.state = OpState::Cancelled;
out.push(Command::Cancel { op: op.id });
Some("Cancelled the Who-Is")
} else {
None
};
let text = match cancelled {
Some(what) => format!("{what}. Press Ctrl-C again to quit."),
None => "Press Ctrl-C again, or q, to quit.".to_string(),
};
self.flash(text, CTRL_C_WINDOW);
}
fn escape(&mut self, out: &mut Vec<Command>) {
match self.overlay {
Overlay::Picker(_) => self.quit = true,
Overlay::Help | Overlay::WhoIs(_) | Overlay::Connection | Overlay::Command(_) => {
self.overlay = Overlay::None
}
Overlay::Safety(_) => self.modal_key(safety::ModalKey::Esc, out),
Overlay::Join(_) => self.decline_join(),
Overlay::None if self.screen == Screen::Browse => match self.shown_browser_mut() {
Some(browser) if !browser.objects.filter().is_empty() => {
browser.objects.clear_filter();
self.settle_objects();
}
_ => self.close_browser(out),
},
Overlay::None if self.screen != Screen::Main => self.screen = Screen::Main,
Overlay::None if !self.devices.filter().is_empty() => {
self.devices.clear_filter();
self.settle_devices();
}
Overlay::None => self.flash = None,
}
}
fn open_who_is(&mut self) {
if !self.may_transmit() {
return;
}
if !matches!(self.link, Link::Up { .. }) {
self.flash("Not connected yet.", FLASH_FOR);
return;
}
let form = self
.last_spec
.as_ref()
.map(WhoIsForm::from_spec)
.unwrap_or_default();
self.overlay = Overlay::WhoIs(form);
}
fn form_key(&mut self, key: FormKey, out: &mut Vec<Command>) {
let known = self.devices.len();
let style = self.style;
let Overlay::WhoIs(form) = &mut self.overlay else {
return;
};
if let FormOutcome::Send(spec) = form.key(key, style, known) {
self.overlay = Overlay::None;
self.start_who_is(spec, out);
}
}
fn start_who_is(&mut self, spec: WhoIsSpec, out: &mut Vec<Command>) {
if let Some(op) = self.op.as_mut().filter(|op| op.running()) {
op.state = OpState::Cancelled;
}
self.next_op += 1;
self.last_spec = Some(spec.clone());
self.op = Some(Operation {
id: self.next_op,
spec: spec.clone(),
replies: 0,
state: OpState::Sending,
});
out.push(Command::WhoIs {
op: self.next_op,
spec,
});
}
fn picker_key(&mut self, key: PickerKey, out: &mut Vec<Command>) {
let Overlay::Picker(picker) = &mut self.overlay else {
return;
};
if let Some(iface) = picker.key(key) {
self.overlay = Overlay::None;
self.link = Link::Connecting;
self.flash(
format!(
"Using {} ({}, broadcast {}).",
iface.name, iface.ip, iface.broadcast
),
FLASH_FOR,
);
out.push(Command::Connect {
interface: iface.ip,
broadcast: iface.broadcast,
});
}
}
fn reset_session(&mut self) {
self.devices = DeviceTable::default();
self.browser = None;
if self.screen == Screen::Browse {
self.screen = Screen::Main;
}
self.watch = WatchList::default();
self.network = network::NetworkState::default();
self.sc = sc::ScState::default();
self.mstp = mstp::MstpState::default();
self.who_has = None;
self.names = names::NameCache::default();
self.capture.sources.retain(|(_, session)| !session);
self.safety.armed = None;
self.safety.policy = None;
self.safety.workspace = false;
self.safety.pending = None;
self.duplicates = Duplicates::default();
self.op = None;
self.last_spec = None;
self.filter_editing = false;
}
fn disconnect(&mut self, out: &mut Vec<Command>) {
let monitoring = self.link == Link::Passive && self.monitor.is_some();
if monitoring
|| matches!(
self.link,
Link::Connecting | Link::Up { .. } | Link::Failed(_)
)
{
self.link = Link::Disconnecting;
self.overlay = Overlay::None;
out.push(Command::Disconnect);
}
}
fn open_connection(&mut self) {
if self.connection.is_some() {
self.overlay = Overlay::Connection;
} else {
self.flash(
"Profiles disabled. Start bacnet tui without transport flags to manage them.",
FLASH_FOR,
);
}
}
fn connection_key(&mut self, key: connection::Key, out: &mut Vec<Command>) {
if !matches!(self.link, Link::Down | Link::Failed(_)) {
self.flash("Disconnect (x) before changing profiles.", FLASH_FOR);
return;
}
if let Some(connection) = &mut self.connection {
if let Some(command) = connection.key(key) {
let command = match self.hold_for_join(command) {
Some(command) => command,
None => return,
};
if let Command::Profile(super::profile::service::Request::Connect {
profile, ..
}) = &command
{
let kind = profile.transport.kind();
(self.transport, self.style) = (kind.label(), kind.style());
self.link = Link::Connecting;
}
out.push(command);
}
}
}
}
pub(crate) fn update(app: &mut App, action: Action) -> Vec<Command> {
let mut out = Vec::new();
if !matches!(action, Action::Tick { .. }) {
app.dirty = true;
}
let before = app.notice_context();
apply(app, action, &mut out);
app.drop_stale_notice(&before);
out
}
fn apply(app: &mut App, action: Action, out: &mut Vec<Command>) {
match action {
Action::Tick {
now,
dropped,
log_generation,
} => {
app.tick(now, dropped, log_generation);
app.safety_tick(out);
app.ask_names(out);
}
Action::Resize => {}
Action::Quit => app.request_quit(),
Action::CtrlC => app.ctrl_c(out),
Action::Escape => app.escape(out),
Action::ToggleHelp => {
app.overlay = match std::mem::replace(&mut app.overlay, Overlay::None) {
Overlay::None => {
app.help_scroll.set(0);
Overlay::Help
}
Overlay::Help => Overlay::None,
other => other,
}
}
Action::ScrollHelp(movement) => {
let at = app.help_scroll.get();
app.help_scroll.set(match movement {
Movement::Up => at.saturating_sub(1),
Movement::Down => at.saturating_add(1),
Movement::PageUp => at.saturating_sub(10),
Movement::PageDown => at.saturating_add(10),
Movement::Home => 0,
Movement::End => usize::MAX,
});
}
Action::ToggleLog => app.show_log = !app.show_log,
Action::OpenWhoIs => app.open_who_is(),
Action::OpenConnection => app.open_connection(),
Action::Connection(key) => app.connection_key(key, out),
Action::Disconnect => {
let stay = matches!(app.overlay, Overlay::Connection);
app.disconnect(out);
if stay {
app.overlay = Overlay::Connection;
}
}
Action::Reconnect => {
if matches!(app.link, Link::Down | Link::Failed(_)) {
if app.connection.is_some() {
app.open_connection();
} else if let Some(join) = app.join_again.clone() {
app.overlay = Overlay::Join(join);
} else {
app.link = Link::Connecting;
out.push(Command::Reconnect);
}
}
}
Action::Move(movement) => match app.screen {
Screen::Watch => app.watch.move_selection(movement),
Screen::Capture => app.capture_key(capture::CaptureKey::Move(movement), out),
_ => match app.shown_browser_mut() {
Some(browser) => browser.move_selection(movement),
None => app.devices.move_selection(movement, &app.names),
},
},
Action::Open => app.open(out),
Action::CyclePane { back } => {
if let Some(browser) = app.shown_browser_mut() {
browser.cycle_focus(back);
}
}
Action::Refresh => app.refresh(out),
Action::CycleSort => {
app.devices.cycle_sort();
app.settle_devices();
}
Action::ReverseSort => {
app.devices.reverse();
app.settle_devices();
}
Action::StartFilter => {
if let Some(browser) = app.shown_browser_mut() {
browser.focus = browser::Pane::Objects;
}
app.filter_editing = true;
}
Action::Filter(key) => app.filter_key(key),
Action::Form(key) => app.form_key(key, out),
Action::Picker(key) => app.picker_key(key, out),
Action::ShowScreen(Screen::Capture) => app.show_capture(out),
Action::ShowScreen(Screen::Browse) => app.show_browser(),
Action::ShowScreen(screen) => app.screen = screen,
Action::AddWatch => app.watch_selected(out),
Action::RemoveWatch => app.unwatch_selected(out),
Action::OpenCommandLine => app.open_command_line(),
Action::CommandLine(key) => app.command_line_key(key, out),
Action::ToggleArm => app.toggle_arm(out),
Action::OpenLedger => app.open_ledger(),
Action::OpenWrite { relinquish } => app.open_write(relinquish),
Action::Modal(key) => app.modal_key(key, out),
Action::Network(key) => app.network_key(key, out),
Action::Sc(key) => app.sc_key(key),
Action::Join(key) => app.join_key(key, out),
Action::Mstp(key) => app.mstp_key(key),
Action::Capture(key) => app.capture_key(key, out),
Action::Worker(event) => app.worker_event(event, out),
}
}