BLE protocol library for the modern Divoom Backpack M / TimeBox-Evo-audio family (Anyka 105xE / AkOS firmware).
Project description
divoom-protocol
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
Status
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.
Quick start
pip install -e .[dev]
pytest # run unit tests
python examples/hello_magenta.py
The example scans for nearby Divoom devices, connects to the first one found, runs the unlock sequence, and turns the panel magenta.
API
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.
Channel control and streamed playback
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)
Generating animations from pixel arrays
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).
Open questions
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.
Captured animation library
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
Lower-level escape hatch
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.
Diagnostics
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.py
Under 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.
Protocol notes
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).
Prior art and attribution
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.
Platforms
- macOS — works via CoreBluetooth backend (
bleakhandles the FFI) - Linux / Raspberry Pi — works via BlueZ backend
- Windows —
bleaksupports it but untested here
Troubleshooting
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.
License
MIT. Use freely. Attribution appreciated but not required.
Disclaimer
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file divoom_protocol-0.2.0.tar.gz.
File metadata
- Download URL: divoom_protocol-0.2.0.tar.gz
- Upload date:
- Size: 51.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c300b5db72f48272b2c446ec587028121d57a86610699d492bd4dd870c0a15fa
|
|
| MD5 |
3af46fc61c41bd746b965195f7a58297
|
|
| BLAKE2b-256 |
1281dd20a57975f3d9d7bf78f784c53f581521e42cd8fe6c464ac7d10a75f241
|
File details
Details for the file divoom_protocol-0.2.0-py3-none-any.whl.
File metadata
- Download URL: divoom_protocol-0.2.0-py3-none-any.whl
- Upload date:
- Size: 39.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0d06aee7b60fbbb097c4efb32dc249f276eee1e4d8e0913605269c27582cc39a
|
|
| MD5 |
83dc3bc4b257fe19bf0789e8004c4527
|
|
| BLAKE2b-256 |
686f6df3288d546156a917ae09fa7c02fa13ee51d31de5fc354310caef9ad2f6
|