use {
crate::{
CancelContext,
error::{Error, Result},
protocol::crc::crc16_xmodem,
},
log::{debug, trace},
std::{
io::{Read, Write},
time::Duration,
},
};
pub mod control {
pub const SOH: u8 = 0x01;
pub const STX: u8 = 0x02;
pub const EOT: u8 = 0x04;
pub const ACK: u8 = 0x06;
pub const NAK: u8 = 0x15;
pub const CAN: u8 = 0x18;
pub const C: u8 = b'C';
}
pub const SOH_BLOCK_SIZE: usize = 128;
pub const STX_BLOCK_SIZE: usize = 1024;
#[derive(Debug, Clone)]
pub struct YmodemConfig {
pub char_timeout: Duration,
pub c_timeout: Duration,
pub max_retries: u32,
pub verbose: u8,
}
impl Default for YmodemConfig {
fn default() -> Self {
Self {
char_timeout: Duration::from_millis(1000),
c_timeout: Duration::from_secs(60),
max_retries: 10,
verbose: 0,
}
}
}
pub struct YmodemTransfer<'a, P: Read + Write> {
port: &'a mut P,
config: YmodemConfig,
cancel: &'a CancelContext,
}
impl<'a, P: Read + Write> YmodemTransfer<'a, P> {
fn check_interrupted(&self) -> Result<()> {
self.cancel
.check()
}
pub fn new(port: &'a mut P, cancel: &'a CancelContext) -> Self {
Self {
port,
config: YmodemConfig::default(),
cancel,
}
}
pub fn with_config(port: &'a mut P, config: YmodemConfig, cancel: &'a CancelContext) -> Self {
Self {
port,
config,
cancel,
}
}
fn read_byte(&mut self, _timeout: Duration) -> Result<u8> {
let mut buf = [0u8; 1];
match self
.port
.read(&mut buf)
{
Ok(1) => Ok(buf[0]),
Ok(_) => Err(Error::Timeout("read_byte: no data".into())),
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
Err(Error::Timeout("read_byte: timeout".into()))
},
Err(e) => Err(Error::Io(e)),
}
}
pub fn wait_for_c(&mut self) -> Result<()> {
debug!("Waiting for 'C' from receiver...");
let start = std::time::Instant::now();
while start.elapsed()
< self
.config
.c_timeout
{
self.check_interrupted()?;
match self.read_byte(
self.config
.char_timeout,
) {
Ok(control::C) => {
debug!("Received 'C', starting transfer");
return Ok(());
},
Ok(c) => {
trace!("Received unexpected char: 0x{c:02X}");
},
Err(Error::Timeout(_)) => {},
Err(e) => return Err(e),
}
}
Err(Error::Timeout("Timeout waiting for 'C'".into()))
}
fn build_block(seq: u8, data: &[u8], use_stx: bool) -> Vec<u8> {
let block_size = if use_stx {
STX_BLOCK_SIZE
} else {
SOH_BLOCK_SIZE
};
let header = if use_stx { control::STX } else { control::SOH };
let mut block = Vec::with_capacity(3 + block_size + 2);
block.push(header);
block.push(seq);
block.push(!seq);
if data.len() >= block_size {
block.extend_from_slice(&data[..block_size]);
} else {
block.extend_from_slice(data);
block.resize(3 + block_size, 0x00);
}
let crc = crc16_xmodem(&block[3..3 + block_size]);
block.push((crc >> 8) as u8);
block.push((crc & 0xFF) as u8);
block
}
fn send_block(&mut self, block: &[u8]) -> Result<()> {
for retry in 0..self
.config
.max_retries
{
self.check_interrupted()?;
trace!("Sending block (attempt {})", retry + 1);
self.port
.write_all(block)?;
self.port
.flush()?;
match self.read_byte(
self.config
.char_timeout,
) {
Ok(control::ACK) => {
trace!("Block ACKed");
return Ok(());
},
Ok(control::NAK) => {
debug!("Block NAKed, retrying...");
},
Ok(control::CAN) => {
return Err(Error::Ymodem("Transfer cancelled by receiver".into()));
},
Ok(c) => {
debug!("Unexpected response: 0x{c:02X}, retrying...");
},
Err(Error::Timeout(_)) => {
debug!("Timeout waiting for ACK, retrying...");
},
Err(e) => return Err(e),
}
}
Err(Error::Ymodem(format!(
"Block transfer failed after {} retries",
self.config
.max_retries
)))
}
pub fn send_file_info(&mut self, filename: &str, filesize: usize) -> Result<()> {
debug!("Sending file info: {filename} ({filesize} bytes)");
let mut data = Vec::with_capacity(SOH_BLOCK_SIZE);
data.extend_from_slice(filename.as_bytes());
data.push(0x00);
data.extend_from_slice(
filesize
.to_string()
.as_bytes(),
);
data.push(0x00);
let block = Self::build_block(0, &data, false);
self.send_block(&block)
}
pub fn send_eot(&mut self) -> Result<()> {
debug!("Sending EOT");
for _retry in 0..self
.config
.max_retries
{
self.check_interrupted()?;
self.port
.write_all(&[control::EOT])?;
self.port
.flush()?;
match self.read_byte(
self.config
.char_timeout,
) {
Ok(control::ACK) => {
debug!("EOT ACKed");
return Ok(());
},
Ok(control::C) => {
return Ok(());
},
Ok(_) | Err(Error::Timeout(_)) => {},
Err(e) => return Err(e),
}
}
Ok(())
}
pub fn send_finish(&mut self) -> Result<()> {
debug!("Sending finish block");
let block = Self::build_block(0, &[], false);
self.send_block(&block)
}
pub fn transfer<F>(&mut self, filename: &str, data: &[u8], mut progress: F) -> Result<()>
where
F: FnMut(usize, usize),
{
self.check_interrupted()?;
debug!(
"Starting YMODEM transfer: {} ({} bytes)",
filename,
data.len()
);
self.wait_for_c()?;
self.send_file_info(filename, data.len())?;
let mut seq: u8 = 1;
let mut offset = 0;
let total = data.len();
while offset < total {
self.check_interrupted()?;
let chunk_end = (offset + STX_BLOCK_SIZE).min(total);
let chunk = &data[offset..chunk_end];
let block = Self::build_block(seq, chunk, true);
self.send_block(&block)?;
offset = chunk_end;
seq = seq.wrapping_add(1);
progress(offset, total);
}
self.send_eot()?;
let _ = self.send_finish();
debug!("YMODEM transfer complete");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_block_soh() {
let data = [0x01, 0x02, 0x03];
let block = YmodemTransfer::<std::io::Cursor<Vec<u8>>>::build_block(1, &data, false);
assert_eq!(block[0], control::SOH);
assert_eq!(block[1], 1);
assert_eq!(block[2], 0xFE);
assert_eq!(block.len(), 3 + SOH_BLOCK_SIZE + 2);
}
#[test]
fn test_build_block_stx() {
let data = vec![0xAA; STX_BLOCK_SIZE];
let block = YmodemTransfer::<std::io::Cursor<Vec<u8>>>::build_block(5, &data, true);
assert_eq!(block[0], control::STX);
assert_eq!(block[1], 5);
assert_eq!(block[2], 0xFA);
assert_eq!(block.len(), 3 + STX_BLOCK_SIZE + 2);
}
struct MockSerial {
read_buf: std::collections::VecDeque<u8>,
write_buf: Vec<u8>,
}
impl MockSerial {
fn new(response: &[u8]) -> Self {
Self {
read_buf: response
.iter()
.copied()
.collect(),
write_buf: Vec::new(),
}
}
}
impl std::io::Read for MockSerial {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self
.read_buf
.is_empty()
{
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "no data"));
}
let n = buf
.len()
.min(
self.read_buf
.len(),
);
for b in buf
.iter_mut()
.take(n)
{
*b = self
.read_buf
.pop_front()
.unwrap();
}
Ok(n)
}
}
impl std::io::Write for MockSerial {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.write_buf
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn test_ymodem_transfer_single_c_wait() {
let response = vec![
control::C, control::ACK, control::ACK, control::ACK, control::ACK, ];
let mut port = MockSerial::new(&response);
let config = YmodemConfig {
char_timeout: Duration::from_millis(100),
c_timeout: Duration::from_millis(200),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let test_data = vec![0x42; 100]; let result = ymodem.transfer("test.bin", &test_data, |_, _| {});
assert!(
result.is_ok(),
"YMODEM transfer should succeed with single 'C' (no second 'C' after block 0). Error: \
{:?}",
result.err()
);
}
#[test]
fn test_ymodem_no_c_before_finish() {
let response = vec![
control::C, control::ACK, control::ACK, control::ACK, control::ACK, ];
let mut port = MockSerial::new(&response);
let config = YmodemConfig {
char_timeout: Duration::from_millis(100),
c_timeout: Duration::from_millis(200),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let test_data = vec![0x55; 50];
let result = ymodem.transfer("test.bin", &test_data, |_, _| {});
assert!(
result.is_ok(),
"YMODEM should complete without waiting for 'C' before finish block. Error: {:?}",
result.err()
);
}
#[test]
fn test_ymodem_transfer_exact_block_size() {
let response = vec![
control::C, control::ACK, control::ACK, control::ACK, control::ACK, ];
let mut port = MockSerial::new(&response);
let config = YmodemConfig {
char_timeout: Duration::from_millis(100),
c_timeout: Duration::from_millis(200),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let test_data = vec![0xCC; STX_BLOCK_SIZE]; let result = ymodem.transfer("exact_block.bin", &test_data, |_, _| {});
assert!(
result.is_ok(),
"YMODEM should handle exactly 1024-byte payload. Error: {:?}",
result.err()
);
}
#[test]
fn test_ymodem_transfer_multi_block() {
let num_blocks = 3;
let mut response = vec![
control::C, control::ACK, ];
response.extend(std::iter::repeat_n(control::ACK, num_blocks)); response.push(control::ACK); response.push(control::ACK);
let mut port = MockSerial::new(&response);
let config = YmodemConfig {
char_timeout: Duration::from_millis(100),
c_timeout: Duration::from_millis(200),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let test_data = vec![0xDD; STX_BLOCK_SIZE * num_blocks];
let mut progress_calls = 0;
let result = ymodem.transfer("multi_block.bin", &test_data, |current, total| {
assert_eq!(total, STX_BLOCK_SIZE * num_blocks);
assert!(current <= total);
progress_calls += 1;
});
assert!(
result.is_ok(),
"YMODEM should handle multi-block transfer. Error: {:?}",
result.err()
);
assert_eq!(
progress_calls, num_blocks,
"Progress should be called once per block"
);
}
#[test]
fn test_wait_for_c_interrupted_immediate() {
crate::test_set_interrupted(true);
let mut port = MockSerial::new(&[]);
let config = YmodemConfig {
char_timeout: Duration::from_millis(50),
c_timeout: Duration::from_millis(100),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let result = ymodem.wait_for_c();
assert!(matches!(
result,
Err(Error::Io(ref io)) if io.kind() == std::io::ErrorKind::Interrupted
));
crate::test_set_interrupted(false);
}
#[test]
fn test_transfer_interrupted_before_start() {
crate::test_set_interrupted(true);
let mut port = MockSerial::new(&[]);
let config = YmodemConfig {
char_timeout: Duration::from_millis(50),
c_timeout: Duration::from_millis(100),
max_retries: 1,
verbose: 0,
};
let cancel = crate::cancel_context_from_global();
let mut ymodem = YmodemTransfer::with_config(&mut port, config, &cancel);
let result = ymodem.transfer("app.bin", &[0x11, 0x22], |_, _| {});
assert!(matches!(
result,
Err(Error::Io(ref io)) if io.kind() == std::io::ErrorKind::Interrupted
));
assert!(
port.write_buf
.is_empty(),
"Interrupted transfer should not write any YMODEM data"
);
crate::test_set_interrupted(false);
}
}