Sougen Blog

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

niimbotlabel-printerblenodejsbarcode

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

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 は実装が素直ですが、Bluetooth の部分が Linux 専用の API(socket.AF_BLUETOOTH と BTPROTO_RFCOMM)で書かれていて macOS では動きません。
フォークも探しましたが、bleak のようなクロスプラットフォームの実装に差し替えたものは見つかりませんでした。

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

採用したのは Node.js の @mmote/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 です)。

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(384 × 240 px)を使います。
枠と文字は、次の節で決める印字範囲の内側に収めてあります。

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)。

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 から順に上げ、画像の幅が上限を超える手前の値を採ります。

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 は、識別番号を右端に縦書きする通常のラベルでコードに使える幅です。
バーコードのラベルでは、全幅を使えるように識別番号を下の帯へ移しました。

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)を常駐させました。

エンドポイントは、死活と接続状態を返す /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 として置きました。
依存は npm install @mmote/niimblue-node async-mutex qrcode sharp で入ります。

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

// 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 のどちらでも同じ書き方で動きます。

# プリンターの近くの 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}"