Decoding a Bluetooth Label Printer Protocol from btsnoop
Most Bluetooth label printers costing a few thousand baht come with only a mobile app. There is no way to print from my own inventory system, even though the device is just an ordinary thermal printer. The remaining option is to listen to what my phone says to my printer, then reproduce it correctly.
I have a Bluetooth label printer at my shop that I bought for printing product price labels. It works well when printing one label at a time from the mobile app, but when I wanted to connect it to the inventory system I wrote, to print hundreds of labels at once from a purchase order, I hit a wall — no SDK, no documentation, not even a manual saying which protocol it uses.
The device is an ordinary thermal printer with a Bluetooth module, nothing special. So I took the most direct approach: listen to what my phone sends to my printer when I press print, then reproduce it correctly.
Everything in this article was done with my own equipment — my phone, my printer, and labels I bought myself. I captured my own Bluetooth traffic on my own device to learn the protocol and connect it to a system I wrote for my own use. I did not touch the manufacturer's software, take apart anyone else's equipment, or do this for resale. If you follow along, use only your own devices.
Enable HCI snoop logging on the phone
Android has built-in support for recording Bluetooth traffic at the HCI level. It is in Developer options under "Enable Bluetooth HCI snoop log". Once enabled, the system writes every packet passing between the Bluetooth stack and the Bluetooth chip to a file.
- Open Developer options and turn on Enable Bluetooth HCI snoop log
- Turn Bluetooth off and back on so the stack starts writing a new file (without this step, the log is often empty)
- Open the printer app and print 2 short labels with slightly different content
- Turn off HCI snoop logging as soon as printing finishes, otherwise the file will grow and include unrelated traffic
- Copy the file to the computer, then start dissecting it
The log file's location varies by manufacturer. A method that works across devices is to retrieve it through a bugreport, which already includes the snoop log:
adb bugreport bugreport.zip
unzip -o bugreport.zip 'FS/data/misc/bluetooth/logs/*'
# ได้ btsnoop_hci.log หรือไฟล์ .cfa มา
ls -la FS/data/misc/bluetooth/logs/
Mine came to 76,818 bytes from printing two labels. That sounds small, but it contained everything I needed to know.
btsnoop file structure: a 16-byte header followed by records
The btsnoop file format is very straightforward, and the important point is that all numbers are big-endian, unlike the contents of Bluetooth packets, which are almost entirely little-endian. This is an easy mistake to make.
The 16-byte file header consists of the 8-byte magic btsnoop\0 + a 4-byte version + a 4-byte datalink type (Android provides 1002 = HCI UART). This is followed by consecutive records, each with a 24-byte header and then the actual data of the specified length.
Only two fields matter here: incl_len (the length of the data in this record) and flags, which indicates direction:
flags bit 0 -> 0 = มือถือส่งออก (sent) 1 = เครื่องพิมพ์ตอบกลับ (received)
flags bit 1 -> 0 = data 1 = command/event
Peel away the layers: HCI -> ACL -> L2CAP -> RFCOMM
The first byte of a record's data is the HCI packet type. I only care about 0x02, which is ACL data. The rest (command 0x01, event 0x04) handles pairing/link management and can all be skipped.
The RFCOMM layer took me the longest. There are two details that, if missed, leave the data off by one byte throughout the file:
First, the length field has two forms. If the lowest bit of the length byte is 1, it is the short form, and the actual length = that byte shifted right by 1 bit. If it is 0, it is the long form: the next byte must be read and combined into a 15-bit value. Almost all frames carrying image data use the long form.
Second, the credit byte. UIH frames with the P/F bit set have an extra flow-control byte inserted before the actual data. Without skipping it, the data is shifted by one byte, and there is also a one-byte FCS at the end of the frame that must be stripped.
The actual parser
This is all the code I actually used. It needs no libraries beyond struct and sys:
#!/usr/bin/env python3
"""Quick btsnoop parser - dump RFCOMM payload bytes sent host->peer."""
import struct, sys, os
PATH = sys.argv[1]
with open(PATH, "rb") as f:
hdr = f.read(16)
assert hdr[:8] == b"btsnoop\x00", f"bad magic: {hdr[:8]!r}"
print(f"# version={int.from_bytes(hdr[8:12],'big')} "
f"datalink={int.from_bytes(hdr[12:16],'big')}")
out_chunks, in_chunks = [], []
idx = 0
while True:
rec_hdr = f.read(24)
if len(rec_hdr) < 24:
break
orig_len, incl_len, flags, drops = struct.unpack(">IIII", rec_hdr[:16])
ts = struct.unpack(">Q", rec_hdr[16:24])[0]
data = f.read(incl_len)
if len(data) < incl_len:
break
idx += 1
direction_recv = bool(flags & 0x01)
# เอาเฉพาะ ACL data (HCI type 0x02)
if not data or data[0] != 0x02:
continue
rest = data[1:]
if len(rest) < 4:
continue
# ACL header: handle+flags(2) + length(2) - little-endian
acl_len = int.from_bytes(rest[2:4], "little")
l2cap = rest[4:4 + acl_len]
if len(l2cap) < 4:
continue
# L2CAP: length(2) + cid(2). cid < 0x40 = signaling, ข้าม
l2_len = int.from_bytes(l2cap[0:2], "little")
cid = int.from_bytes(l2cap[2:4], "little")
if cid < 0x40:
continue
l2_payload = l2cap[4:4 + l2_len]
if len(l2_payload) < 4:
continue
# RFCOMM: addr(1) ctrl(1) len(1 หรือ 2) [credit(1)] data fcs(1)
addr, ctrl, ln1 = l2_payload[0], l2_payload[1], l2_payload[2]
if ln1 & 0x01: # length แบบสั้น
rfcomm_len = ln1 >> 1
data_start = 3
else: # length แบบยาว 15 บิต
if len(l2_payload) < 5:
continue
rfcomm_len = (ln1 >> 1) | (l2_payload[3] << 7)
data_start = 4
# UIH ที่มีบิต P/F ติด -> มี credit byte แทรกอยู่หนึ่งตัว
if (ctrl & 0xEF) == 0xEF and (ctrl & 0x10):
data_start += 1
rfcomm_data = l2_payload[data_start:data_start + rfcomm_len]
if not rfcomm_data:
continue
dlci = (addr >> 2) & 0x3F # DLCI 0 = ช่องควบคุม ข้าม
if dlci == 0:
continue
(in_chunks if direction_recv else out_chunks).append(
(idx, ts, dlci, rfcomm_data))
print(f"# SENT chunks: {len(out_chunks)}, RECV chunks: {len(in_chunks)}")
# ข้อความควบคุมสั้นๆ ที่แทรกอยู่ระหว่างก้อนรูป = คำสั่งที่น่าสนใจที่สุด
print("\n=== HOST -> PRINTER (control msgs < 50 bytes) ===")
for i, ts, dlci, d in out_chunks:
if len(d) < 50:
print(f"#{i:4d} dlci=0x{dlci:02x} len={len(d):3d}: {d.hex(' ')}")
all_sent = b"".join(d for _, _, _, d in out_chunks)
with open(PATH + ".sent.bin", "wb") as f2:
f2.write(all_sent)
print(f"\n# total sent host->peer: {len(all_sent)} bytes")
The most useful trick, I think, is the line that filters out "messages shorter than 50 bytes" for separate inspection. Image data chunks are thousands of bytes long, while actual commands are only 2-8 bytes. Once separated, the structure becomes visible immediately.
Running it gives this:
# version=1 datalink=1002
# SENT chunks: 74, RECV chunks: 159
# total sent host->peer: 19285 bytes
Those 19,285 bytes are the entire payload from printing 2 labels. The remaining 57 KB consists of handshakes, credits, ACKs, and status responses, which can be ignored.
Compare with open-source libraries until the commands make sense
Having a pile of bytes still did not mean I could read them. The next step was to compare them with work others had already done. I started with two projects whose source code is fully available: niimprint (Python, MIT) and NiimBlueLib (TypeScript). Both support the NIIMBOT family of label printers.
The niimprint frame format is very clear, with magic bytes at both ends and an XOR checksum:
class NiimbotPacket:
@classmethod
def from_bytes(cls, pkt):
assert pkt[:2] == b"\x55\x55"
assert pkt[-2:] == b"\xaa\xaa"
type_ = pkt[2]
len_ = pkt[3]
data = pkt[4 : 4 + len_]
checksum = type_ ^ len_
for i in data:
checksum ^= i
assert checksum == pkt[-3]
return cls(type_, data)
def to_bytes(self):
checksum = self.type ^ len(self.data)
for i in self.data:
checksum ^= i
return bytes((0x55, 0x55, self.type, len(self.data),
*self.data, checksum, 0xAA, 0xAA))
So I searched the capture file for 55 55 and aa aa — not a single match. That meant my printer was not in this family. This was the first lesson: Bluetooth label printers on the market all look alike, but they can use completely different protocols internally.
Looking instead at the first few captured bytes:
1f 11 38 1f 11 13 1f 11 12 1f 11 07 1f 11 09 1f 11 11 ...
1b 40
1d 76 30 00 30 00 c8 00 00 00 00 ...
1b 40 is ESC @ = reset the printer, while 1d 76 30 is GS v 0 = print a raster bitmap. Both are standard ESC/POS commands that have been publicly documented for thirty years. My printer speaks ESC/POS, not some secret protocol.
Its own custom frames
Wrapped in 55 55 ... aa aa, with type + length + XOR checksum. It requires a dedicated library such as niimprint / NiimBlueLib to communicate. Send arbitrary bytes and the printer stays silent.
Open standard commands
Send raw bytes directly over RFCOMM, with no enclosing frame or checksum. Documentation is readily available, and the whole implementation fits in one file.
The series of 1f 11 XX commands at the beginning are chip vendor-specific commands for querying status (paper out, cover open, battery). I compared them with the timing of the printer's responses and concluded that I could remove the entire series — printing still worked, just without advance knowledge of its status, a tradeoff I was willing to make.
Decode the print command parameters
According to the GS v 0 specification, 1d 76 30 00 30 00 c8 00 breaks down as GS v 0 m xL xH yL yH, where xL/xH is the number of bytes per line (not dots), and yL/yH is the number of lines. Both are little-endian:
m = 0x00 โหมดปกติ ไม่ขยาย
x = 0x0030 = 48 ไบต์/บรรทัด -> 48 x 8 = 384 จุด
y = 0x00c8 = 200 บรรทัด
384 dots at a resolution of 8 dots per millimeter = 48 mm, which fits the 58 mm paper I use. The data size must be 48 x 200 = 9,600 bytes. I went back and measured the distance between two 1b 40 sequences in the file: 9,617 bytes — an extra 17 bytes, exactly the size of the command headers. That confirmed I was reading it correctly, not guessing.
Print directly from a script
Once I knew it was ESC/POS over RFCOMM, all that remained was to open a socket and send the bytes. No Bluetooth library is needed; standard Python on Linux supports this directly:
import socket, struct
from PIL import Image
WIDTH_DOTS = 384 # 58mm ที่ 8 dots/mm
WIDTH_BYTES = WIDTH_DOTS // 8 # = 48
def raster(img: Image.Image) -> bytes:
"""แปลงรูปเป็นคำสั่ง GS v 0 ตามที่ดักมาได้"""
img = img.convert("L").resize(
(WIDTH_DOTS, int(img.height * WIDTH_DOTS / img.width)))
img = img.point(lambda p: 0 if p < 128 else 255, "1")
h = img.height
buf = bytearray()
px = img.load()
for y in range(h):
for xb in range(WIDTH_BYTES):
b = 0
for bit in range(8):
# ESC/POS: บิต 1 = จุดดำ ส่วน PIL mode "1": 0 = ดำ
if px[xb * 8 + bit, y] == 0:
b |= 0x80 >> bit
buf.append(b)
header = b"\x1d\x76\x30\x00" + struct.pack("<HH", WIDTH_BYTES, h)
return header + bytes(buf)
def print_label(mac: str, img_path: str, channel: int = 1):
sock = socket.socket(socket.AF_BLUETOOTH,
socket.SOCK_STREAM,
socket.BTPROTO_RFCOMM)
sock.connect((mac, channel))
try:
sock.send(b"\x1b\x40") # ESC @ - รีเซ็ต
sock.send(raster(Image.open(img_path))) # GS v 0 - บิตแมป
sock.send(b"\x1b\x64\x03") # ESC d 3 - เลื่อนกระดาษ
finally:
sock.close()
print_label("AA:BB:CC:11:22:33", "label.png")
The first attempt produced black lines across the whole label because I had inverted the bits incorrectly. ESC/POS treats a 1 bit as a black dot, while PIL mode "1" treats 0 as black — exactly the opposite. On the second attempt, I got a readable label.
Do not send a very large block of data all at once. The printer's buffer is much smaller than you might expect. Mine started producing labels with missing beginnings and ends when I sent more than about 4 KB at a time. Splitting the data into 1-2 KB chunks with a 20-30 millisecond delay solved it completely. Always include a paper feed command at the end of the job, too. Otherwise, the last line stays under the print head, out of sight, making it look as though it was not printed.
A repeatable sequence of steps
I have used this method with several other Bluetooth devices. The sequence is always the same: enable snoop logging on my own phone -> perform the action I want to understand once, as briefly as possible -> stop logging -> strip the layers with a parser -> separate short messages from long ones -> compare them with open-source libraries and public specifications -> confirm by checking that the data sizes match.
Personally, I think the last step is the most important. It is too easy to guess, "This is probably that command." But when I take 48 x 200 = 9,600 and measure the actual distance in the file, and it matches, that means I really understand it, rather than getting lucky.
If you have a label printer sitting unused because you cannot connect it to your own system, feel free to work through these steps. It might already speak ESC/POS like mine, and the job could be done in 30 lines. If I have left anything out or made a typo, I will come back and fix it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first