<!-- Source: https://medius.k4tech.net/native/frame -->
# Frame format

_The one packet shape_

Every 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.

```text
[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 request_

`SEQ` 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`](/native/commands/requests.md#requests) | The box copies your `SEQ` onto the [`RESP`](/native/commands/requests.md#resp), so with several requests outstanding the matching `SEQ` tells you which reply answers which. |

## Opcodes

_The TYPE byte_

The 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`](/native/commands/move.md#move) | PC→box | 3 or 5 bytes | none |
| `0x02` | reserved | \- | \- | \- |
| `0x03` | [`INJECT`](/native/commands/inject.md#inject) | PC→box | 4 bytes | none |
| `0x04` | [`RESET`](/native/commands/admin.md#reset) | PC→box | 0 bytes | none |
| `0x05` | [`QUERY`](/native/commands/requests.md#requests) | PC→box | 1 byte | [`RESP`](/native/commands/requests.md#resp) |
| `0x06` | [`RESP`](/native/commands/requests.md#resp) | box→PC | varies | none |
| `0x07` | [`REBOOT`](/native/commands/admin.md#reboot) | PC→box | 1 byte | none |
| `0x08` | [`LOG`](/native/commands/admin.md#log) | box→PC | varies | none |
| `0x09` | [`LED`](/native/commands/led.md#led) | PC→box | 3 bytes | none |
| `0x0A` | [`LOCK`](/native/commands/lock.md#lock) | PC→box | 5 bytes | none |
| `0x0B` | [`CATCH`](/native/commands/catch.md#catch) | PC→box | 1 byte | none |
| `0x0C` | [`MOTION_EVENT`](/native/commands/catch.md#motion-event) | box→PC | 6 bytes | none |
| `0x0D` | reserved | \- | \- | \- |
| `0x0E` | reserved | \- | \- | \- |
| `0x0F` | [`USAGE_EVENT`](/native/commands/catch.md#usage-event) | box→PC | varies | none |
| `0x10` | reserved | \- | \- | \- |
| `0x11` | [`OPTION`](/native/commands/option.md#option) | PC→box | varies | none |
| `0x12` | [`CLIP_APPEND`](/native/commands/clip.md#append) | PC→box | varies | none |
| `0x13` | [`CLIP_CTRL`](/native/commands/clip.md#ctrl) | PC→box | 1 byte | none |
| `0x14` | [`CLIP_SET`](/native/commands/clip.md#set) | PC→box | 2 bytes | none |
| `0x15` | [`CLIP_TRIGGER`](/native/commands/clip.md#trigger) | PC→box | 6 bytes | none |

`0x02`, `0x0D`, and `0x0E` were the old `WHEEL`, `KEY`, and `CONSUMER` commands, folded into [`MOVE`](/native/commands/move.md#move) (motion-tagged) and [`INJECT`](/native/commands/inject.md#inject) (class-tagged). `0x10` was `CONS_EVENT`, folded into the class-tagged [`USAGE_EVENT`](/native/commands/catch.md#usage-event). The old numbers are never reused.

## Checksum & integrity

_Rejecting corrupted frames_

The last two bytes are a [CRC16-CCITT](https://en.wikipedia.org/wiki/Cyclic_redundancy_check) 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 |

```python
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`](/native/commands/move.md#move) of `dx = 100`, `dy = 0`.

-   Opcode `0x01`.
-   Payload is the `motion` byte (`00` = cursor) then the two 16-bit values `dx` and `dy` (`64 00`, `00 00`).
-   `LEN` is `05 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.
