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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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). |
| GLYPH_SIZE | 0x22 (34) |
v13+. Get/set the legend size — how large a key’s main legend is drawn. A closed range (0 small / 1 medium / 2 large); anything else NACKs. |
| GET_LAYER_NAMES | 0x23 (35) |
v14+. Read-only. The keyboard reports how many layers it lets you remap and what each is called — the labels the layout editor puts on its layer tabs. The reply leads with its own total length, then the count, then one NUL-terminated name per layer. |
| MACRO_INFO | 0x24 (36) |
v15+. How many macros the keyboard holds, how long a label may be, and how much of the shared body buffer is used. Read-only. |
| MACRO_BODY | 0x25 (37) |
v15+. Read or write a window of the shared macro body buffer — offset, count, bytes. The bodies are NUL-separated, so the host reads the whole buffer, edits one and writes it back. |
| MACRO_LABEL | 0x26 (38) |
v15+. Get/set one macro’s label — the short text the keycap spells out along its bottom edge. |
| CRASH_RECORD | 0x27 (39) |
v16+. Read the keyboard’s last crash record — 0 this half, 1 the link-side half (pulled over the split link), 2 clears it. The reply is a flags byte (present / fresh) and the 48-byte record: crash kind, core, the faulting pc/lr/sp, what the firmware was doing, uptime and firmware version. |
| IDLE_TIMEOUT | 0x28 (40) |
v18+. Get/set how long the keyboard waits before it starts fading into the idle style — a preset index (0 = 15 sec … 5 = 5 min), or 0xFF to query. A closed range like GLYPH_SIZE: anything else NACKs. The reply carries both the preset and its duration in seconds, so a host older than the keyboard can still label a preset it has never heard of. |
| BOOTLOADER / HANDEDNESS | 0x17 / 0x19 |
Enter bootloader; set which half is which. |
Layer names — the reply format
Section titled “Layer names — the reply format”GET_LAYER_NAMES answers with a payload that describes its own length, so a reader
knows when it has the whole thing without waiting for a timeout:
| Offset | Bytes | Meaning |
|---|---|---|
0 |
1 | total — the whole payload length, this byte included |
1 |
1 | count — how many layers are named |
2… |
rest | count NUL-terminated ASCII names, at most 8 characters each |
With today’s eight remappable layers that is 54 bytes, which fits a single 64-byte
report (three of which are the P, command and status header). A longer set simply
spans more reports; the reader concatenates each report’s payload and stops once it
holds total bytes.
Two details follow from carrying the total rather than padding each name to a fixed width. The zero fill at the end of the final report is never examined, because the total already says where the names stop — so it can’t be mistaken for a separator. And a layer with no name is expressible: it is simply an empty entry, which stays distinguishable from that fill.
Resource-flash transport — BEGIN / CHUNK / COMMIT
Section titled “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
Section titled “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
Section titled “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. - v13 — GLYPH_SIZE (
0x22): the legend size — draw a key’s main legend at one of three sizes, leaving the Shift/AltGr previews alone. Unlike the glyph script, the range is closed: the keyboard refuses a size it doesn’t know, because an accepted-but-unrenderable size would look like a setting that does nothing. - v14 — GET_LAYER_NAMES (
0x23): the keyboard names its own remappable layers, so the layout editor labels its tabs from the board in front of you rather than from a list baked into the host at build time. It reports the same layer count as the existing count command, so the editor can never draw a tab it has no name for. - v15 — macros (
0x24–0x26): store text or a short key sequence on the keyboard and type it back with one key, with a short label the keycap spells out so a row of macro keys readspush,work mail,sign offrather than M0, M1, M2. All three commands sit behind a single feature gate — a host that could read the macro list but not the bodies would draw an editor over data it cannot fetch. See Macros. - v18 — IDLE_TIMEOUT (
0x28): how long the keyboard waits before it starts fading into the idle style becomes a setting — six presets from 15 seconds to 5 minutes — where it used to be fixed at 2 minutes when the firmware was built. 2 minutes stays the default, so a keyboard nobody touches behaves as it always did. Like the legend size and unlike the glyph script, the range is closed: a keyboard refuses a preset it doesn’t know, because an accepted one would be stored and then silently resolved to some other duration. - v16 — CRASH_RECORD (
0x27): the firmware records a hardware fault, an unexpected exception or a watchdog restart, reboots through it, and announces it on its next boot — on the console (which the host turns into an alert) and over this command. See when the keyboard firmware crashes.