Skip to content

Commit 617afe2

Browse files
committed
feat(usb): read the preview firmware render from usblp on Linux
Generalize the reply loop over AsyncRead so TCP, macOS nusb, and the Linux usblp char device (driven through an AsyncFd adapter) share one framing.
1 parent 46c4710 commit 617afe2

4 files changed

Lines changed: 375 additions & 103 deletions

File tree

‎src-tauri/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/Cargo.toml‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,22 @@ printers = "2.3.0"
2828
# Keychain, Linux DBus Secret Service. Linux CI needs libdbus-1-dev.
2929
keyring = { version = "3", features = ["windows-native", "apple-native", "sync-secret-service"] }
3030

31+
[target.'cfg(target_os = "linux")'.dependencies]
32+
# O_NONBLOCK for the usblp read-back fd (AsyncFd needs a non-blocking fd).
33+
# Already transitive via printers, so this pins no new code.
34+
libc = "0.2"
35+
3136
[target.'cfg(target_os = "macos")'.dependencies]
3237
# Direct USB printer IO: macOS has no usblp equivalent, so list/send/query go
33-
# through IOKit via this pure-Rust stack (no libusb to link or vendor). The
34-
# tokio feature gives the endpoints AsyncRead/AsyncWrite, so the TCP reply
35-
# loop in transport.rs is reused verbatim.
38+
# through IOKit via this pure-Rust stack (no libusb to link). The tokio feature
39+
# gives the endpoints AsyncRead/AsyncWrite, reusing the transport.rs reply loop.
3640
nusb = { version = "0.2", features = ["tokio"] }
3741

3842
[dev-dependencies]
39-
# rt only: the macros feature would pull tokio-macros, which lags tokio
40-
# releases on crates.io; tests build their runtime by hand instead.
41-
tokio = { version = "1", features = ["rt"] }
43+
# rt for hand-built runtimes, test-util for the paused clock in the reply-loop
44+
# tests; the macros feature would pull tokio-macros, which lags tokio releases
45+
# on crates.io, so tests build their runtime by hand instead.
46+
tokio = { version = "1", features = ["rt", "test-util"] }
4247

4348
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
4449
tauri-plugin-window-state = "2"

‎src-tauri/src/transport.rs‎

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
// Shared plumbing for the TCP printer commands: connect, write-with-timeout,
1+
// Shared plumbing for the printer commands: connect, write-with-timeout,
22
// read-one-reply, the blocking-pool wrapper, and the payload bound, so a
3-
// timeout or classification change lives in one place. The USB transport
4-
// (usb.rs) frames its IO differently (ZLP-terminated writes, short-packet
5-
// reads), so it does its own bulk IO rather than going through these.
3+
// timeout or classification change lives in one place. The USB transports
4+
// frame their writes themselves (ZLP-terminated on macOS), but their reply
5+
// reads go through read_reply like TCP.
66

77
use std::time::Duration;
88
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -16,10 +16,9 @@ const IO_TIMEOUT: Duration = Duration::from_secs(4);
1616

1717
// A ~DY upload of a full label bitmap is ~200 KiB base64; bound far above that.
1818
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
19-
// The printer renders the format before replying, and a label carrying a font
20-
// download (~DY) can take an entry-level printer well past 10s to store, render,
21-
// and answer ^HY (observed: the ZD230 keeps working while a shorter wait times
22-
// out). Generous, since a preview is a manual one-shot the user waits on.
19+
// The printer renders before replying; a ~DY font download can take an
20+
// entry-level printer past 10s (observed on the ZD230). Generous: a preview is
21+
// a manual one-shot the user waits on.
2322
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(30);
2423
// After data flows, a short idle gap marks the end of the reply (the printer
2524
// keeps the channel open, so waiting for a close would always hit the timeout).
@@ -76,14 +75,15 @@ pub(crate) async fn write_all_timeout(stream: &mut TcpStream, bytes: &[u8]) -> R
7675
/// reply that never starts, trickles past the deadline, or overflows the bound
7776
/// is an error. An empty body is reported as "no response" because print-only
7877
/// endpoints accept a job silently, which is not a queryable response.
79-
pub(crate) async fn read_reply(stream: &mut TcpStream) -> Result<Vec<u8>, String> {
78+
pub(crate) async fn read_reply<R: tokio::io::AsyncRead + Unpin>(
79+
stream: &mut R,
80+
) -> Result<Vec<u8>, String> {
8081
let mut body = Vec::new();
8182
let mut buf = [0u8; 64 * 1024];
8283
let deadline = tokio::time::Instant::now() + READ_DEADLINE;
8384
loop {
84-
// A complete reply ends on the idle gap long before this cap, so reaching
85-
// it means the stream is incomplete (slow render, trickle): report the
86-
// timeout explicitly instead of returning a truncated body downstream.
85+
// Reaching this cap means the reply never idled (slow render, trickle):
86+
// report the timeout instead of returning a truncated body.
8787
if tokio::time::Instant::now() >= deadline {
8888
return Err("read timed out".to_string());
8989
}
@@ -110,3 +110,64 @@ pub(crate) async fn read_reply(stream: &mut TcpStream) -> Result<Vec<u8>, String
110110
}
111111
Ok(body)
112112
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use super::*;
117+
118+
// Paused clock: the timeouts auto-advance when the reader idles, so the
119+
// 30s/60s waits complete instantly.
120+
fn rt() -> tokio::runtime::Runtime {
121+
tokio::runtime::Builder::new_current_thread()
122+
.enable_all()
123+
.start_paused(true)
124+
.build()
125+
.unwrap()
126+
}
127+
128+
#[test]
129+
fn read_reply_ends_on_idle_gap() {
130+
rt().block_on(async {
131+
let (mut a, mut b) = tokio::io::duplex(1024);
132+
a.write_all(b"~DYreply").await.unwrap();
133+
// `a` stays open, so only the idle gap can end the reply.
134+
let body = read_reply(&mut b).await.unwrap();
135+
assert_eq!(body, b"~DYreply");
136+
});
137+
}
138+
139+
#[test]
140+
fn read_reply_reports_no_response() {
141+
rt().block_on(async {
142+
let (_a, mut b) = tokio::io::duplex(1024);
143+
let err = read_reply(&mut b).await.unwrap_err();
144+
assert_eq!(err, "no response from printer");
145+
});
146+
}
147+
148+
#[test]
149+
fn read_reply_treats_eof_without_data_as_no_response() {
150+
rt().block_on(async {
151+
let (a, mut b) = tokio::io::duplex(1024);
152+
drop(a);
153+
let err = read_reply(&mut b).await.unwrap_err();
154+
assert_eq!(err, "no response from printer");
155+
});
156+
}
157+
158+
#[test]
159+
fn read_reply_times_out_on_trickle() {
160+
rt().block_on(async {
161+
let (mut a, mut b) = tokio::io::duplex(1024);
162+
// A byte just inside every idle gap holds the reply open past the
163+
// overall deadline.
164+
tokio::spawn(async move {
165+
while a.write_all(b"x").await.is_ok() {
166+
tokio::time::sleep(Duration::from_millis(1000)).await;
167+
}
168+
});
169+
let err = read_reply(&mut b).await.unwrap_err();
170+
assert_eq!(err, "read timed out");
171+
});
172+
}
173+
}

0 commit comments

Comments
 (0)