react-native-niimbot
v0.1.0
Published
Print to Niimbot BLE label printers (B21 Pro, B1 Pro, B1, M2-H, D11) from React Native / Expo on iOS and Android.
Maintainers
Readme
react-native-niimbot
Print to Niimbot BLE label printers from React Native / Expo, on iOS and Android.
Built for the Niimbot B21 Pro, with the same protocol covering the B1 Pro, B1, B1 SE, B21, M2-H, D11_H and D110_M.
There was no React Native or Expo library for these printers — the ecosystem was browser (Web Bluetooth), Python, Node and Flutter only. This is a TypeScript port of the protocol with a native BLE transport.
How it works
The design principle is that native code is a dumb byte pipe and all protocol logic lives in TypeScript. The Swift and Kotlin modules only scan, connect, write and notify; framing, opcodes, image encoding and the print sequence are pure TS.
That has a practical payoff: the entire print path is unit-tested against a simulated printer, with no hardware involved. 158 tests cover the frame codec, every packet layout, the row encoder, request correlation and the full job sequence.
The native module uses the Expo Modules API, which supports React Native's New Architecture by default — so there is no bridgeless/interop risk to manage.
Requirements
- Expo SDK 52+ (uses
Uint8Arrayargument marshalling andaddListeneron the module) - A custom dev client or release build. This cannot work in Expo Go.
- iOS 15.1+, Android 7+ (API 24)
Install
npm install react-native-niimbotAdd the config plugin to app.json:
{
"expo": {
"plugins": [
["react-native-niimbot", {
"bluetoothAlwaysPermission": "This app connects to your label printer over Bluetooth."
}]
]
}
}Then rebuild the native project:
npx expo prebuild --clean && npx expo run:iosThe plugin adds NSBluetoothAlwaysUsageDescription on iOS (mandatory — without it iOS kills the app the moment Bluetooth is initialised) and the Android BLE permissions with usesPermissionFlags="neverForLocation", so ACCESS_FINE_LOCATION is not required on Android 12+.
Usage
import { B21_PRO, labelSizePx, printheadRuler } from 'react-native-niimbot';
import { checkPreconditions, findAndConnect, scanForPrinters, connectPrinter } from 'react-native-niimbot/ble';
// 1. Check we can actually scan (see the note on Android below).
const status = await checkPreconditions();
if (status !== 'ok') throw new Error(status);
// 2. Find and connect.
const printers = await scanForPrinters({ timeoutMs: 6000 });
const printer = await connectPrinter(printers[0].id, { expectModel: B21_PRO });
console.log(printer.identity);
// { modelId: 785, model: {...}, dpi: 300, printheadPixels: 591, firmware, serial, battery }
// 3. Print. The library takes pixels — it does not rasterize for you.
const { width, height } = labelSizePx(B21_PRO, 50, 30); // 591 x 354
await printer.printImage(
{ data: rgbaBytes, width, height, format: 'rgba' },
{ density: 3, copies: 1, onProgress: (p) => console.log(p.phase, p.percent) },
);
await printer.close();findAndConnect() collapses steps 1–2 for the common single-printer case.
Supplying pixels
The core deliberately has zero dependencies and takes pixels in one of three formats:
| format | Layout |
|---|---|
| rgba | width * height * 4, non-premultiplied RGBA8888 |
| gray8 | width * height, 0 = black, 255 = white |
| mono1 | stride * height, MSB-first, bit set = black (passed through with no conversion) |
Conversion uses Rec. 601 luma with a threshold of 128, and treats alpha ≤ 32 as white — matching the reference implementation. Optional Floyd–Steinberg dithering is available via { dither: 'floyd-steinberg' }, but leave it off for text, QR codes and line art.
Rasterize at the exact target pixel size. The library never resamples: scaling a bitmap for a 1-bit thermal head produces grey fringes that threshold into ragged edges. It only crops, pads and shifts.
Producing those pixels in a React Native app is out of scope here. @shopify/react-native-skia is the natural companion — Skia.Surface.Make(w, h) gives a CPU-backed RGBA8888 surface and image.readPixels() hands back a Uint8Array directly, with no PNG encode or base64 round-trip, and it works headlessly so print output does not depend on anything being mounted or on screen.
Printing many labels
For N identical labels, use copies: the bitmap is uploaded once and the printer repeats it internally.
await printer.printBitmap(bitmap, { copies: 10 });For N different labels, use printBatch: pages stream back to back within a single job, with a look-ahead, so the printer does not stop and retract between labels.
await printer.printBatch([bitmapA, bitmapB, bitmapC]);Know your hardware: the B21 Pro
- Monochrome direct thermal, 300 dpi. There is no colour. Everything becomes 1-bit black or white.
- Maximum print width ~50 mm (591 px). The feed axis is unbounded, so a 40 × 90 mm label is fine — just make sure the ≤ 50 mm side runs across the printhead. Rotate with
rotate90cw()if needed. - Anything wider than the printhead is silently truncated by the printer, not scaled.
printBitmaptherefore refuses an over-wide bitmap rather than letting you lose the right edge, andlabelSizePx()reportsclamped: true.
Verifying on hardware
The B21 Pro's print path is not validated upstream: its model id and geometry come from niimbluelib's table, and its v4 task sequence is inferred from the documented protocol. Sources disagree on the printhead width (591 vs 567 vs 584), so confirm it on paper. The library ships test patterns for exactly this:
import { printheadRuler, singleRowTest, densityTest, stressTest } from 'react-native-niimbot';
// 1. Smallest possible job — proves the handshake without bitmap volume.
await printer.printBitmap(singleRowTest(591));
// 2. Measure the real printable width. Whichever marks appear tell you the answer.
await printer.printBitmap(printheadRuler({ width: 591, marks: [566, 583, 589] }));
// 3. Density and head cleanliness.
await printer.printBitmap(densityTest(591, 200));
// 4. Flow control: every row is distinct, so run-length encoding cannot help.
await printer.printBitmap(stressTest(591, 354));Recommended order: identify → single row → ruler → full label → density → stress → repeat on the other platform.
Pass { log: console.log } to connectPrinter for a full hex trace of every frame in and out.
If a print comes out blank or cut off
| Symptom | Likely cause |
|---|---|
| PAGE_NOT_ACKED error | BLE writes were dropped. Try { pacingMs: 10 }. |
| Blank label, printer reports 100% | Dropped burst writes. Same fix. |
| Label cut off partway | PrintEnd arrived mid-print — the status poll was skipped or timed out. |
| Right edge missing | Bitmap wider than the printhead. |
| Paper retracts between labels | PrintEnd sent per page instead of per job. |
| Nothing found when scanning, no error, Android ≤ 11 | Location services are off. checkPreconditions() returns location-off. |
The printer does not need to be paired in system Bluetooth settings — BLE GATT needs no bond. Niimbot printers expose two Bluetooth addresses (one Classic, one LE) and we only ever use the LE one, so the address you see in system settings will not match the id from scanForPrinters().
Protocol reference
Frame: [0x55, 0x55, cmd, len, ...data, crc, 0xAA, 0xAA], crc = cmd XOR len XOR data.
Transport: service e7810a71-73ae-499d-8c15-faa9aef0c3f2, characteristic bef8d6c9-9c21-4c9e-b632-bd58c1009f9f (NOTIFY + WRITE_NO_RESPONSE).
The v4 job sequence, as used by the B21 Pro:
connect (raw 03 55 55 c1 01 01 c1 aa aa)
SetDensity 0x21 -> 0x31
SetLabelType 0x23 -> 0x33
PrintStart 0x01 -> 0x02 (9 bytes, includes speed)
per page:
PrintStatus 0xA3 (one-way, +30 ms — this is what opens the page)
SetPageSize 0x13 -> 0x14 (13 bytes: rows, cols, copies)
rows: 0x84 blank / 0x85 pixels, run-length, max run 200
PageEnd 0xE3 -> 0xE4 (the ack proves no rows were dropped)
poll PrintStatus 0xA3 -> 0xB3 until page >= target
PrintEnd 0xF3 -> 0xF4 (exactly once per job)Three details are load-bearing and must not be "tidied up": the one-way status probe that opens each v4 page, the completion poll before PrintEnd, and PrintEnd running once per job rather than once per page.
Row packets use "total mode": [row_hi, row_lo, 0x00, total_lo, total_hi, run, ...pixels], where total is the black-pixel count of a single row. Identical adjacent rows collapse into one packet via run; fully blank rows use the cheaper 0x84. A 50 × 30 mm label with a bit of text typically compresses from 354 rows to a few dozen packets.
Protocol-3 printers (B1, B21, M2-H) additionally require an arming handshake after connect — status + eight PrinterInfo queries + heartbeat — or they accept every setup command and then never print. This is handled automatically for b1-task models.
Development
npm run build # compile to dist/
npm test # 158 tests, no hardware needed
npm run typecheckMockTransport is a simulated printer that decodes requests and answers them like real hardware. It is exported, so app-level tests can use it too:
import { MockTransport, NiimbotPrinter } from 'react-native-niimbot';
const transport = new MockTransport({ modelId: 785 });
const printer = await NiimbotPrinter.open(transport);
await printer.printBitmap(myBitmap);
console.log(transport.requestOpcodes); // assert the exact command sequencetoPbm() and toAscii() dump encoder output so you can inspect a label without printing it.
Credits
The protocol implementation is a TypeScript port derived from two MIT-licensed reverse-engineering projects. Neither is a runtime dependency; both are gratefully acknowledged:
- niimbot-web-bluetooth — the hardware-validated
v4andb1print sequences, total-mode row encoding, and the BLE flow-control findings. - niimbluelib / niim.blue — the printer model registry and packet abstractions.
Protocol notes also draw on the NIIMBOT Community Wiki.
License
MIT
