EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / ดักโปรโตคอลเครื่องพิมพ์ฉลากบลูทูธจาก btsnoop แล้วสั่งพิมพ์เอง
Linux

ดักโปรโตคอลเครื่องพิมพ์ฉลากบลูทูธจาก btsnoop แล้วสั่งพิมพ์เอง

โดย Ton-Or ·25 ส.ค. 2026·อ่าน 9 นาที
ดักโปรโตคอลเครื่องพิมพ์ฉลากบลูทูธจาก btsnoop แล้วสั่งพิมพ์เอง

เครื่องพิมพ์ฉลากบลูทูธราคาหลักพันส่วนใหญ่มาพร้อมแอปมือถืออย่างเดียว จะสั่งพิมพ์จากระบบสต๊อกของตัวเองก็ไม่มีทาง ทั้งที่ตัวเครื่องเป็นแค่ thermal printer ธรรมดา ทางที่เหลือคือฟังว่ามือถือของเราคุยอะไรกับเครื่องของเราบ้าง แล้วพูดตามให้ถูก

พอดีที่ร้านผมมีเครื่องพิมพ์ฉลากบลูทูธอยู่ตัวหนึ่ง ซื้อมาแปะราคาสินค้า ใช้งานได้ดีถ้าพิมพ์ทีละใบจากแอปบนมือถือ แต่พอจะเอาไปต่อกับระบบสต๊อกที่ผมเขียนเอง เพื่อยิงฉลากทีเดียวเป็นร้อยใบตามใบสั่งซื้อ ก็จบเลยครับ — ไม่มี SDK ไม่มีเอกสาร ไม่มีแม้แต่คู่มือบอกว่าใช้โปรโตคอลอะไร

ตัวเครื่องมันคือ thermal printer ธรรมดาที่มีโมดูลบลูทูธ ไม่ได้มีอะไรวิเศษ ผมเลยเลือกวิธีที่ตรงที่สุด คือฟังว่ามือถือของผมส่งอะไรไปให้เครื่องพิมพ์ของผมตอนกดพิมพ์ แล้วพูดตามให้ถูก

ขอบเขตของบทความนี้

ทุกอย่างในบทความทำกับอุปกรณ์ของผมเอง — มือถือของผม เครื่องพิมพ์ของผม ฉลากที่ผมซื้อมาเอง สิ่งที่ดักคือทราฟฟิกบลูทูธของตัวเองบนเครื่องตัวเอง เพื่อเรียนรู้โปรโตคอลและต่อเข้ากับระบบที่ผมเขียนใช้เอง ไม่ได้ยุ่งกับซอฟต์แวร์ของผู้ผลิต ไม่ได้แกะของใคร และไม่ได้ทำเพื่อไปขายต่อ ใครจะทำตามก็ทำกับเครื่องของตัวเองเท่านั้นนะครับ

เปิด HCI snoop log บนมือถือ

Android มีความสามารถบันทึกทราฟฟิกบลูทูธระดับ HCI ติดมาในตัวอยู่แล้ว อยู่ใน Developer options ชื่อ "Enable Bluetooth HCI snoop log" เปิดแล้วระบบจะเขียนทุกแพ็กเก็ตที่วิ่งระหว่าง Bluetooth stack กับชิปบลูทูธลงไฟล์ให้

  1. เปิด Developer options แล้วสวิตช์ Enable Bluetooth HCI snoop log ให้ติด
  2. ปิด-เปิดบลูทูธหนึ่งรอบ ให้ stack เริ่มเขียนไฟล์ใหม่ (ไม่ทำขั้นนี้ log มักว่าง)
  3. เข้าแอปเครื่องพิมพ์ สั่งพิมพ์ฉลากสั้นๆ สัก 2 ใบ เนื้อหาต่างกันนิดเดียวพอ
  4. ปิดสวิตช์ HCI snoop log ทันทีที่พิมพ์เสร็จ ไม่งั้นไฟล์จะโตและมีทราฟฟิกอย่างอื่นปน
  5. ดึงไฟล์ออกมาที่เครื่องคอม แล้วค่อยมานั่งแกะ

ไฟล์ log อยู่คนละที่ในแต่ละยี่ห้อ วิธีที่ใช้ได้ทุกเครื่องคือดึงผ่าน bugreport ซึ่งเก็บ 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/

ของผมได้มา 76,818 ไบต์ จากการพิมพ์ฉลากสองใบ ฟังดูน้อย แต่ในนั้นมีทุกอย่างที่ต้องรู้ครับ

โครงไฟล์ btsnoop: header 16 ไบต์ แล้วต่อด้วย record ยาวเรื่อยๆ

รูปแบบไฟล์ btsnoop ตรงไปตรงมามาก และสำคัญคือ ทุกตัวเลขเป็น big-endian ซึ่งสวนทางกับข้างในแพ็กเก็ตบลูทูธที่เป็น little-endian เกือบทั้งหมด ตรงนี้พลาดกันบ่อย

ส่วนหัวไฟล์ 16 ไบต์: magic btsnoop\0 8 ไบต์ + version 4 ไบต์ + datalink type 4 ไบต์ (Android ให้มา 1002 = HCI UART) จากนั้นเป็น record ต่อกันไปเรื่อยๆ record ละ 24 ไบต์หัว แล้วตามด้วยข้อมูลจริงตามความยาวที่บอกไว้

ฟิลด์ที่ใช้จริงมีอยู่สองตัว คือ incl_len (ความยาวข้อมูลในเรคอร์ดนี้) กับ flags ซึ่งบอกทิศทาง:

flags bit 0  ->  0 = มือถือส่งออก (sent)   1 = เครื่องพิมพ์ตอบกลับ (received)
flags bit 1  ->  0 = data                  1 = command/event

ลอกออกทีละชั้น: HCI -> ACL -> L2CAP -> RFCOMM

ไบต์แรกของข้อมูลในเรคอร์ดคือ HCI packet type เราสนใจแค่ 0x02 คือ ACL data ที่เหลือ (command 0x01, event 0x04) เป็นเรื่องจับคู่/จัดการลิงก์ ข้ามได้หมด

ชั้นที่ต้องลอกออกก่อนถึงคำสั่งเครื่องพิมพ์btsnoop recordheader 24 ไบต์ big-endian->HCItype 0x02 = ACL->L2CAPlen + cid->RFCOMMaddr ctrl len [credit] fcs->payloadคำสั่งเครื่องพิมพ์

จุดที่กินเวลาผมนานสุดคือชั้น RFCOMM ครับ มันมีสองเรื่องที่ถ้าไม่รู้จะได้ไบต์เพี้ยนไปหนึ่งตัวตลอดทั้งไฟล์:

เรื่องแรก ฟิลด์ length มีสองแบบ ถ้าบิตล่างสุดของไบต์ length เป็น 1 แปลว่าเป็นแบบสั้น ความยาวจริง = ไบต์นั้นเลื่อนขวา 1 บิต แต่ถ้าเป็น 0 แปลว่าเป็นแบบยาว ต้องอ่านไบต์ถัดไปมาต่อเป็น 15 บิต ซึ่งเฟรมที่แบกข้อมูลรูปภาพจะเป็นแบบยาวเกือบทั้งหมด

เรื่องที่สอง credit byte เฟรม UIH ที่มีบิต P/F ติด จะมีไบต์ flow-control แทรกอยู่หน้าข้อมูลจริงอีกหนึ่งตัว ไม่ข้ามตัวนี้ข้อมูลจะเลื่อนไปหนึ่งไบต์ และท้ายเฟรมยังมี FCS อีกหนึ่งไบต์ที่ต้องตัดทิ้ง

เฟรม RFCOMM ในไฟล์ที่ดักมา ไล่จากซ้ายไปขวาaddr1 ไบต์ctrl1 ไบต์length1 หรือ 2 ไบต์credit1 ไบต์ ถ้ามีdataคำสั่งเครื่องพิมพ์fcs1 ไบต์ ตัดทิ้งlength มีสองแบบบิตล่างสุด = 1 สั้น ความยาว = ไบต์นั้นเลื่อนขวา 1 บิตบิตล่างสุด = 0 ยาว อ่านไบต์ถัดไปมาต่อเป็น 15 บิตcredit byte แทรกอยู่หนึ่งตัวเฉพาะเฟรม UIH ที่บิต P/F ติดไม่ข้ามตัวนี้ ข้อมูลเลื่อนไปหนึ่งไบต์
สองจุดที่ทำให้ไบต์เพี้ยนไปหนึ่งตัวตลอดไฟล์ คือ length ที่มีทั้งแบบสั้นและแบบยาว กับ credit byte ที่โผล่มาเฉพาะเฟรม UIH ที่ติดบิต P/F

parser ตัวจริง

โค้ดทั้งหมดที่ผมใช้จริงมีเท่านี้ ไม่ต้องพึ่งไลบรารีอะไรเลย มีแค่ struct กับ 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")

เคล็ดที่ผมว่าคุ้มที่สุดคือบรรทัดที่กรอง "ข้อความสั้นกว่า 50 ไบต์" ออกมาดูต่างหากครับ เพราะก้อนข้อมูลรูปภาพจะยาวเป็นพันไบต์ ส่วนคำสั่งจริงจะสั้นแค่ 2-8 ไบต์ พอแยกออกมาแล้วโครงสร้างมันโผล่มาให้เห็นเลย

รันแล้วได้แบบนี้:

# version=1 datalink=1002
# SENT chunks: 74, RECV chunks: 159
# total sent host->peer: 19285 bytes
76.8 KBไฟล์ btsnoop
559record ทั้งหมด
233ACL data
19,285 Bส่งไปเครื่องพิมพ์

ตัวเลข 19,285 ไบต์นี้คือเนื้อจริงทั้งหมดจากการพิมพ์ 2 ใบ ที่เหลืออีก 57 KB คือ handshake, credit, ACK และการตอบสถานะ ซึ่งไม่ต้องสนใจ

เทียบกับไลบรารีโอเพนซอร์สจนอ่านคำสั่งออก

ได้กองไบต์มาแล้วก็ยังอ่านไม่ออกอยู่ดี ขั้นต่อไปคือเอาไปเทียบกับงานที่คนอื่นทำไว้แล้ว ผมเริ่มจากสองโครงการที่เปิดซอร์สไว้ครบ คือ niimprint (Python, MIT) และ NiimBlueLib (TypeScript) ทั้งคู่ทำเครื่องพิมพ์ฉลากตระกูล NIIMBOT

ฝั่ง niimprint เฟรมของเขาชัดเจนมาก มี magic หัวท้ายและ checksum แบบ XOR:

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

ผมเลยไปไล่หา 55 55 กับ aa aa ในไฟล์ที่ดักมา — ไม่เจอสักตัวเดียว แปลว่าเครื่องของผมไม่ได้อยู่ในตระกูลนี้ ตรงนี้เป็นบทเรียนข้อแรกครับ: เครื่องพิมพ์ฉลากบลูทูธในตลาดหน้าตาเหมือนกันหมด แต่ข้างในเป็นคนละโปรโตคอลกันได้เลย

พอดูไบต์แรกๆ ที่ดักมาแทน:

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 คือ ESC @ = รีเซ็ตเครื่องพิมพ์ ส่วน 1d 76 30 คือ GS v 0 = พิมพ์บิตแมปแบบ raster สองอันนี้เป็นคำสั่งมาตรฐาน ESC/POS ที่มีเอกสารสาธารณะมาสามสิบปีแล้ว เครื่องของผมพูด ESC/POS ครับ ไม่ใช่โปรโตคอลลับอะไรเลย

ตระกูล NIIMBOT

เฟรมเฉพาะของตัวเอง

ห่อด้วย 55 55 ... aa aa มี type + length + XOR checksum ต้องใช้ไลบรารีเฉพาะทางอย่าง niimprint / NiimBlueLib คุยด้วย ส่งไบต์มั่วไปเครื่องเงียบสนิท

ตระกูล ESC/POS

คำสั่งมาตรฐานเปิด

ยิงไบต์ดิบเข้า RFCOMM ได้ตรงๆ ไม่มีเฟรมครอบ ไม่มี checksum เอกสารหาอ่านได้ทั่วไป เขียนเองได้ในไฟล์เดียวจบ

ส่วน 1f 11 XX ที่โผล่มาเป็นชุดตอนต้น เป็นคำสั่งเฉพาะของผู้ผลิตชิป ใช้ถามสถานะ (กระดาษหมด ฝาเปิด แบตเตอรี่) ผมเทียบกับจังหวะที่เครื่องตอบกลับมาแล้วสรุปว่าตัดออกได้ทั้งชุด — พิมพ์ได้เหมือนเดิม แค่ไม่รู้สถานะล่วงหน้า ซึ่งผมยอมแลก

ถอดพารามิเตอร์ของคำสั่งพิมพ์

1d 76 30 00 30 00 c8 00 แกะตามสเปก GS v 0 คือ GS v 0 m xL xH yL yH โดย xL/xH คือจำนวนไบต์ต่อบรรทัด (ไม่ใช่จำนวนจุด) และ yL/yH คือจำนวนบรรทัด ทั้งคู่เป็น little-endian:

m  = 0x00        โหมดปกติ ไม่ขยาย
x  = 0x0030 = 48 ไบต์/บรรทัด  ->  48 x 8 = 384 จุด
y  = 0x00c8 = 200 บรรทัด

384 จุดที่ความละเอียด 8 จุดต่อมิลลิเมตร = 48 มม. ตรงกับกระดาษ 58 มม. ที่ผมใช้อยู่พอดี ส่วนขนาดข้อมูลต้องเป็น 48 x 200 = 9,600 ไบต์ ผมกลับไปวัดระยะระหว่าง 1b 40 สองตัวในไฟล์ ได้ 9,617 ไบต์ — เกินมา 17 ไบต์ซึ่งคือหัวคำสั่งพอดี ตรงนี้แหละครับที่ยืนยันว่าอ่านถูกแล้ว ไม่ได้เดา

สั่งพิมพ์เองจากสคริปต์

พอรู้ว่าเป็น ESC/POS บน RFCOMM ที่เหลือคือเปิดซ็อกเก็ตแล้วยิงไบต์ ไม่ต้องมีไลบรารีบลูทูธอะไรเลย Python มาตรฐานบน Linux ทำได้ในตัว:

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

ยิงรอบแรกออกมาเป็นเส้นดำเต็มใบครับ เพราะผมกลับบิตผิด ESC/POS นับบิต 1 เป็นจุดดำ ส่วน PIL โหมด "1" นับ 0 เป็นดำ กลับด้านกันพอดี รอบที่สองก็ได้ฉลากที่อ่านออกแล้ว

ข้อควรระวังตอนยิงจริง

อย่ายิงข้อมูลรวดเดียวเป็นก้อนใหญ่มาก บัฟเฟอร์ของเครื่องพิมพ์เล็กกว่าที่คิดเยอะ ของผมพอเกินราวๆ 4 KB ต่อครั้งจะได้ฉลากที่หัวขาดท้ายขาด แก้ด้วยการหั่นส่งทีละ 1-2 KB แล้วหน่วง 20-30 มิลลิวินาที ก็หายสนิท ส่วนคำสั่งเลื่อนกระดาษท้ายงานต้องมีเสมอ ไม่งั้นบรรทัดสุดท้ายจะยังค้างอยู่ใต้หัวพิมพ์ มองไม่เห็น แล้วจะนึกว่าพิมพ์ตกไป

สรุปเป็นขั้นตอนที่ใช้ซ้ำได้

วิธีนี้ผมเอาไปใช้กับอุปกรณ์บลูทูธตัวอื่นได้อีกหลายตัว ลำดับเดิมทุกครั้ง: เปิด snoop log บนมือถือตัวเอง -> ทำสิ่งที่อยากรู้หนึ่งครั้ง สั้นที่สุดเท่าที่ทำได้ -> ปิด log -> ลอกชั้นด้วย parser -> แยกข้อความสั้นออกจากข้อความยาว -> เอาไปเทียบกับไลบรารีโอเพนซอร์สและสเปกสาธารณะ -> ยืนยันด้วยการนับขนาดข้อมูลให้ตรง

ส่วนตัวผมคิดว่าขั้นที่สำคัญที่สุดคือขั้นสุดท้ายครับ เพราะการเดาว่า "น่าจะเป็นคำสั่งนี้" มันง่ายเกินไป แต่พอเอาตัวเลข 48 x 200 = 9,600 ไปวัดระยะจริงในไฟล์แล้วมันตรง นั่นแปลว่าเข้าใจถูกจริง ไม่ใช่บังเอิญ

ใครมีเครื่องพิมพ์ฉลากค้างอยู่เพราะต่อเข้าระบบตัวเองไม่ได้ ลองไล่ดูตามนี้ได้ตามสะดวกครับ เผลอๆ เครื่องพูด ESC/POS อยู่แล้วแบบของผม งานจบใน 30 บรรทัด ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย