Everything here was measured against a PD-KB401B (HHKB Professional Classic,
US layout) running firmware A4.29, on macOS 27.0.
The wire protocol was first worked out by happy-hacking-gnu, whose source comments reference symbol names from PFU's own tool. This document re-derives it against a Classic — a model happy-hacking-gnu does not cover — and corrects two of its names along the way.
04FE:0020, bcdDevice 1, full speed, one configuration, three interfaces:
| # | Class | Endpoints | Contents |
|---|---|---|---|
| 0 | HID boot keyboard (subclass 1, protocol 1) | 1 | the standard 8-byte 6KRO report and LED output |
| 1 | HID (subclass 0) | 1 | report 1: 8 consumer bits; report 2: a 232-bit NKRO bitmap; report 3: 6 application-launch bits |
| 2 | HID, vendor usage page 0xFF00 |
2 | the control channel |
Interface 2 is what the Keymap Tool talks to. Its report descriptor is 34 bytes:
usage page 0xFF00, usage 0x01, one 64-byte input report and one 64-byte
output report, no report IDs.
QMK and VIA use 0xFF60 / usage 0x61 for raw HID; PFU picked its own. Match on
the usage page rather than the interface number.
Interface 1 is where the media keys actually come out — the keymap can name them, but they are sent as consumer usages, not keyboard usages.
Requests and responses are always exactly 64 bytes, zero padded.
Request (host to keyboard, output report)
| Offset | Contents |
|---|---|
| 0–1 | AA AA |
| 2 | command |
| 3 | chunk marker (0x00 for single-packet commands) |
| 4 | payload length |
| 5.. | payload |
Response (keyboard to host, input report)
| Offset | Contents |
|---|---|
| 0–1 | 55 55 |
| 2 | command, echoed |
| 3 | status |
| 4 | chunk marker |
| 5 | payload length |
| 6.. | payload |
The request payload starts at offset 5 and the response payload at offset 6, because the status byte only exists on the response. Aligning the two is the easiest way to end up one byte out.
| Value | Meaning |
|---|---|
0x00 |
success |
0x01 |
rejected — the board is in the read-only state that DUMP_FIRMWARE leaves behind |
Status 0x01 is not just a failed write. Reads in that state can return values
that were never written, so nothing you read after seeing it means anything
until the keyboard has been replugged. See firmware.md.
Commands that span several packets number them (n << 6) | n:
| n | Marker |
|---|---|
| 1 | 0x41 |
| 2 | 0x82 |
| 3 | 0xC3 |
The same counter appears in both the top two bits and the bottom six. The redundancy is presumably a consistency check.
| ID | Name | Verified | Notes |
|---|---|---|---|
0x01 |
NOTIFY_APPLICATION_STATE |
payload 00 01 <state>, 0 on open and 1 on close. Everything works without it |
|
0x02 |
GET_KEYBOARD_INFO |
yes | returns a 57-byte struct |
0x03 |
RESET_FACTORY_DEFAULTS |
yes | restores every mode and both layers |
0x04 |
CONFIRM_KEYMAP |
yes | commits a keymap write |
0x05 |
GET_DIP_STATE |
yes | six DIP switch states |
0x06 |
GET_KEYBOARD_MODE |
yes | 0 HHK, 1 Mac, 2 Win |
0x07 |
RESET_DIPSW |
happy-hacking-gnu calls this after a remap; not needed here | |
0x86 |
WRITE_KEYMAP |
yes | three chunks, then CONFIRM_KEYMAP |
0x87 |
GET_KEYMAP |
yes | three chunks; the mode parameter is honoured |
0xD0 |
DUMP_FIRMWARE |
yes | returns 64 KiB, and leaves the board read-only |
0xE0–0xE3 |
FIRMUP_* |
updates the application bank | |
0xE4–0xE7 |
UPDATEBOOT_* |
rewrites the backup bank. Do not |
NOTIFY_APPLICATION_STATE is sent by the official tool but is not a
precondition for anything: reads, writes and commits all succeed without it.
57 bytes of payload:
| Offset in response | Length | Field |
|---|---|---|
| 6 | 20 | type number, NUL terminated |
| 26 | 4 | revision |
| 30 | 16 | serial |
| 46 | 8 | application firmware version (bank 2) |
| 54 | 8 | boot firmware version (bank 1) |
| 62 | 1 | which one is running: 0 application, 1 boot |
Version fields lead with 0x0A or 0x0B, naming the A and B banks. Formatting
them as %X%d.%d%d gives the versions the official tool shows — A4.29 and
B4.16 on the board used here.
happy-hacking-gnu identifies models from the type number: one containing 20 is
JIS, one containing KB8 is a HYBRID. PD-KB401B is neither, being a US
Classic.
GET_KEYBOARD_MODE returns 0 for HHK, 1 for Mac and 2 for Win, selected by the
first two DIP switches:
| SW1 | SW2 | Mode |
|---|---|---|
| off | off | HHK (factory setting) |
| on | off | Win |
| off | on | Mac |
There is no mode 3. The firmware holds three keymap tables, the manual documents three settings, and asking for a fourth times out with no reply at all.
happy-hacking-gnu calls mode 2 "Lite" and mode 3 "Secret". Neither name appears
in the Professional Classic manual (P3PC-6661-06); mode 2 is Win, and mode 3
does not exist on this model.
The mode also moves without touching the DIP switches: Fn+Control+W and
Fn+Control+M select Win and Mac, though a board whose DIP switches say HHK
returns to HHK. GET_KEYBOARD_MODE reports the effective mode.
The remaining switches: SW3 chooses whether the Delete key sends Delete or Backspace and is ignored in Mac mode; SW4 turns the left diamond into Fn.
One layer is a 128-byte array indexed by key number. Keys 1 to 60 are real; index 0 and 61 onward are always zero. Key 1 is the bottom right key and numbering runs right to left and bottom to top, so Esc is 60.
There are two layers — base and Fn — held separately for each of the three modes, which is why both reads and writes carry a mode.
128 bytes across three packets, split differently in each direction:
| Chunk 1 | Chunk 2 | Chunk 3 | |
|---|---|---|---|
| read (responses) | 58 bytes | 58 bytes | 12 bytes |
| write (requests) | mode, layer, 57 bytes | 59 bytes | 12 bytes |
On a read the mode and layer ride in the request, with payload length 2. On a write they occupy the first two payload bytes of chunk 1, which is why that chunk carries fewer keymap bytes than chunk 2.
A read requests only the first chunk. The keyboard pushes the other two on its own. Code that waits for one response per request will drop them.
A write ends with CONFIRM_KEYMAP. The change takes effect immediately, with no
replug.
The per-chunk status byte does not tell you whether a write landed. Read the keymap back and compare.
Values are USB HID keyboard usages, with these exceptions:
| Code | Meaning |
|---|---|
0x01 |
Fn — a firmware-internal code, not HID ErrorRollOver |
0x78 |
Stop. HHK mode puts this on the right diamond in the Fn layer |
0x8A |
Henkan (International4). HHK mode only |
0x8B |
Muhenkan (International5). HHK mode only |
0xE8 |
Volume Down. Mac mode only |
0xE9 |
Volume Up. Mac mode only |
0xEA |
Mute. Mac mode only |
0xEB |
Eject. Mac mode only |
0xE8–0xEB are reserved in the HID keyboard page; the firmware converts them
into consumer usages on interface 1. The mapping is a lookup table rather than
arithmetic — they land on consumer bits 5, 6, 4 and 7. Assigning them elsewhere
is not obviously safe.
HHK mode puts Henkan and Muhenkan on the diamond keys, which are the Japanese input toggles. Mac and Win modes put the GUI modifiers there instead.
See keymaps.md for the factory contents of all six layers.
No hidapi, no libusb. IOHIDManager is enough, and the result is a native arm64
binary with no third-party dependency.
- Match on
kIOHIDVendorIDKey == 0x04FEandkIOHIDPrimaryUsagePageKey == 0xFF00. That picks interface 2 uniquely. - Send with
IOHIDDeviceSetReport(kIOHIDReportTypeOutput, 0, buf, 64). - Replies arrive as input reports, not as
GetReport. Register an input report callback, schedule the device on a run loop, and pump the run loop. - No Input Monitoring permission is needed.
ioregmarks interfaces 0 and 1RequiresTCCAuthorization; interface 2 is not marked, and no prompt appears. IOHIDDeviceSetReporthas no timeout of its own and blocks indefinitely once the board is read-only. Bound the whole operation instead — the tools here use a 60 second watchdog.
Porting from happy-hacking-gnu means shifting every buffer index by one: hidapi
prepends a report ID byte, so its buffer[1] is the first byte on the wire.