技術 約11分で読めます

HP Sprocket 200のPC印刷をBLEからRFCOMMに切り替えてM1 Macで高速化した

いけさん目次

前回の記事でHP Sprocket 200をBLE経由でPCから印刷できるようにしたが、転送速度がかなり遅かった。
143KBのJPEGを500Bチャンク×293回で送るのに約60秒。
2x3インチの小さな紙1枚に1分弱待たされる計算で、フォトブース用途では行列のテンポが死ぬ。

そこに前回記事を読んだドイツのAndroid開発者 olie.xdev さんからメールが来て、自分のwiki Sprocket 200 Bluetooth Protocol でRFCOMM側のフレーミングを完全に解析していると教えてくれた。
向こうの計測値は 約5.4 KB/s、144KBのJPEGで約26秒、チャンク数148
うちのBLE実装(約2.4 KB/s、60秒、293チャンク)と比べてざっと2倍速い。

この実験ができるのは完全にolie.xdevさんが詳細なwikiを公開してくれて、わざわざメールで教えてくれたおかげ。 前回記事はBLEのコマンドコードを10個ほど特定するに留まったが、wikiはRFCOMM側の完全なフレーミング、27個以上のコマンドコード、印刷フローまで網羅されていて、自分のリバエンの労力ゼロでmacOS実装を組み立てられた。先に感謝を書いておく。

うちのフォトブースは「撮影→ローカルAIで処理→印刷」のループで、ボトルネックはM1 Maxでの推論側だが、印刷段が60秒から半分以下になるなら体感は大きく変わる。
というわけでmacOS用のRFCOMMクライアントをPythonで書いて、転送時間を実測する。

何を計測するか

最初に区別を明確にしておく。
紙の吐き出し速度自体はZINK処理パイプラインで律速されていて、トランスポートを変えても変わらない。 変わるのは「ホストから画像データを送り終わるまでの時間」(= 転送時間)だけ。

ホスト:  [接続] [画像送信 ← ここが変わる] [完了通知受信]
プリンター:                              [ZINK現像・排紙 ← ここは変わらない]

つまり計測対象は FILE_WRITE_REQ を最初に送ってから、ステータス0x02(COMPLETE)を含むFILE_WRITE_RSPを受け取るまで の経過時間。
ユーザー体験としては「印刷ボタンを押してから紙が出始めるまでの待ち時間」が短くなる。

BLEとRFCOMMで何が違うか

HP Sprocket 200のBLEとBT Classicは、コマンド層(Sprocket Protocol)は同じだが、フレーミングが完全に別物。
olie.xdevのwikiから要点をまとめると次の通り。

項目BLE GATTRFCOMM
物理層LEClassic
ペアリングLE Secure / Just WorksJust Works (PIN不要)
サービスカスタムUUID (...946209)SPP (0x1101) + カスタムCh.1
1パケットの上限244B(Androidで実用上の安全値)、Mac CoreBluetoothは500B程度まで通るアプリ層MTUなし。プリンターがCONN_SETUP_RSPで1000Bを通知
1チャンクのデータ~242B (244B - 1B BLEヘッダ)~998B (1000B - cmd 1B - handle 1B)
フレーミング先頭1Bにseq/last_flag先頭"HP+" + flag + 長さ(1or2B)
144KB画像のチャンク数~600 (※olie計測) / 293 (※うちのBLE実測、500Bチャンク時)~148
ACKackPeriodで間引き可ストリーム、チャンクごとにFILE_WRITE_RSP

要するに 「1往復で運べるデータ量がBLEの約4倍」がRFCOMMの利点
チャンクサイズが2倍になればRTTの影響も半減するので、理論上は2倍以上の高速化になる(チャンク数の減り方が線形以上に効く)。

RFCOMMの大フレーム形式

RFCOMMはペイロードが255Bを超える場合、長さフィールドが2バイト(LE)になる「大フレーム」形式を使う。
画像転送はこれが必須。

小フレーム (≤255B):
[48 50 2B] [01] [LL]     [CMD] [payload...]
 "HP+"      flag  1B len   cmd

大フレーム (>255B、画像転送用):
[48 50 2B] [02] [LL HH]  [CMD] [payload...]
 "HP+"      flag  2B len LE  cmd

"HP+"(0x48 0x50 0x2B)というマジックバイトが毎フレーム入る。
レスポンスも同じフォーマットだが、最初の0x48が単独で届いて残りが後続して読めるので、 read(1) してから available() で残りを読むのが安全らしい。

RFCOMMセッション確立シーケンス

BLEと違ってRFCOMM側は少し手数が多い。
olie.xdevが公式アプリのトラフィックをキャプチャした結果、次の順番で叩いている。

graph TD
    A[RFCOMM Ch.2 SPP接続<br/>MTU 1006] --> B[IF_CONFIG_REQ<br/>interface=0x02]
    B --> C[IF_CONFIG_RSP<br/>ホストMAC返却]
    C --> D[CONN_SETUP_REQ<br/>maxHostMsg=4096]
    D --> E[CONN_SETUP_RSP<br/>maxTargetMsg=1000]
    E --> F[RD_SYS_ATT_REQ<br/>DeviceId/FeatureSet]
    F --> G[SET_TIME_REQ<br/>timestamp+tz]
    G --> H[RD_SYS_CFG_REQ<br/>UserColor読取]
    H --> I[RES_ALLOC_REQ<br/>UserColor RGB]
    I --> J[RD_STATUS_REQ]
    J --> K[LIST_JOBS_REQ]
    K --> L[RD_JOB_PROP_REQ]
    L --> M[PRINT_START_REQ]
    M --> N[WR_JOB_PROP_REQ<br/>LED色 + timestamp]
    N --> O[FILE_WRITE_REQ × N<br/>998Bチャンク]
    O --> P[印刷実行]

olieのwikiでは「LIST_JOBS_REQ / RD_JOB_PROP_REQ / SET_TIME_REQ / RD_SYS_CFG_REQ / RES_ALLOC_REQ の5つはPossibly not required(公式アプリがやってるだけで必須かは未確認)」と書かれている。
今回の実装ではまず公式と同じ順番で全部叩いて動作確認し、その後で切り分け実験(後述)で省略しても動くことを確認した。olieのwikiの予想は当たっていた。

コマンドコード抜粋

前回記事ではコマンドコードを10個ほどしか特定できていなかったが、wikiでは27個以上が判明している。
今回新たに使うのは以下。

コード名前用途
0x18SET_TIME_REQ現在時刻同期
0x1ARD_SYS_CFG_REQUserColor等の設定読み取り
0x24CONN_SETUP_REQ最大メッセージサイズ交渉
0x25CONN_SETUP_RSP同 応答
0x2CLIST_JOBS_REQ現在のジョブID取得
0x2ERD_JOB_PROP_REQジョブ属性読み取り
0x30WR_JOB_PROP_REQジョブにLED色とtimestampを設定
0x36RES_ALLOC_REQ印刷リソース確保(UserColor RGB付き)

macOSでのRFCOMM実装

bleak(BLE)と違って、PythonのmacOSからRFCOMMを叩く標準的なライブラリは存在しない。
PyObjC経由で IOBluetoothDeviceIOBluetoothRFCOMMChannel を直接呼ぶ形になる。
venvに pyobjc-framework-IOBluetooth を入れて使用。

python3 -m venv .venv
.venv/bin/pip install pyobjc-framework-IOBluetooth pyobjc-framework-Cocoa Pillow

接続〜MTU確認

import objc
from Foundation import NSObject, NSRunLoop, NSDate, NSDefaultRunLoopMode
from IOBluetooth import IOBluetoothDevice

class RFCOMMDelegate(NSObject):
    def init(self):
        self = objc.super(RFCOMMDelegate, self).init()
        self._rx = bytearray()
        self._frames = []
        return self

    def rfcommChannelData_data_length_(self, ch, data, length):
        chunk = bytes(data[:int(length)]) if length else b''
        self._rx.extend(chunk)
        # decode HP+ frames from _rx into _frames here

    def rfcommChannelOpenComplete_status_(self, ch, status):
        ...

dev = IOBluetoothDevice.deviceWithAddressString_("F4-39-09-D2-8C-2D")
delegate = RFCOMMDelegate.alloc().init()
result, channel = dev.openRFCOMMChannelAsync_withChannelID_delegate_(
    None, 2, delegate  # Ch.2 = SPP (HP+プロトコル)
)
# channel.getMTU() == 1006

openRFCOMMChannelAsync_withChannelID_delegate_ の第1引数 None がチャンネルの出力ポインタで、PyObjCがタプル (IOReturn, IOBluetoothRFCOMMChannel) で返してくれる。
チャンネルからの受信は rfcommChannelData_data_length_ デリゲートメソッドに非同期で届くので、自前で受信バッファを持ってHP+フレームを切り出す必要がある。

詰まったポイント1: RFCOMMチャンネル番号

前回記事で SDP 列挙してチャンネル1とチャンネル2の二つを見つけていたが、 HP+プロトコルが流れるのはチャンネル2(SPP 0x1101) の方
チャンネル1にはカスタムUUID(...DEAFDECACAFF)が割り当てられていて、接続すると FF 55 02 00 EE 10 の6バイトビーコンが1秒おきに流れてくるだけで、HP+コマンドには応答しない。
最初にチャンネル1で実装してビーコンしか返ってこないので「プロトコル壊れたか?」と数十分悩んだ。wikiにはSPP UUIDの記載はあってもチャンネル番号は書いてないので、SDPで自分で確認するしかない。

HP+フレームのエンコード/デコード

HP_HDR = b'\x48\x50\x2b'

def encode_frame(cmd_code, payload=b''):
    msg = bytes([cmd_code]) + payload
    if len(msg) <= 255:
        return HP_HDR + bytes([0x01, len(msg)]) + msg
    return HP_HDR + bytes([0x02]) + struct.pack('<H', len(msg)) + msg

writeSync_length_(bytes, len) で同期送信する。
画像チャンクは大フレーム形式が必要なので、encode_frame 内で自動的に切り替えるのが楽。

詰まったポイント2: Bluetooth権限

macOSではPython.app(/Applications/Xcode.app内蔵)がBluetooth権限のentitlementを持っていないため、IOBluetoothの呼び出しが暗黙にブロックされてプロンプトすら出ない場合がある。
ターミナル(Ghostty/Terminal.app等)に対して「システム設定 → プライバシーとセキュリティ → Bluetooth」で権限を付与すると通る。
初回はターミナルから一度Bluetoothを触る操作(例: system_profiler SPBluetoothDataType)をしておくと権限プロンプトが表示される。

詰まったポイント3: プリンタが固まったらハードリセット

実装中、何度かプリンタが「PRINT_STATUS=7 (PRINTING) のまま PrintProgress=0 から動かない」「校正シートが排出されない」状態に陥った。電源長押し(2〜3秒)では反応しない。
このとき使うのが ハードリセットボタン。HP公式の手順は「紙トレイの蓋を開けて、用紙トレイ近くの小さな丸ボタンをペーパークリップで充電LEDが消えるまで(約3秒)長押し」。蓋を開けた状態だと、内側にあるリセットボタンが、蓋閉検知用の物理スイッチのすぐ横に見える。

リセットボタン位置(左の長方形が蓋検知スイッチ、その下の小さい丸がハードリセット)

押すと完全に再起動してキャリブレーション(青いスマートシートが自動排出)から再開する。
カスタム名やUserColorなどの設定は保持される(factory resetではなくhardware reset)。HP公式マニュアルにも記載があるが、Bluetoothプロトコル実装で詰まらせた場合の挙動として知っておくと早い。

計測

テスト画像

test_640x1002.jpg
  解像度: 640x1002 (Sprocket 200の正規サイズ、wiki記載)
  品質: JPEG quality=90
  サイズ: 139,876 bytes

前回のBLE記事では668x1002で146,002バイトの画像だったが、wiki記載の正規サイズ640x1002に合わせた。
紙はCanon iNSPiC用ZINK(HP純正スマートシートでキャリブ済み)、室温状態から開始。

実測結果

項目BLE版(前回)RFCOMM版(今回)改善
トランスポートBLE GATTRFCOMM (SPP Ch.2)-
画像サイズ146,002B139,876B(近似比較)
チャンクサイズ500B998B約2倍
チャンク数293141約半分
転送時間約60秒5.38秒約 1/11
スループット約2.4 KB/s25.37 KB/s約10.6倍

FILE_WRITE_REQ の1回目を送信した時刻から、status=0x02 (COMPLETE) を含む FILE_WRITE_RSP を受信した時刻までを計測。

[+] channel open; MTU=1006
[i] maxTargetMsg=1000
[i] UserColor RGB = ff c9 dd
[i] file_handle=0x03 job_id=0x0700
--- FILE_WRITE loop ---
[i] chunk_size=998B, expected_chunks=141
    [1/141] handle=0x03 status=0x01 recv=998/139876
    [2/141] handle=0x03 status=0x01 recv=1996/139876
    ...
    [140/141] handle=0x03 status=0x01 recv=139720/139876
    [141/141] handle=0x03 status=0x02 recv=139876/139876
[+] COMPLETE at chunk 141

=== RESULT (transport) ===
bytes:         139876
chunks:        141
chunk_size:    998B
transfer_time: 5.38s
throughput:    25.37 KB/s

olie.xdevの計測値(Android 16、約26秒/5.4 KB/s)と比べてmacOS実装が約5倍速かった。
M1 MacのBTコントローラ(BCM4378、PCIe接続)とApple側のBTスタックがAndroid端末より明らかに帯域あるので、純粋にホスト側のハードウェア&スタック差。プロトコル側の優位ではない。

印刷結果

印刷中のSprocket 200。前面のLEDストリップが点灯している(RES_ALLOCで指定したUserColor RGB値が反映される)

転送完了後、プリンタがZINK現像→排紙を始める。
紙そのものの吐き出し速度はサーマル処理側で律速されるので、紙が出てくる時間自体は変わらない。体感で変わるのは「印刷ボタンを押してから紙が出始めるまで」の待ち時間。

Canon iNSPiC用紙に印刷した結果。Sprocket 200本体と比較

印刷品質は前回のBLE版と同じ(同じ画像、同じ紙、同じプリンタ)。色味は他社製紙のためややくすむが許容範囲。

印刷結果のクローズアップ

切り分け実験

最初の試行で印刷が詰まったとき、3つの修正を同時に入れて回復させたが、どれが効いたのか不明だった。電源リセット後に3パターン試して切り分けた。

テスト内容転送時間印刷
1チャンネル即閉じ(COMPLETE後にmonitor loop削除)5.39秒
2テスト1 + LIST_JOBS_REQ/RD_JOB_PROP_REQ 省略5.51秒
3テスト2 + WR_JOB_PROP_REQ の色を白(ff ff ff)に5.44秒

3つとも問題なく印刷できた。

切り分け実験の印刷結果。最初の成功分と合わせて同じ画像が4枚出てきた

つまり最初に「3つの修正で直った」と思っていたものは、実際は どれも必須ではなかった。olieのwikiが「Possibly not required」と書いてた通り。

ではなぜ最初の試行は詰まったのかというと、その前に RFCOMMチャンネル1(誤ったほう)に対して何度もHP+フレームを投げて暴れていた残留状態がプリンタファーム側に残っていて、Ch.2に切り替えた直後の試行も巻き込まれていたという仮説が一番自然。電源リセット後はCh.2で何を投げても綺麗に通る。

教訓:

  • 不審な状態に陥ったらまずプリンタを電源リセット(ハードリセット)。プロトコルを疑う前に物理側の状態をクリアする
  • RFCOMMは最初に正しいチャンネルを掴むこと。Ch.1は罠
  • olieのwikiが「Possibly not required」と書いてるステップは、Ch.2でクリーンな状態から始める限り本当にnot required

実装が終わったのでolie.xdevに数値付きで返信する。
向こうのwikiで救われた分、こっちは以下をフィードバックする:

  • macOS実装の転送速度実測値(Android比約5倍)
  • RFCOMMチャンネル2(SPP)が正解という確認
  • 「Possibly not required」ステップを実機で省略して通った確認(macOS、Ch.2、リセット直後の状態で)
  • macOSのPython.appがBluetooth権限なしで沈黙する話
  • 詰まったらハードリセットで復旧する流れ

Sprocket 200 Bluetooth Protocol — openZinkBooth wiki は本当に保存版。Sprocketで遊びたい人は必読。