Tech 11 min read

HP Sprocket 200 over RFCOMM on M1 Mac: 11× faster than BLE in 5.38s for 140KB

IkesanContents

In the previous post I got an HP Sprocket 200 printing from a Mac over BLE, but the transfer was slow.
About 60 seconds to push a 143 KB JPEG in 500-byte chunks × 293 round trips.
For a tiny 2x3-inch ZINK print, that’s nearly a minute of dead air — for the photo-booth use case I’m building, the queue tempo dies.

Then olie.xdev — a German Android developer — emailed me. He’d read the BLE post while building openZinkBooth, an open-source photo-booth app for the Sprocket, and pointed me at his wiki Sprocket 200 Bluetooth Protocol, which fully reverse-engineers the RFCOMM framing.
His measurements: ~5.4 KB/s, ~26 seconds for a 144 KB JPEG, 148 chunks.
Roughly twice as fast as my BLE implementation (~2.4 KB/s, 60s, 293 chunks).

This entire experiment exists because olie.xdev published a thorough wiki and emailed me about it. My previous post identified maybe 10 HPLPP command codes; the wiki covers the complete RFCOMM framing, 27+ commands, and the print flow. I built the macOS implementation with zero additional reverse-engineering effort on my side. Thanks first.

The photo-booth I’m building is a “capture → local AI processing → print” loop. The real bottleneck is local inference on M1 Max, but if the print stage drops from 60 seconds to half of that, the user experience changes a lot.
So I wrote a macOS RFCOMM client in Python and measured the actual transfer time.

What we’re measuring

First, let’s draw a line.
The paper ejection speed itself is governed by the ZINK pipeline, and changing the transport doesn’t move it. What changes is the time to get image data from the host into the printer (the transfer time).

Host:    [connect] [image send  ← this changes] [wait COMPLETE]
Printer:                                         [ZINK develop & eject ← unchanged]

The measurement target is from the first FILE_WRITE_REQ until the FILE_WRITE_RSP whose status byte is 0x02 (COMPLETE).
In UX terms, this shortens “time between hitting print and paper starting to come out.”

What’s different between BLE and RFCOMM

On the Sprocket 200, BLE and BT Classic share the same command layer (Sprocket Protocol), but the framing is entirely separate.
Summary from olie.xdev’s wiki:

ItemBLE GATTRFCOMM
PHYLEClassic
PairingLE Secure / Just WorksJust Works (no PIN)
Servicecustom UUID (...946209)SPP (0x1101) + custom Ch.1
Per-packet cap244B (Android-safe), Mac CoreBluetooth handles up to ~500Bno app-layer MTU; printer advertises 1000B via CONN_SETUP_RSP
Per-chunk payload~242B (244B - 1B BLE header)~998B (1000B - 1B cmd - 1B handle)
Framing1B header with seq/last_flag"HP+" magic + flag + 1or2B length
Chunks for 144KB image~600 (olie measured) / 293 (mine on BLE, 500B chunks)~148
ACKthinned by ackPeriodstreaming, FILE_WRITE_RSP per chunk

So RFCOMM’s edge is “~4× more data per round trip” compared with BLE.
A 2× chunk size also halves the RTT impact, so theoretically the speedup is greater than 2× (chunk count drops more than linearly).

Large-frame format

When a payload exceeds 255 bytes, RFCOMM uses a “large frame” with a 2-byte (LE) length field.
Image transfers require this.

Small frame (≤255B):
[48 50 2B] [01] [LL]     [CMD] [payload...]
 "HP+"      flag  1B len   cmd

Large frame (>255B, used for image transfer):
[48 50 2B] [02] [LL HH]  [CMD] [payload...]
 "HP+"      flag  2B len LE  cmd

"HP+" (0x48 0x50 0x2B) is a magic prefix on every frame.
Responses use the same format, but the leading 0x48 arrives by itself and the rest follows shortly, so the wiki recommends read(1) followed by available() to capture a full frame.

RFCOMM session setup sequence

Unlike BLE, RFCOMM needs a few more handshake steps.
olie.xdev captured the official app’s traffic and documented this order:

graph TD
    A[RFCOMM Ch.2 SPP connect<br/>MTU 1006] --> B[IF_CONFIG_REQ<br/>interface=0x02]
    B --> C[IF_CONFIG_RSP<br/>host MAC returned]
    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/>read 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 color + timestamp]
    N --> O[FILE_WRITE_REQ × N<br/>998B chunks]
    O --> P[print starts]

The wiki notes: “LIST_JOBS_REQ / RD_JOB_PROP_REQ / SET_TIME_REQ / RD_SYS_CFG_REQ / RES_ALLOC_REQ are Possibly not required — the official app does them but unconfirmed.”
My implementation first followed the official order to confirm everything works, then the isolation experiment (later in this post) confirmed that skipping them works too. olie’s hunch was right.

Command codes used

The previous BLE post identified about 10 command codes; the wiki documents 27+. The new ones for this implementation:

CodeNameUse
0x18SET_TIME_REQsync current time
0x1ARD_SYS_CFG_REQread settings (e.g. UserColor)
0x24CONN_SETUP_REQnegotiate max message size
0x25CONN_SETUP_RSPresponse
0x2CLIST_JOBS_REQget current job ID
0x2ERD_JOB_PROP_REQread job properties
0x30WR_JOB_PROP_REQset LED color and timestamp on job
0x36RES_ALLOC_REQreserve print resources (with UserColor RGB)

macOS RFCOMM implementation

Unlike bleak (BLE), Python has no standard library for talking RFCOMM on macOS.
The path is PyObjC and calling IOBluetoothDevice / IOBluetoothRFCOMMChannel directly.
I installed pyobjc-framework-IOBluetooth in a venv:

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

Connection and MTU check

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+ protocol)
)
# channel.getMTU() == 1006

The first argument None is the out-pointer for the channel; PyObjC returns the result as a tuple (IOReturn, IOBluetoothRFCOMMChannel).
Inbound data arrives via the rfcommChannelData_data_length_ delegate method asynchronously, so you maintain your own receive buffer and slice out HP+ frames on the host side.

Gotcha 1: RFCOMM channel number

In the previous BLE post I enumerated SDP and found two channels (1 and 2). The HP+ protocol lives on Channel 2 (SPP 0x1101), not Channel 1.
Channel 1 has a custom UUID (...DEAFDECACAFF) and emits a 6-byte beacon FF 55 02 00 EE 10 every ~1 second; it doesn’t respond to HP+ commands at all.
I started on Channel 1 first, got nothing but beacons, and lost half an hour wondering if the protocol was broken. The wiki documents the SPP UUID but not the channel number, so SDP enumeration is your only path.

HP+ frame encoding/decoding

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

Send synchronously with writeSync_length_(bytes, len).
Image chunks always need the large-frame form, so it’s easier to make encode_frame switch automatically based on length.

Gotcha 2: Bluetooth entitlement

On macOS, the Python.app shipped inside /Applications/Xcode.app lacks the Bluetooth usage entitlement, so IOBluetooth calls silently hang — no permission prompt ever appears.
Granting the terminal app (Ghostty / Terminal.app / etc.) Bluetooth permission in System Settings → Privacy & Security → Bluetooth unblocks it.
On the first run, touching Bluetooth from the terminal once (e.g. system_profiler SPBluetoothDataType) will surface the permission prompt.

Gotcha 3: Hard reset when the printer locks up

Several times during development, the printer ended up “stuck in PRINT_STATUS=7 (PRINTING) with PrintProgress=0, calibration sheet won’t eject.” A 2-3 second power-button hold did nothing.
The fix is the hardware reset button. HP’s official procedure: open the paper-tray cover, then press and hold the small round button near the paper tray with a paperclip until the charging LED turns off (about 3 seconds). With the cover open, the reset button is visible right next to the rectangular cover-detect switch on the inside.

Reset button location (the rectangular switch on the left is cover-detect; the small round button under it is hardware reset)

Pressing it fully restarts the printer and replays calibration (the blue smart sheet ejects automatically).
Custom name, UserColor, and other settings are preserved (this is a hardware reset, not a factory reset). HP documents the procedure, but it’s worth knowing when you’re soft-bricking the device with your own protocol implementation.

Measurement

Test image

test_640x1002.jpg
  resolution: 640x1002 (Sprocket 200 native size, per wiki)
  quality: JPEG quality=90
  size: 139,876 bytes

The previous BLE post used 668x1002 / 146,002 bytes; this run uses the wiki-documented native size of 640x1002.
Paper: Canon iNSPiC ZINK (calibrated with an HP-branded smart sheet). Printer starts from room temperature.

Measured results

ItemBLE (previous)RFCOMM (this post)Delta
TransportBLE GATTRFCOMM (SPP Ch.2)-
Image size146,002B139,876B(close enough)
Chunk size500B998B~2×
Chunk count293141~half
Transfer time~60s5.38s~1/11
Throughput~2.4 KB/s25.37 KB/s~10.6×

Measurement window: from the first FILE_WRITE_REQ sent, to the FILE_WRITE_RSP carrying status=0x02 (COMPLETE).

[+] 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

That’s ~5× faster than olie.xdev’s measurements on Android 16 (~26s / 5.4 KB/s).
Almost certainly host-side: the M1 Mac’s BT controller (BCM4378, PCIe-attached) and Apple’s BT stack have far more headroom than a phone’s. Not a protocol-level win.

Sprocket 200 mid-print. The front LED strip is lit with the UserColor RGB value set via RES_ALLOC

After the transfer completes, the printer runs ZINK development and ejects the paper.
The paper feed speed itself is governed by the thermal pipeline and doesn’t change — what changes is the wait between hitting print and the paper starting to emerge.

Final print on Canon iNSPiC paper, next to the Sprocket 200

Print quality matches the BLE run (same image, same paper, same printer). Colors are slightly muted because the paper isn’t HP-branded, but it’s well within usable range.

Close-up of the print

Isolation experiment

The first attempt that printed successfully bundled three fixes I’d applied at once, and I didn’t know which one mattered. After a power-cycle, I bisected with three runs:

TestChangeTransferPrint
1Close the channel immediately (drop the post-COMPLETE monitor loop)5.39s
2Test 1 + skip LIST_JOBS_REQ / RD_JOB_PROP_REQ5.51s
3Test 2 + force WR_JOB_PROP_REQ color to white (ff ff ff)5.44s

All three printed without issue.

Isolation experiment prints — the same image came out four times total, including the original successful run

So what I thought “three simultaneous fixes resolved it” turned out to be none of them mattered individually. olie’s “Possibly not required” was right all along.

So why did the first attempt hang? The most natural hypothesis is that before I switched to Channel 2, I’d thrown a bunch of HP+ frames at the wrong channel (Channel 1), leaving the printer’s firmware in a degraded state that the first Channel-2 attempt also got swept into. After the power cycle, anything on Channel 2 works cleanly.

Takeaways:

  • When the printer enters a suspicious state, hard-reset before suspecting the protocol. Clear physical-side state first.
  • Lock onto the correct RFCOMM channel from the very first connection. Channel 1 is a trap.
  • The wiki’s “Possibly not required” steps really aren’t required — as long as you start from a clean state on Channel 2.

With the implementation working, I’ll reply to olie.xdev with numbers.
The wiki saved me a lot of effort, so the things I can give back:

  • macOS transfer-speed measurements (~5× faster than Android)
  • Confirmation that RFCOMM Channel 2 (SPP) is the correct one
  • Verified on the device that the “Possibly not required” steps can be skipped (macOS, Ch.2, from a clean reset state)
  • Note that macOS Python.app silently fails without the Bluetooth entitlement
  • Hard reset as the recovery path when the protocol implementation soft-bricks the printer

Sprocket 200 Bluetooth Protocol — openZinkBooth wiki is a keeper. Mandatory reading for anyone hacking on the Sprocket.