HID Protocol Reference
PolyKybdHost talks to the firmware over a custom raw HID report protocol. The same protocol is used by the HIL test rig to exercise the firmware in CI.
Report format
- 64-byte raw HID reports.
- Byte 0 — Report ID
- Byte 1 — Command ID
- Byte 2+ — payload
Responses are prefixed with the command echo plus a status marker:
"P\xNN."— ACK (success), whereNNis the command ID"P\xNN!"— NACK (rejected / unsupported / out of bounds)
The main dispatcher lives in the firmware’s hid_com.c (raw_hid_receive()); a separate range
(0x50+) handles the resource-flash transport. The PROTOCOL_VERSION, reported inside the
GET_ID identity string, tells the host which commands this keyboard understands, so each
feature can be enabled or greyed out individually.
Command areas
The commands are grouped below by purpose. IDs are shown in hex, with the decimal form where the source commonly uses it.
Identity & language
| Command | ID | Notes |
|---|---|---|
| GET_ID | 0x06 | Identity string incl. PROTOCOL_VERSION and firmware version, plus (v6+) a per-bundle font-pack version block after the string’s NUL. The connect handshake and liveness check. |
| GET_LANG | 0x07 | Query the active keyboard language. |
| CHANGE_LANG | 0x09 | Select the active language. |
| GET_LANG_LIST_PACKED | 0x1b (27) | v2+. Language list as a count byte plus one 2-byte (ISO 639-1 index, ISO 3166-1 alpha-2 index) pair per language. The only language-list command on current firmware. |
| GET_LANG_LIST (ASCII) | 0x08 | Retired. Returned 4 ASCII chars per language; now NACKs (P\x08!). |
| GET_DEFAULT_LAYER | 0x16 (22) | Read/select the persisted default layer. |
Overlays — the bulk data path
| Command | ID | Notes |
|---|---|---|
| SEND_OVERLAY | 0x0A | Plain overlay: each 360-byte keycap in 6 × 60-byte segments. (v11 packs modifier + segment into one header byte so a 60-byte segment fits the report exactly.) |
| START / SEND_COMPRESSED_OVERLAY | 0x10 / 0x11 | RLE-compressed keycap in 1–2 packets; decompressed in firmware (optionally on RP2040 core1). The common case. |
| START / SEND_ROI_OVERLAY | 0x12 / 0x13 | Partial refresh of a region of a keycap’s display. |
| SEND_OVERLAY_MAPPING | 0x15 (21) | Maps overlay pool slots to displays / usage bits, at a fixed 10 bits per index. Silent since v3 (no per-chunk ACK). The only mapping command a pre-v12 keyboard understands. |
| SEND_OVERLAY_MAPPING_W | 0x21 (33) | v12+. The same mapping stream, but a width byte says how many bits each index uses (8–11), so the host packs each group of pairs as tightly as it fits. Also silent. |
| OVERLAY_FLAGS on / off | 0x0B / 0x0C | Enable/disable overlay rendering; enable force-syncs overlay state to the slave. |
| SAVE_MRU | 0x1A (26) | Persist the keyboard’s most-recently-used emoji / language picks. Unrelated to the host’s overlay cache. |
Configuration & display
| Command | ID | Notes |
|---|---|---|
| SET_BRIGHTNESS | 0x0D (13) | Keycap OLED contrast; a flags byte selects volatile / host-auto (v5+). |
| KEYPRESS | 0x0E (14) | Host-injected key event. |
| IDLE_STATE | 0x0F (15) | Start / stop the idle animation. |
| SET_UNICODE_MODE | 0x14 (20) | Select the OS unicode-input mode. |
| DISPLAY_OFF | 0x18 (24) | Blank the keycaps. |
| IDLE_STYLE | 0x1C (28) | v4+. Get/set the idle style (pulse / jitter). |
| SET_OS | 0x1D (29) | Active host OS → modifier legends. |
| GLYPH_SCRIPT | 0x1E (30) | v9+. Get/set the glyph-script override (open-ended index since v10). |
| BOOTLOADER / HANDEDNESS | 0x17 / 0x19 | Enter bootloader; set which half is which. |
Resource-flash transport — BEGIN / CHUNK / COMMIT
| Command | ID | Notes |
|---|---|---|
| FONTPACK_BEGIN | 0x50 | Erase the target slot; a byte selects the target (font-pack bundle, firmware, resource pack). |
| FONTPACK_CHUNK | 0x51 | Sequential data chunks; deferred sector erase; bridged to the slave. |
| FONTPACK_COMMIT | 0x52 | Verify CRC32 and mark the slot present. |
The same three-command flow, with a different target byte, drives firmware update, font-pack bundles, and other resource packs.
A firmware update may additionally carry an Ed25519 image signature (command 0x45, sent
before COMMIT) that the keyboard verifies against a key built into its firmware. It is additive
— older hosts simply never send it, so there is no PROTOCOL_VERSION bump — and it is now
enforced. See Firmware signing.
Enforcement gives the firmware COMMIT four status bytes rather than two:
| Byte | Meaning |
|---|---|
. | Accepted — the staged image verified. |
? | Awaiting a physical ACCEPT/REJECT on the keycaps (unsigned image). Re-poll COMMIT until it changes; the staged image and its CRC are untouched between polls. |
S | Refused — not validly signed (rejected at the prompt, timed out, or the signature failed to verify). |
! | Staged-CRC mismatch. |
? and S are why the refusal reason is no longer guessed from a bare NACK. A COMMIT carrying
'x' in data[2] cancels a pending prompt — safe to expose over HID because it can only ever
deny, whereas accepting stays a keypress on the board.
Overlay addressing
A keycap image is addressed by 90 keycap slots × 16 modifier variants:
overlay_index = keycode_slot + 90 * modifier_variantSince v12 the modifier variant is simply the modifier bitmask — bit 0 Ctrl, bit 1 Shift, bit 2 Alt, bit 3 GUI — so all 16 combinations are addressable and the numbering is self-describing (variant 0 = bare, 5 = Ctrl+Alt, 10 = GUI+Shift, 15 = all four). Before v12 there were 9 variants and GUI could not be combined with anything.
That index is an address, not storage. It selects an entry in a lookup table that points at one of 600 slots in the image pool (360 bytes each, ≈ 211 KiB). Variants that share the same artwork share a slot, so adding variants costs address space rather than image memory — which is why v12 grew the addressable set from 810 to 1440 without needing more room for pixels.
Protocol versions
The firmware advertises a PROTOCOL_VERSION (in the GET_ID string) that the host reads to decide
which commands to use. The versions do not have to match. The host connects to any keyboard
from v2 onwards and then gates each feature on its own:
- Same version — everything works.
- Keyboard older than the host — it still connects, and only the features that keyboard is too old for are greyed out, with a “some features need a firmware update” hint.
- Keyboard newer than the host — the host asks what you want to do, defaulting to a safe connect that sticks to the stable command set until you update the host app.
- Older than v2 — refused, with “Firmware too old… please update the keyboard firmware”. The host cannot even list the keyboard’s languages below that.
Selected milestones:
- v2 — the packed language list (
0x1b): a count byte plus one 2-byte ISO index pair per language, replacing the 4-ASCII-char legacy list. The legacy list (0x08) is retired and NACKs. - v3 — the overlay-mapping command (
0x15) made silent (no per-chunk ACK), matching the other bulk overlay commands and removing a frequent source of stale replies. - v4 — IDLE_STYLE (
0x1c): choose the idle anti-burn-in style (pulse / jitter). - v5 — a brightness flags byte on
SET_BRIGHTNESS(0x0d): volatile vs host-auto. - v6 — a per-bundle font-pack version block appended to the GET_ID reply, so the host flashes only the bundles a keyboard is missing or behind on.
- v9 — GLYPH_SCRIPT (
0x1e): override the letter/digit legends with an alternative script. - v10 — the glyph script became an open-ended index (an unknown script renders the normal legend), so new scripts ship without a protocol bump.
- v11 — the plain overlay upload (
0x0a) packs modifier + segment into one header byte, so a full 60-byte segment fits a 64-byte report exactly. - v12 — GUI combines with the other modifiers for app overlays:
Cmd+Shift,Cmd+Altand the rest each get their own artwork instead of all falling back to the plain GUI icon, taking the addressable modifier variants from 9 to 16. The upload commands are unchanged, but the wider index space no longer fits the fixed 10-bit fields of0x15, so SEND_OVERLAY_MAPPING_W (0x21) was added alongside it.