Frame format
Every message, both directions, has this shape.
[SOF 0xA5][TYPE u8][SEQ u8][LEN u16 LE][PAYLOAD 0..512][CRC16 u16 LE]
| Field | Bytes | Notes |
|---|---|---|
SOF | 1 | start-of-frame marker, always 0xA5 |
TYPE | 1 | opcode |
SEQ | 1 | per-frame sequence number |
LEN | 2 | payload byte count, little-endian |
PAYLOAD | 0-512 | command data; empty for argument-free commands |
CRC16 | 2 | checksum over the frame body |
Max frame 519 bytes. Multi-byte numbers are little-endian: 16-bit 100 is 64 00.
Sequence numbers Reply matching
SEQ is a one-byte, caller-set counter per frame, typically incrementing and wrapping at 255.
| Command | Role |
|---|---|
| Ordinary commands | Dropped-frame detection only. |
QUERY | Copied onto the RESP, pairing each reply with its request when several are outstanding. |
TRANSFER | Copied onto the TRANSFER_RESP. |
UPDATE | An UPDATE_RESP matches its request by the echoed op. DATA acknowledgements carry a rolling SEQ, since one covers a whole window of chunks. |
OpcodesTYPE byte
An unrecognised opcode is ignored.
| Opcode | Name | Direction | Payload | Reply |
|---|---|---|---|---|
0x01 | MOVE | PC→box | 4 or 6 bytes | none |
0x02 | reserved | - | - | - |
0x03 | INJECT | PC→box | 4 bytes | none |
0x04 | RESET | PC→box | 1 byte | none |
0x05 | QUERY | PC→box | 1 or 2 bytes | 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 | 6 bytes | none |
0x0B | CATCH | PC→box | 6 bytes | none |
0x0C | MOTION_EVENT | box→PC | 13 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-40 bytes | none |
0x16 | TRAFFIC_EVENT | box→PC | varies | none |
0x17 | UPDATE | PC→box | 2-508 bytes | UPDATE_RESP |
0x18 | UPDATE_RESP | box→PC | 7 bytes | none |
0x19 | RAW | PC→box | 2-512 bytes | none |
0x1A | TRANSFER | PC→box | 9-512 bytes | TRANSFER_RESP |
0x1B | TRANSFER_RESP | box→PC | 2-506 bytes | none |
0x1C | REWRITE | PC→box | 9-510 bytes | none |
0x1D | PATCH | PC→box | 1-510 bytes | none |
0x1E | TRANSFORM | PC→box | 8 bytes | none |
Retired numbers are never reused. 0x02 was WHEEL, now MOVE (motion-tagged). 0x0D and 0x0E were KEY and CONSUMER, now INJECT (class-tagged). 0x10 was CONS_EVENT, now the class-tagged USAGE_EVENT.
ChecksumRejecting corrupted frames
The last two bytes are a CRC16-CCITT checksum over TYPE | SEQ | LEN | PAYLOAD, stored little-endian. On a mismatch the box drops the frame with no reply and resyncs at the next 0xA5.
| 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 MOVE frame
A cursor MOVE of dx = 100, dy = 0.
- Opcode
0x01. - Payload:
motion(00= cursor), 16-bitdxanddy(64 00,00 00), thenflags(00). LENis06 00.
The CRC is the little-endian crc16_ccitt of 01 00 06 00 00 64 00 00 00 00. Compute it; don't copy a literal.