Lamp testbench in Rust around the HPCS 6500

lampbench station / export / label / tm30 / calibrate replace the Python
lamp_station.py, lamp_export.py, label_printer.py, tm30.py and
calibration.py that lived in HPCS6500-py. The driver is a submodule
(HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of
colour-science 0.4.7), brother-ql (raster protocol, byte-identical to
brother_ql) and lamp-label (QR + ID labels for the QL-820NWB).

Bundles are byte-identical to lamp_export.py's (golden tests); the station
runs end to end in a test against the simulated instrument.
This commit is contained in:
grabowski committed 2026-10-22 17:26:14 +07:00
commit 2a3f28001c
241 files changed
+46390

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
//! Byte-for-byte comparison against Python brother_ql output.
//!
//! The golden data is produced by `tools/golden.py` (brother_ql_next 0.12 +
//! Pillow): each manifest line names the input PNG(s), model, label and
//! `convert()` keyword arguments; `<name>.bin` is what Python produced.
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use brother_ql::{Bitmap, ConvertOptions, Label, Model, Rotate, convert};
fn golden_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_png(path: &Path) -> Bitmap {
let mut decoder = png::Decoder::new(File::open(path).unwrap());
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().unwrap();
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).unwrap();
let channels = match info.color_type {
png::ColorType::Grayscale => 1,
png::ColorType::GrayscaleAlpha => 2,
png::ColorType::Rgb => 3,
png::ColorType::Rgba => 4,
png::ColorType::Indexed => panic!("palette not expanded"),
};
let (w, h) = (info.width, info.height);
Bitmap::from_fn(w, h, |x, y| {
let i = y as usize * info.line_size + x as usize * channels;
buf[i] < 128
})
}
fn parse_bool(v: &str) -> bool {
match v {
"True" | "true" | "1" => true,
"False" | "false" | "0" => false,
_ => panic!("bad bool {v}"),
}
}
fn parse_options(tokens: &[&str]) -> ConvertOptions {
let mut o = ConvertOptions::default();
for t in tokens {
let (k, v) = t.split_once('=').unwrap();
match k {
"cut" => o.cut = parse_bool(v),
"compress" => o.compress = parse_bool(v),
"dpi_600" => o.dpi_600 = parse_bool(v),
"hq" => o.hq = parse_bool(v),
"dither" => o.dither = parse_bool(v),
"threshold" => o.threshold = v.parse().unwrap(),
"rotate" => {
o.rotate = match v {
"auto" => Rotate::Auto,
"0" => Rotate::R0,
"90" => Rotate::R90,
"180" => Rotate::R180,
"270" => Rotate::R270,
_ => panic!("bad rotate {v}"),
}
}
_ => panic!("unknown option {k}"),
}
}
o
}
fn first_difference(a: &[u8], b: &[u8]) -> Option<usize> {
a.iter()
.zip(b)
.position(|(x, y)| x != y)
.or_else(|| (a.len() != b.len()).then_some(a.len().min(b.len())))
}
#[test]
fn convert_matches_python_byte_for_byte() {
let dir = golden_dir();
let manifest = std::fs::read_to_string(dir.join("manifest.txt"))
.expect("golden data missing: run tools/golden.py");
let mut images: HashMap<String, Bitmap> = HashMap::new();
let mut failures = Vec::new();
let mut passed = 0;
let mut label_cases = 0;
for line in manifest.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
let (name, model, label, pngs) = (tokens[0], tokens[1], tokens[2], tokens[3]);
let opts = parse_options(&tokens[4..]);
let bitmaps: Vec<Bitmap> = pngs
.split(',')
.map(|p| {
images
.entry(p.to_string())
.or_insert_with(|| load_png(&dir.join(p)))
.clone()
})
.collect();
let model = Model::lookup(model).unwrap();
let label = Label::lookup(label).unwrap();
let expected = std::fs::read(dir.join(format!("{name}.bin"))).unwrap();
let actual = convert(model, label, &bitmaps, &opts)
.unwrap_or_else(|e| panic!("{name}: convert failed: {e}"));
match first_difference(&actual, &expected) {
None => {
passed += 1;
if name.starts_with("label_L") {
label_cases += 1;
}
}
Some(at) => failures.push(format!(
"{name}: differs at byte {at} (rust {} bytes, python {} bytes): rust {:02X?} python {:02X?}",
actual.len(),
expected.len(),
&actual[at..(at + 8).min(actual.len())],
&expected[at..(at + 8).min(expected.len())],
)),
}
}
println!("{passed} golden cases byte-identical ({label_cases} lamp-label cases)");
assert!(
failures.is_empty(),
"{} mismatches:\n{}",
failures.len(),
failures.join("\n")
);
assert!(passed >= 50, "too few golden cases ({passed})");
}
#[test]
fn convert_rejects_like_python() {
let model = Model::lookup("QL-820NWB").unwrap();
// d12 has 94 printable rows, below the QL-820NWB minimum of 150
let d12 = Label::lookup("d12").unwrap();
let err = convert(
model,
d12,
&[Bitmap::new(94, 94)],
&ConvertOptions::default(),
)
.unwrap_err();
assert!(
matches!(
err,
brother_ql::Error::InvalidRasterHeight { height: 94, .. }
),
"{err}"
);
let l = Label::lookup("23x23").unwrap();
let err = convert(
model,
l,
&[Bitmap::new(202, 201)],
&ConvertOptions::default(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"bad image dimensions: 202x201, expecting 202x202"
);
assert!(Model::lookup("QL-9").is_err());
assert!(Label::lookup("23").is_err());
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
# name model label images options... (generated by tools/golden.py)
label_L0001_x1 QL-820NWB 23x23 label_L0001.png cut=True
label_L0001_x2 QL-820NWB 23x23 label_L0001.png,label_L0001.png cut=True
label_L0006_x1 QL-820NWB 23x23 label_L0006.png cut=True
label_L0006_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png cut=True
label_L0123_x1 QL-820NWB 23x23 label_L0123.png cut=True
label_L0123_x2 QL-820NWB 23x23 label_L0123.png,label_L0123.png cut=True
label_L12345_x1 QL-820NWB 23x23 label_L12345.png cut=True
label_L12345_x2 QL-820NWB 23x23 label_L12345.png,label_L12345.png cut=True
label_L9_x1 QL-820NWB 23x23 label_L9.png cut=True
label_L9_x2 QL-820NWB 23x23 label_L9.png,label_L9.png cut=True
white_202_x1 QL-820NWB 23x23 white_202.png cut=True
white_202_x2 QL-820NWB 23x23 white_202.png,white_202.png cut=True
black_202_x1 QL-820NWB 23x23 black_202.png cut=True
black_202_x2 QL-820NWB 23x23 black_202.png,black_202.png cut=True
checker_202_x1 QL-820NWB 23x23 checker_202.png cut=True
checker_202_x2 QL-820NWB 23x23 checker_202.png,checker_202.png cut=True
corner_tl_x1 QL-820NWB 23x23 corner_tl.png cut=True
corner_tl_x2 QL-820NWB 23x23 corner_tl.png,corner_tl.png cut=True
corner_tr_x1 QL-820NWB 23x23 corner_tr.png cut=True
corner_tr_x2 QL-820NWB 23x23 corner_tr.png,corner_tr.png cut=True
corner_bl_x1 QL-820NWB 23x23 corner_bl.png cut=True
corner_bl_x2 QL-820NWB 23x23 corner_bl.png,corner_bl.png cut=True
corner_br_x1 QL-820NWB 23x23 corner_br.png cut=True
corner_br_x2 QL-820NWB 23x23 corner_br.png,corner_br.png cut=True
random_202_x1 QL-820NWB 23x23 random_202.png cut=True
random_202_x2 QL-820NWB 23x23 random_202.png,random_202.png cut=True
label_L0006_nocut QL-820NWB 23x23 label_L0006.png cut=False
label_L0006_compress_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png compress=True
random_202_compress QL-820NWB 23x23 random_202.png compress=True
checker_202_compress QL-820NWB 23x23 checker_202.png compress=True
black_202_compress QL-820NWB 23x23 black_202.png compress=True
white_202_compress QL-820NWB 23x23 white_202.png compress=True
corner_tl_rot0 QL-820NWB 23x23 corner_tl.png rotate=0
corner_tl_rot90 QL-820NWB 23x23 corner_tl.png rotate=90
corner_tl_rot180 QL-820NWB 23x23 corner_tl.png rotate=180
corner_tl_rot270 QL-820NWB 23x23 corner_tl.png rotate=270
corner_br_rot0 QL-820NWB 23x23 corner_br.png rotate=0
random_202_thr100 QL-820NWB 23x23 random_202.png threshold=100
random_202_thr0 QL-820NWB 23x23 random_202.png threshold=0
random_202_thr50 QL-820NWB 23x23 random_202.png threshold=50
random_202_lq QL-820NWB 23x23 random_202.png hq=False
random_202_dither QL-820NWB 23x23 random_202.png dither=True
random_202_dither_thr100 QL-820NWB 23x23 random_202.png dither=True threshold=100
multi_labels QL-820NWB 23x23 label_L0001.png,label_L0006.png,label_L12345.png cut=True
random_404_dpi600 QL-820NWB 23x23 random_404.png dpi_600=True
random_404_dpi600_compress QL-820NWB 23x23 random_404.png dpi_600=True compress=True
endless62_696 QL-820NWB 62 random_696x300.png
endless62_resize500 QL-820NWB 62 random_500x200.png
endless62_rot90 QL-820NWB 62 random_300x696.png rotate=90
endless62_dpi600 QL-820NWB 62 random_1392x150.png dpi_600=True
endless29_306 QL-820NWB 29 random_306x300.png compress=True
diecut62x29_auto_rotate QL-820NWB 62x29 random_271x696.png
round_d24 QL-820NWB d24 random_236.png
ql1100_102 QL-1100 102 sparse_1164x301.png compress=True
ql1100_62 QL-1100 62 sparse_696x301.png compress=True
ql700_62_compress_ignored QL-700 62 random_696x300.png compress=True
ql500_29 QL-500 29 random_306x300.png
ql570_62x29 QL-570 62x29 random_696x271.png cut=False
ql800_23x23_x2 QL-800 23x23 label_L0006.png,label_L0006.png compress=True
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
//! `send_network` against a fake printer on 127.0.0.1.
//!
//! The Python network backend never reads from the socket (helpers.send
//! returns right after the write, even with `blocking=True`), so the fake
//! printers here check: the exact bytes arrive, the client closes the
//! connection on its own (EOF) right after writing, and it does not wait for
//! the status replies a real QL printer sends.
use std::io::{Read, Write};
use std::net::{Shutdown, TcpListener};
use std::path::Path;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use brother_ql::{Error, Outcome, SendStatus, send_network};
fn job() -> Vec<u8> {
// a real two-label job for L0006 as produced by Python brother_ql
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/label_L0006_x2.bin");
std::fs::read(p).expect("golden data")
}
const SENT: SendStatus = SendStatus {
instructions_sent: true,
outcome: Outcome::Sent,
did_print: false,
ready_for_next_job: false,
};
/// 32-byte QL status reply (80 20 42 ...): model QL-820NWB (0x41), 23 mm
/// die-cut media, given status/phase type.
fn status_reply(status_type: u8, phase_type: u8) -> [u8; 32] {
let mut r = [0u8; 32];
r[0] = 0x80;
r[1] = 0x20;
r[2] = 0x42;
r[3] = 0x34;
r[4] = 0x41;
r[5] = 0x30;
r[10] = 23; // media width
r[11] = 0x0B; // die-cut
r[17] = 23; // media length
r[18] = status_type;
r[19] = phase_type;
r
}
#[test]
fn sends_exact_bytes_and_closes() {
let data = job();
for prefix in ["tcp://", ""] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).unwrap(); // returns only once the client closed
got
});
let status = send_network(
&format!("{prefix}127.0.0.1:{port}"),
&data,
true,
Duration::from_secs(5),
)
.unwrap();
assert_eq!(status, SENT);
let got = server.join().unwrap();
assert_eq!(got.len(), data.len());
assert!(got == data, "received bytes differ");
}
}
#[test]
fn blocking_does_not_wait_for_status_replies() {
let data = job();
let n = data.len();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = vec![0u8; n];
s.read_exact(&mut got).unwrap();
// A real printer answers ESC i S and later reports "printing
// completed" (0x01) and "phase change: waiting to receive" (0x06/0x00).
// Hold them back for a while: the client must not wait for them.
let eof = {
let mut buf = [0u8; 1];
s.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
matches!(s.read(&mut buf), Ok(0))
};
tx.send(()).unwrap();
let _ = s.write_all(&status_reply(0x01, 0x00));
let _ = s.write_all(&status_reply(0x06, 0x00));
let _ = s.shutdown(Shutdown::Both);
(got, eof)
});
let t0 = Instant::now();
let status = send_network(
&format!("tcp://127.0.0.1:{port}"),
&data,
true,
Duration::from_secs(5),
)
.unwrap();
let elapsed = t0.elapsed();
assert_eq!(status, SENT, "network backend never reports printed/ready");
let (got, eof) = server.join().unwrap();
rx.recv().unwrap();
assert!(got == data);
assert!(eof, "client did not close the connection after writing");
assert!(
elapsed < Duration::from_secs(2),
"send_network waited {elapsed:?}"
);
}
#[test]
fn non_blocking_is_identical() {
let data = b"\x1B\x40hello".to_vec();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).unwrap();
got
});
let status = send_network(
&format!("127.0.0.1:{port}"),
&data,
false,
Duration::from_secs(5),
)
.unwrap();
assert_eq!(status, SENT);
assert_eq!(server.join().unwrap(), data);
}
#[test]
fn connection_refused_is_an_error() {
let port = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let err = send_network(
&format!("tcp://127.0.0.1:{port}"),
b"x",
true,
Duration::from_secs(2),
)
.unwrap_err();
assert!(matches!(err, Error::Connect { .. }), "{err}");
let err = send_network("tcp://", b"x", true, Duration::from_secs(2)).unwrap_err();
assert!(matches!(err, Error::InvalidAddress(_)), "{err}");
}
#[test]
fn write_times_out_when_printer_stalls() {
// The fake printer accepts but never reads, so the socket buffers fill up.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done_tx, done_rx) = mpsc::channel::<()>();
let server = thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let _ = done_rx.recv_timeout(Duration::from_secs(30));
drop(s);
});
let data = vec![0u8; 64 << 20];
let t0 = Instant::now();
let res = send_network(
&format!("127.0.0.1:{port}"),
&data,
true,
Duration::from_millis(500),
);
let elapsed = t0.elapsed();
done_tx.send(()).unwrap();
server.join().unwrap();
match res {
Err(Error::Write(e)) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut, "{e}"),
other => panic!("expected a write timeout, got {other:?}"),
}
assert!(elapsed < Duration::from_secs(5), "timeout took {elapsed:?}");
}