mod app;
mod capture;
mod event_loop;
mod format;
mod help;
mod keymap;
mod log_layer;
mod message;
#[cfg_attr(not(feature = "serial"), allow(dead_code))]
mod monitor;
#[cfg_attr(not(feature = "serial"), allow(dead_code))]
mod mstp;
mod network;
mod point;
mod profile;
mod safety;
#[cfg_attr(not(feature = "sc-tls"), allow(dead_code))]
mod sc;
mod terminal;
mod view;
mod watched;
mod worker;
#[cfg(test)]
mod tests;
use std::io::Write as _;
use std::net::Ipv4Addr;
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use ratatui::crossterm::event::EventStream;
use tokio::sync::mpsc;
pub(crate) use self::terminal::unusable_terminal;
use self::app::picker::Picker;
use self::app::{App, AppConfig, Link, Overlay};
use self::event_loop::LoopIo;
use self::log_layer::{LogRing, RingLayer, LOG_CAPACITY};
use self::terminal::Signals;
use self::worker::coordinator;
use self::worker::session::{ConnectPlan, SessionOptions, TransportKind};
use self::worker::watch::WatchOptions;
use self::worker::write::SafetyOptions;
use self::worker::{EventSink, COMMAND_CHANNEL_CAPACITY, EVENT_CHANNEL_CAPACITY};
use crate::args::{Cli, Command};
use crate::core::interfaces::list_ipv4_interfaces;
use crate::transport::TransportArgs;
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
pub(crate) async fn run(cli: Cli) -> crate::CliResult {
if let Err(hint) = terminal::check_terminal() {
eprintln!("{hint}");
std::process::exit(1);
}
let monitor = monitor::plan_from(&cli);
let kind = if monitor.is_some() {
TransportKind::MstpMonitor
} else if cli.mstp.is_some() {
TransportKind::Mstp
} else if cli.sc {
TransportKind::Sc
} else if cli.ipv6 {
TransportKind::Bip6
} else {
TransportKind::Bip
};
let (capture_ring, capture_device) = match &cli.command {
Some(Command::Tui {
capture_ring,
capture_device,
..
}) => (*capture_ring as usize, capture_device.clone()),
_ => (capture::DEFAULT_RING, None),
};
if capture_device.is_some() && !cfg!(feature = "pcap") {
eprintln!("Error: --capture-device requires the 'pcap' feature. Rebuild with: cargo install bacnet-cli --features pcap");
std::process::exit(1);
}
let (fps, log_file, mut options) = match &cli.command {
Some(Command::Tui {
fps,
log_file,
max_rate,
cov_lifetime,
poll_interval,
passive,
..
}) => (
*fps,
log_file.clone(),
SessionOptions {
max_rate: *max_rate,
watch: WatchOptions {
lifetime: *cov_lifetime,
poll_every: Duration::from_secs(*poll_interval),
workspace: None,
request_budget: worker::watch::DEFAULT_REQUEST_BUDGET,
},
safety: SafetyOptions {
passive: *passive,
..SafetyOptions::default()
},
mstp: Default::default(),
},
),
_ => (
20,
None,
SessionOptions {
max_rate: worker::browse::DEFAULT_MAX_RATE,
watch: WatchOptions::default(),
safety: SafetyOptions::default(),
mstp: Default::default(),
},
),
};
options.safety.transport = kind.label();
if kind == TransportKind::Sc && !cfg!(feature = "sc-tls") {
eprintln!("Error: BACnet/SC requires the 'sc-tls' feature. Rebuild with: cargo install bacnet-cli --features sc-tls");
std::process::exit(1);
}
options.mstp.monitor.clone_from(&monitor);
options.mstp.thread = crate::mstp_thread::from_cli(&cli);
let serial = matches!(kind, TransportKind::Mstp | TransportKind::MstpMonitor);
if serial && !cfg!(feature = "serial") {
eprintln!("Error: {}", crate::mstp::NO_SERIAL);
std::process::exit(1);
}
let log = LogRing::new(LOG_CAPACITY);
if let Err(error) = init_tracing(cli.verbose, log_file.as_deref(), &log) {
eprintln!("Error: {error}");
std::process::exit(1);
}
let (plan, profiles, connection, picker, join) = if let Some((service, connection)) =
profile::startup::open(&cli, profile::store::Store::discover)
.map_err(std::io::Error::other)?
{
(None, Some(service), Some(connection), None, None)
} else {
let (interface, broadcast, picker) = if options.safety.passive || monitor.is_some() {
let interface = cli.interface.unwrap_or(Ipv4Addr::UNSPECIFIED);
(interface, cli.broadcast, None)
} else {
choose_interface(&cli, kind, &log)
};
let args =
TransportArgs::from_cli(&cli, interface, broadcast).map_err(std::io::Error::other)?;
let join = args
.mstp
.clone()
.filter(|_| !options.safety.passive)
.map(|settings| {
app::mstp::JoinDialog::for_flags(
settings,
crate::mstp::list_ports().unwrap_or_default(),
)
});
let plan = ConnectPlan {
kind,
args,
await_interface: picker.is_some() || join.is_some(),
};
(Some(plan), None, None, picker, join)
};
let color = std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty());
let mut app = Box::new(App::new(
AppConfig {
transport: kind.label(),
style: kind.style(),
color,
picker,
join,
passive: options.safety.passive,
now: tokio::time::Instant::now().into_std(),
},
log,
));
if let Some(connection) = connection {
app.connection = Some(connection);
app.link = Link::Down;
app.transport = "";
app.overlay = Overlay::Connection;
}
let thread = &options.mstp.thread;
app.mstp_thread = crate::mstp_thread::describe(thread.realtime_priority, &thread.cpu_affinity);
if app.connection.is_none()
&& crate::mstp_thread::given(&cli)
&& !matches!(kind, TransportKind::Mstp | TransportKind::MstpMonitor)
{
crate::mstp_thread::warn_unused(kind.label());
}
if let Some(plan) = &monitor {
app.start_monitor(plan, !options.safety.passive);
}
app.capture = app::capture::CaptureState::new(capture_ring);
let signals = Signals::install()?;
let mut terminal = match terminal::enter() {
Ok(terminal) => terminal,
Err(error) => {
eprintln!(
"{}",
terminal::unusable_hint(&format!("raw mode failed: {error}"))
);
std::process::exit(1);
}
};
let (sink, events) = EventSink::channel(EVENT_CHANNEL_CAPACITY);
let dropped = sink.dropped_counter();
let (commands, command_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
#[cfg(feature = "pcap")]
let wire = capture_device.map(|device| {
let interface = cli.interface.unwrap_or(Ipv4Addr::UNSPECIFIED);
capture::worker::spawn_pcap(Some(device), interface, cli.port, sink.clone())
});
#[cfg(not(feature = "pcap"))]
let wire: Option<tokio::task::JoinHandle<()>> = {
drop(capture_device);
None
};
let mut worker = coordinator::spawn(plan, profiles, sink, command_rx, options);
let mut io = LoopIo {
input: EventStream::new(),
events,
commands,
dropped,
fps,
signals,
alive: terminal::is_active,
};
let result = Box::pin(event_loop::run(&mut terminal, &mut app, &mut io)).await;
terminal::restore();
drop(terminal);
let LoopIo {
input,
events,
commands,
mut signals,
..
} = io;
drop((input, events, commands));
let mut signal = None;
let notice = tokio::time::sleep(STOP_TIMEOUT);
tokio::pin!(notice);
let mut warned = false;
loop {
tokio::select! {
joined = &mut worker => {
if let Err(error) = joined { app.fail(format!("network coordinator failed: {error}"), 1); }
break;
}
_ = &mut notice, if !warned => {
warned = true;
tracing::warn!("waiting for the network worker to release its resources");
}
received = signals.recv() => { signal.get_or_insert(received); }
}
}
if let Some(wire) = wire {
if let Err(error) = wire.await {
app.fail(format!("packet capture failed: {error}"), 1);
}
}
let (error, code) = match (result, signal) {
(Err(error), _) => (Some(error.to_string()), 1),
(Ok(_), Some(signal)) if app.exit_error.is_none() => {
(Some(format!("terminated by {}", signal.name)), signal.code)
}
(Ok(_), _) => (app.exit_error.take(), app.exit_code.unwrap_or(1)),
};
if let Some(error) = error {
let _ = writeln!(std::io::stderr(), "Error: {error}");
std::process::exit(code);
}
Ok(())
}
fn choose_interface(
cli: &Cli,
kind: TransportKind,
log: &LogRing,
) -> (Ipv4Addr, Ipv4Addr, Option<Picker>) {
if let Some(interface) = cli.interface {
return (interface, cli.broadcast, None);
}
if kind != TransportKind::Bip {
return (Ipv4Addr::UNSPECIFIED, cli.broadcast, None);
}
let explicit_broadcast = cli.explicit_args.iter().any(|id| id == "broadcast");
let mut interfaces = list_ipv4_interfaces();
if explicit_broadcast {
for interface in &mut interfaces {
interface.broadcast = cli.broadcast;
}
}
match interfaces.len() {
0 => {
log.info("No network interfaces found; binding to 0.0.0.0.");
(Ipv4Addr::UNSPECIFIED, cli.broadcast, None)
}
1 => {
let only = interfaces.remove(0);
log.info(format!(
"Using interface {} ({}, broadcast {}).",
only.name, only.ip, only.broadcast
));
(only.ip, only.broadcast, None)
}
_ => (
Ipv4Addr::UNSPECIFIED,
cli.broadcast,
Some(Picker::new(interfaces)),
),
}
}
fn init_tracing(verbosity: u8, log_file: Option<&Path>, ring: &LogRing) -> Result<(), String> {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
let level = match verbosity {
0 => "info",
1 => "debug",
_ => "trace",
};
let file_layer = match log_file {
Some(path) => {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("cannot open --log-file {}: {e}", path.display()))?;
Some(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_target(false)
.with_writer(Mutex::new(file)),
)
}
None => None,
};
tracing_subscriber::registry()
.with(EnvFilter::new(level))
.with(RingLayer::new(ring.clone()))
.with(file_layer)
.try_init()
.map_err(|e| e.to_string())
}