Skip to main content

marlin_binary_transfer/adapters/
common.rs

1//! Shared types used by both the [`blocking`](crate::adapters::blocking) and
2//! [`tokio`](crate::adapters::tokio) adapter modules.
3//!
4//! Living here rather than in either adapter module so that enabling one
5//! adapter doesn't require enabling the other to resolve `UploadOptions` /
6//! `UploadStats` / `UploadError` symbols.
7
8use thiserror::Error;
9
10use crate::file_transfer::{Compression, FileError};
11
12/// Conservative fallback when the device-advertised block size is zero
13/// (which shouldn't happen after a successful SYNC handshake but we
14/// handle it defensively).
15const FALLBACK_CHUNK_SIZE: usize = 256;
16
17/// Resolve the per-WRITE chunk size from caller options and the device
18/// max.
19///
20/// - `requested == 0` means "use the device-advertised max verbatim".
21/// - `requested > 0` is honored, but capped to the device max so a
22///   caller asking for 4096 against a device that says 512 doesn't
23///   blow past what the device can buffer.
24/// - `device_max == 0` falls back to [`FALLBACK_CHUNK_SIZE`].
25pub(crate) fn resolve_chunk_size(requested: usize, device_max: u16) -> usize {
26    let device_max = if device_max == 0 {
27        FALLBACK_CHUNK_SIZE
28    } else {
29        device_max as usize
30    };
31    if requested == 0 {
32        device_max
33    } else {
34        requested.min(device_max)
35    }
36}
37
38/// Per-chunk progress payload passed to a [`ProgressCallback`]. Values are
39/// cumulative across the upload.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct Progress {
42    /// Bytes sent so far across all WRITE packets (post-compression).
43    pub bytes_sent: u64,
44    /// Number of WRITE packets acknowledged so far.
45    pub chunks_sent: u64,
46    /// Total bytes read from `src` (constant across calls within one upload).
47    pub source_bytes: u64,
48}
49
50impl Progress {
51    /// Fraction of the source uploaded so far, in `[0.0, 1.0]`. Returns
52    /// `None` when `source_bytes` is `0` (zero-length upload — undefined
53    /// ratio). With compression enabled `bytes_sent` is the post-compression
54    /// wire count, so this ratio can exceed `source_bytes` only if the
55    /// compressor expands the data; the result is clamped to `1.0`.
56    pub fn fraction(&self) -> Option<f32> {
57        if self.source_bytes == 0 {
58            return None;
59        }
60        let f = self.bytes_sent as f32 / self.source_bytes as f32;
61        Some(f.min(1.0))
62    }
63
64    /// Percent uploaded, in `[0.0, 100.0]`. Returns `None` when
65    /// `source_bytes` is `0`. See [`Progress::fraction`] for the
66    /// compression caveat.
67    pub fn percent(&self) -> Option<f32> {
68        self.fraction().map(|f| f * 100.0)
69    }
70}
71
72/// Closure type the adapters invoke after each acknowledged WRITE packet.
73/// Boxed for object safety; `Send` so async callers can ship the callback
74/// into `spawn_blocking`.
75pub type ProgressCallback = Box<dyn FnMut(Progress) + Send>;
76
77/// Caller-supplied options controlling the upload.
78///
79/// Not `Clone` because [`progress`](Self::progress) holds a `FnMut` closure.
80/// `Debug` is implemented manually for the same reason.
81pub struct UploadOptions {
82    /// Destination filename on the device's SD card. Required.
83    pub dest_filename: String,
84    /// Compression preference. Defaults to [`Compression::None`].
85    pub compression: Compression,
86    /// Set to true to make the device pretend to receive a file without
87    /// actually writing it; useful for protocol smoke tests.
88    pub dummy: bool,
89    /// Bytes per WRITE packet. Capped to the device-advertised maximum
90    /// after the SYNC handshake completes. `0` means "use the device's
91    /// max_block_size verbatim".
92    pub chunk_size: usize,
93    /// Optional per-chunk progress callback fired once after each
94    /// acknowledged WRITE. See [`Progress`] for the payload.
95    pub progress: Option<ProgressCallback>,
96}
97
98impl std::fmt::Debug for UploadOptions {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("UploadOptions")
101            .field("dest_filename", &self.dest_filename)
102            .field("compression", &self.compression)
103            .field("dummy", &self.dummy)
104            .field("chunk_size", &self.chunk_size)
105            .field("progress", &self.progress.as_ref().map(|_| "<callback>"))
106            .finish()
107    }
108}
109
110impl Default for UploadOptions {
111    fn default() -> Self {
112        Self {
113            dest_filename: String::new(),
114            compression: Compression::None,
115            dummy: false,
116            chunk_size: 0,
117            progress: None,
118        }
119    }
120}
121
122/// Upload statistics returned on success.
123#[derive(Debug, Clone, Default)]
124pub struct UploadStats {
125    /// Bytes read from `src`.
126    pub source_bytes: u64,
127    /// Bytes written across all WRITE packets (post-compression).
128    pub bytes_sent: u64,
129    /// Number of WRITE packets.
130    pub chunks_sent: u64,
131    /// Compression actually used (resolved from [`Compression::Auto`]).
132    pub compression: Compression,
133}
134
135/// Errors the adapter upload helpers can produce.
136#[derive(Debug, Error)]
137pub enum UploadError {
138    /// Wrapping I/O error from the transport.
139    #[error("transport I/O error: {0}")]
140    Io(#[from] std::io::Error),
141    /// Underlying file-transfer state machine reported a failure.
142    #[error("file transfer failed: {0}")]
143    Transfer(#[from] FileError),
144    /// Reached an unrecoverable protocol state (e.g. device returned
145    /// nothing for too long with no progress).
146    #[error("upload stalled: {0}")]
147    Stalled(&'static str),
148    /// The session never completed the SYNC handshake before the helper
149    /// gave up.
150    #[error("SYNC handshake did not complete")]
151    HandshakeFailed,
152    /// Compression was requested but the `heatshrink` feature is not
153    /// enabled at compile time.
154    #[cfg(not(feature = "heatshrink"))]
155    #[error("heatshrink compression requested but the `heatshrink` feature is disabled")]
156    CompressionFeatureDisabled,
157    /// Heatshrink compression error.
158    #[cfg(feature = "heatshrink")]
159    #[error("heatshrink error: {0}")]
160    Heatshrink(#[from] crate::compression::HeatshrinkError),
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn requested_zero_uses_device_max() {
169        assert_eq!(resolve_chunk_size(0, 512), 512);
170    }
171
172    #[test]
173    fn requested_under_max_is_honored() {
174        assert_eq!(resolve_chunk_size(128, 512), 128);
175    }
176
177    #[test]
178    fn requested_over_max_is_capped() {
179        assert_eq!(resolve_chunk_size(4096, 512), 512);
180    }
181
182    #[test]
183    fn requested_zero_with_zero_device_max_falls_back() {
184        assert_eq!(resolve_chunk_size(0, 0), FALLBACK_CHUNK_SIZE);
185    }
186
187    #[test]
188    fn requested_nonzero_with_zero_device_max_capped_to_fallback() {
189        assert_eq!(resolve_chunk_size(1024, 0), FALLBACK_CHUNK_SIZE);
190    }
191
192    #[test]
193    fn requested_equal_to_max_is_honored() {
194        assert_eq!(resolve_chunk_size(512, 512), 512);
195    }
196
197    #[test]
198    fn progress_percent_zero_source_is_none() {
199        let p = Progress {
200            bytes_sent: 0,
201            chunks_sent: 0,
202            source_bytes: 0,
203        };
204        assert!(p.percent().is_none());
205        assert!(p.fraction().is_none());
206    }
207
208    #[test]
209    fn progress_percent_halfway() {
210        let p = Progress {
211            bytes_sent: 500,
212            chunks_sent: 5,
213            source_bytes: 1000,
214        };
215        assert_eq!(p.fraction(), Some(0.5));
216        assert_eq!(p.percent(), Some(50.0));
217    }
218
219    #[test]
220    fn progress_percent_complete() {
221        let p = Progress {
222            bytes_sent: 1000,
223            chunks_sent: 10,
224            source_bytes: 1000,
225        };
226        assert_eq!(p.percent(), Some(100.0));
227    }
228
229    #[test]
230    fn progress_percent_clamps_when_expanded_by_compression() {
231        // If compression expands the payload, bytes_sent can exceed source_bytes.
232        // We clamp to 100% rather than report >100%, which would confuse UIs.
233        let p = Progress {
234            bytes_sent: 1500,
235            chunks_sent: 15,
236            source_bytes: 1000,
237        };
238        assert_eq!(p.percent(), Some(100.0));
239    }
240}