A driver for the Nelko P21 Label Printer
Find a file
2026-05-19 23:18:34 +01:00
crates Initial commit 2026-05-19 23:18:34 +01:00
.gitignore Initial commit 2026-05-19 23:18:34 +01:00
.rustfmt.toml Initial commit 2026-05-19 23:18:34 +01:00
Cargo.lock Initial commit 2026-05-19 23:18:34 +01:00
Cargo.toml Initial commit 2026-05-19 23:18:34 +01:00
LICENCE Initial commit 2026-05-19 23:18:34 +01:00
README.md Initial commit 2026-05-19 23:18:34 +01:00

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.