Second protocol family (8089:000b) behind a new backend seam, 15 key and 3 knob pads, Knobs North plus the style and colour grid, Windows import fix, window resize fix.
11 KiB
The protocols, and how the app is put together
How this pad was reverse engineered
- Probe.
macropad-probe.pywalks/sys/class/hidrawand decodes each HID report descriptor. The pad shows up as two interfaces: a normal keyboard (Generic Desktop) and a vendor defined one (usage page0xff00) with a 64 byte input and output report, ID 3. That's the config channel. - Guess. The upstream project documents two protocols, "Legacy" and "Extended". Both were tried. The pad accepted the reports and then quietly ignored them. Educated guessing had run out.
- Watch. The breakthrough was noticing
MINI_KEYBOARD.exeruns under Wine. Linux'susbmoncan record every USB transfer, somacropad-capture.pywatched the real software write a single key. Twenty minutes of watching beat hours of guessing. - Repeat. Separate captures of the knobs and of media keys filled in the rest. Every packet from every capture is now replayed by the test suite.
Lesson learned the hard way: ch57x-keyboard-tool had already worked out most of this, and its source is the best reference for these pads. Search before you sniff.
The wire format
Every binding is two 65 byte writes to /dev/hidrawN: a config report, then a commit.
03 fd 01 01 01 00 00 00 00 00 01 00 05 00 ... key 1 -> 'b'
│ │ │ │ │ └──┬┘ └───┬──┘ │ │ └── keycode (HID usage 0x05 = b)
│ │ │ │ │ │ │ │ └───── modifier bitmask
│ │ │ │ │ │ │ └──────── count
│ │ │ │ │ │ └───────────── unused
│ │ │ │ │ └──────────────────── delay? (unverified, see below)
│ │ │ │ └────────────────────────── type: 1 keys, 2 media, 3 mouse, 8 LED
│ │ │ └───────────────────────────── layer, counted from 1
│ │ └──────────────────────────────── action: which control
│ └─────────────────────────────────── magic
└────────────────────────────────────── report ID
03 fd fe ff 00 00 ... commit
Action bytes on 1189:8840
| Control | Bytes |
|---|---|
| Keys 1 to 12 | 0x01 to 0x0c (the firmware reserves up to 0x0f) |
| Knob 1: turn left, press, turn right | 0x10, 0x11, 0x12 |
| Knob 2: turn left, press, turn right | 0x13, 0x14, 0x15 |
Media keys pack differently: count is always 2, and the 16 bit consumer usage follows it directly, little endian.
03 fd 08 01 02 00 00 00 00 00 02 92 01 00 ... key 8 -> Calculator (usage 0x0192)
Modifier bitmask (standard HID): 0x01 Ctrl, 0x02 Shift, 0x04 Alt, 0x08 Meta, and 0x10 to 0x80 for the right hand versions.
The vendor app sometimes sends several configs and one commit at the end. Both styles work. This app commits after every binding, so it always knows exactly which ones landed.
Delays are the open question. The byte field above comes from rOzzy1987's format and hasn't been seen on this pad. ch57x-keyboard-tool sends a delay as a separate report of type 5 instead, which is probably right. That's why sequences are switched off in the app until a capture settles it.
Three sources, compared
What each source sends to a 1189:8840. The pad accepts the ch57x-keyboard-tool style and the vendor style, so it's evidently tolerant. Nothing here is a new discovery about the hardware; the last column is simply what the vendor's own software does, captured independently.
| rOzzy1987 "Extended" | ch57x-keyboard-tool | Vendor app (captured) | |
|---|---|---|---|
| Byte 1 of a config report | 0xFE |
0xFE |
0xFD |
| Layers counted from | 0 |
1 |
1 |
| First knob action | 13 |
0x10 |
0x10 |
| Media: count, usage offset | from payload, 12 | 0, 11 |
2, 11 |
| Delay | bytes 5 and 6 | separate report, type 5 |
not captured yet |
| Finishing a write | flash command | aa aa, fd fe ff, aa aa |
fd fe ff |
rOzzy1987's format is for different product IDs, which is why porting it to this pad failed until the capture.
Reading the pad, and how we nearly missed it
The project ran for weeks on the assumption that these pads were write only. Two things caused that:
- Nothing documented a read. Neither rOzzy1987's project nor ch57x-keyboard-tool implements one.
- The capture tool only recorded outgoing transfers. So even though the pad had been answering all along, nothing was ever written down.
What settled it was noticing that the vendor software has a "view settings" button, and that it showed bindings written by MacroPad. It couldn't have known those from a file, so it was asking the pad. Capturing both directions gave the answer.
Two query commands, both answered on the interrupt IN endpoint of the same report id:
03 fb ... what are you?
03 fb 0c 02 reply: 12 keys, 2 knobs
03 fa 0f 03 01 02 ... send me layer 1
(15 key slots, 3 layers, layer 1, 2 knobs)
03 fa 02 01 01 00 ... 01 05 17 key 2 is ctrl+alt+t
03 fa 10 01 02 00 ... 01 ea 00 dial 1 left is volume down
... one report per control, then silence
Replies use the same layout as a config write, with 0xFA in place of the 0xFD magic. Media replies carry a count of 1 where writes send 2; both are accepted.
Three things fell out of it:
- The pad reports its own layout, so key and knob counts don't have to be guessed from the USB ID.
- There are three layers, and the firmware keeps 15 key slots and 3 knobs whatever the hardware has. Ours answers for 6 controls that don't physically exist.
- The dial mapping is confirmed from the other direction. Reading back what we had written returns exactly the mapping we assumed, which is stronger evidence than a write that appeared to work.
The vendor's request has trailing bytes that change between runs and look like uninitialised memory from the Windows program. Zeros work fine.
usbmon only shows you 32 bytes
The kernel's usbmon text interface caps captured data at 32 bytes per transfer. Every line in a capture is exactly 32 bytes, even though the reports are 65. For single keys and media that's plenty. For long key sequences, anything past the 10th key lands in bytes nobody has seen yet. If you're capturing long macros, use Wireshark or the binary /dev/usbmonN interface instead.
The udev rule that cost me an hour
The standard way to give desktop users access to a device is TAG+="uaccess" in a udev rule. Everyone names these 99-something.rules. That doesn't work.
udev reads every rules file in one lexical order by filename. The tag is only turned into an actual permission by systemd's 73-seat-late.rules, which contains:
TAG=="uaccess", ENV{MAJOR}!="", RUN{builtin}+="uaccess"
A rule in 99-macropad.rules adds the tag after that line has already run. The device is tagged, and nothing ever acts on it. Same for a file with no number at all, since letters sort after digits. The rule has to sort before 73-, so MacroPad uses 60-macropad.rules.
The app's permission check reads udevadm info to see whether the device got the tag, finds any rule file mentioning the pad, and compares its name against whichever file on your system applies the permission.
Where the app keeps its notes
~/.config/macropad/state-1189-8840.json holds what the app last read from or wrote to the pad. Since the pad can be read, this is a cache rather than the source of truth. Every entry is exactly a command the CLI understands:
{
"device": "1189:8840",
"format": 1,
"layers": {
"1": {
"dial1-left": { "binding": { "media": "volumedown" }, "written": "2026-09-11T18:02:11" },
"key4": { "binding": { "keys": "super+ctrl+left" }, "written": "2026-09-11T18:02:11" },
"key7": { "reason": "write interrupted after 1 of 2 reports", "unknown": true }
}
}
}
It's written atomically (write, then rename), so a crash can never leave half a file.
Command line
No window needed. macropad.py is plain Python with nothing to install:
cd ~/.local/share/macropad/app
python3 macropad.py controls # every name you can use
python3 macropad.py set key1 ctrl+c
python3 macropad.py set dial1-right volumeup
python3 macropad.py set key3 "ctrl+shift+n" --dry-run # show the bytes, send nothing
python3 macropad.py set action:0x10 f5 # raw action byte, for other pads
macropad doctor # the app's permission check, in text
The CLI doesn't update the app's notes. Things set this way show as whatever the app last knew.
Tests
python3 -m unittest -v
The suite replays every packet from every real capture in tests/captures/, decodes it, re-encodes it, and checks the bytes match exactly. It also covers the permission diagnosis, half-finished writes, and builds the whole window offscreen. Screenshots in this README are generated by docs/make_screenshots.py from the real app.
Project layout
macropad.py the protocol and the command line tool (no dependencies)
macropad_gui/
core.py bindings, the app's notes, the permission check, writing
padview.py the drawing of the pad
themes.py palettes and drawing styles
fonts/ the Sketch theme's handwriting font, with its licence
app.py the window
keymap.py physical key to HID name, for Record
macropad-probe.py read only: what is this device?
macropad-capture.py watch what the vendor software sends (usbmon)
macropad-handshake.py which report IDs does a pad accept?
install.sh, uninstall.sh
packaging/ launcher icon and the udev rule
tests/ real captures and the tests that replay them