# NIIMBOT のラベルプリンターを PC から制御する

2026-10-10

![NIIMBOT B1 から出てきたラベル。HELLO と NIIMBOT B1 を書いたテキストのラベルが 2 枚と、niimblue-node のリポジトリの URL を入れた QR のラベルが 1 枚つながっている](printed-labels.jpg)

NIIMBOT の B1 という小型のラベルプリンターを買いました。
本来はスマホのアプリでラベルを作って印刷する製品ですが、宛名や QR コードを PC 側のスクリプトから刷りたくなりました。
フリマやオークションの発送でラベルを何枚も作るので、手でアプリを操作するより、手元のデータから直接印刷できたほうが早いです。

はじめに接続方法でつまずきました。
手元の B1 は、USB-C にデータケーブルでつないでも macOS の `ioreg` には現れず、カーネルの USB ログにも挿入イベントが出ませんでした。
あとで採用する niimblue-node の README には、Windows で B1 を USB シリアル（COM ポート）につなぐ例が載っています。
個体や世代、OS によって違うのかもしれませんが、手元の Mac から制御できる経路は BLE（Bluetooth Low Energy）だけでした。

この記事では、ライブラリの選定、印字できる範囲の決め方、QR とバーコードを読み取れる形で刷る方法、他のマシンから印刷を投げる HTTP サーバーの順に書きます。

## ライブラリ選び

macOS から BLE で B1 を扱えるライブラリを探し、3 つを比べました。

Python の [niimprint](https://github.com/AndBondStyle/niimprint) は実装が素直ですが、Bluetooth の部分が Linux 専用の API（`socket.AF_BLUETOOTH` と `BTPROTO_RFCOMM`）で書かれていて macOS では動きません。
フォークも探しましたが、bleak のようなクロスプラットフォームの実装に差し替えたものは見つかりませんでした。

[labbots/NiimPrintX](https://github.com/labbots/NiimPrintX) は bleak ベースで macOS でも動き、CLI と GUI の両方があります。
ただし、GUI 向けの `imagemagick` や `cairo`、`glib` などを brew で入れる必要があり、CLI だけを使いたい用途には依存が重すぎました。

採用したのは Node.js の [@mmote/niimblue-node](https://github.com/MultiMote/niimblue-node) です。
画像処理の `sharp` と BLE の `noble`（下位の依存）以外は JavaScript のパッケージなので、brew で入れるものがありません。
niimblue-node の README は `noble` のビルド用に Xcode を求めていますが、`noble` には macOS 向けのビルド済みバイナリが同梱されていて、手元ではコンパイルは走りませんでした。
NIIMBOT のプロトコルを実装した niimbluelib の作者によるもので、README では macOS 15.5 と D110、Windows と B1 の組み合わせで検証されています。
macOS と B1 の組み合わせは、手元で動くことを確かめました。

macOS の Core Bluetooth は実際の MAC アドレスを隠すので、プリンターは名前（`B1-<シリアル>`）で指定します。
B1 は数分操作しないと BLE のアドバタイズを止めるので、「見つからない」ときは本体の電源ボタンを短く押して起こしてから実行します。

導入とプリンター名の確認は次のとおりです。
後の節で使う `sharp` と `qrcode`、`async-mutex` も一緒に入れておきます（手元の版は `@mmote/niimblue-node` 1.1.0、`sharp` 0.33 です）。

```bash
npm init -y
npm install @mmote/niimblue-node sharp qrcode async-mutex
# BLE の機器を 15 秒探す（-n はミリ秒）
./node_modules/.bin/niimblue-cli scan -t ble -n 15000
```

出力に `B1-<シリアル>` を含む行があれば、その名前を `-a` に渡して印刷します。
ターミナルに Bluetooth の権限がないと見つからないので、システム設定の「プライバシーとセキュリティ」→「Bluetooth」で、使っているターミナルを許可しておきます。
試しに刷る画像には、50×30 mm のラベルに合わせて作った [test-label.png](/articles/niimbot-label-printer/test-label.png)（384 × 240 px）を使います。
枠と文字は、次の節で決める印字範囲の内側に収めてあります。

```bash
curl -O https://blog.sougen.dev/articles/niimbot-label-printer/test-label.png
./node_modules/.bin/niimblue-cli print -t ble -a "B1-<シリアル>" -p B1 -o top test-label.png
```

`-p B1` は印刷タスクの種類、`-o top` は印字の向きです。

## 50×30 のラベルで印字できる範囲

最初に 50×30 mm のラベルを使い、四隅まで印字しようとして角が欠けました。
ラベルの台紙は角が丸く、縁には印字しても紙に乗らない余白があります。

B1 の印字ヘッドは 384 ドット幅です。
解像度は 203 DPI（8 ドット/mm）なので、幅にすると 48 mm あります。
一方、50 mm 幅の台紙でも、シールの実面積は約 45 × 27 mm しかありません（手元のロールを測った値です）。
ヘッドの 48 mm のほうがシールより広いので、ヘッドいっぱいに描くと右側がシールからはみ出します。

そこで印字範囲を内側に寄せる余白（インセット）を決めました。

```
左 0 / 右 24 / 上 12 / 下 12（px）
```

左端はプリンターが用紙センサーを基準に位置を合わせるので 0 のままでよく、はみ出す右側だけを 24 px 削りました。
残る描画領域は 360 × 216 px になります。

後述のサーバーでは、ラベルの寸法とインセットを定数にして、描画領域を計算で出しています。
テキストのラベルは、描画領域に枠と文字を置いた SVG を作り、`sharp` で PNG にします。

## QR コードを刷る

URL を QR にして刷るだけなら、後述のサーバーの `/print/qr` が `qrcode` で SVG を作り、`sharp` でラベルの PNG に合成して印刷します。
短い URL なら 50 mm 幅でも十分に読み取れます。

発送で使うときは、画面に表示された QR を一度読み取ってから作り直す方法に落ち着きました。
オークションの発送画面に出るヤマト運輸の受付用 QR は、中身が受付番号とパスワードをカンマでつないだ 19 文字だけなのに、Version 8（49 × 49 モジュール）という必要以上に大きな版で描かれています。
これをスクリーンショットからそのままラベルに刷ると、プリンターの解像度に対してモジュール（QR の最小のマス）が細かすぎて、2 値化のときに潰れて読み取れません。
そこで `jsQR` で画像から中身の文字列（payload）だけを取り出し、誤り訂正レベル M で QR を作り直しました。
`qrcode` は数字の部分を数字モードで符号化するので、作り直した QR は Version 1（21 × 21 モジュール）に収まります。
1 モジュールが太くなり、印刷してもくっきり出ます。

取り出しと作り直しは次の 2 つの関数で行います。
`jsQR` は追加で入れます（`npm install jsqr`）。

```js
import { readFileSync } from "node:fs";
import sharp from "sharp";
import QRCode from "qrcode";
import jsQR from "jsqr";

// 画像の中から QR を探し、中身の文字列（payload）を返す
export async function decodeQrPayload(png) {
  const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
  const code = jsQR(new Uint8ClampedArray(data), info.width, info.height);
  return code?.data ?? null;
}

// payload から QR を描き直す。size の既定値は描画領域の高さ（216 px）
export async function generatePerfectQr(payload, opts = {}) {
  const size = opts.size ?? 216;
  return QRCode.toBuffer(payload, {
    errorCorrectionLevel: opts.ecLevel ?? "M",
    margin: 1,
    width: size,
    color: { dark: "#000000ff", light: "#ffffffff" },
  });
}

// 使い方
const payload = await decodeQrPayload(readFileSync("screenshot.png"));
if (!payload) throw new Error("QR decode failed");
const qrPng = await generatePerfectQr(payload, { size: 216, ecLevel: "M" });
```

周りの余白（クワイエットゾーン）は、規格では 4 モジュール分を取ることになっています。
ラベルでは QR の周りがもともと白地なので、`margin: 1` まで詰めて、限られた面積を QR 本体に回しています。

できた `qrPng` は 216 × 216 px の PNG で、後述の HTTP サーバーの `/print/image` にそのまま送って刷ります。

## バーコードは 1 モジュールを整数ドットにする

同じ要領でバーコード（CODE128）も刷ろうとしましたが、こちらは読み取れませんでした。
原因は、バーの幅がプリンターのドット幅に揃っていないことでした。

CODE128 は、バーと隙間を基準の幅（1 モジュール）の 1〜4 倍で組み合わせて情報を表します。
バーコードの画像を任意の倍率で縮小すると、1 モジュールあたりのドット数が端数になり、幅の比が崩れます。
203 DPI の感熱印字では、1 モジュールが 1 ドット（0.125 mm）だと、印字濃度を最大にしても最も細いバーが潰れてスキャナで読めませんでした（27 桁の数字を載せたコードで確認しました）。

直し方は、バーコードを描くときに 1 モジュールをちょうど整数ドットにすることです。
使える幅に収まる最大の整数倍率でバーコードを描き、ラベルにはリサイズせずにそのまま貼り付けます。
この処理が意図どおりに働いているかを確かめるため、生成したラベル画像をもう一度デコードし、元の文字列に戻るかを調べるテストを書きました。

画面のバーコードからの payload の取り出しには `@zxing/library` を、描き直しには `bwip-js` を使いました（`npm install @zxing/library bwip-js`）。
`bwip-js` は 1 モジュールを `scale` ドットの整数幅で描くので、`scale` を 1 から順に上げ、画像の幅が上限を超える手前の値を採ります。

```js
import sharp from "sharp";
import bwipjs from "bwip-js/node";

export async function generatePerfectBarcode(payload, opts = {}) {
  return bwipjs.toBuffer({
    bcid: "code128",
    text: payload,
    scale: opts.scale ?? 3,
    height: opts.height ?? 12,
    includetext: opts.includetext ?? true,
    textxalign: "center",
    paddingwidth: opts.paddingwidth ?? 4,
    paddingheight: opts.paddingheight ?? 4,
  });
}

export async function computeMaxIntegerScale(payload, maxWidth, opts = {}) {
  const includetext = opts.includetext ?? false;
  const paddingwidth = opts.paddingwidth ?? 4;
  let best = 0;
  for (let scale = 1; scale <= 30; scale++) {
    const png = await generatePerfectBarcode(payload, { scale, includetext, paddingwidth });
    const meta = await sharp(png).metadata();
    const width = meta.width ?? 0;
    if (width === 0) throw new Error("bwip-js returned zero-width PNG");
    if (width > maxWidth) break;
    best = scale;
  }
  if (best === 0) throw new Error(`payload cannot fit in ${maxWidth}px even at scale=1`);
  return best;
}
```

ラベルに貼るときは、幅方向をリサイズせずに中央へ置きます。
`resize` で枠に収めると、任意の倍率で縮小されて幅の比が崩れるからです。

倍率の上限には、描画領域 360 px から識別番号の列（44 px）を除いた 316 px を渡しています。
316 px は、識別番号を右端に縦書きする通常のラベルでコードに使える幅です。
バーコードのラベルでは、全幅を使えるように識別番号を下の帯へ移しました。

```js
const scale = await computeMaxIntegerScale(payload, 316, { includetext: false });
const barcodePng = await generatePerfectBarcode(payload, { scale, includetext: false });
```

ただし、27 桁のコードはクワイエットゾーンを除いても 200 モジュールあり、等倍（1 モジュール 0.125 mm）でしか収まりません。
2 倍にするとバーコードの本体だけで 400 ドットになり、316 ドットはもちろん、ヘッドの全幅 384 ドットにも入りません。
整数ドットに揃えても、1 ドットのバーは感熱印字で潰れます。
最も細いバーを 2 ドット（0.25 mm）確保するには、もっと幅の広いロールが要ります。
発送ラベルは結局、同じ内容をより小さい面積で表せる QR に寄せました。

## 印刷を受け付ける HTTP サーバー

ラベルを刷るたびに BLE をつなぎ直すのは遅く、複数のスクリプトが同時にプリンターへ接続すると衝突します。
そこで、印刷ジョブを受け付ける小さな HTTP サーバー（`niimbot-server.mjs`）を常駐させました。

- BLE の接続と切断をサーバーの中に隠蔽する
- `async-mutex` で印刷を 1 件ずつ直列化する
- 30 秒使わなければ自動で切断する

エンドポイントは、死活と接続状態を返す `/health`、プリンター情報を返す `/info`、そして印刷用の 3 つ（`/print/text`・`/print/qr`・`/print/image`）です。
別のマシンの発送スクリプトからは、この `/print/image` に PNG を投げて刷っています。
認証は付けておらず、既定では `0.0.0.0` で待ち受けるので、外部には公開せず宅内 LAN の中だけで使っています。
同じ LAN の端末なら誰でも刷れるので、プリンターをつないだマシンの中だけで使うなら `NIIMBOT_HOST=127.0.0.1` で起動します。

サーバーの全体は、ラベル画像を作る部分も含めて 1 ファイルにまとめ、[niimbot-server.mjs](/articles/niimbot-label-printer/niimbot-server.mjs) として置きました。
依存は `npm install @mmote/niimblue-node async-mutex qrcode sharp` で入ります。

接続と切断の部分は次のとおりです。
接続を使う処理はすべて `bleLock.runExclusive` の中で動かし、アイドル切断のタイマーも同じロックを取ってから切断します。

```js
// niimbot-server.mjs（抜粋）
const scheduleIdleDisconnect = () => {
  if (idleTimer) clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    bleLock.runExclusive(async () => {
      if (client?.isConnected()) {
        await client.disconnect().catch((e) => console.warn("disconnect err", e.message));
      }
      client = null;
    });
  }, IDLE_DISCONNECT_MS);
};
```

印刷では、PNG の透過部分を白で埋めてから 2 値化し、`ImageEncoder` でプリンター向けのデータに変換して送ります。
送り終えるたびにアイドル切断のタイマーを掛け直すので、続けて刷るあいだは接続が保たれます。

ルーティングは、`メソッド パス` の文字列をキーにした表を引くだけで、Node.js 標準の `http` で書いています。

開発中はラベルを無駄にしないよう、モックモードを付けました。
環境変数 `NIIMBOT_MOCK=1` を指定すると、BLE に接続せず、生成した PNG をファイルに書き出します。
印刷前に PNG を目視で確かめる `--dry-run` も、ラベル調整のスクリプトに入れてあります。
レイアウトを 3 回作り直したときも、ラベルを 1 枚も消費せずに済みました。

サーバーはプリンターの近くの Mac で起動し、別のマシンから PNG を base64 にして JSON で送ります。
`base64` に標準入力から読ませると、macOS と Linux のどちらでも同じ書き方で動きます。

```bash
# プリンターの近くの Mac で起動する
NIIMBOT_ADDR="B1-<シリアル>" node niimbot-server.mjs

# 別のマシンから PNG を送る。成功すると {"ok":true} が返る
curl -X POST http://printer-host.example:3838/print/image \
  -H 'Content-Type: application/json' \
  -d "{\"imageBase64\":\"$(base64 < test-label.png | tr -d '\n')\",\"density\":3}"
```
