- Rust 100%
| crates | ||
| .gitignore | ||
| .rustfmt.toml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENCE | ||
| README.md | ||
nelko-p21-rs
Rust tools for speaking to a Nelko P21 label printer
This is a small Rust workspace for printing labels on a Nelko P21 without using the official app. It contains a protocol crate, a couple of transport crates, a graphics crate for turning text and SVG templates into pixels, and a CLI that ties those pieces together.
The printer itself speaks a fairly small serial protocol over Bluetooth Classic RFCOMM/SPP. The payloads are mostly
TSPL-like commands with a few P21-specific status/config commands mixed in. The most important print command is
BITMAP, which takes a 96x284 one-bit image payload. The printer does not understand SVG, fonts, PNG, or templates
directly - those are conveniences layered above the protocol in this project.
How it fits together
The workspace is intentionally split so each crate has a narrow job:
| Crate | Purpose |
|---|---|
nelko-p21-rs |
Protocol types, command builders, response parsing, pixel packing, and the Printer trait. |
nelko-p21-serial |
A serial transport for an already-mounted device like /dev/rfcomm0. |
nelko-p21-rfcomm |
A Linux RFCOMM transport that connects directly to a Bluetooth MAC/channel. |
nelko-p21-graphics |
Text and SVG rendering into grayscale pixels. |
nelko-p21-cli |
The command-line tool that composes the other crates. |
The important boundary is that the protocol implementation does not know how a connection was made. It only needs a type
that implements Read + Write.
let transport = /* serial, rfcomm, test fixture, etc */;
let mut printer = nelko_p21_rs::NelkoP21::new(transport);
That makes it easy to use the same P21 implementation over a pre-mounted serial device, a native RFCOMM socket, or an in-memory test transport.
Protocol crate
The nelko-p21-rs crate is the protocol layer. It exposes the generic Printer trait and the concrete NelkoP21<T>
implementation.
use nelko_p21_rs::{NelkoP21, Printer};
let transport = /* any Read + Write transport */;
let mut printer = NelkoP21::new(transport);
let status = printer.status()?;
println!("{status}");
The trait models the operations the printer supports:
pub trait Printer {
fn readiness(&mut self) -> Result<ReadinessStatus>;
fn status(&mut self) -> Result<PrinterStatus>;
fn config(&mut self) -> Result<DeviceConfig>;
fn battery(&mut self) -> Result<BatteryData>;
fn set_timeout(&mut self, timeout: TimeoutSetting) -> Result<()>;
fn set_beep(&mut self, beep: BeepSetting) -> Result<()>;
fn selftest(&mut self) -> Result<()>;
fn print_bitmap(&mut self, bitmap: &[u8], options: &PrintOptions) -> Result<PrinterStatus>;
}
print_bitmap expects the actual packed P21 bitmap payload. For this printer that means 3408 bytes: 96 pixels wide,
284 rows tall, one bit per pixel. The protocol crate contains pixels::pack_bitmap for converting easy-to-work with
grayscale pixels into that compact printer format.
use nelko_p21_rs::pixels::pack_bitmap;
let pixels: Vec<u8> = /* 96 * 284 grayscale pixels */;
let bitmap = pack_bitmap(&pixels);
printer.print_bitmap(&bitmap, &Default::default())?;
The grayscale convention is deliberately simple:
0 = black / ink
255 = white / no ink
Values at or below 127 become black bits, and values above 127 become white bits.
The P21 protocol
The P21 uses Bluetooth Classic serial communication. In practice this is either an RFCOMM device exposed as a serial port, or an RFCOMM socket opened directly from userspace.
The protocol has two broad groups of commands.
The first group is device/status oriented:
ESC ! o status / cancel pause style command
ESC ! ? readiness status
CONFIG? printer configuration
BATTERY? battery status
BEEP <byte> beep setting
TIMEOUT <b> auto-off timeout setting
SELFTEST printer self-test
The second group is print oriented and looks like a subset of TSPL:
SIZE 14.0 mm,40.0 mm
GAP 5.0 mm,0 mm
DIRECTION 1,1
DENSITY 15
CLS
BITMAP 0,0,12,284,1,<3408 byte bitmap>
PRINT 1
The label body is always sent as a bitmap. The graphics crate can help produce pixels, but the protocol layer only knows about the final packed bytes.
Transports
There are two transport crates.
Serial
nelko-p21-serial opens a regular serial device. This is useful if you already created an RFCOMM device with BlueZ
tooling:
rfcomm connect /dev/rfcomm0 14:17:36:95:C1:34
Then the CLI can talk to /dev/rfcomm0 like any other serial device.
RFComm [Bluetooth]
nelko-p21-rfcomm opens an RFCOMM socket directly using the Linux Bluetooth socket API. It takes a MAC address and
channel and implements Read + Write, so the protocol crate treats it the same as any other transport.
nelko-p21-cli device status \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34 \
--bt-channel 1
This does not try to manage pairing, trust, discovery, or SDP lookup. The scope is intentionally just the data transport: given a MAC address and an RFCOMM channel, establish the stream.
CLI
The CLI is the easiest way to use the workspace.
Most commands that talk to the printer accept the same transport flags:
--transport serial|bluetooth
--device /dev/rfcomm0
--bt-addr 14:17:36:95:C1:34
--bt-channel 1
Bluetooth is the CLI default for explicit device and print subcommands, so pass --bt-addr when using those
commands. Use --transport serial when you want to talk to a pre-mounted device such as /dev/rfcomm0.
Transport flags live on the command itself, so you can write commands in the order you naturally expect:
nelko-p21-cli device status --transport bluetooth --bt-addr 14:17:36:95:C1:34
Logging is controlled with --log-level at the root:
nelko-p21-cli --log-level debug device status \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
The CLI logs connection attempts and successful connections from the command layer. The transport crates themselves stay quiet.
Device commands
Device-level operations are grouped under device:
nelko-p21-cli device status --transport bluetooth --bt-addr 14:17:36:95:C1:34
nelko-p21-cli device readiness --transport bluetooth --bt-addr 14:17:36:95:C1:34
nelko-p21-cli device config --transport bluetooth --bt-addr 14:17:36:95:C1:34
nelko-p21-cli device battery --transport bluetooth --bt-addr 14:17:36:95:C1:34
nelko-p21-cli device selftest --transport bluetooth --bt-addr 14:17:36:95:C1:34
Settings commands are also under device:
nelko-p21-cli device timeout 30 --transport bluetooth --bt-addr 14:17:36:95:C1:34
nelko-p21-cli device beep true --transport bluetooth --bt-addr 14:17:36:95:C1:34
With native Bluetooth RFCOMM:
nelko-p21-cli device battery \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
With a pre-mounted serial device:
nelko-p21-cli device battery \
--transport serial \
--device /dev/rfcomm0
Printing text
For quick labels you can print raw text:
nelko-p21-cli print text "hello world" \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
The old shorthand still works too. It uses the default serial transport and is intended for a pre-mounted /dev/rfcomm0
device:
nelko-p21-cli "hello world"
Text printing uses the graphics crate to render a landscape text layout and then rotates it into the P21's portrait bitmap orientation before packing.
Useful options:
nelko-p21-cli print text "10K RES" \
--font-size 24 \
--margin 4 \
--density 15 \
--copies 1 \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
You can also point at a font file:
nelko-p21-cli print text "10K RES" \
--font fonts/firacode/FiraCode-Bold.ttf \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
Printing SVG templates
SVG printing is usually nicer for real labels because you can design a layout once and fill it with variables.
nelko-p21-cli print svg templates/smd-component.svg \
--var display_name="10K RES" \
--var package=0603 \
--var id=R1002 \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
You can point the cli to a directory containing fonts if you want the SVG font-family and font-weight attributes to
resolve against a specific font family:
nelko-p21-cli print svg templates/smd-component.svg \
--font-dir /path/to/fonts \
--var display_name="10K RES" \
--var package=0603 \
--var id=R1002 \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34
The SVG command also supports --scale-mode exact|fit|stretch. The default is exact, which expects a 284x96
landscape SVG. That is the natural editing size for this project before the graphics pipeline rotates pixels for
printing.
Previewing templates
preview renders an SVG template to stdout without connecting to the printer. The output is binary PGM (P5), which
includes dimensions and can be piped into viewers like feh:
nelko-p21-cli preview templates/smd-component.svg \
--font-dir fonts/firacode \
--var display_name="10K RES" \
--var package=0603 \
--var id=R1002 | feh -
Preview output is landscape (284x96) so it looks like the SVG you designed. Printing output is rotated internally to
match the printer's 96x284 bitmap format.
There is also a double component template that splits the label in half:
nelko-p21-cli preview templates/smd-component-double.svg \
--font-dir fonts/firacode \
--var left_display_name="10K RES" \
--var left_package=0603 \
--var left_id=R1002 \
--var right_display_name="100N CAP" \
--var right_package=0402 \
--var right_id=C104 | feh -
Graphics crate
The nelko-p21-graphics crate turns higher-level descriptions into grayscale pixels. It does not pack those pixels into
printer bytes; packing belongs to the protocol crate.
There are two modules:
nelko_p21_graphics::text
nelko_p21_graphics::svg
Both modules return final P21-oriented grayscale pixels with length LABEL_WIDTH_DOTS * LABEL_HEIGHT_DOTS.
Text rendering
The text module is for simple labels generated from a string. It supports system font discovery and explicit font
files:
use nelko_p21_graphics::text::{self, TextOptions};
let pixels = text::render(
"10K RES",
&TextOptions { font_size: 24.0, margin: 4 },
)?;
For a specific font:
let pixels = text::render_with_font_file(
"fonts/firacode/FiraCode-Bold.ttf",
"10K RES",
&TextOptions { font_size: 24.0, margin: 4 },
)?;
SVG rendering
The svg module is for designed labels and templates. It uses usvg, resvg, and tiny-skia to rasterize SVG into
grayscale pixels.
use std::collections::BTreeMap;
use nelko_p21_graphics::svg::{self, ScaleMode, SvgOptions};
let svg = std::fs::read_to_string("templates/smd-component.svg")?;
let variables = BTreeMap::from([
("display_name".to_string(), "10K RES".to_string()),
("package".to_string(), "0603".to_string()),
("id".to_string(), "R1002".to_string()),
]);
let pixels = svg::render_template(
&svg,
&variables,
&SvgOptions {
scale_mode: ScaleMode::Exact,
font_dir: Some("fonts/firacode".into()),
},
)?;
Template variables use {{name}} placeholders. Values are XML-escaped before substitution, so normal text values can
safely contain characters like & and <.
SVG templates can use font weights as normal SVG/CSS attributes:
<text font-family="Fira Code" font-weight="700">{{display_name}}</text>
<text font-family="Fira Code" font-weight="500">{{package}}</text>
<text font-family="Fira Code" font-weight="400">{{id}}</text>
As long as the matching font faces are available in the configured font_dir, usvg/fontdb can resolve the requested
weights.
Templates and fonts
This repository includes a small templates/ directory for SMD component labels and a fonts/firacode/ directory with
Fira Code faces.
The single SMD template expects:
display_name
package
id
The double SMD template expects:
left_display_name
left_package
left_id
right_display_name
right_package
right_id
The templates are plain SVG. You can edit them in Inkscape or any other SVG editor, but keep the canvas at 284x96 if
you want to use --scale-mode exact.
Linux and Bluetooth notes
The direct RFCOMM transport is Linux-specific and uses BlueZ-compatible socket constants through libc. It does not do
discovery, pairing, trust management, or SDP channel lookup. Pair the printer through your normal desktop or BlueZ tools
first, then connect by MAC address and channel.
If direct RFCOMM is not working, try the serial path first:
rfcomm connect /dev/rfcomm0 14:17:36:95:C1:34
nelko-p21-cli device status --transport serial --device /dev/rfcomm0
Once that works, direct RFCOMM should usually be:
nelko-p21-cli device status \
--transport bluetooth \
--bt-addr 14:17:36:95:C1:34 \
--bt-channel 1
If channel 1 does not work, the printer may be exposing SPP on another channel. This project currently expects you to
provide the channel explicitly.
Credits
This was originally based on the reverse-engineered protocol published at https://github.com/merlinschumacher/nelko-p21-print. The Python reference and captured traffic in that project made it possible to build the Rust protocol layer here.