p21-print (0.2.0)
Installation
[registries.forgejo]
index = "sparse+ " # Sparse index
# index = " " # Git
[net]
git-fetch-with-cli = truecargo add p21-print@0.2.0 --registry forgejoAbout this package
p21-print
A Rust library (p21) and CLI (p21) to print labels on a Nelko / Neko
P21 Bluetooth label printer, and to read its battery level.
Built with AI assistance and verified against a physical Nelko/Neko P21 — the protocol decoding, printing, and battery/config reads were all confirmed on real hardware.
Unlike the Niimbot binary packet protocol used by many similar printers, the P21 firmware exposes a plain-ASCII TSPL2 command interface over a Bluetooth Classic RFCOMM (SPP) serial link. That makes the driver small: build a few text commands, embed a 1-bit bitmap, and write it to a socket.
This is a from-scratch, much simpler counterpart to the Go/Fyne GUI at https://github.com/TylerCode/Fyne-P21-Print, informed by the reverse-engineering notes at https://github.com/merlinschumacher/nelko-p21-print and https://printers.niim.blue.
Install
cargo build --release
# binary at target/release/p21
Only Linux is supported for the Bluetooth transport (raw AF_BLUETOOTH /
BTPROTO_RFCOMM socket). The library's image/protocol code is platform-neutral.
Pairing the printer
Pair once with the system Bluetooth stack, then note the MAC address:
bluetoothctl
# scan on; pair XX:XX:XX:XX:XX:XX; trust XX:XX:XX:XX:XX:XX; quit
No rfcomm bind and no /dev/rfcommN device node are needed — the CLI opens the
RFCOMM socket directly. SPP is almost always channel 1 (the default).
Usage
# Remember the paired device so you don't repeat --device every time.
p21 config set-device XX:XX:XX:XX:XX:XX
# List the fixed label sizes the printer supports.
p21 sizes
# Print an image (auto-scaled to fit the label, thresholded to 1-bit).
p21 print label.png
# Print an SVG: it is rasterized crisply to the label's dot resolution.
p21 print label.svg
# Common print options (--size must be one of the fixed sizes).
# The image is auto-rotated to fit the label: landscape artwork is rotated 90°
# onto the portrait label, portrait artwork prints as-is. Inversion is off by
# default.
p21 print logo.png --size 14x50 --density 10 \
--threshold 140 --copies 2 --invert true
# Before printing, the CLI validates the image against the chosen label: it
# picks the best orientation, warns if the pixel size differs (the image will
# be scaled), and refuses to print if the aspect ratio doesn't match either
# orientation.
# Status / device.
p21 battery # e.g. "75%"
p21 info # raw CONFIG? response
p21 beep # beeper on (--off to silence)
# Config helpers.
p21 config show
p21 config path
Supported input formats
Raster images (PNG, JPEG, BMP, GIF, WEBP) via the image feature, and
SVG via the default svg feature. SVG input is rasterized to the label's
fixed dot resolution, so labels can be designed as vector graphics and printed
crisply at any label size without upscaling blur — resolution-independent
designs. Transparent areas are treated as white (not printed). SVG goes through
the same orientation auto-rotate and aspect-ratio validation as raster input.
Device selection order: --device → $P21_DEVICE → config file. The config file
lives at ~/.config/p21-print/config.toml (override with $P21_CONFIG) and can
hold defaults for device, channel, density, size, and threshold.
Supported label sizes
The printer only accepts a fixed set of media sizes (all 96 dots / ~12 mm wide; the pixel heights are fixed by the firmware, not derived from the mm length):
| Name | Dots | mm |
|---|---|---|
12x40mm |
96 × 284 | 12 × 40 |
14x40mm |
96 × 284 | 14 × 40 (default) |
14x50mm |
96 × 355 | 14 × 50 |
14x75mm |
96 × 532 | 14 × 75 |
15x30mm |
96 × 213 | 15 × 30 |
Library
use std::str::FromStr;
use std::time::Duration;
use p21::{BdAddr, Bitmap, LabelSize, PrintJob, Printer, RfcommStream};
let addr = BdAddr::from_str("XX:XX:XX:XX:XX:XX")?;
let link = RfcommStream::connect(&addr, 1, Some(Duration::from_secs(3)))?;
let mut printer = Printer::new(link);
println!("battery: {}%", printer.battery()?);
let label = LabelSize::L12X40; // one of the fixed supported sizes
let bitmap = Bitmap::from_image_path(
"label.png", label.width_dots(), label.height_dots(),
128 /*threshold*/, false /*invert*/, false /*rotate*/,
)?;
printer.print(&PrintJob { label, density: 8, direction: (0, 0), copies: 1, bitmap })?;
Printer<T> is generic over any Read + Write, so the protocol is unit-tested
against an in-memory link with no hardware.
Protocol notes
- Transport: Bluetooth Classic RFCOMM/SPP, 8N1. Commands are ASCII TSPL2,
each terminated with
\r\n. - Print sequence:
\x1b!o(cancel pause) →SIZE w mm,h mm→GAP 5.0 mm,0.0 mm→DIRECTION 0,0→DENSITY d→CLS→BITMAP 0,0,widthBytes,height,1,<raw>→PRINT copies. - Bitmap: printhead is always 96 dots (~12 mm) wide; pixel heights are fixed
per label size. 1-bit, MSB-first, rows padded to whole bytes. Following the
TSPL convention, a cleared bit (0) is a printed black dot and a set bit (1)
is white (padding stays white). If a print comes out inverted, pass
--invert. - Battery:
BATTERY?→ replyBATTERY+ two bytes: a BCD-encoded charge level (0x99= 99%) and a charging flag. Note the space separator and the BCD encoding — reading the value as a raw integer gives wrong numbers. - Config:
CONFIG?→ replyCONFIG+ 10 bytes: protocol, DPI, hardware version (3), firmware version (3), auto-off timeout, beep flag.p21 infoprints the raw hex and the decoded fields.
Hardware notes
Verified against a physical P21: battery reporting and printing work, and the bitmap polarity above is correct (black artwork prints black). Findings worth knowing:
-
Density: the default is 15 (max). Lower densities left the thin feed-edge lines faint. Override with
--densityif you want lighter output. -
Feed edges: the very first and last rows (in the feed direction) print faint, so keep important artwork ~1 px off the top and bottom edges.
-
Resolution: 1-px-per-square detail tends to blur toward solid grey; 2-px squares print noticeably cleaner. Size fine detail accordingly.
-
Reconnecting: a fresh connection immediately after a job can briefly fail with
Device or resource busywhile BlueZ / the printer releases the previous RFCOMM session. The driver now retries the connection automatically for up to 5 s, so sequential commands (e.g.printthenbattery) work without manual retries. -
Beeper: the
BEEPcommand isBEEP+ a0x01/0x00byte (note the space separator, likeBATTERY?/CONFIG?). Verified on hardware — the setting round-trips throughCONFIG?and is audible. When enabled, the printer beeps once for every command it receives (so running several commands in a row makes it beep each time);p21 beep --offdisables it.
Test images
cargo run --example gen_test_images writes fixtures for the default 14x40mm
label into test-images/ (git-ignored). Because printing rotates 90° by
default, the fixtures are generated in landscape (284×96) so they fit the
96-wide dot grid after rotation:
border-284x96.png— 2-px frame flush to all four edges.checkerboard-1px-284x96.png— 1-px squares.checkerboard-2px-284x96.png— 2-px squares (prints cleaner than 1 px).
Layout
Single crate, library + binary:
src/
lib.rs crate root, public re-exports, in-memory protocol tests
error.rs Error / Result
bitmap.rs image/SVG -> packed 1-bit bitmap (Bitmap)
protocol.rs TSPL2 command construction (LabelSize, PrintJob)
transport.rs RFCOMM socket + BdAddr (Linux)
printer.rs Printer<T: Read + Write> driver
main.rs clap CLI (feature `cli`)
config.rs TOML config for the CLI
Development
cargo test
cargo clippy --all-targets
License
MIT
Dependencies
| ID | Version |
|---|---|
| anyhow | ^1 |
| clap | ^4 |
| directories | ^5 |
| image | ^0.25 |
| libc | ^0.2 |
| resvg | ^0.47 |
| serde | ^1 |
| thiserror | ^2 |
| tiny-skia | ^0.12 |
| toml | ^0.8 |
| usvg | ^0.47 |
| chrono | ^0.4 |