Skip to main content

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 55 envelope + seq-token unlock), brightness, RGB lighting, channel switches (Clock / Lighting / Cloud), persistent startup channel via 0x8a 0x01 <ch>, static image upload via 0x44, streamed multi-chunk animation playback via 0x8b, Signal-channel slot playback via 0x45 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 N plays Signal slots; the corresponding opcode for Custom channels has not been identified.
  • Several 0x45 second-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 eyes
  • mr_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:

  1. Write to BLE characteristic 49535343-8841-43F4-A8D4-ECBE34729BB3 (NOT the ACA3 one — that's inert on this generation despite being advertised as a write target)
  2. 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 is 0x0001.
  3. KEYSTONE: after SetUTC, bump the seq counter so the high byte becomes 0x01. All subsequent commands use 0x01XX. Without this jump the device ACKs writes but the display driver silently ignores them. This is the single most important rule in the protocol.
  4. Run iOS's ~30-command init sequence in the exact order, throttled to ~60ms per command. ~1s pause before deviceInfo (slot 30).
  5. Init ends with bd 2f 02 / 31. NEVER re-send these after init — doing so causes immediate BLE disconnect.
  6. Brightness, Lighting, channel switches send directly without re-unlock.
  7. Image preamble bd 31 SLOT 01 / 9f CONFIRM (CONFIRM = SLOT + 0xB1) goes right before each 0x44 image. Slot starts at 0 and increments by 3.
  8. The preamble does NOT precede non-image commands.
  9. Solid colors go through Lighting (45 01 RR GG BB BRI 00 01), not 0x44 image.
  10. 0x44 image bytes are NOT bit-reversed. (hass-divoom does bit-reversal for older Divoom hardware; that's wrong for this generation.)
  11. The 3 bytes between frameSize and colorCount are F4 01 00, not zeros.
  12. Image transfers larger than ~138 bytes are split into 138-byte BLE writes at the app layer, regardless of MTU.
  13. 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 0x44 static 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 (bleak handles the FFI)
  • Linux / Raspberry Pi — works via BlueZ backend
  • Windows — bleak supports 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

divoom_protocol-0.2.0.tar.gz (51.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

divoom_protocol-0.2.0-py3-none-any.whl (39.5 kB view details)

Uploaded Python 3

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

Hashes for divoom_protocol-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c300b5db72f48272b2c446ec587028121d57a86610699d492bd4dd870c0a15fa
MD5 3af46fc61c41bd746b965195f7a58297
BLAKE2b-256 1281dd20a57975f3d9d7bf78f784c53f581521e42cd8fe6c464ac7d10a75f241

See more details on using hashes here.

File details

Details for the file divoom_protocol-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for divoom_protocol-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0d06aee7b60fbbb097c4efb32dc249f276eee1e4d8e0913605269c27582cc39a
MD5 83dc3bc4b257fe19bf0789e8004c4527
BLAKE2b-256 686f6df3288d546156a917ae09fa7c02fa13ee51d31de5fc354310caef9ad2f6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page