Skip to content

Instantly share code, notes, and snippets.

@andycopley
Created May 21, 2026 09:00
Show Gist options
  • Select an option

  • Save andycopley/a44654da2ef9cb0da0ceaa8711afcade to your computer and use it in GitHub Desktop.

Select an option

Save andycopley/a44654da2ef9cb0da0ceaa8711afcade to your computer and use it in GitHub Desktop.
PicPak BLE Protocol - How to send data over bluetooth to your PicPak e-ink colour frame

PicPak BLE Protocol

A reverse-engineered protocol reference for the PicPak 4-colour e-ink photo frame. Captured against firmware V0.4.1 by combining BLE HCI snoop logs from the official Android app with APK decompilation.

This document describes the wire protocol only — what bytes go on the BLE characteristics, in what order, and what they mean. It is enough to write a custom uploader, downloader, or device manager in any language.

If you are publishing your own client based on this, you may want to be explicit that you are not affiliated with the manufacturer.


Device summary

Property Value
MCU ESP32-C3 (RISC-V; Wi-Fi capable but unused in firmware)
Panel 4-colour e-ink, 400 × 300 pixels
Palette Black, White, Yellow, Red
Observed firmware V0.4.1 (hardware tag V0.0.1)
BLE advertised name PicPak

The device advertises a single BLE service and accepts a single connected client at a time.


GATT layout

Service UUID 0xFF00 (0000ff00-0000-1000-8000-00805f9b34fb).

Handle Char UUID Role Properties
0x002a 0xFF01 Image data: image upload chunks, framebuffer stream-back, list/delete/read commands Write, Read, Indicate
0x002b CCCD for FF01 (write 0x0200 to subscribe to indications)
0x002d 0xFF02 Device info / control: name, status, version queries Write, Read, Indicate
0x002e CCCD for FF02 (write 0x0200 to subscribe to indications)
0x???? 0xFF03 OTA firmware updates Write, Read, Indicate

Negotiated MTU: 515 bytes (max ATT payload ~512 bytes per write/indication).

Some macOS BLE inspectors (e.g. Bluetility) hide CCCDs from service discovery and show only three characteristics. The full handle map is visible on Android via HCI snoop captures.

Connection sequence

  1. Connect to the PicPak via BLE (by name, since the local Bluetooth address may not be stable across platforms).
  2. Discover GATT services (standard discovery).
  3. Write 0x0200 to handle 0x002b to enable indications on FF01.
  4. Write 0x0200 to handle 0x002e to enable indications on FF02.
  5. Exchange initial control commands (see Command vocabulary).

After that, the connection is steady-state — either side can push data at any time.


Frame format

Every message — command, response, or bulk data chunk — uses this envelope:

0xAA <opcode> <payload bytes> 0xFF
  • 0xAA — start-of-frame magic byte
  • <opcode> — one byte identifying the message type
  • <payload> — variable, structure depends on opcode
  • 0xFF — end-of-frame magic byte

Long messages (image chunks, OTA chunks) include a 16-bit little-endian length field and a sequence/packet number inside the payload.


Command vocabulary

Short commands (client → device)

Hex Sent to Purpose
aa 06 02 ff FF02 Request device name
aa 07 02 ff FF02 Request status (purpose still unknown; see below)
aa 08 02 ff FF02 Request device info (battery %, firmware version, etc.)
aa 30 ff FF01 List images — returns bitmap of slot occupancy
aa 03 <slot_lo> <slot_hi> ff FF01 Read image — device streams data packets back
aa 04 <slot_lo> <slot_hi> 02 ff FF01 Read MD5 — device returns the 16-byte MD5 of the stored image
aa 32 <slot_lo> <slot_hi> ff FF01 Delete image at given slot

Responses (device → client, indicated on FF02)

Opcode Length Payload
0x06 11 b Device name: aa 06 01 06 "PicPak" ff (byte 3 = length of the name string)
0x07 9 b Status code: aa 07 01 10 0e 00 00 01 ff. Empirically constant across battery / connection state changes. Purpose unknown.
0x08 57 b Device info: battery, firmware/hardware version, serial (see below)

0x08 response layout

Captured at multiple battery states against a real device:

aa 08 64 28 00 56 30 2e 30 2e 31 00 00 00 00  ← 100%, plugged in
aa 08 58 28 00 56 30 2e 30 2e 31 00 00 00 00  ← 88%, unplugged
   ^^ ^^ ^^
   |  |  └ always 0x00 — padding
   |  └─── byte 3: undecoded flag (constant 0x28 in observation;
   |       likely panel rev / hardware metadata, not charging state)
   └────── byte 2: battery percentage (0..100, decimal)

Full layout (57 bytes total):

Offset Size Field
0 1 0xAA (start magic)
1 1 0x08 (opcode)
2 1 Battery percentage (0..100)
3 1 Flag byte (constant 0x28 observed; meaning unknown)
4 1 0x00 padding
5 10 Hardware version, NULL-padded ASCII
15 10 Firmware version, NULL-padded ASCII
25 10 Serial, NULL-padded ASCII
35 21 Trailing zero padding
56 1 0xFF (end magic)

Battery decode confirmed by watching the byte change while charging:

Reported by official app Byte 2 hex Byte 2 decimal
79% 0x4f 79
80% (charged ~1%) 0x50 80
88% (unplugged) 0x58 88
100% (plugged in) 0x64 100

Direct decimal-to-percent mapping; no transformation needed.

About 0x07

Despite carrying a 6-byte payload, 0x07 returns 01 10 0e 00 00 01 regardless of battery level or charging state. It is probably a device-state code (panel idle / busy / error) but no observed transition has changed the bytes. Treat its meaning as empirical-unknown.


Image slots

Images are stored in numbered slots. Slots are 1-indexed. The device exposes 500 image slots (maxImageCount = 500 in the firmware companion app).

The device does not assign slots; the client picks the destination. Behaviour when uploading to an already-occupied slot is unverified — recommended practice is to call list-images first and pick an empty slot.

List images

Send to FF01:

0xAA 0x30 0xFF

Device replies via indication on FF01:

0xAA <packetType> <slot_1> <slot_2> ... <slot_N> 0xFF

Each slot_X byte: 0x01 = occupied, anything else (typically 0x00) = empty. Number of slots N = response_length - 3.

Delete image

Send to FF01:

0xAA 0x32 <id_lo> <id_hi> 0xFF

Where id is the 1-indexed slot number, 16-bit little-endian.

Device replies via indication on FF01:

0xAA <packetType> <id_lo> <id_hi> <result> 0xFF
  • result == 0x00 means success
  • Non-zero means failure

Recommended: list-images first to confirm the slot exists, then delete, then list again to confirm. The device's behaviour when deleting an empty slot is unverified.


Image upload

Image data is sent by writing chunks to FF01 using Write Request (ATT opcode 0x12). Each write is acknowledged with a Write Response (0x13).

Data chunk envelope (9 bytes around the payload)

0xAA <dataType> <slot_lo> <slot_hi> <packetNum> <isLast> <len_lo> <len_hi> <payload bytes...> 0xFF
Offset Size Field Meaning
0 1 Magic Always 0xAA
1 1 dataType 0x01 = image data, 0x02 = framebuffer read response
2 2 imageNumber 16-bit LE; slot this packet belongs to (1-indexed)
4 1 packetNumber 0-indexed chunk sequence (max ~256, typically 128)
5 1 isLastPacket 0x01 = final chunk, 0x00 = more to come
6 2 dataLength 16-bit LE; payload size (max payload ≤ 236, so high byte is 0x00)
8 N payload Raw 2bpp-packed pixel bytes
8+N 1 Footer Always 0xFF

Maximum payload size: 236 bytes, giving a 245-byte total packet (packetLengthLimit - 9).

MD5 commit packet (22 bytes)

After all data chunks, send an MD5 hash to commit the upload:

0xAA 0x04 <slot_lo> <slot_hi> <flag> <16 bytes MD5> 0xFF
Offset Size Field
0 1 0xAA
1 1 0x04 (MD5 packet type)
2 2 imageNumber (16-bit LE)
4 1 flag byte (observed 0x00; purpose unclear, likely image format flag)
5 16 MD5 hash of the packed pixel data
21 1 0xFF

The MD5 is computed over the packed image bytes (the 30,000-byte 2bpp buffer), not the source JPEG and not any individual chunk. On receipt the device verifies the hash and presumably commits to flash + renders if correct.

Canonical upload sequence

For a full 400×300 image (30,000 bytes of packed pixel data):

  1. Pick an empty slot (use list-images first).
  2. Encode the image to 30,000 bytes of 2bpp-packed data (see Image encoding).
  3. Compute the MD5 of those 30,000 bytes.
  4. Send N data chunks via Write Request to FF01, where N = ceil(30000 / 236) = 128:
    • Chunks 0..126: 236-byte payload, isLast = 0
    • Chunk 127: ~232-byte payload (the remainder), isLast = 1
    • All carrying the chosen slot number in the header.
  5. Send the MD5 commit packet to FF01.
  6. Wait for device response indicating success or failure.

A small inter-packet delay (~100 ms in the official app) is conservative; the Write Response acks provide flow control so it can go faster.


Image read

The device can stream back any stored image.

Send to FF01:

0xAA 0x03 <slot_lo> <slot_hi> 0xFF

5 bytes. The slot number is 16-bit LE.

The device responds with data packets in the same envelope as upload chunks:

0xAA <type> <slot_lo> <slot_hi> <pkt_num> <is_last> <len_lo> <len_hi> <payload> 0xFF

Reassembly:

  1. Collect indications on FF01 until you see one with is_last == 0x01.
  2. Sort by packetNumber if needed (order is normally sequential but defending against out-of-order is cheap).
  3. Concatenate payloads.
  4. The result is a 30,000-byte 2bpp-packed buffer.

Read-back is slower than upload — observed ~30 seconds per image vs a few seconds to upload. Reason unclear; possibly smaller per-chunk size in the read direction.

Reading the stored MD5 for a slot

0xAA 0x04 <slot_lo> <slot_hi> 0x02 0xFF

6 bytes. The trailing 0x02 distinguishes this from the upload MD5 commit packet (which has the same 0x04 opcode but a different shape).

Device returns the 16-byte MD5 of the image at that slot. Useful for verifying an upload arrived intact, detecting which slots have changed, or caching downloads.


Image encoding

To produce the 30,000-byte buffer for upload:

1. Resize

Source image → resize to 400 × 300 pixels. Bilinear or Lanczos both fine.

2. Palette

Exactly four colours:

Index RGB Name
0 (0, 0, 0) Black
1 (255, 255, 255) White
2 (255, 255, 0) Yellow
3 (255, 0, 0) Red

3. Dither

The official Android app uses Floyd-Steinberg:

       *   7/16
3/16  5/16  1/16

The wire protocol doesn't care which dithering algorithm produced the indices — substitute any error-diffusion or ordered dither you like.

4. Pack

Pack pixels into bytes with 4 pixels per byte, MSB first:

byte = (p0 << 6) | (p1 << 4) | (p2 << 2) | p3

Total bytes: 400 × 300 × 2 / 8 = 30,000.

5. Orient

The panel's native row scan is bottom-to-top. Row 0 of the packed data ends up at the bottom row of the panel, not the top. Column ordering within each row is left-to-right.

In practice: flip the source image vertically before encoding, e.g.

img = img.transpose(Image.FLIP_TOP_BOTTOM)  # then resize → dither → pack

ROTATE_180 is not the same thing — it adds an unwanted horizontal mirror. Y-axis flip only.

Decoding (verification or read-back)

PALETTE = [(0,0,0), (255,255,255), (255,255,0), (255,0,0)]
for byte in packed_bytes:
    for r in range(4):
        idx = (byte >> (6 - 2 * r)) & 3
        # idx is the palette index for the next pixel

After decoding bytes to pixels, apply FLIP_TOP_BOTTOM again to view the image right-side-up on a screen.


Reference Python implementations

These are slow but readable. They are sufficient to send an image to a real device.

Encoder (image → packed bytes)

# encode.py
from PIL import Image

PALETTE = [
    (0, 0, 0),         # 0: black
    (255, 255, 255),   # 1: white
    (255, 255, 0),     # 2: yellow
    (255, 0, 0),       # 3: red
]
W, H = 400, 300

def nearest_palette_index(r, g, b):
    best_idx, best_dist = 0, float('inf')
    for i, (pr, pg, pb) in enumerate(PALETTE):
        d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2
        if d < best_dist:
            best_dist = d
            best_idx = i
    return best_idx

def floyd_steinberg(pixels, w, h):
    buf = [[list(pixels[y * w + x]) for x in range(w)] for y in range(h)]
    out = [0] * (w * h)
    for y in range(h):
        for x in range(w):
            r, g, b = buf[y][x]
            r = max(0, min(255, r)); g = max(0, min(255, g)); b = max(0, min(255, b))
            idx = nearest_palette_index(r, g, b)
            out[y * w + x] = idx
            pr, pg, pb = PALETTE[idx]
            er, eg, eb = r - pr, g - pg, b - pb
            def add(dx, dy, factor):
                if 0 <= x + dx < w and 0 <= y + dy < h:
                    buf[y + dy][x + dx][0] += er * factor
                    buf[y + dy][x + dx][1] += eg * factor
                    buf[y + dy][x + dx][2] += eb * factor
            add(+1,  0, 7/16)
            add(-1, +1, 3/16)
            add( 0, +1, 5/16)
            add(+1, +1, 1/16)
    return out

def pack_2bpp(indices):
    out = bytearray()
    for i in range(0, len(indices), 4):
        p0 = indices[i]     if i     < len(indices) else 0
        p1 = indices[i + 1] if i + 1 < len(indices) else 0
        p2 = indices[i + 2] if i + 2 < len(indices) else 0
        p3 = indices[i + 3] if i + 3 < len(indices) else 0
        out.append((p0 << 6) | (p1 << 4) | (p2 << 2) | p3)
    return bytes(out)

def encode(path):
    img = (Image.open(path)
              .convert('RGB')
              .transpose(Image.FLIP_TOP_BOTTOM)   # required by panel scan order
              .resize((W, H), Image.LANCZOS))
    indices = floyd_steinberg(list(img.getdata()), W, H)
    return pack_2bpp(indices)   # exactly 30,000 bytes

For production use, vectorise the dither loop with numpy — keep the working buffer as a float32 ndarray and write the error distribution as array ops. The naive implementation above is roughly 30× slower than that, but the output is bit-identical.

Decoder (packed bytes → image)

from PIL import Image

PALETTE = [(0,0,0), (255,255,255), (255,255,0), (255,0,0)]
W, H = 400, 300

def decode(packed_bytes, out_path="decoded.png"):
    if len(packed_bytes) < W * H * 2 // 8:
        packed_bytes = packed_bytes + b'\x00' * (W * H * 2 // 8 - len(packed_bytes))
    pixels = []
    for byte in packed_bytes:
        for r in range(4):
            idx = (byte >> (6 - 2 * r)) & 3
            pixels.append(PALETTE[idx])
    img = Image.new('RGB', (W, H))
    img.putdata(pixels[:W * H])
    img = img.transpose(Image.FLIP_TOP_BOTTOM)   # undo panel scan order
    img.save(out_path)

BLE uploader (using bleak)

# upload.py
import asyncio, hashlib
from bleak import BleakScanner, BleakClient

FF01_DATA = "0000ff01-0000-1000-8000-00805f9b34fb"

MAX_PAYLOAD = 236

def make_data_packet(slot, packet_num, is_last, payload):
    n = len(payload)
    return bytes([
        0xAA,
        0x01,
        slot & 0xFF, (slot >> 8) & 0xFF,
        packet_num & 0xFF,
        0x01 if is_last else 0x00,
        n & 0xFF, (n >> 8) & 0xFF,
    ]) + payload + bytes([0xFF])

def make_md5_packet(slot, packed_image, flag=0):
    md5 = hashlib.md5(packed_image).digest()
    return bytes([
        0xAA, 0x04,
        slot & 0xFF, (slot >> 8) & 0xFF,
        flag & 0xFF,
    ]) + md5 + bytes([0xFF])

async def upload(packed_image, slot, device_name="PicPak"):
    device = await BleakScanner.find_device_by_name(device_name, timeout=10)
    if not device:
        raise RuntimeError(f"'{device_name}' not found")
    async with BleakClient(device) as client:
        await client.start_notify(FF01_DATA, lambda s, d: print("FF01 ←", d.hex()))
        await asyncio.sleep(0.5)
        n_chunks = (len(packed_image) + MAX_PAYLOAD - 1) // MAX_PAYLOAD
        for i in range(n_chunks):
            payload = packed_image[i*MAX_PAYLOAD : (i+1)*MAX_PAYLOAD]
            pkt = make_data_packet(slot, i, i == n_chunks - 1, payload)
            await client.write_gatt_char(FF01_DATA, pkt, response=True)
        await client.write_gatt_char(FF01_DATA, make_md5_packet(slot, packed_image), response=True)
        await asyncio.sleep(3)

if __name__ == "__main__":
    import sys
    with open(sys.argv[1], "rb") as f:
        packed = f.read()
    slot = int(sys.argv[2]) if len(sys.argv) > 2 else 1
    asyncio.run(upload(packed, slot))

BLE manager (list, delete, read, MD5 verify)

# manage.py
import asyncio, hashlib
from collections import defaultdict
from bleak import BleakScanner, BleakClient

FF01_DATA = "0000ff01-0000-1000-8000-00805f9b34fb"

class PicPakManager:
    def __init__(self, client):
        self.client = client
        # Route responses to per-slot buckets (the device can return out of order).
        self._md5 = {}
        self._md5_evt = defaultdict(asyncio.Event)
        self._read_buf = defaultdict(list)
        self._read_evt = defaultdict(asyncio.Event)
        self._status = None
        self._status_evt = asyncio.Event()
        self._delete = {}
        self._delete_evt = defaultdict(asyncio.Event)

    def _on_ind(self, sender, data: bytearray):
        if len(data) < 3 or data[0] != 0xAA or data[-1] != 0xFF:
            return
        op = data[1]
        if op == 0x01 and len(data) > 9:
            slot = data[2] | (data[3] << 8)
            length = data[6] | (data[7] << 8)
            self._read_buf[slot].append((data[4], bytes(data[8:8+length])))
            if data[5] == 0x01:
                self._read_evt[slot].set()
        elif op == 0x04 and len(data) >= 22:
            slot = data[2] | (data[3] << 8)
            self._md5[slot] = bytes(data[5:21])
            self._md5_evt[slot].set()
        elif op == 0x30 or (len(data) > 10 and op != 0x04):
            self._status = {i+1: data[2+i] == 0x01 for i in range(len(data)-3)}
            self._status_evt.set()
        elif op == 0x32 and len(data) == 6:
            slot = data[2] | (data[3] << 8)
            self._delete[slot] = data[4] == 0
            self._delete_evt[slot].set()

    async def setup(self):
        await self.client.start_notify(FF01_DATA, self._on_ind)
        await asyncio.sleep(0.3)

    async def list_images(self):
        self._status_evt.clear(); self._status = None
        await self.client.write_gatt_char(FF01_DATA, bytes([0xAA, 0x30, 0xFF]), response=True)
        await asyncio.wait_for(self._status_evt.wait(), 3.0)
        return self._status

    async def delete_image(self, slot):
        self._delete_evt[slot].clear(); self._delete.pop(slot, None)
        cmd = bytes([0xAA, 0x32, slot & 0xFF, (slot >> 8) & 0xFF, 0xFF])
        await self.client.write_gatt_char(FF01_DATA, cmd, response=True)
        await asyncio.wait_for(self._delete_evt[slot].wait(), 3.0)
        return self._delete[slot]

    async def read_image(self, slot, timeout=60.0):
        self._read_evt[slot].clear(); self._read_buf[slot].clear()
        cmd = bytes([0xAA, 0x03, slot & 0xFF, (slot >> 8) & 0xFF, 0xFF])
        await self.client.write_gatt_char(FF01_DATA, cmd, response=True)
        await asyncio.wait_for(self._read_evt[slot].wait(), timeout)
        return b''.join(p for _, p in sorted(self._read_buf[slot]))

    async def read_md5(self, slot):
        self._md5_evt[slot].clear(); self._md5.pop(slot, None)
        cmd = bytes([0xAA, 0x04, slot & 0xFF, (slot >> 8) & 0xFF, 0x02, 0xFF])
        await self.client.write_gatt_char(FF01_DATA, cmd, response=True)
        await asyncio.wait_for(self._md5_evt[slot].wait(), 5.0)
        return self._md5[slot]

    async def verify(self, slot, expected_packed):
        return await self.read_md5(slot) == hashlib.md5(expected_packed).digest()

async def main():
    device = await BleakScanner.find_device_by_name("PicPak", timeout=10)
    async with BleakClient(device) as client:
        mgr = PicPakManager(client)
        await mgr.setup()
        slots = await mgr.list_images()
        occupied = [s for s, full in slots.items() if full]
        print(f"{len(occupied)} images: {occupied}")
        for s in occupied:
            print(f"  slot {s}: MD5={(await mgr.read_md5(s)).hex()}")

if __name__ == "__main__":
    asyncio.run(main())

The opcode dispatch in the indication handler is approximate — different response shapes share opcodes, so disambiguation by length/structure is necessary. Tune against your device.


OTA firmware updates

The PicPak supports flashing new firmware over BLE via the FF03 characteristic.

Sequence

  1. Client writes a 33-byte start packet to FF03 with the firmware size, MD5, and version string.
  2. Device acknowledges.
  3. Client streams OTA data chunks to FF03, each acknowledged individually.
  4. Device verifies MD5 on receipt and applies the update on success.

Start OTA packet (33 bytes)

0xAA 0x10 <size_0..size_3> <16 bytes MD5> <10 bytes version> 0xFF
Offset Size Field
0 1 0xAA
1 1 0x10 (start-OTA opcode)
2 4 Firmware size in bytes (32-bit LE)
6 16 MD5 hash of the entire firmware blob
22 10 Version string, NULL-padded ASCII (e.g. "V0.4.2\0\0\0\0")
32 1 0xFF

The version string is for display; the MD5 is the integrity check.

OTA data chunk

0xAA 0x11 <idx_lo> <idx_hi> <isLast> <len> <payload> 0xFF
Offset Size Field
0 1 0xAA
1 1 0x11 (OTA data opcode)
2 2 Packet index (16-bit LE; ~4000 chunks for a ~944 KB firmware)
4 1 isLast flag (1 if final chunk)
5 1 Payload length (1 byte; max 255)
6 N Payload bytes
6+N 1 0xFF

Each chunk gets an explicit ack from the device on FF03 before the next chunk is sent.

Properties of the OTA path

  • No code signing. The device verifies MD5 (integrity) but not signatures (authenticity). Any firmware that hashes correctly will be accepted.
  • Per-chunk acks. Mid-upload failures are recoverable — the device doesn't commit anything until the full firmware is received and verified.

Risks of running custom firmware

The OTA flow itself is well-defended (MD5 verification, no commit on partial upload). The danger is in the firmware contents:

  • If custom firmware doesn't boot, the existing firmware has already been overwritten — bricked.
  • If custom firmware boots but the BLE stack doesn't come up, you cannot OTA-recover — bricked.
  • If custom firmware boots but mis-drives the e-ink panel, you can permanently damage the display (high voltages, bad waveforms).

Before any custom firmware attempt, identify and verify the UART / USB recovery pads inside the device case so a hardware recovery path exists if the BLE stack stops working.


Implementation notes

These are gotchas, not part of the protocol per se. Battle-tested against firmware V0.4.1.

Slow / out-of-order responses

The device can take longer than expected to reply, particularly during back-to-back read_md5 or read_image calls. If responses are matched to requests by arrival order, a late MD5 for slot N can arrive during the call for slot N+1 and be incorrectly attributed.

Fix: in the indication handler, route responses to per-slot buckets (each slot gets its own Event and storage). Wait for the specific slot's event in the caller. The PicPakManager example above does this.

Inter-command delay

A small delay between consecutive commands (~300 ms) materially reduces "device didn't respond" errors during back-to-back operations like a full library dump. The device's BLE stack appears not to be fully event-driven; back-pressure helps.

Read-back is much slower than upload

A single image upload takes a few seconds (128 chunks). A single image download takes 30–60 seconds. Don't busy-poll; plan for the cost when downloading many slots.

Padding on read

An upload of 29,952 bytes (48 short of the full 30,000) was observed — the device pads internally. When reading back, expect either exactly 30,000 bytes or the originally uploaded size. Be defensive about exact byte counts and pad with zeros if short before decoding.

Bottom-to-top scan applies to reads too

Anything read back from the device is in bottom-to-top order, same as uploads need to be. Decoders should FLIP_TOP_BOTTOM after assembling pixels (the decoder above already does this).

macOS sees a UUID, not a Bluetooth MAC

On macOS, device.address returns a system-generated UUID rather than the device's actual Bluetooth MAC. This is a Core Bluetooth design choice. Don't try to filter by MAC on macOS — find by name.

On Android (HCI snoop captures) you'll see the real MAC. The two won't match.

MTU 515 is real

The PicPak's BLE stack actively negotiates a 515-byte MTU — unusually generous (many ESP32 BLE projects leave it at the BLE 4.0 default of 23). Your stack will negotiate this for you. The implication: the device is throughput-tuned. The 243-byte chunks the official app uses are an internal app convention, not a BLE constraint.

Don't overwrite occupied slots without testing

The behaviour when uploading to a slot that already contains an image has not been verified. Always list-images first and pick an empty slot.


Known unknowns

The protocol is essentially complete for the canonical use cases (upload, list, download, MD5 verify, delete). Remaining gaps:

  • The OTA characteristic UUID — exists but not yet identified by reading the app source. Grep app-service.js (the companion app's main bundle) for otaCharacteristicId.
  • The flag byte in MD5 commit packets — observed as 0x00, purpose unclear. Possibly an image format / type flag.
  • The 0x07 status response payload — bytes 01 10 0e 00 00 01. Function unknown.
  • createDeviceNamePacket / device-settings packets — referenced in the app source on the device-info characteristic, but the packet format hasn't been extracted yet.
  • The 0x31 solid-fill region opcode — seen in framebuffer read-back streams; possibly RLE compression. Not blocking since the standard 0x01/0x02 data packets carry full image data.
  • Behaviour when overwriting an occupied slot — needs experimental verification.

Sample captures

Raw values observed in HCI snoops, useful when cross-checking against new firmware versions.

Bulk chunk header sample

aa 02 01 00 00 00 ec 00 ...236 bytes... ff      ← seq 0
aa 02 01 00 01 00 ec 00 ...236 bytes... ff      ← seq 1
aa 02 01 00 02 00 ec 00 ...236 bytes... ff      ← seq 2

Control exchange

TX  aa 06 02 ff                             ← "get name"
RX  aa 06 01 06 50 69 63 50 61 6b ff        ← "PicPak"
TX  aa 08 02 ff                             ← "get info"
RX  aa 08 27 21 00 56 30 2e 30 2e 31 00...  ← V0.0.1 / V0.4.1 / no sn
TX  aa 07 02 ff                             ← "get status"
RX  aa 07 01 10 0e 00 00 01 ff              ← status payload

Packet count summary for one full upload

ATT opcode Count What it was
0x12 Write Request (client → device) 180 Image chunks + control writes + CCCD writes
0x13 Write Response 180 Per-write acks
0x1D Handle Value Indication (device → client) 1,691 Device pushing framebuffer back as preview
0x1E Handle Value Confirmation (client → device) 1,691 Client acking each indication
Other (discovery, MTU) ~25 One-time setup
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment