macOS tooling and a Codex skill for reverse-engineered Bluetooth Low Energy label printers.
This is unofficial software. It currently supports:
- Brother PT-N25BT on 12 mm tape.
- SUPVAN E10 on 12 mm tape.
- NIIMBOT B21S, tested on 50 × 30 mm gap labels.
- Discover and inspect nearby BLE peripherals from macOS.
- Generate true 1bpp preview images before printing.
- Generate printer-specific job files with calibrated trailing padding.
- Print through signed CoreBluetooth app bundles so macOS Bluetooth permissions attach to stable app identities.
Before printing custom artwork, inspect a nearest-neighbor enlarged preview and reject layouts where icons, dividers, waveforms, arrows, bolts, dots, frames, or borders touch or crowd the text. Text should be treated as the primary content; decorative elements should be omitted unless they fit with clear whitespace.
For PT-N25BT bitmap labels, tools/ptn25bt/generate_prn.py includes helper
functions for custom scripts:
pixel_text_box(...)computes the exact box for bitmap text.assert_boxes_clear(...)rejects layout boxes that collide or get closer than the configured padding.
Use at least 4 px of clearance between text and non-text decorations, and 8-12 px when there is enough room.
- macOS with Bluetooth enabled.
- Xcode command line tools for
swiftcandcodesign. - Python 3.10 or later with Pillow.
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txtOptional BLE scanner:
./scripts/build-ble-scan.sh
open -Wn .build/BLEScan.app \
--stdout work/ble-scan.out \
--stderr work/ble-scan.err \
--args --scan-seconds 20Build:
./scripts/build-ptn25bt.shGenerate a 1bpp preview and PRN:
. .venv/bin/activate
python tools/ptn25bt/generate_prn.py work/brother-hello.prn \
--text "HELLO" \
--length-px 320 \
--preview-png work/brother-hello.pngReview work/brother-hello.png, then print:
open -Wn .build/PTN25BT.app \
--stdout work/brother-print.out \
--stderr work/brother-print.err \
--args --name PT-N25BT --scan-seconds 60 send-file "$PWD/work/brother-hello.prn"Known-good defaults:
- 180 dpi raster stream.
- 64-dot printable height inside 128-dot transfer lines.
- Brother feed/margin command
ESC i d = 0. 13.4 mmof blank trailing raster columns appended after the artwork.- Preview PNGs intentionally omit that trailing padding.
Useful calibration patterns:
python tools/ptn25bt/generate_prn.py work/brother-border.prn \
--calibration border \
--length-px 220 \
--border-px 2 \
--preview-png work/brother-border.png
python tools/ptn25bt/generate_prn.py work/brother-showoff.prn \
--calibration showoff \
--length-px 560 \
--preview-png work/brother-showoff.pngBuild:
./scripts/build-supvan-e10.shGenerate a true 1bpp preview and compressed .spv job:
. .venv/bin/activate
python tools/supvan-e10/generate_job.py work/e10-hello.spv \
--text "HELLO" \
--length-mm 40 \
--preview-png work/e10-hello.pngReview work/e10-hello.png, then print:
open -Wn .build/SupvanE10.app \
--stdout work/e10-print.out \
--stderr work/e10-print.err \
--args --name T0011 --scan-seconds 25 send-file "$PWD/work/e10-hello.spv"Known-good defaults:
- 8 dots/mm, which is 203.2 dpi.
- 96-dot transfer height for 12 mm tape.
- 12 bytes per vertical column.
- LZMA-Alone compressed 4000-byte page buffers.
6.0 mmof blank trailing columns appended after the artwork.- Preview PNGs intentionally omit that trailing padding.
- Default density/deepness is
4; the app supports up to7.
Useful calibration pattern:
python tools/supvan-e10/generate_job.py work/e10-border.spv \
--calibration border \
--length-px 240 \
--preview-png work/e10-border.pngBuild the signed app (macOS 14 or later):
./scripts/build-niimbot-b21s.sh
mkdir -p workGenerate a single-copy JSON job and true 1bpp previews:
. .venv/bin/activate
python tools/niimbot-b21s/generate_job.py work/b21s-name.json \
--text "John" --width-mm 50 --height-mm 30 \
--preview-png work/b21s-name.pngAdd --logo /path/to/logo.png to place a logo above the name. Text automatically
shrinks to fit, including descenders. Alternatively, use --image /path/to/art.png
instead of --text to fit existing artwork while preserving its aspect ratio.
Transparent artwork is composited onto white before thresholding. Use --font
to select a local TrueType/OpenType font.
Review work/b21s-name.png and work/b21s-name-4x.png, validate the job without
accessing Bluetooth, then print:
.build/niimbot-b21s validate-file work/b21s-name.json
open -Wn .build/NiimbotB21S.app \
--stdout work/b21s-print.out \
--stderr work/b21s-print.err \
--args --scan-seconds 30 send-file "$PWD/work/b21s-name.json"The app discovers a printer whose name is B21S or begins with B21S-.
When several are nearby, select one with --name EXACT_ADVERTISED_NAME or
--uuid MACOS_PERIPHERAL_UUID; UUID selection takes precedence over name.
Printer selection options go before the command. For read-only status:
open -Wn .build/NiimbotB21S.app \
--stdout work/b21s-status.out \
--stderr work/b21s-status.err \
--args --scan-seconds 30 statusRead stdout and stderr after each run. A completed job reports one page with 100% print/feed progress and an acknowledged PrintEnd; this does not verify visible marks on the physical label. If a job fails after raster transfer begins, inspect the output and printer before retrying to avoid unintended duplicates. Roll RFID data does not supply label dimensions; set the generator dimensions from the actual stock.
Known-good settings, physically verified on B21S model 777 / firmware 40.33:
- 8 dots/mm (203.2 dpi), with a 384-dot / 48 mm printhead. A 50 × 30 mm label uses a 384 × 240 pixel printable area.
- Six-byte page size: height, width, and copy count as big-endian 16-bit values. The four-byte form can acknowledge success and feed a completely blank label.
- Single-copy jobs, gap label type
1, density3(adjustable from1to5). - MSB-first row raster,
1for black, split black-pixel counts per 128-dot printhead segment, and 15 ms pacing between rows. - No continuous-tape trailing padding is added.
Offline regression checks (no scanning or printing):
python -m unittest discover -s tests -vThis repository is also a Codex skill. The root SKILL.md contains the operational workflow for agents that need to generate, inspect, or print labels.
Install it directly as a local Codex skill:
mkdir -p ~/.codex/skills
git clone https://github.com/johnboiles/ble-label-printers.git \
~/.codex/skills/ble-label-printersBLE service and characteristics:
- Service:
A76EB9E0-F3AC-4990-84CF-3A94D2426B2B - Read/status:
A76EB9E1-F3AC-4990-84CF-3A94D2426B2B - Write-with-response:
A76EB9E2-F3AC-4990-84CF-3A94D2426B2B - Write-without-response and ACK notify:
A76EB9E3-F3AC-4990-84CF-3A94D2426B2B - Printer status notify:
A76EB9E4-F3AC-4990-84CF-3A94D2426B2B
BLE write-without-response payloads are framed as:
06 f0 <packet_count> 00 <payload>
Successful segment ACK:
06 f0 01
Observed device:
- Advertised name:
T0011B2112291094 - Advertised service:
FEE7 - Active service:
0000E0FF-3C17-D293-8E48-14FE2E4DA212 - Notify/write characteristic:
FFE1 - Write characteristic:
FFE9 - Notify characteristic:
FFEA
The Android app maps E10 to T15Print:
dpi = 8.0fMaxDotValue = 96mPerLineByte = 12printingProcess = 15
Command frames start with 7e 5a, command responses echo the command byte at
offset 7, and E-series bulk frames are sent as 512-byte frames split into four
128-byte BLE writes.
- Service:
E7810A71-73AE-499D-8C15-FAA9AEF0C3F2. - Notify/write characteristic:
BEF8D6C9-9C21-4C9E-B632-BD58C1009F9F. - Subscribe before writing without response. Frames use
55 55 COMMAND LENGTH PAYLOAD XOR AA AA; XOR covers command, length, and payload. Connect (C1) additionally uses a leading03byte. - Page setup acknowledgments can contain
01 00, so successful print setup accepts the01prefix rather than requiring an exact one-byte reply. - The client buffers fragmented/coalesced notifications, checks checksums, honors CoreBluetooth write readiness, and stops on printer errors or timeouts.
The protocol follows NiimBlueLib, with the B21S six-byte page-size correction confirmed by niimprint issue 33 and NiimPrintX PR 50.