Host-side Rust implementation of Marlin's Binary File Transfer Mark II protocol. Uploads G-code files to a 3D printer's SD card over serial — with framing checksums, sync acknowledgement, retransmit on timeout, and optional heatshrink payload compression.
Status: pre-1.0. The Marlin protocol itself is documented as experimental upstream and may evolve. This crate tracks Marlin
bugfix-2.x. Public API follows semver within 0.x; pin a minor version.
The text-mode M28/M29 SD upload path is unreliable in practice — multiple
open Marlin issues, slow over UART, no integrity checks. The binary protocol is
the correct path: roughly 10 KiB/s on 115200-baud UART and 180 KiB/s on native
USB CDC, with per-packet checksums and explicit retransmit.
Before this crate, the only host implementation was the Python
marlin-binary-protocol used by OctoPrint's MarlinBft plugin. This is
an independent Rust port — same wire format, sans-I/O core, three optional
adapters for the common transports.
┌────────────────────────────────────────────────────────────┐
│ adapters::{blocking, tokio, serialport} ← optional I/O │
├────────────────────────────────────────────────────────────┤
│ file_transfer ← protocol-1 state machine (QUERY/OPEN/…) │
├────────────────────────────────────────────────────────────┤
│ session ← sync counter, retransmit, ack matching │
├────────────────────────────────────────────────────────────┤
│ codec ← packet framing + Fletcher-16 checksum │
└────────────────────────────────────────────────────────────┘
The bottom three layers are sans-I/O: you feed in bytes and pull events out,
plumbed through any transport you want. The adapters modules wrap the core
into one-call upload helpers for the common cases.
| Feature | Default | What you get |
|---|---|---|
std |
yes | Currently unconditional; reserved for the future no_std + alloc split. |
blocking |
no | Synchronous upload(transport, src, opts) over any Read + Write transport. |
tokio |
no | Async upload(transport, src, opts).await over AsyncRead + AsyncWrite. |
serial |
no | serialport::open helper preconfigured with sensible defaults. Implies blocking. |
heatshrink |
no | Negotiate and apply heatshrink payload compression with the device. |
use marlin_binary_transfer::adapters::blocking::{upload, UploadOptions};
use marlin_binary_transfer::adapters::serialport;
use marlin_binary_transfer::file_transfer::Compression;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut port = serialport::open("/dev/ttyUSB0", 250_000)?;
let file = std::fs::File::open("model.gco")?;
let stats = upload(&mut *port, file, UploadOptions {
dest_filename: "model.gco".into(),
compression: Compression::Auto,
dummy: false,
chunk_size: 0, // 0 = use the device-advertised maximum
progress: Some(Box::new(|p| {
eprintln!(" {} chunks, {} bytes", p.chunks_sent, p.bytes_sent);
})),
})?;
println!(
"uploaded {} bytes in {} chunks ({:?})",
stats.bytes_sent, stats.chunks_sent, stats.compression,
);
Ok(())
}Cargo:
[dependencies]
marlin-binary-transfer = { version = "0.1", features = ["blocking", "serial", "heatshrink"] }Run the bundled CLI example:
cargo run --example upload --features blocking,serial,heatshrink -- \
--port /dev/ttyUSB0 --baud 250000 \
--src ./model.gco --dest model.gco --compression autouse marlin_binary_transfer::adapters::tokio::{upload, UploadOptions};
use marlin_binary_transfer::file_transfer::Compression;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// `transport` is any AsyncRead + AsyncWrite + Unpin — e.g. a tokio-serial
// SerialStream, a TCP-to-serial bridge, etc.
# let mut transport: tokio::io::DuplexStream = unreachable!();
let mut src = tokio::fs::File::open("model.gco").await?;
let stats = upload(&mut transport, &mut src, UploadOptions {
dest_filename: "model.gco".into(),
compression: Compression::Auto,
..UploadOptions::default()
}).await?;
println!("uploaded {} bytes", stats.bytes_sent);
Ok(())
}The adapters::{blocking,tokio}::upload helpers handle the full lifecycle so
you don't have to drive the state machines manually:
- Send the ASCII trigger
M28 B1to put the firmware in binary mode. - Drive the SYNC handshake; capture the device-advertised block size and protocol version.
- Issue
QUERYto negotiate compression (noneorheatshrinkper the device's advertised capabilities). OPENthe destination file with the chosen compression mode.- Stream the source through
WRITEpackets, retransmitting any packet the device requests viars<n>or that times out. CLOSEthe file (commits to SD).- Send the control-plane CLOSE (proto=0, type=2) so the device returns to ASCII g-code mode. Without this, subsequent ASCII commands on the same serial session are ignored — this was added in 0.1.1.
On any unrecoverable failure (PFT:busy, PFT:fail, PFT:ioerror,
PFT:invalid, fatal session error, sync drift) you get a specific
UploadError::Transfer(FileError::…) rather than a generic timeout.
If the helpers don't fit (custom transport, fan-out to many printers, your own
event loop), build directly on Session and FileTransfer:
use std::time::Instant;
use marlin_binary_transfer::session::{Session, Event};
use marlin_binary_transfer::file_transfer::{FileTransfer, FileEvent, Compression};
let mut session = Session::new();
session.connect(Instant::now());
// Drain `session.poll_outbound()` and write bytes to your transport.
// Push received bytes via `session.feed(bytes, Instant::now())`.
// Call `session.tick(Instant::now())` at least as often as
// `session.response_timeout()` so retransmits fire.
// Drain `session.poll_event()` until `Event::Synced` is observed.
# loop {
# break;
# }
let mut ft = FileTransfer::new(&mut session);
ft.query(Compression::Auto, Instant::now());
// Same pump pattern: poll_outbound → write, read → feed, then poll() for FileEvents.See the rustdoc on session and file_transfer for the full event
vocabulary.
Session::with_response_timeout(d)— how long to wait before retransmitting an in-flight packet. Default: 1 second.Session::with_total_timeout(d)— total budget for a single packet across all retransmits. Default: 20 seconds.
The tokio adapter wraps every inbound read in tokio::time::timeout(...) keyed
to response_timeout, so a quiet transport never deadlocks the loop. The
blocking adapter relies on the transport returning ErrorKind::TimedOut on
idle — adapters::serialport::open configures a 100 ms read timeout for this.
The Marlin firmware must have:
BINARY_FILE_TRANSFERenabled at compile timeMEATPACKdisabled (mutually exclusive with BFT)
You can detect both via the M115 capability output before attempting an
upload.
Rust 1.75 for the library itself. Development tooling (criterion, recent test deps) may require a more recent toolchain.
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.
Protocol design and the original Python reference implementation by
Chris Pepper (@p3p). This crate is an independent
Rust port of the wire format described in
MarlinFirmware/Marlin#14817, cross-checked against the
trippwill/marlin-binary-protocol reference.