mod bridge;
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::{Result, bail};
use crate::engine::RawFrame;
use crate::engine::format::{
InputFormat, detect, header_implies_coalesced, parse_format_header, parse_with,
};
use crate::io::device::{self, FrameSender, Supervisor};
use crate::io::open_serial_rw;
#[cfg(feature = "cli")]
#[derive(Debug, clap::Args)]
#[command(after_help = crate::cli::help_footer())]
pub struct Args {
#[arg(
long,
value_name = "DEVICE",
conflicts_with_all = ["ikonvert", "maretron", "canboat_csv", "socketcan"]
)]
actisense: Option<String>,
#[arg(
long,
value_name = "DEVICE",
conflicts_with_all = ["actisense", "maretron", "canboat_csv", "socketcan"]
)]
ikonvert: Option<String>,
#[arg(
long,
value_name = "URL",
conflicts_with_all = ["actisense", "ikonvert", "canboat_csv", "socketcan"]
)]
maretron: Option<String>,
#[arg(
long,
value_name = "IFACE",
conflicts_with_all = ["actisense", "ikonvert", "maretron", "canboat_csv"]
)]
socketcan: Option<String>,
#[arg(
long = "socketcan-address",
value_name = "ADDR",
default_value_t = 0,
requires = "socketcan"
)]
socketcan_address: u8,
#[arg(
long,
value_name = "URL",
conflicts_with_all = ["actisense", "ikonvert", "maretron", "socketcan"]
)]
canboat_csv: Option<String>,
#[arg(long, value_name = "URL", requires = "canboat_csv")]
canboat_csv_write: Option<String>,
#[arg(long, value_name = "BAUD")]
baud: Option<u32>,
#[arg(long, default_value = "")]
maretron_password: String,
#[arg(long, value_name = "PGN,PGN,...")]
ikonvert_rx: Option<String>,
#[arg(long, value_name = "PGN,PGN,...")]
ikonvert_tx: Option<String>,
#[arg(long)]
ikonvert_rate_limit_off: bool,
#[arg(long)]
no_request_claims: bool,
#[arg(long, value_name = "NAME")]
quirk: Vec<quirks::QuirkKind>,
#[arg(long, default_value = "0.0.0.0")]
bind: Ipv4Addr,
#[arg(long, default_value_t = 2597)]
snapshot_port: u16,
#[arg(long, default_value_t = 2598)]
analyzer_port: u16,
#[arg(long, default_value_t = 2599)]
nmea0183_port: u16,
#[arg(long, default_value_t = 2600)]
input_port: u16,
#[arg(long, default_value_t = 2603)]
raw_port: u16,
#[arg(long, default_value_t = 2601)]
ais_port: u16,
#[arg(long)]
nmea0183_stdout: bool,
#[arg(long = "no-nmea0183-rate-limit")]
no_nmea0183_rate_limit: bool,
#[arg(long = "nmea0183-filter", value_name = "PATH")]
nmea0183_filter: Option<PathBuf>,
#[arg(long = "config-dir", value_name = "DIR")]
config_dir: Option<PathBuf>,
#[arg(long, default_value_t = 2605)]
nmea0183_filter_port: u16,
#[arg(long, default_value_t = 2606)]
overrides_port: u16,
#[command(flatten)]
shape: crate::cli::ShapeArgs,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(short = 'q', long)]
quiet: bool,
}
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub actisense: Option<String>,
pub ikonvert: Option<String>,
pub maretron: Option<String>,
pub socketcan: Option<String>,
pub socketcan_address: u8,
pub socketcan_configure_link: bool,
pub canboat_csv: Option<String>,
pub canboat_csv_write: Option<String>,
pub baud: Option<u32>,
pub maretron_password: String,
pub ikonvert_rx: Option<String>,
pub ikonvert_tx: Option<String>,
pub ikonvert_rate_limit_off: bool,
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 camel_case: crate::engine::output::CamelCase,
pub units: crate::engine::Units,
pub wrap: bool,
pub verbose: bool,
pub quiet: bool,
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
actisense: None,
ikonvert: None,
maretron: None,
socketcan: None,
socketcan_address: 0,
socketcan_configure_link: false,
canboat_csv: None,
canboat_csv_write: None,
baud: None,
maretron_password: String::new(),
ikonvert_rx: None,
ikonvert_tx: None,
ikonvert_rate_limit_off: false,
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,
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();
}
}
#[cfg(feature = "cli")]
impl From<Args> for BridgeConfig {
fn from(a: Args) -> Self {
Self {
actisense: a.actisense,
ikonvert: a.ikonvert,
maretron: a.maretron,
socketcan: a.socketcan,
socketcan_address: a.socketcan_address,
socketcan_configure_link: false,
canboat_csv: a.canboat_csv,
canboat_csv_write: a.canboat_csv_write,
baud: a.baud,
maretron_password: a.maretron_password,
ikonvert_rx: a.ikonvert_rx,
ikonvert_tx: a.ikonvert_tx,
ikonvert_rate_limit_off: a.ikonvert_rate_limit_off,
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,
camel_case: a.shape.camel_case(),
units: a.shape.units(),
wrap: a.shape.wrap(),
verbose: a.verbose,
quiet: a.quiet,
}
}
}
pub fn run(config: BridgeConfig) -> Result<()> {
let mut bridge = Bridge::new(config)?;
bridge.serve()?;
bridge.run()
}
struct OpenedSource {
frames_rx: mpsc::Receiver<RawFrame>,
supervisor: Option<Supervisor>,
pre_coalesced: Arc<AtomicBool>,
claim_addr: Option<Arc<std::sync::atomic::AtomicU8>>,
}
fn open_source(config: &BridgeConfig) -> Result<OpenedSource> {
if let Some(path) = config.actisense.as_deref() {
let baud = config.baud.unwrap_or(115_200);
let path = path.to_string();
let factory = NamedFactory::new("ngt1", move || {
let (reader, writer) = open_serial_rw(&path, baud)?;
Ok(device::ngt1::run(reader, writer))
});
let sup = Supervisor::new(factory);
let (rx, sup) = split_supervisor(sup);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(sup),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: None,
});
}
if let Some(path) = config.ikonvert.as_deref() {
let baud = config.baud.unwrap_or(230_400);
let path = path.to_string();
let rx_list = config.ikonvert_rx.clone();
let tx_list = config.ikonvert_tx.clone();
let rate_limit_off = config.ikonvert_rate_limit_off;
let factory = NamedFactory::new("ikonvert", move || {
let (reader, writer) = open_serial_rw(&path, baud)?;
let config = device::ikonvert::Config {
rx_list: rx_list.clone(),
tx_list: tx_list.clone(),
rate_limit_off,
..Default::default()
};
Ok(device::ikonvert::run(reader, writer, config))
});
let sup = Supervisor::new(factory);
let (rx, sup) = split_supervisor(sup);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(sup),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: None,
});
}
if let Some(url) = config.maretron.as_deref() {
let url = url.to_string();
let password = config.maretron_password.clone();
let factory = NamedFactory::new("maretron", move || {
let (reader, writer) = open_maretron_pair(&url)?;
let cfg = device::maretron::Config {
password: password.clone(),
fixtime: None,
};
Ok(device::maretron::run(reader, writer, cfg))
});
let sup = Supervisor::new(factory);
let (rx, sup) = split_supervisor(sup);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(sup),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: None,
});
}
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 factory = NamedFactory::new("canboat-csv", 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 sup = Supervisor::new(factory);
let (rx, sup) = split_supervisor(sup);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(sup),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: None,
});
}
if let Some(iface) = config.socketcan.as_deref() {
let iface = iface.to_string();
let config = device::socketcan::Config {
address: config.socketcan_address,
model_version: Some("canboat-pipeline-rs"),
configure_link: config.socketcan_configure_link,
..device::socketcan::Config::default()
};
let claim_addr = Arc::new(std::sync::atomic::AtomicU8::new(
device::socketcan::CLAIM_UNCLAIMED,
));
let claim_for_factory = Arc::clone(&claim_addr);
let factory = NamedFactory::new("socketcan", move || {
device::socketcan::run(&iface, config.clone(), Arc::clone(&claim_for_factory))
});
let sup = Supervisor::new(factory);
let (rx, sup) = split_supervisor(sup);
return Ok(OpenedSource {
frames_rx: rx,
supervisor: Some(sup),
pre_coalesced: Arc::new(AtomicBool::new(true)),
claim_addr: Some(claim_addr),
});
}
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(move || stdin_pump(tx, coalesced_for_pump, None))
.expect("spawn stdin-pump");
Ok(OpenedSource {
frames_rx: rx,
supervisor: None,
pre_coalesced,
claim_addr: None,
})
}
fn split_supervisor(mut sup: Supervisor) -> (mpsc::Receiver<RawFrame>, Supervisor) {
let (_dummy_tx, dummy_rx) = mpsc::channel::<RawFrame>();
let rx = std::mem::replace(&mut sup.frames_rx, dummy_rx);
(rx, sup)
}
struct NamedFactory<F> {
name: &'static str,
open: F,
}
impl<F> NamedFactory<F>
where
F: FnMut() -> io::Result<device::DeviceHandle> + Send + 'static,
{
fn new(name: &'static str, open: F) -> Self {
Self { name, open }
}
}
impl<F> device::DeviceFactory for NamedFactory<F>
where
F: FnMut() -> io::Result<device::DeviceHandle> + Send + 'static,
{
fn open(&mut self) -> io::Result<device::DeviceHandle> {
(self.open)()
}
fn name(&self) -> &str {
self.name
}
}
fn open_maretron_pair(url: &str) -> io::Result<(Box<dyn Read + Send>, Box<dyn Write + Send>)> {
open_tcp_pair(url, 6543, "maretron")
}
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))?;
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>,
) {
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_with(active_format.unwrap(), 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>,
) -> 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)))
.expect("spawn stdin-pump");
merge_rx
}
#[allow(dead_code)]
fn _no_source() -> Result<()> {
bail!("no input source");
}