npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 Uint8Array argument marshalling and addListener on 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-niimbot

Add 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:ios

The 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. printBitmap therefore refuses an over-wide bitmap rather than letting you lose the right edge, and labelSizePx() reports clamped: 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 typecheck

MockTransport 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 sequence

toPbm() 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 v4 and b1 print 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