Python BLE protocol library for the modern Divoom Backpack M / TimeBox-Evo-audio device family (Anyka 105xE SoC, AkOS firmware).
Independent, community-driven reverse-engineering. Not affiliated with or endorsed by Divoom Inc. Tested on Backpack M and TimeBox-Evo-audio only; other Divoom devices may use different protocol generations.
This is the bottom layer of a planned three-library stack:
divoom-protocol ← this package: framing, encoder, BLE client. No AI, no opinions.
divoom-agent ← (planned) semantic API: ambient.thinking(), ambient.alert()
divoom-agent-mcp ← (planned) MCP server exposing the agent to LLMs
Alpha. Solid colors via Lighting, image transfer, channel switches, brightness, 4fps streaming, and persistent boot-channel selection are decoded and validated on physical hardware. Multi-chunk animation upload renders content to the panel but persistence to a Custom slot is not yet verified, see open questions. APIs may shift before 1.0.
pip install -e .[dev]
pytest # run unit tests
python examples/hello_magenta.pyThe example scans for nearby Divoom devices, connects to the first one found, runs the unlock sequence, and turns the panel magenta.
import asyncio
from divoom_protocol import DivoomClient
async def main():
async with DivoomClient() as client:
await client.connect("11:75:58:46:fe:3d") # or a CoreBluetooth UUID on Mac
await client.init_session() # ~3s, runs iOS-verbatim init
await client.lighting(0, 255, 255) # solid cyan
await client.set_brightness(50) # 0-100
await client.static_image(my_16x16_pixels) # list of 256 (R,G,B) tuples
await client.clock() # built-in clock channel
asyncio.run(main())DivoomClient.scan() returns matching nearby peripherals. DivoomClient.stream(frames, fps) runs an infinite cycle through a list of frames.
Persistent boot-channel selection (via 0x8a set_startup_channel) is decoded and validated. Multi-chunk animation upload sends bytes to the panel and the panel renders them immediately — "streamed playback." Whether the upload also writes to non-volatile memory so the animation survives a power-cycle is not yet validated; the iOS app appears to send additional commands around the upload that have not been fully decoded. See open questions below.
from divoom_protocol import (
DivoomClient, CHANNEL_CLOCK, CHANNEL_CUSTOM_1,
)
from divoom_protocol.captured_animations import mr_juicy_bounce, mr_juicy_eyeroll
async with DivoomClient(address) as client:
await client.connect()
await client.init_session()
# Persistent: which channel the panel boots into on power-up.
# Survives unplug/replug, validated.
await client.set_startup_channel(CHANNEL_CLOCK)
# or CHANNEL_CUSTOM_1 / CHANNEL_LIGHTING / CHANNEL_CLOUD / CHANNEL_SIGNAL
# Streamed: panel renders immediately while connection is live.
# Whether it also persists to a Custom slot across power-cycle is
# currently unverified.
await client.upload_animation(slot=0, chunks=mr_juicy_bounce)
await client.upload_animation(slot=1, chunks=mr_juicy_eyeroll)
# Play a slot on the Signal preset library (built-in arrows, smileys,
# stop, exclamation, etc.). Custom-channel slot selection appears to
# use a different opcode that has not yet been decoded.
await client.play_slot(0)client.upload_animation_from_frames(slot, frames, frame_time_ms) takes a list of 16x16 RGB frames, encodes them as palette-indexed AA frames (see aa_frame.py and node-divoom-timebox-evo's PROTOCOL.md), bit-packs by palette depth, and wraps the result in the standard 0x8b announce/chunks/commit framing. The encoder math is unit-tested. The BLE upload uses write-with-response flow control to land bytes reliably under macOS's CoreBluetooth backend (without this, sustained back-to-back writes silently overrun the transmit queue and the panel renders nothing).
The protocol is partially decoded. Validated against hardware:
- Init handshake (
FE EF AA 55envelope + seq-token unlock), brightness, RGB lighting, channel switches (Clock / Lighting / Cloud), persistent startup channel via0x8a 0x01 <ch>, static image upload via0x44, streamed multi-chunk animation playback via0x8b, Signal-channel slot playback via0x45 0x04 N.
Still open:
- Persistent writes of uploaded animations to a Custom slot. Uploads render; the iOS app's "this animation is now in slot N of Custom 1 forever" behavior has not yet been reproduced. The iOS app likely sends additional commit/save commands around the upload that have not been decoded.
- Direct slot selection within Custom 1/2/3 channels.
0x45 0x04 Nplays Signal slots; the corresponding opcode for Custom channels has not been identified. - Several
0x45second-byte values — 0x03 (clock variant?), 0x05 (appears to switch to Custom 1 channel), 0x06 (scoreboard/timer mode), 0x07 (no-op observed), 0x08+ (untested). Full mapping is incomplete.
If you have a Backpack M / TimeBox-Evo / Pixoo / Ditoo and you're comfortable running a sysdiagnose, please open an issue. Fresh HCI captures of the iOS app performing specific actions are the fastest path to filling these in.
red = [(255, 0, 0)] * 256
blue = [(0, 0, 255)] * 256
await client.upload_animation_from_frames(slot=0, frames=[red, blue], frame_time_ms=500)For palette stability across frames (smaller wire size, fewer firmware allocations), pass fixed_palette= to aa_frame.encode_animation_stream and build the chunks yourself before calling upload_animation.
divoom_protocol.captured_animations ships the author's user-generated "Mr Juicy" animations as captured iOS chunks, ready to upload via the lower-level upload_animation(slot, chunks=...) interface (which takes pre-built wire chunks):
mr_juicy_eyeroll(22 chunks), character with animating eyesmr_juicy_bounce(8 chunks), character moving around the panel
For protocol exploration / decoding new opcodes, client.send_raw_payload(bytes) sends arbitrary opcode-plus-args inner payloads wrapped in the standard envelope. Use with care — see the method docstring for the safety warning.
The client exposes a diagnostics property returning a live snapshot of the BLE link's health — connection state, MTU, write throughput, success rate, errors, last opcode. Bounded by design (no unbounded growth), safe to read concurrently.
async with DivoomClient(address) as client:
await client.connect()
await client.init_session()
snap = client.diagnostics
print(f"state={snap.state} mtu={snap.mtu} writes_ok={snap.writes_ok}")For a top-style live view, run examples/doctor.py — it connects to a panel and refreshes diagnostics every second with colorized state, throughput, and error age. Useful for verifying connection health during long-running sessions, watching streaming throughput, or instrumenting new protocol-decode work alongside HCI captures.
DIVOOM_ADDRESS=<your-panel-uuid> python examples/doctor.pyUnder the hood, every BLE write is automatically retried once on transient BleakError with a short backoff — catches the queue-saturation hiccups that show up at high write rates without infinite-looping on truly dead links.
The full decode lives in PROTOCOL.md (and originally in PixelForgeProbe ADR-0002). The 13-step recipe in brief:
- Write to BLE characteristic
49535343-8841-43F4-A8D4-ECBE34729BB3(NOT theACA3one — that's inert on this generation despite being advertised as a write target) - First write must be JSON
{"Command":"Device/SetUTC", "Utc": <epoch>, "Time": "YYYY-MM-DD HH:MM:SS"}wrapped in the FE EF AA 55 envelope. Seq for this packet is0x0001. - KEYSTONE: after SetUTC, bump the seq counter so the high byte becomes
0x01. All subsequent commands use0x01XX. Without this jump the device ACKs writes but the display driver silently ignores them. This is the single most important rule in the protocol. - Run iOS's ~30-command init sequence in the exact order, throttled to ~60ms per command. ~1s pause before
deviceInfo(slot 30). - Init ends with
bd 2f 02 / 31. NEVER re-send these after init — doing so causes immediate BLE disconnect. - Brightness, Lighting, channel switches send directly without re-unlock.
- Image preamble
bd 31 SLOT 01 / 9f CONFIRM(CONFIRM = SLOT + 0xB1) goes right before each0x44image. Slot starts at 0 and increments by 3. - The preamble does NOT precede non-image commands.
- Solid colors go through Lighting (
45 01 RR GG BB BRI 00 01), not0x44image. 0x44image bytes are NOT bit-reversed. (hass-divoomdoes bit-reversal for older Divoom hardware; that's wrong for this generation.)- The 3 bytes between
frameSizeandcolorCountareF4 01 00, not zeros. - Image transfers larger than ~138 bytes are split into 138-byte BLE writes at the app layer, regardless of MTU.
- The device's smart-lamp mode is called "Lighting" (not "Lightning" — they're different words).
This protocol decode covers the modern Backpack M / TimeBox-Evo-audio generation over BLE. Two prior open-source efforts targeted the older Timebox Evo generation over Classic BT RFCOMM:
- RomRider/node-divoom-timebox-evo — documented the
0x44static image command structure and frame size formula. Useful starting heuristics; their PROTOCOL.md gave us the rough shape we then verified and corrected. - d03n3rfr1tz3/hass-divoom — Python implementation for Timebox Evo. We borrowed the high-level encoder structure but had to remove its bit-reversal step (which is wrong for our generation) and add many things they don't have.
The findings specific to this device generation — and the substantial majority of this library — are original work from reverse-engineering iOS HCI captures on actual Backpack M and TimeBox-Evo-audio hardware. In particular: BLE works (community consensus said it didn't), the correct write characteristic, the JSON SetUTC handshake, the seq high byte session token (the keystone), the image preamble structure, the F4 01 00 mystery bytes, no bit-reversal, the full iOS init sequence, and the BLE chunking pattern are all new.
- macOS — works via CoreBluetooth backend (
bleakhandles the FFI) - Linux / Raspberry Pi — works via BlueZ backend
- Windows —
bleaksupports it but untested here
Hit a failure? See docs/troubleshooting.md for the common ones: missing init sequence, BLE exclusivity conflicts, macOS TCC crashes, animation upload edge cases, and how to read the diagnostics output when something's off.
MIT. Use freely. Attribution appreciated but not required.
Not affiliated with or endorsed by Divoom. This is independent reverse-engineering of publicly broadcast BLE traffic from devices we own, for interoperability purposes. Use at your own risk.