Reverse Engineering the Cat Printer Bluetooth Protocol

On 15 Aug 2021

Catprinter

I recently joined the very exclusive club of cat printer owners. This is a tiny, battery-powered thermal printer that sells on AliExpress for around $20. It’s meant to be controlled by its proprietary & closed source iPrint iOS/Android app.

The app connects to the printer via Bluetooth Low Energy (BLE). I used jadx to decompile the iPrint Android app back into Java code and hopefully figure out how it talks to the printer. The result of this decompilation is surprisingly good, but not perfect.

Decompiled function example Example of a decompiled function from the app

There are two sources of asynchronicity in the app: some actions are triggered by UI interactions (e.g.: selecting a file, pressing “print”) & the BLE machinery (e.g.: scanning for the device, connecting and sending data). You can witness my descent into madness from my notes.

To connect to a printer, the app relies on the name in the printer’s BLE advertisement packet. In the cat printer case, the name is “GT01”. The app also supports many other printers, each with different capabilities and settings.

Feed paper command Example of a command issued by the app to the printer

To control the printer, the app sends commands to one of the printer’s BLE characteristics. In the “feed paper” command above, there are 9 bytes. Byte at index 6 controls how much paper to feed (payload) and byte at index 7 contains a CRC check over the payload.

The most interesting command is “print this image”. It’s also the trickiest one to follow in the decompiled app. Here’s a taste.

The app breaks down a black and white image into rows. For each row, the app encodes its pixels inside of the “print this image” command’s payload. There are two ways to encode this data this way: compressed and uncompressed.

Compression scheme Optional compression scheme

To compress the data, the protocol uses a sort of run-length encoding. For example, instead of sending 37 white pixels, the app will send just two values: “white 37”. These two values are encoded in a single byte as above. The resulting byte would be (1 << 7) | 37 = 0xa5.

This works well for natural images, in which pixels tend to have similar values around each other. For artificial images, this compression does not pay off, and the app reverts to sending one pixel per bit.

def cmd_print_row(img_row):
    # Try to use run-length compression on the image data.
    encoded_img = run_length_encode(img_row)

    # If the resulting compression takes more than PRINT_WIDTH // 8, it means
    # it's not worth it. So we fallback to a simpler, fixed-length encoding.
    if len(encoded_img) > PRINT_WIDTH // 8:
        encoded_img = byte_encode(img_row)
        b_arr = bs([
            81,
            120,
            -94,
            0,
            len(encoded_img),
            0] + encoded_img + [0, 0xff])
        b_arr[-2] = chk_sum(b_arr, 6, len(encoded_img))
        return b_arr

    # Build the run-length encoded image command.
    b_arr = bs([
        81,
        120,
        -65,
        0,
        len(encoded_img),
        0] + encoded_img + [0, 0xff])
    b_arr[-2] = chk_sum(b_arr, 6, len(encoded_img))
    return b_arr

Python translation of the app’s row-printing function

The function above is part of the rbaron/catprinter Python client I wrote to talk to the catprinter in place of the iPrint app. The client looks for for the printer, connects to it and sends image data for it to print: