Skip to content

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), where NN is 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

CommandIDNotes
GET_ID0x06Identity 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_LANG0x07Query the active keyboard language.
CHANGE_LANG0x09Select the active language.
GET_LANG_LIST_PACKED0x1b (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)0x08Retired. Returned 4 ASCII chars per language; now NACKs (P\x08!).
GET_DEFAULT_LAYER0x16 (22)Read/select the persisted default layer.

Overlays — the bulk data path

CommandIDNotes
SEND_OVERLAY0x0APlain 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_OVERLAY0x10 / 0x11RLE-compressed keycap in 1–2 packets; decompressed in firmware (optionally on RP2040 core1). The common case.
START / SEND_ROI_OVERLAY0x12 / 0x13Partial refresh of a region of a keycap’s display.
SEND_OVERLAY_MAPPING0x15 (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_W0x21 (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 / off0x0B / 0x0CEnable/disable overlay rendering; enable force-syncs overlay state to the slave.
SAVE_MRU0x1A (26)Persist the keyboard’s most-recently-used emoji / language picks. Unrelated to the host’s overlay cache.

Configuration & display

CommandIDNotes
SET_BRIGHTNESS0x0D (13)Keycap OLED contrast; a flags byte selects volatile / host-auto (v5+).
KEYPRESS0x0E (14)Host-injected key event.
IDLE_STATE0x0F (15)Start / stop the idle animation.
SET_UNICODE_MODE0x14 (20)Select the OS unicode-input mode.
DISPLAY_OFF0x18 (24)Blank the keycaps.
IDLE_STYLE0x1C (28)v4+. Get/set the idle style (pulse / jitter).
SET_OS0x1D (29)Active host OS → modifier legends.
GLYPH_SCRIPT0x1E (30)v9+. Get/set the glyph-script override (open-ended index since v10).
BOOTLOADER / HANDEDNESS0x17 / 0x19Enter bootloader; set which half is which.

Resource-flash transport — BEGIN / CHUNK / COMMIT

CommandIDNotes
FONTPACK_BEGIN0x50Erase the target slot; a byte selects the target (font-pack bundle, firmware, resource pack).
FONTPACK_CHUNK0x51Sequential data chunks; deferred sector erase; bridged to the slave.
FONTPACK_COMMIT0x52Verify 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:

ByteMeaning
.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.
SRefused — 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_variant

Since 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.
  • v4IDLE_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.
  • v9GLYPH_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.
  • v12GUI combines with the other modifiers for app overlays: Cmd+Shift, Cmd+Alt and 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 of 0x15, so SEND_OVERLAY_MAPPING_W (0x21) was added alongside it.