use std::{
fs::File,
io::{BufReader, Read, Write},
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use clap::Parser;
use num_complex::Complex32;
use sdr_acars::{AcarsError, AcarsMessage, ChannelBank, FrameParser, IF_RATE_HZ, MskDemod};
const WAV_CHUNK_FRAMES: usize = 4096;
const US_ACARS_CHANNELS: &[f64] = &[
131_550_000.0,
131_525_000.0,
130_025_000.0,
130_425_000.0,
130_450_000.0,
129_125_000.0,
];
#[derive(Parser, Debug)]
#[command(version, about = "ACARS decoder (Rust port of acarsdec)")]
struct Cli {
#[arg(value_name = "WAV", conflicts_with = "iq")]
wav: Option<PathBuf>,
#[arg(long, value_name = "PATH", conflicts_with = "wav")]
iq: Option<PathBuf>,
#[arg(long, default_value_t = 2_500_000)]
rate: u32,
#[arg(long, default_value_t = 130_337_500)]
center: u32,
#[arg(long, value_delimiter = ',', value_parser = parse_mhz)]
channels: Option<Vec<f64>>,
}
fn parse_mhz(s: &str) -> Result<f64, String> {
s.parse::<f64>()
.map(|mhz| mhz * 1_000_000.0)
.map_err(|e| format!("invalid frequency '{s}': {e}"))
}
fn main() -> std::process::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
match run(&cli) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprintln!("sdr-acars-cli: {e}");
std::process::ExitCode::FAILURE
}
}
}
fn run(cli: &Cli) -> Result<(), AcarsError> {
let mut stdout = std::io::stdout().lock();
if let Some(wav_path) = &cli.wav {
decode_wav(wav_path, cli.channels.as_deref(), &mut stdout)
} else if let Some(iq_path) = &cli.iq {
decode_iq(
iq_path,
f64::from(cli.rate),
f64::from(cli.center),
cli.channels.as_deref().unwrap_or(US_ACARS_CHANNELS),
&mut stdout,
)
} else {
Err(AcarsError::InvalidInput(
"no input file: pass a WAV path or --iq <PATH>".into(),
))
}
}
fn decode_wav(
path: &Path,
user_channels: Option<&[f64]>,
out: &mut impl Write,
) -> Result<(), AcarsError> {
let mut reader = hound::WavReader::open(path).map_err(|e| AcarsError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(e),
})?;
let spec = reader.spec();
if spec.sample_rate != IF_RATE_HZ {
return Err(AcarsError::InvalidInput(format!(
"WAV sample rate {} Hz != expected IF rate {IF_RATE_HZ} Hz",
spec.sample_rate
)));
}
let n_channels = spec.channels as usize;
let channels: Vec<f64> = match user_channels {
Some(cs) if cs.len() == n_channels => cs.to_vec(),
Some(cs) => {
return Err(AcarsError::InvalidInput(format!(
"WAV has {n_channels} channels but --channels provided {}",
cs.len()
)));
}
None => {
if n_channels > US_ACARS_CHANNELS.len() {
return Err(AcarsError::InvalidInput(format!(
"WAV has {n_channels} channels but US-6 default only \
covers {} — pass --channels explicitly",
US_ACARS_CHANNELS.len()
)));
}
US_ACARS_CHANNELS.iter().copied().take(n_channels).collect()
}
};
let mut demods: Vec<MskDemod> = (0..n_channels).map(|_| MskDemod::new()).collect();
let mut parsers: Vec<FrameParser> = channels
.iter()
.enumerate()
.map(|(i, &f)| {
#[allow(clippy::cast_possible_truncation)]
FrameParser::new(i as u8, f)
})
.collect();
let mut per_channel: Vec<Vec<f32>> = (0..n_channels)
.map(|_| Vec::with_capacity(WAV_CHUNK_FRAMES))
.collect();
let mut emit_buf: Vec<AcarsMessage> = Vec::new();
for (i, sample_result) in reader.samples::<i16>().enumerate() {
let sample = sample_result.map_err(|e| AcarsError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(e),
})?;
let ch_idx = i % n_channels;
per_channel[ch_idx].push(f32::from(sample) / f32::from(i16::MAX));
if ch_idx == n_channels - 1 && per_channel[0].len() == WAV_CHUNK_FRAMES {
for (idx, samples) in per_channel.iter_mut().enumerate() {
demods[idx].process(samples, &mut parsers[idx]);
parsers[idx].drain(|msg| emit_buf.push(msg));
samples.clear();
}
for msg in emit_buf.drain(..) {
print_message(&msg, out)?;
}
}
}
if !per_channel[0].is_empty() {
for (idx, samples) in per_channel.iter_mut().enumerate() {
demods[idx].process(samples, &mut parsers[idx]);
parsers[idx].drain(|msg| emit_buf.push(msg));
}
for msg in emit_buf.drain(..) {
print_message(&msg, out)?;
}
}
Ok(())
}
fn decode_iq(
path: &Path,
rate: f64,
center: f64,
channels: &[f64],
out: &mut impl Write,
) -> Result<(), AcarsError> {
let mut bank = ChannelBank::new(rate, center, channels)?;
let file = File::open(path).map_err(|e| AcarsError::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut reader = BufReader::new(file);
let mut buf = vec![0_u8; 4096 * 4];
let mut block: Vec<Complex32> = Vec::with_capacity(4096);
let mut emit_buf: Vec<AcarsMessage> = Vec::new();
let mut carry: Vec<u8> = Vec::with_capacity(4);
loop {
let n = reader.read(&mut buf).map_err(|e| AcarsError::Io {
path: path.to_path_buf(),
source: e,
})?;
if n == 0 {
if !carry.is_empty() {
return Err(AcarsError::InvalidInput(format!(
"IQ file ended with {} byte(s) of partial sample (expected multiples of 4)",
carry.len()
)));
}
break;
}
let mut combined: Vec<u8> = Vec::with_capacity(carry.len() + n);
combined.extend_from_slice(&carry);
combined.extend_from_slice(&buf[..n]);
carry.clear();
let usable = combined.len() - (combined.len() % 4);
block.clear();
for chunk in combined[..usable].chunks_exact(4) {
let i = i16::from_le_bytes([chunk[0], chunk[1]]);
let q = i16::from_le_bytes([chunk[2], chunk[3]]);
block.push(Complex32::new(
f32::from(i) / f32::from(i16::MAX),
f32::from(q) / f32::from(i16::MAX),
));
}
carry.extend_from_slice(&combined[usable..]);
if !block.is_empty() {
bank.process(&block, |msg| emit_buf.push(msg));
for msg in emit_buf.drain(..) {
print_message(&msg, out)?;
}
}
}
Ok(())
}
fn print_message(msg: &AcarsMessage, out: &mut impl Write) -> Result<(), AcarsError> {
let chn_one_based = u32::from(msg.channel_idx) + 1;
let stamp = format_timestamp(msg.timestamp);
writeln!(
out,
"\n[#{chn_one_based} (L:{:+5.1} E:{}) {stamp} --------------------------------",
msg.level_db, msg.error_count,
)
.map_err(io_err)?;
write!(out, "Mode : {} ", msg.mode as char).map_err(io_err)?;
write!(
out,
"Label : {} ",
std::str::from_utf8(&msg.label).unwrap_or("??")
)
.map_err(io_err)?;
if msg.block_id != 0 {
write!(out, "Id : {} ", msg.block_id as char).map_err(io_err)?;
if msg.ack == b'!' {
writeln!(out, "Nak").map_err(io_err)?;
} else {
writeln!(out, "Ack : {}", msg.ack as char).map_err(io_err)?;
}
let aircraft_clean: String = msg.aircraft.chars().filter(|&c| c != '.').collect();
write!(out, "Aircraft reg: {aircraft_clean} ").map_err(io_err)?;
if is_downlink_blk(msg.block_id) {
let flight = msg.flight_id.as_deref().unwrap_or("");
writeln!(out, "Flight id: {flight}").map_err(io_err)?;
let msgno = msg.message_no.as_deref().unwrap_or("");
write!(out, "No: {msgno:>4}").map_err(io_err)?;
}
}
writeln!(out).map_err(io_err)?;
if !msg.text.is_empty() {
writeln!(out, "{}", msg.text).map_err(io_err)?;
}
if !msg.end_of_message {
writeln!(out, "ETB").map_err(io_err)?;
}
out.flush().map_err(io_err)?;
Ok(())
}
fn is_downlink_blk(bid: u8) -> bool {
bid.is_ascii_digit()
}
fn format_timestamp(ts: SystemTime) -> String {
match ts.duration_since(UNIX_EPOCH) {
Ok(d) => format!("{}.{:03}", d.as_secs(), d.subsec_millis()),
Err(_) => "0.000".to_string(),
}
}
fn io_err(e: std::io::Error) -> AcarsError {
AcarsError::Io {
path: PathBuf::from("<stdout>"),
source: e,
}
}