use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::engine::format::timestamp::{normalize_timestamp, to_unix_ms};
use crate::engine::format::{
InputFormat, detect, header_implies_coalesced, parse_for, parse_format_header,
};
use crate::engine::{BusProtocol, RawFrame, format_iso_ms};
use super::{DeviceEncoder, DeviceFailure, DeviceHandle, WriterCmd, canboat_csv, writer_thread};
pub const UNTIMED_GAP: Duration = Duration::from_millis(10);
const SCAN_FRAMES: usize = 10_000;
#[derive(Debug, Clone)]
pub struct Config {
pub path: PathBuf,
pub protocol: BusProtocol,
pub sent: Option<PathBuf>,
pub max_gap: Duration,
}
impl Config {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
protocol: BusProtocol::Nmea2000,
sent: None,
max_gap: Duration::from_secs(10),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scan {
pub coalesced: bool,
}
pub fn scan(path: &Path, protocol: BusProtocol) -> io::Result<Scan> {
let reader = BufReader::new(File::open(path)?);
let mut format: Option<InputFormat> = None;
let mut header_coalesced: Option<bool> = None;
let mut frames = 0;
let mut long_frame = false;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim_end_matches('\r');
if trimmed.starts_with('#') {
if format.is_none()
&& let Some(fmt) = parse_format_header(trimmed)
{
format = Some(fmt);
header_coalesced = Some(header_implies_coalesced(trimmed));
}
continue;
}
if trimmed.is_empty() {
continue;
}
let fmt = *format.get_or_insert_with(|| detect(trimmed).unwrap_or(InputFormat::Plain));
if let Ok(Some(frame)) = parse_for(fmt, protocol, trimmed) {
frames += 1;
long_frame |= frame.data.len() > 8;
if header_coalesced.is_some() || long_frame || frames >= SCAN_FRAMES {
break;
}
}
}
if frames == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: no frames canboat can read", path.display()),
));
}
Ok(Scan {
coalesced: header_coalesced.unwrap_or(long_frame),
})
}
pub fn run(config: Config) -> io::Result<DeviceHandle> {
if let Some(sent) = &config.sent
&& same_file(sent, &config.path)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: the replay capture and the sent file must differ",
sent.display()
),
));
}
let file = File::open(&config.path)?;
let mut writer: Box<dyn Write + Send> = match &config.sent {
Some(path) => Box::new(OpenOptions::new().create(true).append(true).open(path)?),
None => Box::new(io::sink()),
};
let (frames_tx, frames_rx) = mpsc::channel::<RawFrame>();
let (cmd_tx, cmd_rx) = mpsc::channel::<WriterCmd>();
let progress = Arc::new(AtomicU64::new(0));
let writer_progress = progress.clone();
let writing = Arc::new(AtomicBool::new(true));
let reader_writing = writing.clone();
let encoder = SentEncoder;
let init = encoder.init_bytes();
let writer_join = thread::Builder::new()
.name("replay-writer".into())
.spawn(move || {
writer_thread(&mut *writer, encoder, init, None, cmd_rx, &writer_progress);
writing.store(false, Ordering::Release);
})?;
let lost = DeviceFailure::default();
let reader_lost = lost.clone();
let reader_join = thread::Builder::new()
.name("replay-reader".into())
.spawn(move || {
if let Err(why) = play(file, &config, &frames_tx, &reader_writing) {
log::error!("{why}");
reader_lost.set(&why);
}
})?;
Ok(DeviceHandle {
frames_rx,
cmd_tx,
joins: vec![reader_join],
writer: Some(writer_join),
progress,
failure: Default::default(),
lost,
stop: None,
claim: None,
})
}
fn same_file(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn play(
mut file: File,
config: &Config,
frames_tx: &mpsc::Sender<RawFrame>,
writing: &AtomicBool,
) -> Result<(), String> {
let mut pass = 0u64;
loop {
pass += 1;
match play_once(file, config, frames_tx, writing) {
Ok(Played::Done(0)) => {
return Err(format!("replay: {} holds no frames", config.path.display()));
}
Ok(Played::Done(frames)) => {
log::info!(
"replay: pass {pass} of {} sent {frames} frames; starting over",
config.path.display()
);
}
Ok(Played::Stopped) => return Ok(()),
Err(e) => {
return Err(format!("replay: reading {}: {e}", config.path.display()));
}
}
file = match File::open(&config.path) {
Ok(f) => f,
Err(e) => {
return Err(format!("replay: reopening {}: {e}", config.path.display()));
}
};
}
}
enum Played {
Done(u64),
Stopped,
}
fn play_once(
file: File,
config: &Config,
frames_tx: &mpsc::Sender<RawFrame>,
writing: &AtomicBool,
) -> io::Result<Played> {
let mut reader = BufReader::new(file);
let mut line = String::with_capacity(512);
let mut format: Option<InputFormat> = None;
let mut pacer = Pacer::new(config.max_gap);
let mut frames = 0u64;
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
return Ok(Played::Done(frames));
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('#') {
if format.is_none() {
format = parse_format_header(trimmed);
}
continue;
}
let fmt = *format.get_or_insert_with(|| detect(trimmed).unwrap_or(InputFormat::Plain));
let Ok(Some(mut frame)) = parse_for(fmt, config.protocol, trimmed) else {
continue;
};
let at_ms = frame.timestamp.as_deref().and_then(timestamp_ms);
thread::sleep(pacer.wait(at_ms));
frame.timestamp = Some(now_iso());
if !writing.load(Ordering::Acquire) || frames_tx.send(frame).is_err() {
return Ok(Played::Stopped);
}
frames += 1;
}
}
struct Pacer {
max_gap: i64,
latest_ms: Option<i64>,
behind: u32,
}
const RESYNC_FRAMES: u32 = 50;
impl Pacer {
fn new(max_gap: Duration) -> Self {
Self {
max_gap: max_gap.as_millis().try_into().unwrap_or(i64::MAX),
latest_ms: None,
behind: 0,
}
}
fn wait(&mut self, at_ms: Option<i64>) -> Duration {
let Some(at) = at_ms else {
return UNTIMED_GAP;
};
let Some(latest) = self.latest_ms else {
self.latest_ms = Some(at);
return Duration::ZERO;
};
let ahead = at - latest;
if ahead <= -self.max_gap {
self.behind += 1;
if self.behind >= RESYNC_FRAMES {
self.behind = 0;
self.latest_ms = Some(at);
}
return Duration::ZERO;
}
self.behind = 0;
if ahead <= 0 {
return Duration::ZERO;
}
self.latest_ms = Some(at);
if ahead < self.max_gap {
Duration::from_millis(ahead as u64)
} else {
Duration::ZERO
}
}
}
struct SentEncoder;
impl DeviceEncoder for SentEncoder {
fn init_bytes(&self) -> Vec<u8> {
canboat_csv::Encoder.init_bytes()
}
fn encode_frame(&self, frame: &RawFrame) -> Option<Vec<u8>> {
if frame.timestamp.is_some() {
return canboat_csv::Encoder.encode_frame(frame);
}
let mut stamped = frame.clone();
stamped.timestamp = Some(now_iso());
canboat_csv::Encoder.encode_frame(&stamped)
}
}
fn timestamp_ms(ts: &str) -> Option<i64> {
to_unix_ms(ts).or_else(|| time_of_day_ms(&normalize_timestamp(ts)))
}
fn time_of_day_ms(ts: &str) -> Option<i64> {
let b = ts.as_bytes();
if b.len() < 8 || b[2] != b':' || b[5] != b':' {
return None;
}
let h: i64 = ts.get(0..2)?.parse().ok()?;
let m: i64 = ts.get(3..5)?.parse().ok()?;
let s: i64 = ts.get(6..8)?.parse().ok()?;
let ms: i64 = match ts.get(8..9) {
Some(".") => ts.get(9..12).and_then(|f| f.parse().ok()).unwrap_or(0),
_ => 0,
};
Some(((h * 60 + m) * 60 + s) * 1000 + ms)
}
fn now_iso() -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format_iso_ms(ms)
}
#[cfg(test)]
mod tests {
use super::*;
const LINES: &str = "# format=FAST\n\
2016-02-28T19:57:02.364Z,7,65280,7,255,8,3f,9f,12,00,00,00,ff,ff\n\
2016-02-28T19:57:02.414Z,2,127250,1,255,8,ff,c7,63,ff,7f,ff,7f,fd\n";
fn capture(name: &str, text: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("canboat-replay-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("capture.raw");
std::fs::write(&path, text).unwrap();
path
}
#[test]
fn waits_as_long_as_the_timestamps_are_apart() {
let mut p = Pacer::new(Duration::from_secs(10));
assert_eq!(p.wait(Some(1_000)), Duration::ZERO);
assert_eq!(p.wait(Some(1_050)), Duration::from_millis(50));
assert_eq!(p.wait(Some(1_050)), Duration::ZERO);
assert_eq!(p.wait(None), UNTIMED_GAP);
assert_eq!(p.wait(Some(20_000)), Duration::ZERO);
assert_eq!(p.wait(Some(20_100)), Duration::from_millis(100));
}
#[test]
fn paces_against_the_latest_timestamp() {
let mut p = Pacer::new(Duration::from_secs(10));
p.wait(Some(5_000));
assert_eq!(p.wait(Some(2_000)), Duration::ZERO);
assert_eq!(p.wait(Some(5_020)), Duration::from_millis(20));
assert_eq!(p.wait(Some(-20_000)), Duration::ZERO);
assert_eq!(p.wait(Some(5_040)), Duration::from_millis(20));
}
#[test]
fn starts_over_when_the_frames_stay_far_behind() {
let mut p = Pacer::new(Duration::from_secs(10));
p.wait(Some(86_399_000));
for i in 0..RESYNC_FRAMES {
assert_eq!(p.wait(Some(500 + i as i64)), Duration::ZERO);
}
let last = 500 + RESYNC_FRAMES as i64 - 1;
assert_eq!(p.wait(Some(last + 20)), Duration::from_millis(20));
}
#[test]
fn reads_dated_and_time_of_day_timestamps() {
assert_eq!(timestamp_ms("1970-01-01T00:00:01.500Z"), Some(1_500));
assert_eq!(timestamp_ms("1970-01-01-00:00:01,500"), Some(1_500));
assert_eq!(timestamp_ms("01:00:00.250"), Some(3_600_250));
assert_eq!(timestamp_ms("garbage"), None);
}
#[test]
fn scan_reads_the_format_header() {
let path = capture("header", LINES);
assert_eq!(
scan(&path, BusProtocol::Nmea2000).unwrap(),
Scan { coalesced: true }
);
let plain = capture(
"plain",
"2016-02-28T19:57:02.364Z,7,65280,7,255,8,3f,9f,12,00,00,00,ff,ff\n",
);
assert_eq!(
scan(&plain, BusProtocol::Nmea2000).unwrap(),
Scan { coalesced: false }
);
let bytes = vec!["00"; 43].join(",");
let long = capture(
"long",
&format!("2016-02-28T19:57:02.364Z,6,129029,3,255,43,{bytes}\n"),
);
assert_eq!(
scan(&long, BusProtocol::Nmea2000).unwrap(),
Scan { coalesced: true }
);
}
#[test]
fn scan_takes_the_format_from_the_header() {
let path = capture(
"garmin",
"# format=GARMIN_CSV1\n\
0,486942,127508,Battery Status,Garmin,6,255,2,1,8,0x017505FF7FFFFFFF\n",
);
assert_eq!(
scan(&path, BusProtocol::Nmea2000).unwrap(),
Scan { coalesced: true }
);
}
#[test]
fn refuses_to_send_into_the_capture_it_plays() {
let path = capture("same", LINES);
let mut config = Config::new(&path);
config.sent = Some(path.parent().unwrap().join(".").join("capture.raw"));
assert_eq!(
run(config).err().map(|e| e.kind()),
Some(io::ErrorKind::InvalidInput)
);
}
#[test]
fn join_stops_a_replay_nobody_reads() {
let handle = run(Config::new(capture("join", LINES))).unwrap();
let (done_tx, done_rx) = mpsc::channel();
thread::spawn(move || {
handle.join();
let _ = done_tx.send(());
});
assert!(
done_rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"join did not return"
);
}
#[test]
fn stops_playing_when_the_writer_has_stopped() {
let path = capture("writer-gone", LINES);
let (frames_tx, frames_rx) = mpsc::channel();
let played = play_once(
File::open(&path).unwrap(),
&Config::new(&path),
&frames_tx,
&AtomicBool::new(false),
)
.unwrap();
assert!(matches!(played, Played::Stopped));
assert!(frames_rx.try_recv().is_err());
}
#[test]
fn scan_rejects_a_capture_without_frames() {
let path = capture("empty", "# nothing here\n\n");
assert_eq!(
scan(&path, BusProtocol::Nmea2000).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn plays_in_a_loop_restamped_and_logs_what_is_sent() {
let path = capture("loop", LINES);
let sent = path.with_file_name("sent.raw");
let _ = std::fs::remove_file(&sent);
let handle = run(Config {
sent: Some(sent.clone()),
..Config::new(&path)
})
.unwrap();
let pgns: Vec<u32> = (0..5)
.map(|_| handle.frames_rx.recv().unwrap().pgn)
.collect();
assert_eq!(pgns, [65280, 127250, 65280, 127250, 65280]);
let frame = handle.frames_rx.recv().unwrap();
let ts = frame.timestamp.expect("restamped");
assert!(ts.starts_with(&now_iso()[..10]), "{ts} is not today");
let out = RawFrame::new(None, 2, 127508, 0, 255, [1u8, 2, 3, 4, 5, 6, 7, 8]);
handle.send_frame(out).unwrap();
let stamped = RawFrame::new(
Some("2026-10-03T12:00:00.000Z".into()),
2,
127508,
0,
255,
[1u8; 8],
);
handle.send_frame(stamped).unwrap();
assert_eq!(handle.close(), super::super::Closed::Confirmed);
let written = std::fs::read_to_string(&sent).unwrap();
assert!(written.starts_with("# format=FAST\r\n"), "{written:?}");
let today = &now_iso()[..10];
assert!(
written.contains(&format!("\r\n{today}"))
&& written.contains(",2,127508,0,255,8,01,02,03,04,05,06,07,08"),
"an unstamped frame is stamped now: {written:?}"
);
assert!(
written.contains("2026-10-03T12:00:00.000Z,2,127508,0,255,8,01,01"),
"a stamped frame keeps its time: {written:?}"
);
}
}