Add SayoDevice support, 15 key pads and theme styles

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.
This commit is contained in:
Mohamed Mahjoub
2026-10-08 22:14:31 +01:00
parent 6e25e350e0
commit 6a419eb3b6
34 changed files with 3410 additions and 572 deletions

209
docs/protocol.md Normal file
View File

@@ -0,0 +1,209 @@
# The protocols, and how the app is put together
<details>
<summary><b>How this pad was reverse engineered</b></summary>
1. **Probe.** `macropad-probe.py` walks `/sys/class/hidraw` and decodes each HID report descriptor. The pad shows up as two interfaces: a normal keyboard (`Generic Desktop`) and a vendor defined one (usage page `0xff00`) with a 64 byte input and output report, ID 3. That's the config channel.
2. **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.
3. **Watch.** The breakthrough was noticing `MINI_KEYBOARD.exe` runs under Wine. Linux's `usbmon` can record every USB transfer, so `macropad-capture.py` watched the real software write a single key. Twenty minutes of watching beat hours of guessing.
4. **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](https://github.com/kriomant/ch57x-keyboard-tool) had already worked out most of this, and its source is the best reference for these pads. Search before you sniff.
</details>
<details>
<summary><b>The wire format</b></summary>
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.
</details>
<a name="what-we-found"></a>
<details>
<summary><b>Three sources, compared</b></summary>
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](https://github.com/kriomant/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.
</details>
<details>
<summary><b>Reading the pad, and how we nearly missed it</b></summary>
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.
</details>
<details>
<summary><b>usbmon only shows you 32 bytes</b></summary>
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.
</details>
<a name="the-udev-rule-that-cost-me-an-hour"></a>
<details>
<summary><b>The udev rule that cost me an hour</b></summary>
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.
</details>
<details>
<summary><b>Where the app keeps its notes</b></summary>
`~/.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:
```json
{
"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.
</details>
<details>
<summary><b>Command line</b></summary>
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.
</details>
<details>
<summary><b>Tests</b></summary>
```
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.
</details>
## 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
```
---