ESP32 (NimBLE) sending raw bitmap to BLE print frames to MakeID L1 thermal printer (need help finding BLE payload encoding/compression/encryption)

Hi I'm Easton👋, this is my first post using my first new account. I'm trying to reproduce an Android printer app’s BLE print protocol from captured hci_snoop logs and want help with identifying the bitmap compression/encoding.

*P.S.: (excuse my use of github repo for attachment for new accounts cannot upload)

Problem Description

I have a Bluetooth LE thermal printer (firmware advertises service 0xABF0, write char 0xABF1, notify char 0xABF2). The official Android app successfully prints images. I captured the phone to printer BLE traffic (using hci_snoop_hci.log and Wireshark). The app splits each print job into 4 GATT Write frames. Each frame starts with 0x66 and contains a header plus compressed/raster payload. After each Write the printer sends a notification (on ABF2) which I believe is the ACK/ready-for-next-chunk signal.

When I replay the frames from an ESP32 (NimBLE), I found:

  1. If I send frames too fast the printer prints only the first chunk and then stops.
  2. If I send one frame then wait for the ABF2 notification, then send the next, printing completes successfully.
    I do not yet know the exact compression format the app uses. Tried PackBits / simple RLE and raw 1bpp tests — no match.

I want help with either:

  1. Identifying the exact compression/encoding algorithm (so I can implement it on ESP32), or
  2. Implementing a robust ESP32 client that reliably replays the exact captured frames in the correct order (waiting for notifications) and/or a compressor that matches the app.

Environment / hardware

  • Printer: Bluetooth LE thermal printer (address 58:8C:81:72:AB:0A, model id seen on advertisement L1C25E01553)
  • Phone used for captures: Samsung (A50 / SamsungE_72...)
  • Development board: NodeMCU-32S (ESP32)
  • Framework: PlatformIO (board: nodemcu-32s), NimBLE-Arduino v2.3.6
  • Serial monitor: 115200

What I’ve captured & tried

  1. Wireshark / hci_snoop captures

I captured five print jobs by using the original APK MakeID-Life on Google Play that sends bitmap to my terminal printer (same image but different mm heights): btsnoop_hci1.log, btsnoop_hci2.log, btsnoop_hci3.log, btsnoop_hci_all_black.log, btsnoop_hci_blank_white.log.

I have pulled the bitmap png files from my Samsung A50 phone from the app data folder and uploaded it to my Github Repo, deveaston06/makeid-l1-printer-sample.

Each job contains 4 main Write Commands to the printer (handle 0x002a), with these high-level patterns (each frame has the same format):

  • always starts with 0x66 (magic number),
  • 35 00 is how many bytes this frame contains (first low byte, then high byte, 53 is the frame length in this case)
  • 1b2f030100010001 could be the printer id
  • 3301 is the printing job id
  • 55 is another magic number (so far its same 55 for 3 first frames, and random seemingly on the last frame),
  • 0003 is how many parts of 4 part frames are still left after this (this case is 3 frames left)
  • 000200000000003d0300ff3fff280000352e000038f30803000020000000892c00 is the bitmap payload (unknown encoding and compression algo)
  • 110000 signifies the end the part frame and start of checkNum
  • 63 at the end is the checkNum (and function below in python is the algorithm reconstructed from the reference APK jadx dump).
def getCheckNum(bArr):
    """
    Exact Java checksum algorithm
    Calculates checksum on all bytes except the last one
    """
    b = 0
    for i in range(len(bArr) - 1):
        byte_val = bArr[i] & 0xFF
        b = (b - byte_val) & 0xFF
    return b
  1. Example frames (hex)

Example frame1 (taken from dump):

66 35 00 1b 2f 03 01 00 01 00 01 33 01 55 00 03
00 02 00 00 00 00 00 3d 03 00 ff 3f ff 28 00 00
35 2e 00 00 38 f3 08 03 00 00 20 00 00 00 89 2c
00 11 00 00 63
  1. Observations
    The second byte after 0x66 (e.g. 0x35 or 0x44) varies, seems to be the compressed payload length.

The sequence ... 0155 0003 / 0155 0002 / 0155 0001 / 0134 0000 appears in logs; I read this as a frame counter or stage marker.

Printer sends notifications on ABF2 after receiving frames. The notification payloads look like:

23 23 01 01 66 25 00 10 00 52 18 0F 00 00 ...

This appears to be an ACK/metadata packet.

I tried running simple decoders:

  • PackBits (Mac) → produced output but not a validate bitmap
  • Simple RLE ([count][value] & [value][count]) — outputs are large but not matching reference PNG
  • raw 1bpp interpretation for common widths → no good match
  1. Working ESP32 behavior
    Using NimBLE I can connect, discover services & characteristics, subscribe to ABF2 notifications.

I can find ABF1 (write) and ABF2 (notify).

Sending frame1 then waiting for ABF2 notify, then frame2 etc. works (prints successfully). But I want to generate frames on-the-fly from images rather than replay hex dumps.

What I need from the community

  1. Compression/Encoding identification help

Which compression algorithm does this look like? (RLE variant, PackBits, PackBits+bitpack, LZ? specifics please)
If you have a known implementation (Java or C/C++) that produces the exact byte patterns for 1bpp thermal images that match the bitstream format starting with 0x66 ..., please share or point to it.

  1. ESP32 (NimBLE) implementation help

Example code that reliably writes frames to the printer char ABF1 and waits for the ABF2 notification before sending the next chunk. (I have code but would appreciate robust production-ready code with proper locking/MTU handling.)

If you can adapt the compressor into a compact C++ function suitable for ESP32 (PlatformIO), that would be ideal. My full source code is in my Github Repo, deveaston06/makeid-l1-printer-sample.

  1. Binary analysis hints

If you can help inspect the Android app (I can pull APK) for the encoding routine, I can provide the APK. If you’ve done APK static analysis and can point to likely class/method names, that would save time. The APK i am using is MakeID on APKPure

Specific questions (answer these if you can)

  1. Is the 0x66 prefix a known vendor packet wrapper for bitmap frames for any printer SDKs? (Which SDK / models use it?)
  2. Which compression method best explains why a 44mm image has more bytes than a 45mm image?
  3. If you know the encoder code in Android (Java) that does this, can you point to the exact function/class? (My Reference APK is in the Github repo)
    Github Repo Link: deveaston06/makeid-l1-printer-sample
    Original APK: MakeID-Life on Google Play
    My Reference APK: MakeID-Life on APKPure

Can You think about splitting the project into smaller parts?

Suggest a simple code testing only the printer.

The next code could test only the communication part.

Thanks for the reply!

This code will replay the frames when i try to print the following bitmap from the official mobile app from Google Play called Make-ID Life

ble_printer_manager.cpp

NimBLERemoteService *pPrinterService = nullptr;
NimBLEClient *pClient = nullptr;
// globals
NimBLERemoteCharacteristic *pWriteChar;
NimBLERemoteCharacteristic *pNotifyChar;
volatile bool ackReceived = false;
std::vector<uint8_t> lastAck; // stores last notification bytes
int currentFrame = 0;
bool printingInProgress = false;
std::vector<uint8_t> frame1;
std::vector<uint8_t> frame2;
std::vector<uint8_t> frame3;
std::vector<uint8_t> frame4;

void setExampleBitmapFrame() {
  frame1 = {0x66, 0x35, 0x00, 0x1b, 0x2f, 0x03, 0x01, 0x00, 0x01, 0x00, 0x01,
            0x33, 0x01, 0x55, 0x00, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x3d, 0x03, 0x00, 0xff, 0x3f, 0xff, 0x28, 0x00, 0x00, 0x35,
            0x2e, 0x00, 0x00, 0x38, 0xf3, 0x08, 0x03, 0x00, 0x00, 0x20, 0x00,
            0x00, 0x00, 0x89, 0x2c, 0x00, 0x11, 0x00, 0x00, 0x63};
  frame2 = {0x66, 0x2f, 0x00, 0x1b, 0x2f, 0x03, 0x01, 0x00, 0x01, 0x00,
            0x01, 0x33, 0x01, 0x55, 0x00, 0x02, 0x00, 0x02, 0x00, 0x38,
            0x00, 0x00, 0x00, 0x80, 0x00, 0x02, 0x03, 0x00, 0x00, 0x38,
            0x00, 0xc3, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
            0xc5, 0x2c, 0x00, 0x11, 0x00, 0x00, 0xb1};
  frame3 = {0x66, 0x2f, 0x00, 0x1b, 0x2f, 0x03, 0x01, 0x00, 0x01, 0x00,
            0x01, 0x33, 0x01, 0x55, 0x00, 0x01, 0x00, 0x02, 0x00, 0x38,
            0x00, 0x00, 0x00, 0x80, 0x00, 0x02, 0x03, 0x00, 0x00, 0x38,
            0x00, 0xc3, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
            0xc5, 0x2c, 0x00, 0x11, 0x00, 0x00, 0xb2};
  frame4 = {0x66, 0x44, 0x00, 0x1b, 0x2f, 0x03, 0x01, 0x00, 0x01, 0x00,
            0x01, 0x33, 0x01, 0x34, 0x00, 0x00, 0x00, 0x02, 0x00, 0x38,
            0x00, 0x00, 0x00, 0x80, 0x00, 0x02, 0x03, 0x00, 0x00, 0x38,
            0x00, 0xc3, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x00, 0x08,
            0x2f, 0x00, 0xff, 0x3f, 0xff, 0x28, 0x00, 0x00, 0x35, 0x2c,
            0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0xaa};
}

// Notification callback
void notifyCallback(NimBLERemoteCharacteristic *chr, uint8_t *data, size_t len,
                    bool isNotify) {
  // copy ack
  lastAck.assign(data, data + len);
  ackReceived = true;

  Serial.print("Notification [");
  Serial.print(chr->getUUID().toString().c_str());
  Serial.print("] : ");
  for (size_t i = 0; i < len; ++i)
    Serial.printf("%02X ", data[i]);
  Serial.println();

  // When printing, use each notification as a signal to send the next frame
  if (printingInProgress && chr == pNotifyChar) {
    delay(50); // slight delay to allow internal buffer clear
    currentFrame++;
    const uint8_t *nextFrame = nullptr;
    size_t lenNext = 0;

    switch (currentFrame) {
    case 1:
      nextFrame = &frame2[0];
      lenNext = frame2.size();
      break;
    case 2:
      nextFrame = &frame3[0];
      lenNext = frame3.size();
      break;
    case 3:
      nextFrame = &frame4[0];
      lenNext = frame4.size();
      break;
    default:
      Serial.println("All frames sent. Printing should complete.");
      printingInProgress = false;
      return;
    }

    Serial.printf("Sending frame %d...\n", currentFrame + 1);
    Serial.printf("%s", nextFrame);
    pWriteChar->writeValue(nextFrame, lenNext, false);
  }
}

void startPrintJob() {
  if (!pWriteChar) {
    Serial.println("No write characteristic available!");
    return;
  }
  currentFrame = 0;
  printingInProgress = true;
  Serial.println("Starting print job (frame 1)...");
  pWriteChar->writeValue(&frame1[0], frame1.size(), false);
}

void startScanner() {
  // Scan
  NimBLEScan *pScan = NimBLEDevice::getScan();
  pScan->setScanCallbacks(new PrinterAdvertisedDeviceCallbacks());
  pScan->setInterval(45);
  pScan->setWindow(15);
  pScan->setActiveScan(true);
  pScan->start(5, false);

  if (PRINTER_MAC[0] == '\0')
    return;
}

void startConnectionFindServices() {
  // Connect
  NimBLEAddress addr(std::string(PRINTER_MAC), BLE_ADDR_PUBLIC);
  pClient = NimBLEDevice::createClient();

  Serial.print("Connecting to printer: ");
  Serial.println(addr.toString().c_str());

  if (!pClient->connect(addr)) {
    Serial.println("Failed to connect.");
    return;
  }
  Serial.println("Connected!");

  // Negotiate MTU
  uint16_t mtu = pClient->getMTU();
  Serial.printf("Negotiated MTU: %u\n", mtu);

  // Find printer service (UUID 0xABF0)
  pPrinterService = pClient->getService("ABF0");
  if (!pPrinterService) {
    Serial.println("Printer service (0xABF0) not found!");
    return;
  }
  Serial.println("Printer service found.");

  // Get write characteristic (ABF1)
  pWriteChar = pPrinterService->getCharacteristic("ABF1");
  if (!pWriteChar) {
    Serial.println("Write characteristic ABF1 not found!");
    return;
  }
  Serial.println("Write characteristic ABF1 found.");

  // Get notify characteristic (ABF2)
  pNotifyChar = pPrinterService->getCharacteristic("ABF2");
  if (!pNotifyChar) {
    Serial.println("Notify characteristic ABF2 not found!");
    return;
  }
  Serial.println("Notify characteristic ABF2 found.");

  // Subscribe to ABF2 notifications
  if (pNotifyChar->canNotify()) {
    if (pNotifyChar->subscribe(true, notifyCallback)) {
      Serial.println("Subscribed to ABF2 notifications.");
    } else {
      Serial.println("Subscribe to ABF2 failed.");
    }
  }
}

void beginBLESniffer() {
  Serial.println("Starting BLE sniffer...");
  NimBLEDevice::init("ESP32-BLE-Sniffer");

  startScanner();
  if (PRINTER_MAC[0] != '\0')
    startConnectionFindServices();
}

main.cpp

#include <Arduino.h>
#include <ble_printer_manager.h>

void setup() {
  Serial.begin(115200);

  NimBLEDevice::init("ESP32-BLE-Sniffer");

  beginBLESniffer();
  if (PRINTER_MAC[0] != '\0') {
    setExampleBitmapFrame(); // replay btsnoop_hci3.log frames
    startPrintJob();
  }
}

void loop() { delay(1000); }

It looks like some last bytes are not transmitted. If possible add a counter counting the number of transnitted bytes and compare with the data needed to complete the draw.

Also thinkable is looking for a flush function to use at the end,

I have a similar problem trying to print to an E1 printer. Can confirm a good portion of your findings, with some questionable fields left.
I tried having AI analyze the payload, but that only confirmed it's some special protocol, not a simple RLE, PackBits or Niimbot protocol.
I can see some (hex) patterns with the bare eye, but still can't decipher a simple "A" printing payload :disappointed_face:

@deveaston maybe we can find a common Discord server to work on that? Both this forum and github are a bit too asynchronous for such kind of collaboration

1 Like

I was facing this problem, but someone from platformIO helped me with this, and I haven't worked on this kind of problem in at least 1 month due to busy college work (I'm taking my Bachelor degree).

Roughly a month ago, I managed to figure out the printer format by error and trial. That felt pretty satisfying, therefore, I say we have a deal (in black and white? get it..? hahah jk).

Yea its bit asynchronous for this kind of collab around here. Can you send me your discord ID, mine is easton49.