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 GATT | RFCOMM |
|---|---|---|
| 物理層 | LE | Classic |
| ペアリング | LE Secure / Just Works | Just 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 |
| ACK | ackPeriodで間引き可 | ストリーム、チャンクごとに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個以上が判明している。
今回新たに使うのは以下。
| コード | 名前 | 用途 |
|---|---|---|
0x18 | SET_TIME_REQ | 現在時刻同期 |
0x1A | RD_SYS_CFG_REQ | UserColor等の設定読み取り |
0x24 | CONN_SETUP_REQ | 最大メッセージサイズ交渉 |
0x25 | CONN_SETUP_RSP | 同 応答 |
0x2C | LIST_JOBS_REQ | 現在のジョブID取得 |
0x2E | RD_JOB_PROP_REQ | ジョブ属性読み取り |
0x30 | WR_JOB_PROP_REQ | ジョブにLED色とtimestampを設定 |
0x36 | RES_ALLOC_REQ | 印刷リソース確保(UserColor RGB付き) |
macOSでのRFCOMM実装
bleak(BLE)と違って、PythonのmacOSからRFCOMMを叩く標準的なライブラリは存在しない。
PyObjC経由で IOBluetoothDevice と IOBluetoothRFCOMMChannel を直接呼ぶ形になる。
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 GATT | RFCOMM (SPP Ch.2) | - |
| 画像サイズ | 146,002B | 139,876B | (近似比較) |
| チャンクサイズ | 500B | 998B | 約2倍 |
| チャンク数 | 293 | 141 | 約半分 |
| 転送時間 | 約60秒 | 5.38秒 | 約 1/11 |
| スループット | 約2.4 KB/s | 25.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端末より明らかに帯域あるので、純粋にホスト側のハードウェア&スタック差。プロトコル側の優位ではない。
印刷結果

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

印刷品質は前回の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つとも問題なく印刷できた。

つまり最初に「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で遊びたい人は必読。