use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::engine::format::{Framing, InputFormat};
use crate::engine::output::{GeoFormat, JsonOptions, TextOptions, write_json, write_text};
use crate::engine::{BusProtocol, FramePacketType, RawFrame, Reassembled, Reassembler};
use crate::io::{
BstD0Reader, BstReader, EblReader, EblWriter, FrameReader, FrameWriter, LineFrameReader,
MxpgnWriter, PlainWriter, TextLineWriter, UsbCanAReader, analyze, container, copy,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum OutFormat {
Plain,
Json,
Text,
#[value(name = "raw-ascii", alias = "ydwg02")]
Ydwg02,
#[value(name = "n2k-ascii", alias = "actisense")]
Actisense,
#[value(name = "actisense-ebl", alias = "ebl")]
ActisenseEbl,
Mxpgn,
#[value(name = "actisense-mxpgn")]
ActisenseMxpgn,
#[value(name = "seasmart")]
SeaSmart,
}
impl OutFormat {
fn is_frame_level(self) -> bool {
matches!(
self,
OutFormat::Plain
| OutFormat::Ydwg02
| OutFormat::Actisense
| OutFormat::ActisenseEbl
| OutFormat::Mxpgn
| OutFormat::ActisenseMxpgn
| OutFormat::SeaSmart
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum FromFormat {
#[value(name = "plain", alias = "fast")]
Plain,
#[value(name = "plain-mix-fast")]
PlainMixFast,
#[value(name = "n2k-ascii", alias = "actisense", alias = "actisense-ascii")]
Actisense,
#[value(name = "raw-ascii", alias = "ydwg02", alias = "yden")]
Ydwg02,
#[value(name = "ikonvert")]
Ikonvert,
#[value(name = "json")]
Json,
#[value(name = "airmar")]
Airmar,
#[value(name = "seasmart", alias = "chetco")]
SeaSmart,
#[value(name = "mxpgn")]
Mxpgn,
#[value(name = "actisense-mxpgn")]
ActisenseMxpgn,
#[value(name = "garmin", alias = "garmin-csv")]
Garmin,
#[value(name = "garmin-csv2")]
GarminCsv2,
#[value(name = "candump")]
Candump,
#[value(name = "actisense-ebl", alias = "ebl")]
ActisenseEbl,
#[value(name = "n2k-actisense")]
BstD0,
#[value(name = "raw-actisense")]
Bst95,
#[value(name = "usb-can-a")]
UsbCanA,
}
impl FromFormat {
fn to_input_format(self) -> Option<InputFormat> {
Some(match self {
FromFormat::Plain => InputFormat::Plain,
FromFormat::PlainMixFast => InputFormat::PlainMixFast,
FromFormat::Actisense => InputFormat::ActisenseAscii,
FromFormat::Ydwg02 => InputFormat::Ydwg02,
FromFormat::Ikonvert => InputFormat::Ikonvert,
FromFormat::Airmar => InputFormat::Airmar,
FromFormat::SeaSmart => InputFormat::SeaSmart,
FromFormat::Mxpgn => InputFormat::Mxpgn,
FromFormat::ActisenseMxpgn => InputFormat::ActisenseMxpgn,
FromFormat::Garmin => InputFormat::GarminCsv,
FromFormat::GarminCsv2 => InputFormat::GarminCsv2,
FromFormat::Candump => InputFormat::Candump,
FromFormat::ActisenseEbl
| FromFormat::BstD0
| FromFormat::Bst95
| FromFormat::UsbCanA
| FromFormat::Json => {
return None;
}
})
}
}
pub const LONG_ABOUT: &str = "\
Convert a capture between formats: any supported input → PLAIN, JSON, or text.
Input line formats (auto-detected from the first line, or forced with --from):
plain, plain-mix-fast, n2k-ascii, raw-ascii, ikonvert, airmar, seasmart,
mxpgn, garmin, garmin-csv2, candump; actisense-mxpgn only with --from.
(n2k-ascii and raw-ascii are Actisense's names; actisense and ydwg02 still
work.) mxpgn is ShipModul's $MXPGN, the data last byte first;
actisense-mxpgn the same sentences with the data in bus order, as an
Actisense gateway (a W2K-2) sends them. Nothing in a stream tells them
apart, so $MXPGN is read as ShipModul's unless --from says otherwise.
Binary input formats (forced with --from; a .ebl file is recognised by name):
actisense-ebl Actisense .ebl binary log
n2k-actisense Actisense N2K Actisense (BST-D0): a W2K-1 or PRO-NDC-1E2K in Actisense mode
raw-actisense Actisense RAW Actisense (BST-95): a PRO-NDC-1E2K or W2K-1 in CAN Actisense mode
Container files (unwrapped automatically by file extension):
.pcap / .pcap.gz libpcap SocketCAN capture (link-type 227)
.nif Navico Information File (filtered + raw capture groups)
Output formats (--to, default json):
plain canboat PLAIN/FAST lines (raw frames, no decode)
json one decoded JSON object per record
text canboat human-readable text, one line per record
raw-ascii RAW ASCII lines, as a YDWG-02 sends them (raw frames)
n2k-ascii Actisense N2K ASCII lines (reassembled messages)
actisense-ebl Actisense .ebl binary log (raw frames)
mxpgn $MXPGN sentences, as a ShipModul MiniPlex sends them (raw frames)
actisense-mxpgn $MXPGN sentences, as an Actisense gateway sends them (raw frames)
seasmart $PCDIN sentences, as a SeaSmart gateway takes them (whole messages)
Decoded-output shape (json/text only):
--id STYLE spaces | camel (default) | uppercamel
--units SYSTEM si (default) | metric
--wrap nest each JSON record in {\"<pgnId>\":{…}} (off by default)
--no-banner drop the leading {\"version\":…,\"units\":…} line (json only)
Decoded output defaults to flat records with camelCase keys and strict
SI units — the shape machine consumers want. For canboat C's historical
output pass --id spaces --units metric. The old --camel / --upper-camel
/ --si flags still work but are deprecated (--camel and --upper-camel
imply --wrap, as they do in canboat C).";
#[derive(Debug, clap::Args)]
pub struct Args {
#[arg(long, value_name = "PATH")]
file: Option<PathBuf>,
#[arg(value_name = "FILE", conflicts_with = "file")]
file_pos: Option<PathBuf>,
#[arg(long, value_enum, value_name = "FORMAT")]
from: Option<FromFormat>,
#[arg(long, value_enum, default_value_t = OutFormat::Json)]
to: OutFormat,
#[arg(long)]
ignore_checksum: bool,
#[arg(long, value_name = "N")]
src: Option<u8>,
#[arg(long, value_name = "N")]
dst: Option<u8>,
#[arg(long, value_name = "PGN")]
pgn: Option<u32>,
#[arg(long, conflicts_with = "filtered_only")]
raw_only: bool,
#[arg(long)]
filtered_only: bool,
#[arg(long)]
nv: bool,
#[arg(long)]
empty: bool,
#[arg(long)]
debug: bool,
#[command(flatten)]
shape: crate::cli::ShapeArgs,
#[command(flatten)]
quirk: crate::cli::QuirkArgs,
#[arg(long)]
no_banner: bool,
#[command(flatten)]
protocol: crate::cli::protocol::ProtocolArgs,
#[arg(long, value_name = "FMT", default_value = "dd")]
geo: GeoArg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum GeoArg {
Dd,
Dm,
Dms,
}
impl From<GeoArg> for GeoFormat {
fn from(g: GeoArg) -> Self {
match g {
GeoArg::Dd => GeoFormat::Dd,
GeoArg::Dm => GeoFormat::Dm,
GeoArg::Dms => GeoFormat::Dms,
}
}
}
impl Args {
fn input(&self) -> Option<&Path> {
self.file.as_deref().or(self.file_pos.as_deref())
}
fn reads_stdin(&self) -> bool {
self.input().is_none_or(|p| p.as_os_str() == "-")
}
fn container_opts(&self) -> container::Options {
container::Options {
filtered: !self.raw_only,
raw: !self.filtered_only,
}
}
}
pub fn run(args: Args) -> Result<()> {
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn"))
.try_init();
args.shape.warn_deprecated();
args.quirk.apply();
let protocol = args.protocol.resolve()?;
let forced = args.from.and_then(FromFormat::to_input_format);
let binary = binary_input(&args);
let live = args.reads_stdin() && crate::cli::stdin_is_live();
let mut out = crate::cli::stdout(live);
if args.to.is_frame_level() {
convert_raw(&args, protocol, forced, binary, live, &mut out)
} else {
convert_decoded(&args, protocol, forced, binary, &mut out)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Binary {
Ebl,
BstD0,
Bst,
UsbCanA,
}
impl Binary {
fn reader<R: io::Read + 'static>(
self,
source: R,
protocol: BusProtocol,
) -> Box<dyn FrameReader> {
match self {
Binary::Ebl => Box::new(EblReader::with_protocol(source, protocol)),
Binary::BstD0 => Box::new(BstD0Reader::new(source)),
Binary::Bst => Box::new(BstReader::with_protocol(source, protocol)),
Binary::UsbCanA => Box::new(UsbCanAReader::with_protocol(source, protocol)),
}
}
}
fn binary_input(args: &Args) -> Option<Binary> {
match args.from {
Some(FromFormat::ActisenseEbl) => Some(Binary::Ebl),
Some(FromFormat::BstD0) => Some(Binary::BstD0),
Some(FromFormat::Bst95) => Some(Binary::Bst),
Some(FromFormat::UsbCanA) => Some(Binary::UsbCanA),
Some(_) => None,
None => args
.input()
.is_some_and(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ebl")))
.then_some(Binary::Ebl),
}
}
fn convert_raw<W: Write>(
args: &Args,
protocol: BusProtocol,
forced: Option<InputFormat>,
binary: Option<Binary>,
live: bool,
out: &mut W,
) -> Result<()> {
let source = open_source(args.input(), args.container_opts())?;
let mut reader: Box<dyn FrameReader> = if let Some(binary) = binary {
binary.reader(source, protocol)
} else if args.from == Some(FromFormat::Json) {
Box::new(crate::json_input::JsonFrameReader::new(
source,
protocol.database(args.shape.units()),
))
} else {
let lines = match forced {
Some(fmt) => LineFrameReader::with_format(source, fmt),
None => LineFrameReader::new(source),
}
.protocol(protocol)
.ignore_checksum(args.ignore_checksum);
if matches!(
args.to,
OutFormat::Mxpgn | OutFormat::ActisenseMxpgn | OutFormat::SeaSmart
) {
Box::new(Joined::new(lines, protocol))
} else {
Box::new(lines)
}
};
let mut writer: Box<dyn FrameWriter + '_> = match args.to {
OutFormat::Plain => Box::new(PlainWriter::new(out)),
OutFormat::Ydwg02 => Box::new(TextLineWriter::ydwg02(out)),
OutFormat::Actisense => Box::new(TextLineWriter::actisense(out)),
OutFormat::ActisenseEbl => Box::new(EblWriter::new(out)),
OutFormat::Mxpgn => Box::new(MxpgnWriter::new(out, protocol)),
OutFormat::ActisenseMxpgn => Box::new(MxpgnWriter::actisense(out, protocol)),
OutFormat::SeaSmart => Box::new(TextLineWriter::seasmart(out)),
OutFormat::Json | OutFormat::Text => unreachable!("decoded formats routed elsewhere"),
};
if !args.has_filter() && !live {
copy(&mut *reader, &mut *writer).context("converting frames")?;
return Ok(());
}
while let Some(frame) = reader.read_frame().context("reading input")? {
if args.keep(&frame) {
writer.write_frame(&frame).context("writing frame")?;
if live {
writer.flush().context("flushing output")?;
}
}
}
writer.flush().context("flushing output")?;
Ok(())
}
struct Joined<R: BufRead> {
lines: LineFrameReader<R>,
protocol: BusProtocol,
reassembler: Reassembler,
coalesced: bool,
}
impl<R: BufRead> Joined<R> {
fn new(lines: LineFrameReader<R>, protocol: BusProtocol) -> Self {
Self {
lines,
protocol,
reassembler: Reassembler::new(),
coalesced: false,
}
}
}
impl<R: BufRead> FrameReader for Joined<R> {
fn read_frame(&mut self) -> io::Result<Option<RawFrame>> {
while let Some(frame) = self.lines.read_frame()? {
let format = self.lines.active_format();
self.coalesced |= self.lines.header_coalesced()
|| (frame.data.len() > 8 && format != Some(InputFormat::PlainMixFast));
let whole = self.coalesced || format.is_some_and(|f| f.framing() == Framing::Coalesced);
let packet_type = if whole {
FramePacketType::Other
} else {
self.protocol.packet_type(frame.pgn)
};
match self.reassembler.push(frame, packet_type) {
Reassembled::PassThrough(f) | Reassembled::Complete(f) => return Ok(Some(f)),
Reassembled::Partial => {}
Reassembled::Error(e) => log::warn!("reassembly error: {e}"),
}
}
Ok(None)
}
}
fn convert_decoded<W: Write>(
args: &Args,
protocol: BusProtocol,
forced: Option<InputFormat>,
binary: Option<Binary>,
out: &mut W,
) -> Result<()> {
let json_opts = JsonOptions {
include_empty: args.empty,
name_value: args.nv,
debug: args.debug,
camel_case: args.shape.camel_case(),
wrap: args.shape.wrap(),
protocol,
};
let text_opts = TextOptions {
show_unavailable: args.empty,
debug: args.debug,
geo: args.geo.into(),
};
let as_json = args.to == OutFormat::Json;
if as_json && !args.no_banner {
writeln!(
out,
"{}",
crate::build_info::version_banner(args.shape.is_si(), args.nv, protocol)
)
.context("writing JSON banner")?;
}
let cfg = analyze::Config {
forced_format: forced,
pgn_filter: args.pgn,
src_filter: args.src,
dst_filter: args.dst,
suppress_startup_record: false,
units: args.shape.units(),
protocol,
fixed_time: None,
ignore_checksum: args.ignore_checksum,
};
let mut line = String::with_capacity(512);
let mut sink_err: Option<io::Error> = None;
let sink = |decoded: &crate::engine::DecodedPgn| {
if sink_err.is_some() {
return;
}
line.clear();
if as_json {
write_json(&mut line, decoded, &json_opts).expect("write to String");
} else {
write_text(&mut line, decoded, &text_opts).expect("write to String");
}
if let Err(e) = out
.write_all(line.as_bytes())
.and_then(|()| out.write_all(b"\n"))
{
sink_err = Some(e);
}
};
let source = open_source(args.input(), args.container_opts())?;
if binary.is_some() || args.from == Some(FromFormat::Json) {
let db = cfg.protocol.database(cfg.units);
let mut reader: Box<dyn FrameReader> = if let Some(binary) = binary {
binary.reader(source, cfg.protocol)
} else {
Box::new(crate::json_input::JsonFrameReader::new(source, db))
};
let mut sink = sink;
while let Some(frame) = reader.read_frame().context("reading input")? {
if cfg.pgn_filter.is_some_and(|w| frame.pgn != w)
|| cfg.src_filter.is_some_and(|w| frame.src != w)
|| cfg.dst_filter.is_some_and(|w| frame.dst != w)
{
continue;
}
if let Ok(decoded) = db.decode(&frame) {
sink(&decoded);
}
}
} else {
analyze::decode_stream(source, &cfg, sink).context("decoding input")?;
}
if let Some(e) = sink_err {
return Err(e).context("writing output");
}
Ok(())
}
fn open_source(file: Option<&Path>, opts: container::Options) -> Result<Box<dyn BufRead>> {
match file {
Some(path) if path.as_os_str() != "-" => {
if container::is_container(path) {
container::plain_reader(path, opts)
.with_context(|| format!("opening {}", path.display()))
} else {
let f = File::open(path).with_context(|| format!("opening {}", path.display()))?;
Ok(Box::new(BufReader::new(f)))
}
}
_ => Ok(Box::new(io::stdin().lock())),
}
}
impl Args {
fn has_filter(&self) -> bool {
self.src.is_some() || self.dst.is_some() || self.pgn.is_some()
}
fn keep(&self, frame: &RawFrame) -> bool {
self.src.is_none_or(|s| frame.src == s)
&& self.dst.is_none_or(|d| frame.dst == d)
&& self.pgn.is_none_or(|p| frame.pgn == p)
}
}