p21-print (0.2.0)

Published 2026-07-19 06:03:10 +00:00 by victor in victor/p21-print

Installation

[registries.forgejo]
index = "sparse+" # Sparse index
# index = "" # Git

[net]
git-fetch-with-cli = true
cargo add p21-print@0.2.0 --registry forgejo

About this package

Library and CLI to print labels on a Nelko/Neko P21 Bluetooth label printer over TSPL2/RFCOMM

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 mmGAP 5.0 mm,0.0 mmDIRECTION 0,0DENSITY dCLSBITMAP 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? → reply BATTERY + 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? → reply CONFIG + 10 bytes: protocol, DPI, hardware version (3), firmware version (3), auto-off timeout, beep flag. p21 info prints 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 --density if 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 busy while 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. print then battery) work without manual retries.

  • Beeper: the BEEP command is BEEP + a 0x01/0x00 byte (note the space separator, like BATTERY?/CONFIG?). Verified on hardware — the setting round-trips through CONFIG? 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 --off disables 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
Details
Cargo
2026-07-19 06:03:10 +00:00
2
MIT
34 KiB
Assets (1)
Versions (1) View all
0.2.0 2026-07-19