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.
| 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.
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.
- Connect to the PicPak via BLE (by name, since the local Bluetooth address may not be stable across platforms).
- Discover GATT services (standard discovery).
- Write
0x0200to handle0x002bto enable indications on FF01. - Write
0x0200to handle0x002eto enable indications on FF02. - Exchange initial control commands (see Command vocabulary).
After that, the connection is steady-state — either side can push data at any time.
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 opcode0xFF— 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.
| 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 |
| 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) |
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.
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.
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.
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.
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 == 0x00means 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 data is sent by writing chunks to FF01 using Write Request (ATT opcode 0x12). Each write is acknowledged with a Write Response (0x13).
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).
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.
For a full 400×300 image (30,000 bytes of packed pixel data):
- Pick an empty slot (use list-images first).
- Encode the image to 30,000 bytes of 2bpp-packed data (see Image encoding).
- Compute the MD5 of those 30,000 bytes.
- 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.
- Chunks 0..126: 236-byte payload,
- Send the MD5 commit packet to FF01.
- 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.
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:
- Collect indications on FF01 until you see one with
is_last == 0x01. - Sort by
packetNumberif needed (order is normally sequential but defending against out-of-order is cheap). - Concatenate payloads.
- 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.
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.
To produce the 30,000-byte buffer for upload:
Source image → resize to 400 × 300 pixels. Bilinear or Lanczos both fine.
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 |
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.
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.
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 → packROTATE_180 is not the same thing — it adds an unwanted horizontal mirror. Y-axis flip only.
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 pixelAfter decoding bytes to pixels, apply FLIP_TOP_BOTTOM again to view the image right-side-up on a screen.
These are slow but readable. They are sufficient to send an image to a real device.
# 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 bytesFor 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.
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)# 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))# 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.
The PicPak supports flashing new firmware over BLE via the FF03 characteristic.
- Client writes a 33-byte start packet to FF03 with the firmware size, MD5, and version string.
- Device acknowledges.
- Client streams OTA data chunks to FF03, each acknowledged individually.
- Device verifies MD5 on receipt and applies the update on success.
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.
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.
- 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.
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.
These are gotchas, not part of the protocol per se. Battle-tested against firmware V0.4.1.
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.
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.
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.
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.
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).
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.
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.
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.
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) forotaCharacteristicId. - The flag byte in MD5 commit packets — observed as
0x00, purpose unclear. Possibly an image format / type flag. - The
0x07status response payload — bytes01 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
0x31solid-fill region opcode — seen in framebuffer read-back streams; possibly RLE compression. Not blocking since the standard0x01/0x02data packets carry full image data. - Behaviour when overwriting an occupied slot — needs experimental verification.
Raw values observed in HCI snoops, useful when cross-checking against new firmware versions.
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
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
| 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 |