mod bridge;
mod mcu006_control;
#[cfg_attr(not(feature = "mdns"), allow(dead_code))]
mod mdns;
mod pipeline;
mod quirks;
mod snapshot;
mod tcp;
pub use bridge::{Bridge, Transmitter};
pub use quirks::QuirkKind;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use anyhow::{Context, Result, bail};
#[cfg(feature = "cli")]
use crate::cli::device::{self as device_args, DeviceArgs, KindArg};
use crate::engine::RawFrame;
use crate::engine::format::{
InputFormat, detect, header_implies_coalesced, parse_for, parse_format_header,
};
use crate::engine::pgn_list::{PgnListStatus, PgnListSupport};
pub use crate::io::device::kind::{ActisenseFormat, Kind};
pub use crate::io::device::open::Device;
use crate::io::device::supervisor::{Named, supervise};
use crate::io::device::{self, DeviceHandle, FrameSender};
#[cfg(feature = "cli")]
#[derive(Debug, clap::Args)]
#[command(after_help = crate::cli::help_footer())]
#[command(after_long_help = format!(
"{}\n\n{}",
crate::cli::help::gateway_help(),
crate::cli::help_footer()
))]
#[command(group(clap::ArgGroup::new("source").multiple(false)))]
pub struct Args {
#[arg(long, value_enum, value_name = "KIND", requires = "device", help_heading = device_args::GATEWAY)]
kind: Option<KindArg>,
#[arg(
long,
value_name = "DEVICE",
group = "source",
requires = "kind",
help_heading = device_args::GATEWAY
)]
device: Option<String>,
#[arg(long, value_name = "DEVICE", group = "source", hide = true)]
actisense: Option<String>,
#[arg(long, value_name = "DEVICE", group = "source", hide = true)]
ikonvert: Option<String>,
#[arg(long, value_name = "URL", group = "source", hide = true)]
maretron: Option<String>,
#[arg(long, value_name = "IFACE", group = "source", hide = true)]
socketcan: Option<String>,
#[arg(long, value_name = "URL", group = "source", help_heading = OTHER_SOURCES)]
canboat_csv: Option<String>,
#[arg(long, value_name = "URL", requires = "canboat_csv", help_heading = OTHER_SOURCES)]
canboat_csv_write: Option<String>,
#[arg(long, value_name = "FILE", group = "source", help_heading = OTHER_SOURCES)]
replay: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "replay", help_heading = OTHER_SOURCES)]
replay_sent: Option<PathBuf>,
#[command(flatten)]
gateway: DeviceArgs,
#[arg(long, value_name = "NAME", help_heading = ON_THE_BUS)]
quirk: Vec<quirks::QuirkKind>,
#[arg(long, help_heading = ON_THE_BUS)]
no_request_claims: bool,
#[arg(long, default_value = "0.0.0.0", help_heading = PORTS)]
bind: Ipv4Addr,
#[arg(long, value_name = "PORT", default_value_t = 2597, help_heading = PORTS)]
snapshot_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2598, help_heading = PORTS)]
analyzer_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2599, help_heading = PORTS)]
nmea0183_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2600, help_heading = PORTS)]
input_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2601, help_heading = PORTS)]
ais_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2603, help_heading = PORTS)]
raw_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2605, help_heading = PORTS)]
nmea0183_filter_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2606, help_heading = PORTS)]
overrides_port: u16,
#[arg(long, value_name = "PORT", default_value_t = 2607, help_heading = PORTS)]
mcu006_port: u16,
#[arg(long, help_heading = PORTS)]
no_mdns: bool,
#[arg(long, help_heading = NMEA0183)]
nmea0183_stdout: bool,
#[arg(long = "no-nmea0183-rate-limit", help_heading = NMEA0183)]
no_nmea0183_rate_limit: bool,
#[arg(long = "nmea0183-filter", value_name = "PATH", help_heading = NMEA0183)]
nmea0183_filter: Option<PathBuf>,
#[arg(long = "config-dir", value_name = "DIR", help_heading = STATE)]
config_dir: Option<PathBuf>,
#[command(flatten, next_help_heading = JSON)]
shape: crate::cli::ShapeArgs,
#[arg(short = 'v', long, help_heading = LOGGING)]
verbose: bool,
#[arg(short = 'q', long, help_heading = LOGGING)]
quiet: bool,
}
#[cfg(feature = "cli")]
const OTHER_SOURCES: &str = "Other sources";
#[cfg(feature = "cli")]
const ON_THE_BUS: &str = "On the bus";
#[cfg(feature = "cli")]
const PORTS: &str = "TCP ports";
#[cfg(feature = "cli")]
const NMEA0183: &str = "NMEA 0183";
#[cfg(feature = "cli")]
const STATE: &str = "State";
#[cfg(feature = "cli")]
const JSON: &str = "JSON output";
#[cfg(feature = "cli")]
const LOGGING: &str = "Logging";
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub device: Option<Device>,
pub protocol: crate::engine::BusProtocol,
pub canboat_csv: Option<String>,
pub canboat_csv_write: Option<String>,
pub replay: Option<PathBuf>,
pub replay_sent: Option<PathBuf>,
pub rx_filter: Vec<u32>,
pub no_request_claims: bool,
pub quirk: Vec<quirks::QuirkKind>,
pub bind: Ipv4Addr,
pub snapshot_port: u16,
pub analyzer_port: u16,
pub nmea0183_port: u16,
pub input_port: u16,
pub raw_port: u16,
pub ais_port: u16,
pub nmea0183_stdout: bool,
pub no_nmea0183_rate_limit: bool,
pub nmea0183_filter: Option<PathBuf>,
pub config_dir: Option<PathBuf>,
pub nmea0183_filter_port: u16,
pub overrides_port: u16,
pub mcu006_port: u16,
pub mdns: bool,
pub camel_case: crate::engine::output::CamelCase,
pub units: crate::engine::Units,
pub wrap: bool,
pub verbose: bool,
pub quiet: bool,
}
impl BridgeConfig {
pub(crate) fn is_node(&self) -> bool {
self.device.as_ref().is_some_and(|dev| {
let caps = dev.capabilities();
caps.node && caps.raw
})
}
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
device: None,
protocol: crate::engine::BusProtocol::Nmea2000,
canboat_csv: None,
canboat_csv_write: None,
replay: None,
replay_sent: None,
rx_filter: Vec::new(),
no_request_claims: false,
quirk: Vec::new(),
bind: Ipv4Addr::new(0, 0, 0, 0),
snapshot_port: 2597,
analyzer_port: 2598,
nmea0183_port: 2599,
input_port: 2600,
raw_port: 2603,
ais_port: 2601,
nmea0183_stdout: false,
no_nmea0183_rate_limit: false,
nmea0183_filter: None,
config_dir: None,
nmea0183_filter_port: 2605,
overrides_port: 2606,
mcu006_port: 2607,
mdns: false,
camel_case: crate::engine::output::CamelCase::Lower,
units: crate::engine::Units::Si,
wrap: false,
verbose: false,
quiet: false,
}
}
}
#[cfg(feature = "cli")]
impl Args {
pub fn warn_deprecated(&self) {
self.shape.warn_deprecated();
}
fn source(&self) -> anyhow::Result<Option<(Kind, String)>> {
if let (Some(kind), Some(device)) = (self.kind, &self.device) {
let kind = kind.resolve(self.gateway.data_format)?;
return Ok(Some((kind, device.clone())));
}
if let Some(format) = self.gateway.data_format {
anyhow::bail!("--data-format {} goes with --kind", format.name());
}
Ok([
(Kind::NGT1, &self.actisense),
(Kind::Ikonvert, &self.ikonvert),
(Kind::Maretron, &self.maretron),
(Kind::SocketCan, &self.socketcan),
]
.into_iter()
.find_map(|(kind, device)| Some((kind, device.clone()?))))
}
pub fn check(&self) -> anyhow::Result<()> {
if self.kind.is_some() && self.device.is_none() {
anyhow::bail!(
"--kind needs --device, where the gateway is (not an old --<gateway> flag)"
);
}
self.gateway.check(self.source()?.map(|(kind, _)| kind))
}
}
#[cfg(feature = "cli")]
impl From<Args> for BridgeConfig {
fn from(a: Args) -> Self {
let source = a
.source()
.expect("Args::check rejects a --kind this cannot resolve");
let g = &a.gateway;
Self {
device: source.map(|(kind, address)| g.device(kind, &address)),
protocol: g.protocol(),
canboat_csv: a.canboat_csv,
canboat_csv_write: a.canboat_csv_write,
replay: a.replay,
replay_sent: a.replay_sent,
rx_filter: g.rx_filter.clone(),
no_request_claims: a.no_request_claims,
quirk: a.quirk,
bind: a.bind,
snapshot_port: a.snapshot_port,
analyzer_port: a.analyzer_port,
nmea0183_port: a.nmea0183_port,
input_port: a.input_port,
raw_port: a.raw_port,
ais_port: a.ais_port,
nmea0183_stdout: a.nmea0183_stdout,
no_nmea0183_rate_limit: a.no_nmea0183_rate_limit,
nmea0183_filter: a.nmea0183_filter,
config_dir: a.config_dir,
nmea0183_filter_port: a.nmea0183_filter_port,
overrides_port: a.overrides_port,
mcu006_port: a.mcu006_port,
mdns: !a.no_mdns,
camel_case: a.shape.camel_case(),
units: a.shape.units(),
wrap: a.shape.wrap(),
verbose: a.verbose,
quiet: a.quiet,
}
}
}
struct OpenedSource {
frames_rx: mpsc::Receiver<RawFrame>,
supervisor: Option<DeviceHandle>,
pre_coalesced: Arc<AtomicBool>,
claim_addr: Option<Arc<std::sync::atomic::AtomicU8>>,
pgn_list_status: Option<PgnListStatus>,
node_name: Option<u64>,
}
fn open_source(config: &BridgeConfig) -> Result<OpenedSource> {
if let Some(dev) = &config.device {
let dev = dev
.clone()
.extra_tx_pgns(quirk_tx_pgns(config))
.node_defaults("canboat-pipeline-rs", config.config_dir.clone())
.reconnect(true);
let pgn_list_status = dev.pgn_list_status();
let node_name = config
.is_node()
.then(|| dev.node_name(config.protocol))
.flatten();
let handle = dev.open(config.protocol)?;
let claim_addr = handle.claim_atom();
let (rx, handle) = split_supervisor(handle);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(handle),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr,
pgn_list_status,
node_name,
});
}
if let Some(read_url) = config.canboat_csv.as_deref() {
let read_url = read_url.to_string();
let write_url = config.canboat_csv_write.clone();
let handle = supervise(Named {
name: "canboat-csv".into(),
open: move || {
let (reader, writer) = match &write_url {
Some(w) => open_canboat_csv_split(&read_url, w)?,
None => open_canboat_csv_pair(&read_url)?,
};
Ok(device::canboat_csv::run(reader, writer))
},
});
let (rx, handle) = split_supervisor(handle);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(handle),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: None,
pgn_list_status: unsupported_pgn_lists(config),
node_name: None,
});
}
if let Some(path) = config.replay.as_deref() {
let scan = device::replay::scan(path, config.protocol)
.with_context(|| format!("--replay {}", path.display()))?;
let replay = device::replay::Config {
path: path.to_path_buf(),
protocol: config.protocol,
sent: config.replay_sent.clone(),
..device::replay::Config::new(path)
};
let handle = supervise(Named {
name: "replay".into(),
open: move || device::replay::run(replay.clone()),
});
let (rx, handle) = split_supervisor(handle);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(handle),
pre_coalesced: Arc::new(AtomicBool::new(scan.coalesced)),
claim_addr: None,
pgn_list_status: unsupported_pgn_lists(config),
node_name: None,
});
}
let pre_coalesced = Arc::new(AtomicBool::new(false));
let (tx, rx) = mpsc::channel::<RawFrame>();
let coalesced_for_pump = pre_coalesced.clone();
thread::Builder::new()
.name("stdin-pump".into())
.spawn({
let protocol = config.protocol;
move || stdin_pump(tx, coalesced_for_pump, None, protocol)
})
.expect("spawn stdin-pump");
Ok(OpenedSource {
frames_rx: rx,
supervisor: None,
pre_coalesced,
claim_addr: None,
pgn_list_status: unsupported_pgn_lists(config),
node_name: None,
})
}
fn quirk_tx_pgns(config: &BridgeConfig) -> Vec<u32> {
let mut pgns = Vec::new();
if config.quirk.contains(&quirks::QuirkKind::Wmm) {
pgns.push(quirks::wmm::PGN_MAGNETIC_VARIATION);
}
pgns
}
fn unsupported_pgn_lists(config: &BridgeConfig) -> Option<PgnListStatus> {
let pgns = quirk_tx_pgns(config);
if pgns.is_empty() {
return None;
}
log::info!(
"this source cannot advertise PGN lists; the quirks' transmit PGNs {pgns:?} are not advertised"
);
Some(PgnListStatus {
tx: PgnListSupport::Unsupported,
rx: PgnListSupport::Unsupported,
dropped: Vec::new(),
})
}
fn split_supervisor(mut sup: DeviceHandle) -> (mpsc::Receiver<RawFrame>, DeviceHandle) {
let (_dummy_tx, dummy_rx) = mpsc::channel::<RawFrame>();
let rx = std::mem::replace(&mut sup.frames_rx, dummy_rx);
(rx, sup)
}
fn open_canboat_csv_pair(url: &str) -> io::Result<(Box<dyn Read + Send>, Box<dyn Write + Send>)> {
open_tcp_pair(url, 2603, "canboat-csv")
}
fn open_canboat_csv_split(
read_url: &str,
write_url: &str,
) -> io::Result<(Box<dyn Read + Send>, Box<dyn Write + Send>)> {
let (read_half, _unused_write) = open_tcp_pair(read_url, 2603, "canboat-csv read")?;
let (_unused_read, write_half) = open_tcp_pair(write_url, 2600, "canboat-csv write")?;
Ok((read_half, write_half))
}
fn open_tcp_pair(
url: &str,
default_port: u16,
log_label: &'static str,
) -> io::Result<(Box<dyn Read + Send>, Box<dyn Write + Send>)> {
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let raw = url.strip_prefix("tcp://").unwrap_or(url);
let with_port = if raw.contains(':') {
raw.to_string()
} else {
format!("{raw}:{default_port}")
};
let resolved = with_port
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::other(format!("no addresses for {with_port}")))?;
log::info!("{log_label}: connecting to {resolved}");
let stream = TcpStream::connect_timeout(&resolved, Duration::from_secs(10))?;
crate::io::set_tcp_keepalive(&stream)?;
let read_clone = stream.try_clone()?;
Ok((Box::new(read_clone), Box::new(stream)))
}
fn stdin_pump(
tx: mpsc::Sender<RawFrame>,
pre_coalesced: Arc<AtomicBool>,
device_sender: Option<FrameSender>,
protocol: crate::engine::BusProtocol,
) {
let stdin = io::stdin();
let mut reader = BufReader::new(stdin.lock());
let mut line = String::with_capacity(1024);
let mut active_format: Option<InputFormat> = None;
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => return,
Ok(_) => {}
Err(e) => {
log::error!("stdin read: {e}");
return;
}
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('#') {
if let Some(fmt) = parse_format_header(trimmed) {
if active_format.is_none() {
active_format = Some(fmt);
log::info!("input format set by header: {:?}", fmt);
}
if header_implies_coalesced(trimmed) && !pre_coalesced.swap(true, Ordering::Relaxed)
{
log::debug!(
"stdin header declares coalesced format; \
pipeline will skip reassembly"
);
}
}
continue;
}
if active_format.is_none() {
active_format = detect(trimmed).or(Some(InputFormat::Plain));
}
let Ok(Some(frame)) = parse_for(active_format.unwrap(), protocol, trimmed) else {
continue;
};
if let Some(sender) = &device_sender {
let _ = sender.send_frame(frame.clone());
}
if tx.send(frame).is_err() {
return;
}
}
}
fn install_stdin_loopback(
device_frames_rx: mpsc::Receiver<RawFrame>,
sender: FrameSender,
pre_coalesced: Arc<AtomicBool>,
protocol: crate::engine::BusProtocol,
) -> mpsc::Receiver<RawFrame> {
let (merge_tx, merge_rx) = mpsc::channel::<RawFrame>();
let merge_tx_device = merge_tx.clone();
let merge_tx_stdin = merge_tx;
thread::Builder::new()
.name("device-forward".into())
.spawn(move || {
while let Ok(f) = device_frames_rx.recv() {
if merge_tx_device.send(f).is_err() {
return;
}
}
})
.expect("spawn device-forward");
thread::Builder::new()
.name("stdin-pump".into())
.spawn(move || stdin_pump(merge_tx_stdin, pre_coalesced, Some(sender), protocol))
.expect("spawn stdin-pump");
merge_rx
}
#[allow(dead_code)]
fn _no_source() -> Result<()> {
bail!("no input source");
}