Protocol, descriptors, record layouts and measured firmware behaviour.
Command-line usage lives in examples.md; the
README covers install and quick start.
- Device identity
- USB topology
- Programming protocol
- Key and action encoding
- LED encoding
- Read-back (
0xFA) - Wire capture format
- Config semantics
- Verification strategy
- Vendor tool reverse engineering
- Related tooling
- Open problems
- Environment limitations
| Field | Value |
|---|---|
| USB VID:PID | 514c:8851 |
| Vendor string | c̪USB Keyboardȉ (sloppy embedded string, non-UTF8 padding) |
| Product string | USB Keyboardȉ |
| Serial | 433132353134362E → ASCII C125146. |
| bcdUSB / bcdDevice | 1.10 / 1.00 |
| Speed | Full Speed (12 Mb/s) |
| MaxPower | 400 mA |
| Maker (label) | Hangzhou Hangkai Technology Co. / sikaicase.com |
| MCU | WCH (WinChipHead) CH57x RISC-V (2.4 GHz, optional BLE; this unit is wired-only) |
0x514c (and sibling 0x1189) is not a USB-IF-registered company — one factory
uses both VIDs for this whole family of CH57x macro keypads, rebadged widely.
Family PIDs on the same firmware: 0x8830–0x8833, 0x8840, 0x8842, 0x8850, 0x8851, 0x8890.
Two HID interfaces in one configuration (lsusb -v -d 514c:8851):
Interface 0 — vendor / programming channel (what upload, read, led use)
- class
3, subclass0, protocol0— vendor-defined HID, creates no input device - report descriptor 36 bytes: usage page
0xFF00, one report ID0x03, 64-byte IN and 64-byte OUT reports - endpoints
0x82IN /0x02OUT, interrupt, 64 bytes,bInterval1 - appears as
/dev/hidrawNonly (no/dev/inputnode) — pressing keys never appears here, however hard you press
Interface 1 — real keyboard/mouse (what watch reads)
- class
3, subclass1(boot), protocol1(keyboard) - report descriptor 207 bytes, four report IDs:
0x01keyboard (6-key array),0x02mouse (3 buttons, X/Y/wheel),0x04keyboard (9-key array),0x05consumer control (16-bit usage) - endpoint
0x81IN only, interrupt, 16 bytes,bInterval1 (1 ms → 1000 Hz poll) - bound to
hid-generic→/dev/input/eventN(two nodes: keyboard + consumer)
Consequences for the implementation:
- Report IDs
0x01/0x04mean the descriptor advertises up to 9 keys; the firmware family supports layouts up to 21 buttons, and the tool cannot infer the physical layout —rows/columns/knobscome fromconfig.yaml. Device::open()sends a 64-byte zero init packet (mirrors the Rustopen_device). Interface 1 has no OUT endpoint, so the init write is skipped there — that is whywatch(which pins interface 1) must not attempt it.- Claiming interface 1 auto-detaches
hid-generic; the keys stop typing to the OS until the tool exits, and the driver is re-attached on close.
Transport: interrupt OUT to endpoint 0x02, 64-byte payload, first byte is the HID
report ID 0x03 (libusb writes the payload without a separate report-ID prefix byte;
the vendor tool sends 65 bytes via hidapi, which prepends it). Every command is one
64-byte report, zero-padded. Every OUT report is answered by a one-byte 00 IN
report.
| Command | Bytes | Purpose |
|---|---|---|
| Init | [0x03,0x00 ×63] |
sent once on open (vendor interface only) |
| Identify | [0x03,0xFB,0xFB,0xFB,…] → read 64 B |
button count + board variant |
| Read config | [0x03,0xFA,0x0F,0x03,n] (legacy) / [0x03,0xFA,0x19,0x00,n] (new) → read 64 B ×N |
stream stored bindings back |
| Bind key | [0x03,0xFE,keyID,layer+1,kind,…payload] |
one binding |
| LED block | [0x03,0xFE,0xB0,layer+1,0x08,…,code@12] |
per-layer backlight setting |
| Finish | [0x03,0xAA,0xAA,0,…] |
per-key terminator |
| Commit | [0x03,0xFD,0xFE,0xFF] |
save to flash (vendor tool sleeps 200 ms here) |
Per bound key, upload emits exactly:
[0] bind 03 fe <keyID> <layer+1> <kind> …
[64] finish 03 aa aa
[128] commit 03 fd fe ff
[192] finish 03 aa aa
plus a 4th 64-byte report (kind 5, delay) between bind and finish when the action
has a {delay(n)}.
Identify response — this unit answers 03 fb 03 00 00 …:
| Byte | Meaning | This board |
|---|---|---|
[0] |
report ID | 0x03 |
[1] |
echo of opcode 0xFB |
0xfb |
[2] |
programmable button count (knobs excluded) | 3 |
[3] |
board variant / style byte | 0x00 |
[4] |
firmware status | 0x00 (legacy; 0x0a on newer firmware) |
No key codes are returned — identify is layout metadata only.
Key id (Keyboard884x::toKeyId, k884x to_key_id):
| Physical key | Key id |
|---|---|
button n (0-based) |
n + 1 → 1–15 (n ≥ 12 rejected when knobs == 4) |
knob m (0–2), action a |
16 + 3·m + a → 16–24 |
4th knob (buttons ≤ 12) |
13 + a → 13–15 |
| LED block | 0xB0 |
a = 0 ccw, 1 press, 2 cw. Driver constraint:
(buttons ≤ 15 && knobs ≤ 3) || (buttons ≤ 12 && knobs ≤ 4).
Bind record layout (byte offsets inside the 64-byte report; kind at [4]):
| kind | Meaning | Payload |
|---|---|---|
1 |
keyboard sequence | count at [10] (0 when a lone modifier chord), then (modifier, usage) pairs from [11], ≤ 18 accords |
2 |
media / consumer | u16 consumer usage little-endian at [11] |
3 |
mouse | action [10], modifier [11], buttons [12], dx [13], dy [14], wheel [15] |
5 |
delay | u16 ms at [5] (≤ 6000), attaches to the record before it |
8 |
LED block (key id 0xB0) |
mode byte at [12] |
Mouse action bytes: 0x01 click, 0x03 wheel, 0x05 move (buttons 0) / drag.
Modifier bits (keyboard): standard HID bitmap — ctrl 0x01, shift 0x02,
alt 0x04, win 0x08, rctrl 0x10, rshift 0x20, ralt 0x40, rwin 0x80.
Mouse modifier bits: ctrl 0x01, shift 0x02, alt 0x04.
Consumer page (0x0C) usages accepted as media actions:
| name | usage | name | usage |
|---|---|---|---|
screenbrightnessup |
0x6f |
play |
0xcd |
screenbrightnessdown |
0x70 |
mute |
0xe2 |
next |
0xb5 |
volumeup |
0xe9 |
previous / prev |
0xb6 |
favorites |
0x182 |
stop |
0xb7 |
calculator |
0x192 |
screenlock |
0x19e |
webpagehome |
0x223 |
webpageback |
0x224 |
webpageforward |
0x225 |
ledCode = (color << 4) | mode, written to byte [12] of the 0xB0 block.
| Mode string | Mode nibble | Notes |
|---|---|---|
off |
0 |
byte 0x00 |
backlight <color> |
1 |
steady backlight; white is special: 0x05 (mode 5 + color 0) |
shock <color> |
2 |
no baseline backlight, flash/ripple on keypress |
shock2 <color> |
3 |
alternative keypress animation |
press <color> |
4 |
no baseline backlight, only the pressed key lights |
| Color | Nibble | Color | Nibble |
|---|---|---|---|
white (backlight only) |
0 | green |
4 |
red |
1 | cyan |
5 |
orange |
2 | blue |
6 |
yellow |
3 | purple |
7 |
shock2 is matched before shock (shared prefix). 23 valid combinations
(off + white backlight + 7 colours × 3 animated modes). Layer is 0-based in the
CLI, sent as layer + 1.
Measured on this board with --capture (status = 0x00, "legacy" firmware):
03 fa 0f 03 <n>(n= 1, 2, 3 …) streams the stored map back as 64-byte reports, 12–13 per request. The vendor tool caps that counter at3·buttons + 15(24 here); the newer firmware form03 fa 19 00 <n>caps at3·buttons + 1.readConfig()tries the preferred variant, falls back to the other if it yields nothing, stops at the first empty reply, and resends an unanswered request once.- Every OUT report is acknowledged by a one-byte
00IN report, so the reply stream alternates00, record,00, record … Those ACKs are neither records nor end-of-stream: they must not count against a per-request budget (doing so halves the records retrieved) and must not be confused with all-zero filler reports. - Records use the bind layout above except byte
[1], which carries the read opcode0xFAinstead of the write opcode0xFE.decodeBind()accepts both, so read-back records andupload's own output decode identically. - Records arrive in storage order, not key order (ids jump, layers interleave); the
tool keeps stream order because a
delayrecord belongs to the record before it. - This firmware stores 24 slots × 3 layers = 72 binding records — a live
readreturns all 72. Slots 1–3 are the physical keys; slots 4–15 (beyond the grid) and 16–24 (knob range) hold a factory default image (a..r, digits1..9) and are labelledslot Nrather than pretending to be physical keys. - Layer byte:
1-based on the wire (bindKeyrejects index > 15, so 16 layers max); this board stores 3, whichreadshows as L1/L2/L3.
Cross-check against hardware: pressing the keys typed a, b, c before anything
was uploaded, matching the decoded 0xFA map — the read-back path is confirmed end to
end on a factory-default unit.
--capture <file> (or CH57X_CAPTURE=<file>) hooks the two libusb transfer calls in
src/usb.cpp (Device::send, Device::readReport), so the file is exactly what
the wire carried, including timed-out reads (which mark where a reply stream ends).
Writer/reader: src/capture.cpp.
frame := 'C' 'X' | dir | len(u8) | seq(u32 le) | len payload bytes
dir := 'O' host->device OUT report | 'I' IN report received | 'T' IN timed out
decode <capture-file> [config] walks that log offline, groups IN reports by the
03 fa … request that produced them, drops one-byte ACKs and all-zero filler, reports
records per request, and prints the same table read prints. Capture once with a
device, then iterate on the parser with no device and no root.
Reference fixture: testdata/board-514c-8851.captured
is a partial capture — 4 requests, 37 records, the 4th unanswered — recorded during an
early read attempt. decode on it prints 37 records and flags the gap; a live read on
the board returns all 72. It is kept because the partial case is worth testing against.
config.yaml fields: model (optional; 514c:8851 auto-detects to ch57x-1),
orientation, rows, columns, knobs, and layers[] with a buttons grid
(rows lists of columns) and knobs[] (ccw/press/cw).
orientationmaps the config grid onto the physical keys:normal,upsidedown,clockwise,counterclockwise.normal/upsidedownare horizontal,clockwise/counterclockwisetransposerows×columns.- A key slot left empty (
~/null) is skipped — no record is written for it. - Limited layout: when
(rows == 1 || columns == 1) && knobs == 1, the firmware only honours modifiers on the first key of a sequence, soctrl-ais accepted anda,ctrl-bis rejected byrender()(src/config.cpp:106). - Model names are decoupled from the PID (they select driver + preferred endpoint):
ch57x-1=514c:8851(EP0x02);ch57x-2=1189:8890,ch57x-3=514c:8850(drivers not ported —createDriverthrows).
- Byte-exact dry run.
dumpprints the exact 64-byte OUT reportsuploadwould transmit;expected_dump.txtis that output for the repoconfig.yaml. Regenerate after intentionally changingconfig.yaml:./build/ch57x-keyboard-tool dump config.yaml > expected_dump.txt - The reports match the known-good Rust reference byte for byte — e.g. the media bind
is
03 fe <keyID> 01 02 … <usage-low>(03 fe 01 01 02 … eaforvolumedown), matchingtest_media_macro_bytesinkriomant/ch57x-keyboard-tool/src/keyboard/k884x.rs, followed by the per-key finish/commit/finish sequence. ch57x-selftest(171 checks, no USB): name/code tables, parser, key-id mapping, wire-encoding byte offsets vs the Rust reference, record decode round-trip including raw hardware records carrying the0xfaread opcode, capture round-trip, LED codes, orientation/render, and the first-key-only limited-layout rule.show-keys,validate,dump,decodeneed no device and no root.
The vendor bundle — see vendor/README.md for the download URL,
checksums and file inventory (the binaries themselves are not committed) — is a full
MinGW/Qt build tree: .o objects with unstripped symbol tables, moc sources, Makefiles.
Ghidra RE of widget.o (the protocol core, ~200 defined symbols) shows the protocol is
identical to ch57x-keyboard-tool's k884x.rs.
- Stack — Qt 5.14.2 Widgets, qmake (
KEY_PRO.pro→MINI_KEYBOARD.exe), MinGW GCC 32-bit (mingw73_32), USB through hidapi (hid_enumerate/hid_open/hid_write/hid_read_timeout/hid_set_nonblocking); app sourcesmain.cpp,widget.cpp(+dialog1/2/3,reminderwidget,thread). - VID/PID — VID
0x514cas 16/32-bit immediates; PID support table (u16 array in.data):0x8842 0x8840 0x8830 0x8831 0x8832 0x8833 0x8850 0x8851 0x8840 0x1189(0x8851present). The open-source tool additionally maps1189:8890→ch57x-2and514c:8850→ch57x-3. - Auto-detect —
Identify_KeyBoard_style()maps(count, variant, status)to ~25Set_Keyboard_<N>add<M>initializers (N = buttons 0–21, M = extra knobs 0–4). That is why the CLI needs an explicitrows×columns/knobs: it does not run this mapping. - LED UI — buttons named
LED_Mode_0…LED_Mode_5andLED_color_1…LED_color_51: 6 mode codes (matching the table above,5= white backlight) and a swatch palette wider than the 8 protocol colour values. No mode labels survive as strings. - The vendor tool also does read-back (
0xFA) and a per-model GUI; the Rust CLI is write-only, this C++ port implements both directions. - Beyond remapping — bindings live in the CH57x MCU flash/EEPROM. Dumping/reflashing
the MCU is possible over WCH two-wire debug (
wchisp/wlink), but read-out protection is likely enabled. The HID vendor channel is the supported path and is all that remapping requires.
| Tool | Notes |
|---|---|
kriomant/ch57x-keyboard-tool |
Rust CLI, the reference implementation this C++ port follows. 514c:8851 = model ch57x-1, endpoint 0x02. Not in any release tag: support landed on main 2026-07-14 (7e2074e, #188) + endpoint fix aff3382; latest release v1.7.0 lacks 514c. Build from main: cargo install ch57x-keyboard-tool --git https://github.com/kriomant/ch57x-keyboard-tool. Write-only (no read). |
jvspier/macropad-rebind |
WebHID browser GUI (Chromium only), read-back + LED control, 514c:8851 listed as fully working. Serve index.html locally, needs /dev/hidraw access. |
MINI_KEYBOARD.exe (vendor; how to fetch it: vendor/README.md) |
Windows-only; auto-detects layout; same protocol. |
- Layer toggle on this 3-key board. The stored map has L1–L3; the vendor UI exposes one layer — the others are probably FN-based.
- LED round-trip on hardware.
setLedencoding is byte-identical to the Rust reference and covered by selftest, but aledwrite followed byreadhas not been confirmed on the board. - Newer firmware path (
identifystatus0x0a→03 fa 19 00 <n>) is implemented but untested against hardware. - Second unit / other PIDs (
ch57x-2,ch57x-3) — drivers not ported.
The dev sandbox has no /dev/bus/usb, no /dev/hidraw*, no dmesg and no
/dev/usbmon, so live packet capture and control transfers cannot run there;
read/upload/led must be exercised on the host, normally under sudo (the
optional udev/99-ch57x.rules can drop that requirement). Everything else came from lsusb -v,
sysfs (/sys/bus/usb, /sys/bus/hid/*/report_descriptor),
/proc/bus/input/devices, the fixture captures, and the upstream tools/docs.
Host hardware, toolchain versions and the agent / MCP tooling used here are recorded in environment.md.