# AK980 wire protocol

All offsets below are **wire offsets**: byte 0 is the HID report id, bytes 1–64
are the payload. This matters because the reference C++ implementation numbers
some structures from the payload and others from the wire, and has shipped
off-by-one bugs in both directions.

## Transport

| Property | Value |
|---|---|
| Interface | HID collection with usage page **0xFF13** (Windows enumerates it as MI_03) |
| HID report id | **0x00**, unnumbered |
| Payload | 64 bytes |
| Direction | `HidD_SetFeature` on Windows → `IOHIDDeviceSetReport(kIOHIDReportTypeFeature, 0, …)` on macOS |
| Inter-packet delay | ~2 ms; the firmware drops packets sent faster |

macOS wants the report id as a separate argument and the payload without it.
Windows passes a 65-byte buffer whose byte 0 is the report id. `Frame` stores the
Windows view and strips byte 0 on the way out, so documented offsets are literal.

**Do not use the boot keyboard collection (usage page 0x0001).** It enumerates
first and is what a naive `hid_open` picks; it ignores every command here.

## Collections on a real unit (measured 2026-08-19, USB-C)

`0c45:8009`, manufacturer SONiX, product string "AK980 PRO":

| Usage page | Usage | in | out | feature | Descriptor | Role |
|---|---|---|---|---|---|---|
| 0x0001 | 0x0006 | 8 | 1 | 0 | 65 B | boot keyboard |
| 0x000C | 0x0001 | 16 | 1 | 1 | 158 B | consumer controls (+ 0xFFFF/0x0001) |
| **0xFF13** | 0x0001 | **64** | **64** | **64** | 30 B | **vendor control channel** |
| **0xFF68** | 0x0061 | 64 | **4096** | 0 | 35 B | **bulk data channel** |

Two things fall out of this table.

The 64-byte input/output/feature sizes on 0xFF13 confirm the frame model in this
document: report id 0x00, 64-byte payload, all three report types available.

More usefully, **0xFF68 declares 4096-byte output reports and no feature reports
at all**. 4096 is exactly the bulk screen block size. The bulk upload path
therefore spans both collections — control frames as feature reports on 0xFF13,
4 KiB payload blocks as output reports on 0xFF68. No published source records
this; the reference implementation notes only that its transport "has no bulk
write surface", which is what you conclude if you are holding a single 0xFF13
handle. It is not a missing surface, it is a different endpoint.

## Frame shape

Nearly every command is:

```
byte 0 : 0x00      HID report id
byte 1 : 0x04      frame magic  (NOT a report id, despite the name in some sources)
byte 2 : opcode
byte 3+: arguments
```

Two confirmed exceptions carry `0x00` at byte 1 instead: the battery query and
the time-data packet. The device is silent if you send those with the magic.

## Opcodes

| Op | Name | Notes |
|---|---|---|
| 0x01 | firmware version | reply carries major.minor.patch at payload bytes 2–4 |
| 0x02 | save RTC / save screen | distinct from 0x0E |
| 0x05 | set keycode | layer, row, col, keycode big-endian |
| 0x07 | settings batch | sub 0x10 |
| 0x08 | zone static colour | zone, R, G, B |
| 0x09 | zone effect | zone, effect, speed |
| 0x0A | RGB buffer upload | 60-byte chunks |
| 0x0B | global brightness | 0–100 |
| 0x0C | set layer | |
| 0x0D | macro upload | 56-byte chunks |
| 0x0E | commit to EEPROM | |
| 0x13 | firmware lighting mode | 20 effects |
| 0x18 | begin envelope | |
| 0x20 | sub 0x01 battery, sub 0x04 per-key RGB write | |
| 0x28 | set time | |
| 0x72 | screen bulk begin | |
| 0x7F | screen chunked begin | sub 0x03 |
| 0xF0 | end envelope | |
| 0xF5 | per-key RGB read-back | |

## Time sync — CONFIRMED

Four packets:

```
1  00 04 18 …
2  00 04 28 00 00 00 00 00 00 01 …        byte 9 = 0x01
3  00 00 01 5A yy mm dd hh mm ss 00 dow … AA 55
4  00 04 02 …
```

`yy` is year − 2000. `dow` is 0 = Sunday. The trailer occupies wire bytes 63–64.
The magic `0x5A` at byte 3 is the firmware's discriminator, which is why packet 3
does not carry the usual `0x04`.

This is the one sequence proven against real hardware, which makes it the right
smoke test for the whole stack.

## Battery — CONFIRMED

Write `00 00 20 01 …` as a feature report, then read a feature report back.
Percentage is at **payload** byte 3 (wire byte 4 of the reply). Values outside
0–100 mean the reply is something else; do not report them as a charge level.

## Lighting modes — DECOMPILE

Five packets: `0x18` → `0x13` → data → `0x02` → `0xF0`.

Data packet:

```
byte 1  : 0x04
byte 2  : mode id  (0x00–0x13)
byte 3  : R
byte 4  : G
byte 5  : B
byte 9  : rainbow flag
byte 10 : brightness 0–5
byte 11 : speed 0–5
byte 12 : direction 0=left 1=down 2=up 3=right
byte 15 : 0x55
byte 16 : 0xAA
```

Modes: 0x00 static, 0x01 single-on, 0x02 single-off, 0x03 glittering,
0x04 falling, 0x05 colourful, 0x06 breath, 0x07 spectrum, 0x08 outward,
0x09 scrolling, 0x0A rolling, 0x0B rotating (firmware default), 0x0C explode,
0x0D launch, 0x0E ripples, 0x0F flowing, 0x10 pulsating, 0x11 tilt,
0x12 shuttle, 0x13 off.

## Per-key RGB — DECOMPILE, with a known contradiction

Three steps:

1. Header `00 04 20 04 00 00 00 00 00 <mode> …` where mode is 0x03 wired,
   0x08 wireless.
2. The RGB blob as bare 64-byte feature reports — **no opcode framing at all**.
   Context comes from step 1.
3. Save `00 04 02 …`.

Blob layout differs by link:

- **Wired**: 192 bytes, one intensity byte per LED. Monochrome only — the
  firmware combines the intensity with a base colour set separately via 0x08.
- **Wireless**: 512 bytes, four bytes per LED: reserved, R, G, B.

> The reference implementation places the mode byte at wire byte 10 while its
> own protocol note places it at byte 9. We follow the note; `--perkey-mode-offset`
> exists to test the alternative. A capture settles it.

## Settings batch — DECOMPILE

```
byte 1  : 0x04
byte 2  : 0x07
byte 3  : 0x10
byte 7  : disable Windows key
byte 8  : disable Alt+F4
byte 9  : disable Alt+Tab
byte 10 : Fn layer switch  (0 hold, 1 toggle)
byte 11 : sleep timer minutes  (0, 1, 3, 5, 10, 30)
byte 13 : key response level 1–5
byte 19 : 0xAA
byte 20 : 0x55
```

Wrapped in the `0x18` … `0x02` … `0xF0` envelope.

## TFT screen — DECOMPILE, unverified, three open questions

Panel is 240 × 135, RGB565, up to 140 frames. One frame is 64 800 bytes.

### Chunked path (opcode 0x7F 0x03)

Header:

```
byte 1   : 0x7F
byte 2   : 0x03
byte 3   : 0x00
byte 4   : lcd index + 1
bytes 5-7: total chunk count, 24-bit little-endian
byte 32  : checksum = sum of all other bytes, mod 256
```

Chunk:

```
byte 1     : 0x80 | ((index >> 16) & 0x7F)
byte 2     : index & 0xFF
byte 3     : (index >> 8) & 0xFF
bytes 4-31 : 28 bytes of stream data
byte 32    : checksum
```

The index split is genuinely that odd: high bits ride the marker byte, then low,
then middle. Decode with `idx = b2 | (b3 << 8) | ((b1 & 0x7F) << 16)`.

At 28 bytes and 2 ms per packet, one frame is 2 315 packets ≈ 4.6 s, and a full
140-frame animation is about eleven minutes.

### Bulk path (opcode 0x72)

`0x18` start → `0x72` begin with a 16-bit block count at bytes 9–10 → raw 4 KiB
blocks → `0x02` save. Roughly 143× faster and completely unproven.

### The three open questions

1. **Header offset.** The vendor config says `gif_headlength=256`, and the code
   writes frame count and per-frame delays into the head of the stream — but the
   decompile never assigns the variable that would decide whether 256 bytes are
   actually prepended. Either 0 or 256. `--header-bytes` switches it.
2. **Byte order and walk direction.** The function that produces RGB565 is a
   `mui.dll` export nobody has decompiled. Big-endian top-down row-major is the
   standard assumption. `--little-endian` switches half of it; the test pattern
   reveals the rest.
3. **Output vs feature reports.** The decompile shows output reports for the
   chunked path, but every command *confirmed* on this hardware uses feature
   reports. `--transport chunked-feature` tests the correction.

Each is one flag and one photograph of the panel. See `docs/CAPTURE.md` for the
way to settle all three at once.
