use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::engine::RawFrame;
use crate::engine::format::InputFormat;
use crate::engine::output::{GeoFormat, JsonOptions, TextOptions, write_json, write_text};
use crate::io::{
EblReader, EblWriter, FrameReader, FrameWriter, LineFrameReader, PlainWriter, TextLineWriter,
analyze, container, copy,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum OutFormat {
Plain,
Json,
Text,
Ydwg02,
Actisense,
#[value(name = "actisense-ebl")]
ActisenseEbl,
}
impl OutFormat {
fn is_frame_level(self) -> bool {
matches!(
self,
OutFormat::Plain | OutFormat::Ydwg02 | OutFormat::Actisense | OutFormat::ActisenseEbl
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum FromFormat {
#[value(name = "plain", alias = "fast")]
Plain,
#[value(name = "plain-mix-fast")]
PlainMixFast,
#[value(name = "actisense", alias = "actisense-ascii")]
Actisense,
#[value(name = "ydwg02", alias = "yden")]
Ydwg02,
#[value(name = "ikonvert")]
Ikonvert,
#[value(name = "json")]
Json,
#[value(name = "airmar")]
Airmar,
#[value(name = "chetco")]
Chetco,
#[value(name = "garmin", alias = "garmin-csv")]
Garmin,
#[value(name = "garmin-csv2")]
GarminCsv2,
#[value(name = "candump")]
Candump,
#[value(name = "actisense-ebl")]
ActisenseEbl,
}
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::Chetco => InputFormat::Chetco,
FromFormat::Garmin => InputFormat::GarminCsv,
FromFormat::GarminCsv2 => InputFormat::GarminCsv2,
FromFormat::Candump => InputFormat::Candump,
FromFormat::ActisenseEbl | 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, actisense, ydwg02, ikonvert, airmar, chetco,
garmin, garmin-csv2, candump.
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
ydwg02 Yacht Devices YDWG-02 / YDEN received lines (raw frames)
actisense Actisense N2K-ASCII lines (raw frames)
actisense-ebl Actisense .ebl binary log (raw frames)
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, 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,
#[arg(long)]
j1939: bool,
#[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 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 forced = args.from.and_then(FromFormat::to_input_format);
let ebl = ebl_input(&args);
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
if args.to.is_frame_level() {
convert_raw(&args, forced, ebl, &mut out)
} else {
convert_decoded(&args, forced, ebl, &mut out)
}
}
fn ebl_input(args: &Args) -> bool {
matches!(args.from, Some(FromFormat::ActisenseEbl))
|| (args.from.is_none()
&& args
.input()
.is_some_and(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ebl"))))
}
fn convert_raw<W: Write>(
args: &Args,
forced: Option<InputFormat>,
ebl: bool,
out: &mut W,
) -> Result<()> {
let source = open_source(args.input(), args.container_opts())?;
let mut reader: Box<dyn FrameReader> = if ebl {
Box::new(EblReader::new(source))
} else if args.from == Some(FromFormat::Json) {
Box::new(crate::json_input::JsonFrameReader::new(
source,
if args.j1939 {
crate::engine::PgnDatabase::embedded_j1939(args.shape.units())
} else {
crate::engine::PgnDatabase::embedded(args.shape.units())
},
))
} else {
match forced {
Some(fmt) => Box::new(LineFrameReader::with_format(source, fmt)),
None => Box::new(LineFrameReader::new(source)),
}
};
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::Json | OutFormat::Text => unreachable!("decoded formats routed elsewhere"),
};
if !args.has_filter() {
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")?;
}
}
writer.flush().context("flushing output")?;
Ok(())
}
fn convert_decoded<W: Write>(
args: &Args,
forced: Option<InputFormat>,
ebl: bool,
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(),
};
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)
)
.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(),
j1939: args.j1939,
fixed_time: None,
};
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 ebl || args.from == Some(FromFormat::Json) {
let db = if cfg.j1939 {
crate::engine::PgnDatabase::embedded_j1939(cfg.units)
} else {
crate::engine::PgnDatabase::embedded(cfg.units)
};
let mut reader: Box<dyn FrameReader> = if ebl {
Box::new(EblReader::new(source))
} 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)
}
}