Metadata-Version: 2.4
Name: divoom-protocol
Version: 0.2.0
Summary: BLE protocol library for the modern Divoom Backpack M / TimeBox-Evo-audio family (Anyka 105xE / AkOS firmware).
Author: Orion Parrott
License: MIT
Project-URL: Homepage, https://github.com/orionparrott/divoom-protocol
Project-URL: Source, https://github.com/orionparrott/divoom-protocol
Project-URL: Issues, https://github.com/orionparrott/divoom-protocol/issues
Project-URL: Changelog, https://github.com/orionparrott/divoom-protocol/blob/main/CHANGELOG.md
Keywords: divoom,ble,bluetooth,pixel-art,led-matrix,ambient-display,reverse-engineering,iot
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: bleak>=0.21
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# 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](#open-questions). APIs may shift before 1.0.

## Quick start

```bash
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

```python
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](#open-questions) below.

```python
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](divoom_protocol/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.

```python
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.

```python
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.

```bash
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](PROTOCOL.md) (and originally in [PixelForgeProbe ADR-0002](https://github.com/orionparrott/PixelForgeProbe/blob/main/docs/adr/0002-ble-image-protocol.md)). 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](https://github.com/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](https://github.com/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](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.
