marlin-binary-transfer 0.1.1

Host-side implementation of Marlin's Binary File Transfer Mark II protocol for SD-card upload to 3D printers.
Documentation
//! Shared types used by both the [`blocking`](crate::adapters::blocking) and
//! [`tokio`](crate::adapters::tokio) adapter modules.
//!
//! Living here rather than in either adapter module so that enabling one
//! adapter doesn't require enabling the other to resolve `UploadOptions` /
//! `UploadStats` / `UploadError` symbols.

use thiserror::Error;

use crate::file_transfer::{Compression, FileError};

/// Conservative fallback when the device-advertised block size is zero
/// (which shouldn't happen after a successful SYNC handshake but we
/// handle it defensively).
const FALLBACK_CHUNK_SIZE: usize = 256;

/// Resolve the per-WRITE chunk size from caller options and the device
/// max.
///
/// - `requested == 0` means "use the device-advertised max verbatim".
/// - `requested > 0` is honored, but capped to the device max so a
///   caller asking for 4096 against a device that says 512 doesn't
///   blow past what the device can buffer.
/// - `device_max == 0` falls back to [`FALLBACK_CHUNK_SIZE`].
pub(crate) fn resolve_chunk_size(requested: usize, device_max: u16) -> usize {
    let device_max = if device_max == 0 {
        FALLBACK_CHUNK_SIZE
    } else {
        device_max as usize
    };
    if requested == 0 {
        device_max
    } else {
        requested.min(device_max)
    }
}

/// Per-chunk progress payload passed to a [`ProgressCallback`]. Values are
/// cumulative across the upload.
#[derive(Debug, Clone, Copy, Default)]
pub struct Progress {
    /// Bytes sent so far across all WRITE packets (post-compression).
    pub bytes_sent: u64,
    /// Number of WRITE packets acknowledged so far.
    pub chunks_sent: u64,
    /// Total bytes read from `src` (constant across calls within one upload).
    pub source_bytes: u64,
}

impl Progress {
    /// Fraction of the source uploaded so far, in `[0.0, 1.0]`. Returns
    /// `None` when `source_bytes` is `0` (zero-length upload — undefined
    /// ratio). With compression enabled `bytes_sent` is the post-compression
    /// wire count, so this ratio can exceed `source_bytes` only if the
    /// compressor expands the data; the result is clamped to `1.0`.
    pub fn fraction(&self) -> Option<f32> {
        if self.source_bytes == 0 {
            return None;
        }
        let f = self.bytes_sent as f32 / self.source_bytes as f32;
        Some(f.min(1.0))
    }

    /// Percent uploaded, in `[0.0, 100.0]`. Returns `None` when
    /// `source_bytes` is `0`. See [`Progress::fraction`] for the
    /// compression caveat.
    pub fn percent(&self) -> Option<f32> {
        self.fraction().map(|f| f * 100.0)
    }
}

/// Closure type the adapters invoke after each acknowledged WRITE packet.
/// Boxed for object safety; `Send` so async callers can ship the callback
/// into `spawn_blocking`.
pub type ProgressCallback = Box<dyn FnMut(Progress) + Send>;

/// Caller-supplied options controlling the upload.
///
/// Not `Clone` because [`progress`](Self::progress) holds a `FnMut` closure.
/// `Debug` is implemented manually for the same reason.
pub struct UploadOptions {
    /// Destination filename on the device's SD card. Required.
    pub dest_filename: String,
    /// Compression preference. Defaults to [`Compression::None`].
    pub compression: Compression,
    /// Set to true to make the device pretend to receive a file without
    /// actually writing it; useful for protocol smoke tests.
    pub dummy: bool,
    /// Bytes per WRITE packet. Capped to the device-advertised maximum
    /// after the SYNC handshake completes. `0` means "use the device's
    /// max_block_size verbatim".
    pub chunk_size: usize,
    /// Optional per-chunk progress callback fired once after each
    /// acknowledged WRITE. See [`Progress`] for the payload.
    pub progress: Option<ProgressCallback>,
}

impl std::fmt::Debug for UploadOptions {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("UploadOptions")
            .field("dest_filename", &self.dest_filename)
            .field("compression", &self.compression)
            .field("dummy", &self.dummy)
            .field("chunk_size", &self.chunk_size)
            .field("progress", &self.progress.as_ref().map(|_| "<callback>"))
            .finish()
    }
}

impl Default for UploadOptions {
    fn default() -> Self {
        Self {
            dest_filename: String::new(),
            compression: Compression::None,
            dummy: false,
            chunk_size: 0,
            progress: None,
        }
    }
}

/// Upload statistics returned on success.
#[derive(Debug, Clone, Default)]
pub struct UploadStats {
    /// Bytes read from `src`.
    pub source_bytes: u64,
    /// Bytes written across all WRITE packets (post-compression).
    pub bytes_sent: u64,
    /// Number of WRITE packets.
    pub chunks_sent: u64,
    /// Compression actually used (resolved from [`Compression::Auto`]).
    pub compression: Compression,
}

/// Errors the adapter upload helpers can produce.
#[derive(Debug, Error)]
pub enum UploadError {
    /// Wrapping I/O error from the transport.
    #[error("transport I/O error: {0}")]
    Io(#[from] std::io::Error),
    /// Underlying file-transfer state machine reported a failure.
    #[error("file transfer failed: {0}")]
    Transfer(#[from] FileError),
    /// Reached an unrecoverable protocol state (e.g. device returned
    /// nothing for too long with no progress).
    #[error("upload stalled: {0}")]
    Stalled(&'static str),
    /// The session never completed the SYNC handshake before the helper
    /// gave up.
    #[error("SYNC handshake did not complete")]
    HandshakeFailed,
    /// Compression was requested but the `heatshrink` feature is not
    /// enabled at compile time.
    #[cfg(not(feature = "heatshrink"))]
    #[error("heatshrink compression requested but the `heatshrink` feature is disabled")]
    CompressionFeatureDisabled,
    /// Heatshrink compression error.
    #[cfg(feature = "heatshrink")]
    #[error("heatshrink error: {0}")]
    Heatshrink(#[from] crate::compression::HeatshrinkError),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn requested_zero_uses_device_max() {
        assert_eq!(resolve_chunk_size(0, 512), 512);
    }

    #[test]
    fn requested_under_max_is_honored() {
        assert_eq!(resolve_chunk_size(128, 512), 128);
    }

    #[test]
    fn requested_over_max_is_capped() {
        assert_eq!(resolve_chunk_size(4096, 512), 512);
    }

    #[test]
    fn requested_zero_with_zero_device_max_falls_back() {
        assert_eq!(resolve_chunk_size(0, 0), FALLBACK_CHUNK_SIZE);
    }

    #[test]
    fn requested_nonzero_with_zero_device_max_capped_to_fallback() {
        assert_eq!(resolve_chunk_size(1024, 0), FALLBACK_CHUNK_SIZE);
    }

    #[test]
    fn requested_equal_to_max_is_honored() {
        assert_eq!(resolve_chunk_size(512, 512), 512);
    }

    #[test]
    fn progress_percent_zero_source_is_none() {
        let p = Progress {
            bytes_sent: 0,
            chunks_sent: 0,
            source_bytes: 0,
        };
        assert!(p.percent().is_none());
        assert!(p.fraction().is_none());
    }

    #[test]
    fn progress_percent_halfway() {
        let p = Progress {
            bytes_sent: 500,
            chunks_sent: 5,
            source_bytes: 1000,
        };
        assert_eq!(p.fraction(), Some(0.5));
        assert_eq!(p.percent(), Some(50.0));
    }

    #[test]
    fn progress_percent_complete() {
        let p = Progress {
            bytes_sent: 1000,
            chunks_sent: 10,
            source_bytes: 1000,
        };
        assert_eq!(p.percent(), Some(100.0));
    }

    #[test]
    fn progress_percent_clamps_when_expanded_by_compression() {
        // If compression expands the payload, bytes_sent can exceed source_bytes.
        // We clamp to 100% rather than report >100%, which would confuse UIs.
        let p = Progress {
            bytes_sent: 1500,
            chunks_sent: 15,
            source_bytes: 1000,
        };
        assert_eq!(p.percent(), Some(100.0));
    }
}