Frame format
The one packet shapeEvery message, both directions, is a frame with one fixed shape. The whole protocol is frames; each command is the same machinery with a different opcode and payload.
[SOF 0xA5][TYPE u8][SEQ u8][LEN u16 LE][PAYLOAD ≤512][CRC16 u16 LE]
| Field | Bytes | Notes |
|---|---|---|
SOF | 1 | start-of-frame marker, always 0xA5 |
TYPE | 1 | the opcode |
SEQ | 1 | per-frame sequence number |
LEN | 2 | payload byte count, little-endian |
PAYLOAD | 0-512 | the command's data; empty for argument-free commands |
CRC16 | 2 | checksum over the frame body |
Max payload 512 bytes, so a frame is at most 519. Every multi-byte number is little-endian: the 16-bit value 100 is the bytes 64 00.
Sequence numbers
Matching a reply to its requestSEQ is a one-byte counter you set per frame, typically incrementing and wrapping at 255.
| Command | Role of SEQ |
|---|---|
| Ordinary commands | Only helps you spot a dropped frame. |
QUERY | The box copies your SEQ onto the RESP, so with several requests outstanding the matching SEQ tells you which reply answers which. |
Opcodes
The TYPE byteThe opcodes run from 0x01 to 0x15. Four values are reserved, retired by the unified-input collapse. An unrecognised opcode is ignored harmlessly, which keeps newer and older firmware compatible.
| Opcode | Name | Direction | Payload | Reply |
|---|---|---|---|---|
0x01 | MOVE | PC→box | 3 or 5 bytes | none |
0x02 | reserved | - | - | - |
0x03 | INJECT | PC→box | 4 bytes | none |
0x04 | RESET | PC→box | 0 bytes | none |
0x05 | QUERY | PC→box | 1 byte | RESP |
0x06 | RESP | box→PC | varies | none |
0x07 | REBOOT | PC→box | 1 byte | none |
0x08 | LOG | box→PC | varies | none |
0x09 | LED | PC→box | 3 bytes | none |
0x0A | LOCK | PC→box | 5 bytes | none |
0x0B | CATCH | PC→box | 1 byte | none |
0x0C | MOTION_EVENT | box→PC | 6 bytes | none |
0x0D | reserved | - | - | - |
0x0E | reserved | - | - | - |
0x0F | USAGE_EVENT | box→PC | varies | none |
0x10 | reserved | - | - | - |
0x11 | OPTION | PC→box | varies | none |
0x12 | CLIP_APPEND | PC→box | varies | none |
0x13 | CLIP_CTRL | PC→box | 1 byte | none |
0x14 | CLIP_SET | PC→box | 2 bytes | none |
0x15 | CLIP_TRIGGER | PC→box | 6 bytes | none |
0x02, 0x0D, and 0x0E were the old WHEEL, KEY, and CONSUMER commands, folded into MOVE (motion-tagged) and INJECT (class-tagged). 0x10 was CONS_EVENT, folded into the class-tagged USAGE_EVENT. The old numbers are never reused.
Checksum & integrity
Rejecting corrupted framesThe last two bytes are a CRC16-CCITT checksum over TYPE | SEQ | LEN | PAYLOAD (everything but SOF and the checksum itself), stored little-endian. On a mismatch the box silently drops the frame and resyncs at the next 0xA5, so corrupted frames are never acted on.
| Parameter | Value |
|---|---|
| Polynomial | 0x1021 |
| Initial value | 0xFFFF |
| Bit reflection | None |
| Final XOR | None |
def crc16_ccitt(data):
crc = 0xFFFF
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
def encode_frame(type, seq, payload):
body = bytes([type, seq]) + len(payload).to_bytes(2, "little") + payload
crc = crc16_ccitt(body)
return bytes([0xA5]) + body + crc.to_bytes(2, "little")Example: a MOVE frame
A cursor MOVE of dx = 100, dy = 0.
- Opcode
0x01. - Payload is the
motionbyte (00= cursor) then the two 16-bit valuesdxanddy(64 00,00 00). LENis05 00.
+--------+--------+--------+--------+--------+--------+--------+--------+ | A5 | 01 | 00 | 05 00 | 00 | 64 00 | 00 00 | lo hi | +--------+--------+--------+--------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | motion | dx | dy | CRC16 | +--------+--------+--------+--------+--------+--------+--------+--------+
The CRC bytes are the little-endian crc16_ccitt of 01 00 05 00 00 64 00 00 00. Compute them rather than copying a literal.