From 6a419eb3b6e98565546286c4fbbbfa115ea940cd Mon Sep 17 00:00:00 2001 From: Mohamed Mahjoub Date: Thu, 8 Oct 2026 22:14:31 +0100 Subject: [PATCH] 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. --- README.md | 422 +++--------- docs/adding-a-pad.md | 58 ++ docs/protocol.md | 209 ++++++ install.sh | 0 macropad.py | 31 +- macropad_gui/__init__.py | 12 + macropad_gui/actions.py | 23 +- macropad_gui/app.py | 167 ++++- macropad_gui/backends/__init__.py | 34 + macropad_gui/backends/base.py | 64 ++ macropad_gui/backends/ch57x.py | 216 ++++++ macropad_gui/backends/sayo.py | 287 ++++++++ macropad_gui/backends/sayo_proto.py | 631 ++++++++++++++++++ macropad_gui/core.py | 272 +++----- macropad_gui/fonts/Harmattan-Bold.ttf | Bin 0 -> 17808 bytes macropad_gui/fonts/Harmattan-Medium.ttf | Bin 0 -> 17892 bytes macropad_gui/fonts/Harmattan-Regular.ttf | Bin 0 -> 17876 bytes macropad_gui/fonts/Harmattan-SemiBold.ttf | Bin 0 -> 17864 bytes macropad_gui/fonts/JetBrainsMonoNL-Bold.ttf | Bin 0 -> 16152 bytes .../fonts/JetBrainsMonoNL-BoldItalic.ttf | Bin 0 -> 17252 bytes .../fonts/JetBrainsMonoNL-ExtraBold.ttf | Bin 0 -> 16176 bytes .../fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf | Bin 0 -> 17320 bytes .../fonts/JetBrainsMonoNL-Regular.ttf | Bin 0 -> 16176 bytes .../fonts/JetBrainsMonoNL-SemiBold.ttf | Bin 0 -> 16096 bytes macropad_gui/fonts/OFL-Harmattan.txt | 103 +++ macropad_gui/fonts/OFL-JetBrainsMono.txt | 93 +++ macropad_gui/padview.py | 342 +++++++++- macropad_gui/themes.py | 190 +++++- macropad_gui/vectors.py | 26 + tests/test_actions.py | 37 + tests/test_backends.py | 195 ++++++ tests/test_core.py | 218 +++++- tests/test_sayo.py | 352 ++++++++++ uninstall.sh | 0 34 files changed, 3410 insertions(+), 572 deletions(-) create mode 100644 docs/adding-a-pad.md create mode 100644 docs/protocol.md mode change 100644 => 100755 install.sh create mode 100644 macropad_gui/backends/__init__.py create mode 100644 macropad_gui/backends/base.py create mode 100644 macropad_gui/backends/ch57x.py create mode 100644 macropad_gui/backends/sayo.py create mode 100644 macropad_gui/backends/sayo_proto.py create mode 100644 macropad_gui/fonts/Harmattan-Bold.ttf create mode 100644 macropad_gui/fonts/Harmattan-Medium.ttf create mode 100644 macropad_gui/fonts/Harmattan-Regular.ttf create mode 100644 macropad_gui/fonts/Harmattan-SemiBold.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-Bold.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-BoldItalic.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-ExtraBold.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-Regular.ttf create mode 100644 macropad_gui/fonts/JetBrainsMonoNL-SemiBold.ttf create mode 100644 macropad_gui/fonts/OFL-Harmattan.txt create mode 100644 macropad_gui/fonts/OFL-JetBrainsMono.txt create mode 100644 macropad_gui/vectors.py create mode 100644 tests/test_backends.py create mode 100644 tests/test_sayo.py mode change 100644 => 100755 uninstall.sh diff --git a/README.md b/README.md index 3eca4e0..d6a7e85 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Wayland and X11 Python 3.9 or newer Qt 6 - Tested on USB ID 1189:8840 + Tested on USB IDs 1189:8840 and 8089:000b

@@ -124,7 +124,7 @@ Both write straight to the pad's own memory, so you can even use both. Pick whic | | MacroPad | [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) | |---|---|---| | How you use it | Click a drawing of your pad | Write a YAML file, run a command | -| Pads | `1189:8840` (12 keys, 2 knobs) | `1189:8840`, `8842`, `8890`, several layouts | +| Pads | `1189:8840` (12 or 15 keys, 2 or 3 knobs) and `8089:000b` (SayoDevice, 8 keys) | `1189:8840`, `8842`, `8890`, several layouts | | Systems | Linux | Linux, macOS, Windows | | Shortcuts and media keys | Yes | Yes | | Key sequences with delays | Not yet | Yes | @@ -142,128 +142,94 @@ Other people have built GUIs for these pads too, mostly for the smaller 3 and 6 ## πŸš€ Install -One command does everything: the app, the Qt libraries if you need them, the menu entry, and the USB permission rule. It asks before each step that needs your password, and it's safe to run again to update. - -If you're comfortable in a terminal, [skip to the command](#step-2-copy-this-line). The steps below spell it out for anyone newer to Linux, since these pads are a lot of people's first reason to open one. - -### Step 1: Open a terminal - -A terminal is a window where you type commands instead of clicking. Nothing below can damage your system. - -- **KDE Plasma** (CachyOS, Bazzite, Kubuntu, Fedora KDE, KDE neon): press the **Meta key** (the one with the Windows logo), type **`Konsole`**, press **Enter**. -- **GNOME** (Ubuntu, Fedora, Pop!_OS): press the **Meta key**, type **`Terminal`**, press **Enter**. -- **Almost anywhere**: try pressing **`Ctrl` + `Alt` + `T`** together. - -### Step 2: Copy this line - -Click the copy button on the right side of this box (or select the text and press `Ctrl+C`): +One command does everything: the app, the Qt libraries if you need them, the +menu entry, and the USB permission rule. It asks before each step that needs +your password, and it's safe to run again to update. ``` curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash ``` -

-What does that line actually do? (optional reading) +Then **unplug the pad and plug it back in** so Linux applies the permission, +open your app menu, type **MacroPad** and click it. -In plain words: "download the MacroPad installer from GitHub (`curl`), and run it (`| bash`)". The installer is a normal text file, and you can [read it here](install.sh) before running it if you like. Being suspicious of random commands from the internet is a healthy instinct, and I respect it. +A **green dot** and **Pad ready** at the bottom left means it's working. +Anything else, and the panel on the right explains it, or see +[When something goes wrong](#-when-something-goes-wrong). -
- -### Step 3: Paste it and press Enter - -> [!IMPORTANT] -> Pasting into a terminal is **`Ctrl` + `Shift` + `V`**, not `Ctrl+V`. Right click, then Paste, also works. - -### Step 4: Answer the questions - -The installer says what it's doing as it goes, and asks before anything that needs your password. Pressing Enter accepts the default. - -It needs the password for two things: installing Qt on systems that don't have it, and writing one permission file so the app can talk to your pad. Everything else stays in your home folder. - -> [!NOTE] -> Nothing appears on screen while you type a password in a terminal. No dots, no stars. That's deliberate, not a frozen prompt. - -First run downloads around 100 MB on some systems. You'll see **All done.** when it's finished. - -### Step 5: Replug the pad - -Unplug it and plug it back in, so Linux applies the new permission. - -### Step 6: Open MacroPad - -Open your app menu, type **MacroPad**, and click it. +The app reads your pad when it opens, so the drawing fills in with whatever is +already on it. Keys still showing as hatched are ones it couldn't read; press +**Read pad** to ask again.

MacroPad on its first launch: every key is striped because the app hasn't written anything yet

-A **green dot** and **Pad ready** at the bottom left means it's working. Anything else, and the panel on the right explains it, or see [When something goes wrong](#-when-something-goes-wrong). +
+New to the terminal? The longer version -The app reads your pad when it opens, so the drawing should fill in with whatever is already on it. Keys still showing as hatched are ones it couldn't read; press **Read pad** to ask again. +A terminal is a window where you type commands instead of clicking. To open +one: on KDE Plasma press the Meta key, type `Konsole`, press Enter. On GNOME, +Meta key, type `Terminal`, Enter. Almost anywhere, `Ctrl` + `Alt` + `T`. -> [!TIP] -> To keep it handy: right click **MacroPad** in your app menu and choose **Pin to Task Manager** (Plasma) or **Pin to Dash** (GNOME). +Paste with **`Ctrl` + `Shift` + `V`**, not `Ctrl+V`. Right click then Paste +also works. + +That command downloads the installer from GitHub (`curl`) and runs it +(`| bash`). It's a normal text file and you can [read it first](install.sh). +Being suspicious of commands off the internet is a healthy instinct. + +The installer needs your password for two things: installing Qt on systems +that don't have it, and writing one permission file so the app can reach your +pad. Everything else stays in your home folder. Nothing appears on screen +while you type a password in a terminal, no dots or stars, which is deliberate +rather than a frozen prompt. First run downloads around 100 MB on some +systems, and you'll see **All done.** at the end. + +To keep it handy, right click **MacroPad** in your app menu and choose **Pin +to Task Manager** (Plasma) or **Pin to Dash** (GNOME). + +
--- ## 🎯 Your first binding -### 1. Click a key on the drawing +**Click a key on the drawing.** It gets an orange outline and the panel on the +right shows what it currently does. -It gets an orange outline, and the panel on the right shows what it currently does. +**Say what it should do.** Four ways, whichever suits: -### 2. Type what you want it to do +- **Shortcut**, and type the combo with `+` between keys: `ctrl+z`, + `ctrl+shift+t`, `meta+e`, `f5`. Underneath, it confirms what the key will + send. +- **Record**, then press the combo on your keyboard and it fills the box in + for you. It records the physical key, so the pad sends that same key + whatever your layout. Your desktop grabs some combos (most `Meta` ones) + before any app sees them, so type those instead. +- **Media key**, and pick from the list. Volume and brightness on the knobs is + a classic. +- **Nothing**, to clear the key. -Click **Shortcut**, then type the combo into the box using `+` between keys: `ctrl+z`, `ctrl+shift+t`, `meta+e`, `f5`. Underneath, it confirms what the key will send. The key on the drawing gets a strip of orange tape to show it's changed but not sent yet. +Either way the key gets a strip of orange tape, meaning changed but not sent.

Typing ctrl+z for key 2. The app says Sends Ctrl+Z and the key has orange tape on it

-### Or press Record and just do it - -Click **Record**, then press the actual combo on your keyboard. The app catches it and fills in the box for you. It records the physical key you pressed, so the pad presses that same key whatever your keyboard layout (UK, German, French, anything). - -

- Record mode: the box glows orange and says press the combo now -

- -> [!NOTE] -> Your desktop grabs some combos (like most `Meta` shortcuts) before any app can see them. If Record doesn't catch one, just type it instead. - -### Or pick a media key - -Click **Media key** and pick from the list. Volume and brightness on the knobs is a classic. - -

- Choosing Volume down for dial 1 turn left from the list of media keys -

- -### Or make it do nothing - -To clear a binding, click **Nothing** and write it. Keys set this way show **Nothing** in grey. - -

- Key 5 set to Nothing, waiting to be written, with key 1 already showing Nothing in grey -

- -### 3. Write it to the pad - -Click **Write to pad**. Changed a few things? The orange **Write N changes** button at the bottom right sends them all at once. - -

- Three keys with orange tape waiting to be written, and a Write 3 changes button -

- -The bottom bar tells you exactly what happened. Green means it worked. +**Write it.** Click **Write to pad**, or if you've changed several, the orange +**Write N changes** button at the bottom right sends them all at once. The +bottom bar says exactly what happened, and green means it worked.

After writing: the keys are solid white and the bottom bar says Wrote 3 changes to the pad

-### 4. Press the key - -**Your bindings live on the pad itself**, so they keep working after a reboot, on another computer, even on Windows. Nothing stays running in the background, and you can close the app. (The one exception is [actions](#-make-a-key-open-a-web-page-an-app-type-text-or-run-a-command), which are opt in.) +**Press the key.** Your bindings live on the pad itself, so they keep working +after a reboot, on another computer, even on Windows. Nothing runs in the +background and you can close the app. The one exception is +[actions](#-make-a-key-open-a-web-page-an-app-type-text-or-run-a-command), +which are opt in. --- @@ -363,7 +329,7 @@ Some people stand the pad up with the knobs on top. Some lay it flat with the kn ## πŸ“₯ Already set it up with the vendor app? -If you configured your pad with `MINI_KEYBOARD.exe` and recorded it with the included capture tool ([how](#capture-what-the-vendor-software-sends)), click **Import capture...** and choose the file. The app reads what was saved and fills in the drawing, so those keys stop showing as unknown. +If you configured your pad with `MINI_KEYBOARD.exe` and recorded it with the included capture tool ([how](docs/protocol.md)), click **Import capture...** and choose the file. The app reads what was saved and fills in the drawing, so those keys stop showing as unknown. Only do this if nothing has changed on the pad since that capture. @@ -410,7 +376,7 @@ The app is quite clever here. It checks the three usual reasons this happens and - **No rule yet.** The permission file doesn't exist. - **Rule not loaded.** It exists, but Linux hasn't noticed. Replug the pad. -- **Rule named wrong.** If you made one yourself called something like `99-macropad.rules`, it's read *too late* to work. It needs a number below 73. The app gives you the exact rename command. ([Why?](#the-udev-rule-that-cost-me-an-hour)) +- **Rule named wrong.** If you made one yourself called something like `99-macropad.rules`, it's read *too late* to work. It needs a number below 73. The app gives you the exact rename command. ([Why?](docs/protocol.md)) ### "Heads up: keyd is remapping this pad" @@ -509,7 +475,9 @@ A few ideas for KDE Plasma, where these are the default shortcuts: ## 🧬 Other pads in this family -This app is **confirmed on `1189:8840`**, the 12 key, 2 knob pad. It's the only one it knows so far. +This app is **confirmed on `1189:8840`**, in the 12 key, 2 knob and 15 key, 3 knob shapes, and on **`8089:000b`**, a SayoDevice 8 key pad that speaks an entirely different protocol. + +Those two are separate backends. A pad family is one file in `macropad_gui/backends/` implementing six methods, and everything above that line works in terms of keys and knobs rather than bytes, so adding a third family does not touch the app. > [!TIP] > **Have a different pad right now?** [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) supports `1189:8840`, `1189:8842` and `1189:8890` in several layouts (3, 6, 9, 12 and 15 keys) from the command line, today. @@ -520,57 +488,17 @@ Siblings come in other sizes, and several share vendor ID `1189`. **They don't a The app explaining that it found a 1189:8890 pad that it doesn't support yet

-### Start here: ask the pad - -Install MacroPad, plug your pad in, and run: +**If you have one of those pads**, MacroPad can ask it what it is without +writing anything to it: ``` macropad detect ``` -On most pads in this family that's the whole job. It asks the pad how many keys and knobs it has, reads back what's currently bound, and prints a report. Paste that into [an issue](https://github.com/Probler-Yt/MacroPad/issues) and your pad gets added to the known list. - -The app offers the same thing: when it sees a pad it doesn't recognise, the panel has an **Ask the pad what it is** button. - -### How it decides - -Detection is read only, and the pad has to pass two tests before the app will write anything to it: - -1. It answers the `0xFB` query with a sensible key and knob count. -2. It then reports a full layer, describing exactly the control slots that count implies. - -A pad from a different family fails one of those, and the app leaves it alone rather than guessing. - -The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware. - -### If it doesn't answer - -Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine. - -#### Capture what the vendor software sends - -1. **See what your pad is:** - ``` - python3 ~/.local/share/macropad/app/macropad-probe.py - ``` - This only reads. It lists your pad's ID and its USB interfaces. - -2. **Start the capture** (it watches USB traffic; it never sends anything): - ``` - cd ~/.local/share/macropad/app - sudo python3 macropad-capture.py - ``` - -3. **In a second terminal**, open the vendor app with Wine: - ``` - wine MINI_KEYBOARD.exe - ``` - -4. In the vendor app, set **key 1 to the letter `a`** and click its save/apply button. Then set **each knob's turn left, press and turn right** to different letters, and save again. - -5. Go back to the first terminal and press **`Ctrl+C`**. You now have a file called `macropad-capture.txt`. - -6. **[Open an issue](https://github.com/Probler-Yt/MacroPad/issues)** with that file, your `macropad-probe.py` output, and a photo of your pad. That's genuinely all it takes to get your pad supported. +Paste what that prints into an issue and your pad can be added. The full +process, including what the app checks before it will write to an unknown +pad and what to do when one doesn't answer, is in +**[docs/adding-a-pad.md](docs/adding-a-pad.md)**. --- @@ -586,211 +514,13 @@ That said, the app is built on Qt, which runs on Windows and macOS too. The only ## πŸ€“ For the nerds -
-How this pad was reverse engineered +Both wire protocols, byte by byte, plus the project layout and how the +backends are split, live in **[docs/protocol.md](docs/protocol.md)**. -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. - -
- -
-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](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. - -
- -
-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: - -```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. - -
- -
-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 -``` +The short version: `1189:8840` was reverse engineered from USB captures of +the vendor's software, and `8089:000b` from WebHID captures of the vendor's +web configurator. Every packet in both is replayed by the test suite, so +nothing in there is a guess. --- @@ -798,6 +528,8 @@ tests/ real captures and the tests that replay them - [x] Keys, shortcuts and media keys on `1189:8840` - [x] Both knobs, all three actions each +- [x] 15 key, 3 knob pads, knobs on either side +- [x] A second protocol: SayoDevice `8089:000b`, decoded from scratch - [x] Upright and flat views - [x] Reading the pad, so the app shows what's really on it - [x] One line installer for any distro diff --git a/docs/adding-a-pad.md b/docs/adding-a-pad.md new file mode 100644 index 0000000..ee2fcc4 --- /dev/null +++ b/docs/adding-a-pad.md @@ -0,0 +1,58 @@ +# Adding a pad the app doesn't know yet + +If MacroPad finds your pad but says it isn't supported, this is the whole +process. It takes one command and one issue. + +## Start here: ask the pad + +Install MacroPad, plug your pad in, and run: + +``` +macropad detect +``` + +On most pads in this family that's the whole job. It asks the pad how many keys and knobs it has, reads back what's currently bound, and prints a report. Paste that into [an issue](https://github.com/Probler-Yt/MacroPad/issues) and your pad gets added to the known list. + +The app offers the same thing: when it sees a pad it doesn't recognise, the panel has an **Ask the pad what it is** button. + +## How it decides + +Detection is read only, and the pad has to pass two tests before the app will write anything to it: + +1. It answers the `0xFB` query with a sensible key and knob count. +2. It then reports a full layer, describing exactly the control slots that count implies. + +A pad from a different family fails one of those, and the app leaves it alone rather than guessing. + +The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware. + +## If it doesn't answer + +Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine. + +#### Capture what the vendor software sends + +1. **See what your pad is:** + ``` + python3 ~/.local/share/macropad/app/macropad-probe.py + ``` + This only reads. It lists your pad's ID and its USB interfaces. + +2. **Start the capture** (it watches USB traffic; it never sends anything): + ``` + cd ~/.local/share/macropad/app + sudo python3 macropad-capture.py + ``` + +3. **In a second terminal**, open the vendor app with Wine: + ``` + wine MINI_KEYBOARD.exe + ``` + +4. In the vendor app, set **key 1 to the letter `a`** and click its save/apply button. Then set **each knob's turn left, press and turn right** to different letters, and save again. + +5. Go back to the first terminal and press **`Ctrl+C`**. You now have a file called `macropad-capture.txt`. + +6. **[Open an issue](https://github.com/Probler-Yt/MacroPad/issues)** with that file, your `macropad-probe.py` output, and a photo of your pad. That's genuinely all it takes to get your pad supported. + +--- diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..32cd5f1 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,209 @@ +# The protocols, and how the app is put together + +
+How this pad was reverse engineered + +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. + +
+ +
+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](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. + +
+ +
+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: + +```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. + +
+ +
+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 +``` + +--- diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 diff --git a/macropad.py b/macropad.py index 2a6106e..6754040 100644 --- a/macropad.py +++ b/macropad.py @@ -42,6 +42,9 @@ MODIFIERS = { "ctrl": 0x01, "shift": 0x02, "alt": 0x04, "win": 0x08, "super": 0x08, "lctrl": 0x01, "lshift": 0x02, "lalt": 0x04, "lwin": 0x08, "rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rwin": 0x80, + # The GUI labels this key Meta, which is what it is called on Linux, so + # the CLI accepts that spelling too rather than only win and super. + "meta": 0x08, "lmeta": 0x08, "rmeta": 0x80, } # Standard USB HID Keyboard/Keypad usage IDs. Verified against upstream: @@ -347,8 +350,14 @@ def legacy_media_sequence(report_id, action, usage, layer=0): KNOWN_VENDORS = ("1189",) -def find_devices(): - """Find hidraw nodes that expose a vendor-defined 64-byte config interface.""" +def find_devices(vendors=None): + """ + Find hidraw nodes that expose a vendor-defined 64-byte config interface. + + vendors limits the scan to those USB vendor ids; the default is this + module's own list. Nothing about the wire format depends on it. + """ + vendors = {v.lower() for v in (vendors or KNOWN_VENDORS)} out = [] for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")): dev = os.path.join(node, "device") @@ -363,7 +372,7 @@ def find_devices(): continue _, v, p = hid_id.split(":") vid, pid = v[-4:].lower(), p[-4:].lower() - if vid not in KNOWN_VENDORS: + if vid not in vendors: continue try: @@ -403,8 +412,24 @@ def hexdump(b): return "\n".join(lines) +def version(): + """ + The app's version, or 'unknown' if the CLI was copied out on its own. + + macropad.py is meant to work as a single file with nothing beside it, so + a missing GUI package is not an error here. + """ + try: + from macropad_gui import __version__ + return __version__ + except ImportError: + return "unknown" + + def main(): ap = argparse.ArgumentParser(description="Configure a Holtek-family macro pad.") + ap.add_argument("--version", action="version", + version=f"macropad {version()}") sub = ap.add_subparsers(dest="cmd") sub.add_parser("list", help="show detected pads") diff --git a/macropad_gui/__init__.py b/macropad_gui/__init__.py index 41e08f3..f168abd 100644 --- a/macropad_gui/__init__.py +++ b/macropad_gui/__init__.py @@ -1 +1,13 @@ """Linux GUI for Holtek-family USB macro pads. GPL-3.0.""" + +# The one place the version is written down. The CLI's --version, the app's +# About box and the bug report template all read it from here, so a release +# is one line to change and the tag can never disagree with what ships. +# +# Semver, and the rule used so far: major when the idea of how the app works +# changes, minor for new hardware or features, patch for fixes. +# +# 1.x write only, because these pads were assumed to be unreadable +# 2.0.0 they can be read, plus detection, actions and themes +# 2.1.0 a second protocol family (SayoDevice), 15 key pads, theme styles +__version__ = "2.1.0" diff --git a/macropad_gui/actions.py b/macropad_gui/actions.py index 0536bdf..a9d12f4 100644 --- a/macropad_gui/actions.py +++ b/macropad_gui/actions.py @@ -20,7 +20,6 @@ the desktop; it just gets a copy. """ import errno -import fcntl import glob import json import os @@ -31,6 +30,14 @@ import signal import subprocess import sys import time + +try: + import fcntl +except ImportError: + # Windows has none. Everything that needs it is Linux only anyway, and + # each use checks. Importing this module must not fail, because app.py + # imports it to build the window. + fcntl = None from dataclasses import dataclass from pathlib import Path @@ -265,9 +272,17 @@ class VirtualKeyboard: and the first paste would otherwise go missing. """ - def __init__(self, path=UINPUT, _open=os.open, _ioctl=fcntl.ioctl, + def __init__(self, path=UINPUT, _open=os.open, _ioctl=None, _write=os.write, _sleep=time.sleep): import struct + # Resolved here, not in the signature: a default argument is + # evaluated when the module is imported, which on Windows would + # crash before the window ever got a chance to open. + if _ioctl is None: + if fcntl is None: + raise OSError("a virtual keyboard needs /dev/uinput, which " + "this system hasn't got") + _ioctl = fcntl.ioctl self._struct, self._write, self._sleep = struct, _write, _sleep self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK) _ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY) @@ -570,6 +585,10 @@ def listen(debug=False, log=print): """ lock_path = runtime_dir() / LOCK lock_path.parent.mkdir(parents=True, exist_ok=True) + if fcntl is None: + log("Background actions are Linux only for now: they need /dev/uinput " + "to type, and a file lock to keep one copy running.") + return 1 lock = open(lock_path, "a+") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/macropad_gui/app.py b/macropad_gui/app.py index 0afcb1a..9967cd8 100644 --- a/macropad_gui/app.py +++ b/macropad_gui/app.py @@ -13,12 +13,13 @@ from PySide6.QtGui import (QActionGroup, QColor, QFont, QFontDatabase, QGuiApplication, QIcon, QPalette) from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame, + QScrollArea, QStyle, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget, QCheckBox, QComboBox, QMenu) -from . import actions as act, core, keymap, padview, themes +from . import __version__, actions as act, core, keymap, padview, themes from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView # Set by apply_theme; functions read these when they run, so they follow a @@ -31,29 +32,71 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"), ("brightnessdown", "brightnessup"), ("calculator", "mail"), ("browser", "mycomputer")] +def _status(t): + """The footer's device line and its last message.""" + if t.mono_status: + return (f'QLabel[role="status"] {{ color: {t.ink}; ' + f'font-family: "{t.mono_font}"; font-size: 12px; }}\n' + f'QLabel[role="message"] {{ font-family: "{t.mono_font}"; ' + f'font-size: 12px; font-weight: 600; }}') + return f'QLabel[role="status"] {{ color: {t.ink_dim}; }}' + + +def _primary(t, tint): + """The write button: filled by default, outlined where a theme says so.""" + if t.outline_primary: + return f""" +QPushButton[role="primary"] {{ background: {t.board}; color: {t.accent}; + border: 1px solid {t.accent}; border-radius: {t.button_radius}px; + font-weight: 600; padding: 8px 16px; }} +QPushButton[role="primary"]:hover {{ background: {tint(t.board, t.accent, 0.12)}; }} +QPushButton[role="primary"]:disabled {{ color: {tint(t.window, t.ink_dim, 0.7)}; + border-color: {tint(t.window, t.line, 0.7)}; }}""" + return f""" +QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text}; + border: none; border-radius: {t.button_radius}px; + font-weight: 600; padding: 8px 16px; }} +QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }} +QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}""" + + +def _flat(t): + """Footer buttons: bare text by default, outlined pills where asked.""" + if t.pill_buttons: + return f""" +QPushButton[role="flat"] {{ background: {t.board}; color: {t.ink}; + border: 1px solid {t.ink_dim}; border-radius: {t.button_radius}px; + padding: 6px 14px; }} +QPushButton[role="flat"]:hover {{ border-color: {t.ink}; }} +QPushButton[role="flat"]:disabled {{ color: {t.ink_dim}; border-color: {t.line}; }}""" + return f""" +QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }} +QPushButton[role="flat"]:hover {{ color: {t.ink}; }}""" + + +INSPECTOR_W = 400 # the side panel's width, without its scrollbar + + def stylesheet(t): """The whole window's look, from one theme.""" tint = themes.mix return f""" QWidget {{ color: {t.ink}; background: {t.window}; }} -QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }} +QLabel[role="title"] {{ font-size: {t.title_pt}px; font-weight: 600; }} +{_status(t)} QLabel[role="dim"] {{ color: {t.ink_dim}; }} QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }} QLabel[role="value"] {{ font-size: 15px; }} QPushButton {{ background: transparent; border: 1px solid {t.line}; - border-radius: 4px; padding: 6px 12px; }} + border-radius: {t.button_radius}px; padding: 6px 12px; }} QPushButton:hover {{ border-color: {t.ink_dim}; }} QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }} QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }} -QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text}; - border: none; font-weight: 600; padding: 8px 16px; }} -QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }} -QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }} -QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }} -QPushButton[role="flat"]:hover {{ color: {t.ink}; }} +{_primary(t, tint)} +{_flat(t)} QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }} -QLineEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; - padding: 7px 8px; }} +QLineEdit {{ background: {t.field}; border: 1px solid {t.line}; + border-radius: {t.button_radius}px; padding: 7px 8px; }} QLineEdit:focus {{ border-color: {t.ink_dim}; }} QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }} QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }} @@ -62,6 +105,8 @@ QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.b QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {t.ink_dim}; }} QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }} QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }} +QScrollArea[role="panel"], QWidget[role="panel"] {{ background: {t.window}; + border: none; }} QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }} QFrame[role="editbar"] QLabel {{ background: transparent; }} QSpinBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; @@ -169,7 +214,7 @@ class Inspector(QWidget): def __init__(self): super().__init__() - self.setFixedWidth(400) + self.setFixedWidth(INSPECTOR_W) self.control = None self._loading = False self.editing_layout = False @@ -768,8 +813,22 @@ class Window(QMainWindow): self.pad.set_layout(self.state.layout) self.pad.set_orientation(self.settings.value("orientation", "upright")) self.inspector = Inspector() + # The inspector can get taller than the window: a long banner, a full + # media list, a theme with bigger type. Left to itself it pushes the + # window's minimum height past the screen, and clicking the first key + # makes the window jump to full height. In a scroll area it keeps its + # own height to itself and everything stays reachable. + self.panel = QScrollArea() + self.panel.setWidget(self.inspector) + self.panel.setWidgetResizable(True) + self.panel.setFrameShape(QFrame.NoFrame) + self.panel.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.panel.setProperty("role", "panel") + self.panel.viewport().setProperty("role", "panel") + bar = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + self.panel.setFixedWidth(INSPECTOR_W + bar) body.addWidget(self.pad, 1) - body.addWidget(self.inspector) + body.addWidget(self.panel) outer.addLayout(body, 1) # editor bar, hidden unless you're rearranging the pad @@ -812,26 +871,53 @@ class Window(QMainWindow): fl.setContentsMargins(16, 8, 16, 8) self.dot = QLabel() self.dot.setFixedSize(9, 9) - self.status = _label("", "dim") - self.message = _label("") + self.status = _label("", "status") + self.message = _label("", "message") self.edit_btn = _button("Edit layout", "flat") self.edit_btn.setToolTip("Rearrange the drawing to match your pad") self.read_btn = _button("Read pad", "flat") self.read_btn.setToolTip("Ask the pad what's on it right now") self.theme_btn = _button("Theme", "flat") self.theme_btn.setToolTip("Change how MacroPad looks") + # Style decides how the pad is drawn, colours only recolour it, so + # the menu offers them separately rather than as one long list. self.theme_menu = QMenu(self) - self.theme_group = QActionGroup(self) - self.theme_group.setExclusive(True) + self.style_group = QActionGroup(self) + self.style_group.setExclusive(True) + self.colour_group = QActionGroup(self) + self.colour_group.setExclusive(True) + self.colour_actions = {} current = self.settings.value("theme", themes.DEFAULT) - for t in themes.THEMES.values(): - a = self.theme_menu.addAction(t.name) + style_now, palette_now = themes.parts(current) + + head = self.theme_menu.addAction("Style") + head.setEnabled(False) + for key, (name, _base, _allowed) in themes.STYLES.items(): + a = self.theme_menu.addAction(" " + name) a.setCheckable(True) - a.setChecked(t.key == current) - a.setData(t.key) - self.theme_group.addAction(a) + a.setChecked(key == style_now) + a.setData(("style", key)) + self.style_group.addAction(a) + self.theme_menu.addSeparator() + head = self.theme_menu.addAction("Colours") + head.setEnabled(False) + for key, (name, _colours) in themes.PALETTES.items(): + a = self.theme_menu.addAction(" " + name) + a.setCheckable(True) + a.setChecked(key == palette_now) + a.setData(("palette", key)) + self.colour_group.addAction(a) + self.colour_actions[key] = a + self._sync_theme_menu(style_now, palette_now) + # The version sits at the foot of the Theme menu rather than in a + # menu bar, because there isn't one. It is here to be read off and + # quoted in a bug report, so it is deliberately selectable text in + # the one menu everybody opens. + self.theme_menu.addSeparator() + stamp = self.theme_menu.addAction(f"MacroPad {__version__}") + stamp.setEnabled(False) self.theme_btn.setMenu(self.theme_menu) - self.rotate = _button("Rotate view", "flat") + self.rotate = _button("Rotate pad", "flat") self.import_btn = _button("Import capture…", "flat") self.write_all = _button("", "primary") fl.addWidget(self.dot) @@ -862,7 +948,8 @@ class Window(QMainWindow): self.pad.rearranged.connect(self._rearranged) self.read_btn.clicked.connect(lambda: self._read(quiet=False)) self.rotate.clicked.connect(self._rotate) - self.theme_group.triggered.connect(lambda a: self._set_theme(a.data())) + self.style_group.triggered.connect(lambda a: self._set_theme(a.data())) + self.colour_group.triggered.connect(lambda a: self._set_theme(a.data())) self.import_btn.clicked.connect(self._import) self.note = core._keyd_warning() @@ -1303,9 +1390,30 @@ class Window(QMainWindow): self._set_layout(lay) self.pad.set_editing(True) - def _set_theme(self, key): - t = apply_theme(QApplication.instance(), key) + def _sync_theme_menu(self, style, palette): + """Grey out colours the chosen style does not offer.""" + allowed = themes.STYLES[style][2] + for key, action in self.colour_actions.items(): + action.setEnabled(key in allowed) + action.setChecked(key == palette) + + def _set_theme(self, data): + """data is a theme key, or ("style"|"palette", key) from the menu.""" + current = self.settings.value("theme", themes.DEFAULT) + style, palette = themes.parts(current) + if isinstance(data, tuple): + which, key = data + if which == "style": + style = key + if palette not in themes.STYLES[style][2]: + palette = themes.STYLES[style][2][0] + else: + palette = key + else: + style, palette = themes.parts(data) + t = apply_theme(QApplication.instance(), themes.compose(style, palette).key) self.settings.setValue("theme", t.key) + self._sync_theme_menu(style, palette) self._poll() # redraws the status dot in the new colours self._refresh() self.pad.update() @@ -1364,6 +1472,13 @@ def apply_theme(app, key=None): padview.apply_theme(t) GOOD, BAD, LINE, FIELD = t.good, t.bad, t.line, t.field app.setStyle("Fusion") + if t.ui_font and t.ui_font in padview._families(): + f = QFont(t.ui_font) + if t.ui_pt: + f.setPixelSize(t.ui_pt) + app.setFont(f) + else: + app.setFont(QFont()) pal = QPalette() for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field), (QPalette.Text, t.ink), (QPalette.WindowText, t.ink), diff --git a/macropad_gui/backends/__init__.py b/macropad_gui/backends/__init__.py new file mode 100644 index 0000000..6869295 --- /dev/null +++ b/macropad_gui/backends/__init__.py @@ -0,0 +1,34 @@ +""" +One device family per backend. + +Everything above this line works in terms of a Layout and a set of control +ids and doesn't care what the hardware speaks. Everything below it is one +pad family's protocol. Adding a family means writing the six methods in +base.py and listing the class here. +""" + +from .ch57x import CH57x +from .sayo import Sayo + +REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins +DEFAULT = REGISTRY[0] + + +def for_vid_pid(vid, pid): + for backend in REGISTRY: + if backend.claims(str(vid).lower(), str(pid).lower()): + return backend + return None + + +def for_device(device): + # Falls back rather than returning None, so callers that only ever see + # one family stay simple. Use for_vid_pid when "nobody owns this" is an + # answer you need to act on. + if device is None: + return DEFAULT + return for_vid_pid(device.vid, device.pid) or DEFAULT + + +def known_ids(): + return [(b.vid, b.pids, b.name) for b in REGISTRY] diff --git a/macropad_gui/backends/base.py b/macropad_gui/backends/base.py new file mode 100644 index 0000000..04e4175 --- /dev/null +++ b/macropad_gui/backends/base.py @@ -0,0 +1,64 @@ +""" +What a device family has to provide. + +These raise rather than returning something vague, so a half written backend +fails at the call it can't answer instead of writing wrong bytes to somebody's +hardware. +""" + + +class Backend: + key = "" # short id, used in settings and messages + name = "" # what a person would call this family + vid = "" # USB vendor id, lowercase hex, no 0x + pids = () # product ids this backend is CONFIRMED on + layers = 1 + max_keys = 0 + max_knobs = 0 + + def claims(self, vid, pid): + """True only for ids this backend has been confirmed on.""" + if vid.lower() != self.vid.lower(): + return False + return not self.pids or pid.lower() in {p.lower() for p in self.pids} + + def could_claim(self, vid, pid): + # Siblings share a vendor id but not always a protocol. The scan and + # the udev rule cover the whole vendor so an unconfirmed sibling is + # still found and offered detection; claims() stays strict so it is + # never written to on the strength of its vendor id alone. + return vid.lower() == self.vid.lower() + + def udev_rule(self): + return (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{self.vid}", ' + f'TAG+="uaccess"') + + def adopt(self, layout): + """Where a family can say more about a shape than detection found.""" + return layout + + def supports(self, keys, knobs): + return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs + + # The six a family must answer. + + def action_byte(self, control): + """The slot number this family uses for a control id.""" + raise NotImplementedError + + def info(self, device): + """(keys, knobs) as the pad reports them, or None.""" + raise NotImplementedError + + def read_layer(self, device, layer=1, layout=None): + raise NotImplementedError + + def write(self, device, control, binding, state, layer=1, _write=None): + raise NotImplementedError + + def detect(self, device): + """Read only. Must say no rather than guess.""" + raise NotImplementedError + + def __repr__(self): + return f"<{type(self).__name__} {self.vid}:{'/'.join(self.pids) or '*'}>" diff --git a/macropad_gui/backends/ch57x.py b/macropad_gui/backends/ch57x.py new file mode 100644 index 0000000..dcb2d13 --- /dev/null +++ b/macropad_gui/backends/ch57x.py @@ -0,0 +1,216 @@ +""" +The CH57x family: 1189:8840 and its relatives. + +Reverse engineered from USB captures of the vendor's own software, and every +packet in tests/captures is replayed by the test suite. The wire format lives +in macropad.py and is not to be edited without a capture to back it up. + +Two things about this firmware shape the code: + + * It keeps 15 key slots and 3 knob slots whatever hardware is soldered on, + so a layer read always answers 24 slots. Only the 0xFB query says how + many of those are real. + * There is no addressing by name. A control is a slot number: keys are + 1 to 15, knobs start at 0x10 and take three apiece. +""" + +import errno +import time + +import macropad as proto + +from .base import Backend + +WRITE_GAP = 0.02 # the same pause the CLI leaves between reports +DIAL_ACTIONS = ("left", "push", "right") + + +class CH57x(Backend): + key = "ch57x" + name = "CH57x macro pad" + vid = "1189" + pids = ("8840",) # confirmed; siblings share the vendor id, not the + # protocol, so they go through detect() first + layers = proto.LAYERS + max_keys = proto.KEY_SLOTS + max_knobs = proto.KNOB_SLOTS + + # ------------------------------------------------------------ protocol + + def action_byte(self, control): + """ + Keys count from 1; knobs start at 0x10, three apiece. Confirmed on + 1189:8840 both by writing and by reading back, and it matches the + slots the pad reports for itself. + """ + control = control.lower() + if control.startswith("key"): + return int(control[3:]) + dial, act = control.split("-") + return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act) + + # -------------------------------------------------------------- decode + + def decode_info(self, report): + """(keys, knobs) from a 0xFB reply, or None.""" + if len(report) < 4 or report[1] != proto.MAGIC_INFO: + return None + return report[2], report[3] + + def decode_config(self, report, layout=None): + """ + (control, layer, Binding) from a config report, or None. + + layout decides which slots count as real controls, and it matters + more than it looks: decode a 15 key pad against a 12 key layout and + keys 13 to 15 and the third knob vanish without a word. + """ + from .. import core + if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE, + proto.MAGIC_READ): + return None + action, layer, kind = report[2], report[3], report[4] + controls = (layout or core.DEFAULT_LAYOUT).control_for_action + control = controls.get(action) + if control is None: + return None + delay = report[5] | (report[6] << 8) + count = report[10] + + if kind == proto.KeyType.MULTIMEDIA and count: + media = core._MEDIA_BY_USAGE.get(report[11] | (report[12] << 8)) + return (control, layer, core.Binding(media=media)) if media else None + + if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC): + mods, code = report[11], report[12] + if not mods and not code: + return control, layer, core.Binding(none=True) + return None + + if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count: + steps = [] + for i in range(count): + off = 11 + 2 * i + if off + 1 >= len(report): + return None + mods, code = report[off], report[off + 1] + names = [n for n, bit in core._MOD_ORDER if mods & bit] + if code: + if code not in core._KEYNAME_BY_CODE: + return None + names.append(core._KEYNAME_BY_CODE[code]) + steps.append("+".join(names)) + return control, layer, core.Binding(keys=",".join(steps), delay=delay) + return None + + # ---------------------------------------------------------------- read + + def info(self, device): + from .. import core + for reply in core._exchange(device.path, + proto.native_info(device.report_id), want=1): + got = self.decode_info(reply) + if got: + return got + return None + + def read_layer(self, device, layer=1, layout=None): + from .. import core + out = {} + request = proto.native_read(device.report_id, layer) + for reply in core._exchange(device.path, request): + decoded = self.decode_config(reply, layout) + if not decoded: + continue + control, got_layer, binding = decoded + if got_layer == layer: + out[control] = binding + return out + + # --------------------------------------------------------------- write + + def write(self, device, control, binding, state, layer=1, _write=None): + """ + Send one binding (config report, then commit) and update the shadow + state. On success the state file is already saved. + """ + from .. import core + write = _write or proto.write_report + reports = binding.reports(device.report_id, self.action_byte(control), + layer) + sent = 0 + try: + for r in reports: + write(device.path, r) + sent += 1 + time.sleep(WRITE_GAP) + except OSError as e: + why = core._explain(e) + # if the very first write failed on open, nothing reached the pad + untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV, + errno.EACCES, errno.EPERM) + if not untouched: + state.mark_unknown(control, f"write interrupted after {sent} of " + f"{len(reports)} reports: {why}", layer) + state.save() + raise core.WriteError(f"{control}: {why}", + pad_touched=not untouched, cause=e) + state.record(control, binding, layer) + state.save() + + # -------------------------------------------------------------- detect + + def detect(self, device): + """ + Ask an unknown pad what it is, without configuring anything. + + Both commands are queries. We only conclude the pad speaks this + protocol if it answers both in the right shape: a well formed info + reply, and a layer whose controls are exactly the slots that reply + implies. A pad from another family fails one of those and we leave + it alone. + """ + from .. import core + try: + info = self.info(device) + except core.ReadError as e: + return core.Detection(why=f"it didn't answer: {e}") + if not info: + return core.Detection(why="it didn't answer the 'what are you' query") + + keys, knobs = info + if not self.supports(keys, knobs): + return core.Detection(why=f"it reported {keys} keys and {knobs} " + "knobs, which isn't a pad this protocol " + "can describe") + + probe = core.Layout(keys, knobs, rows=keys, cols=1) # geometry unused + expected = set(probe.control_for_action) + try: + replies = core._exchange(device.path, + proto.native_read(device.report_id, 1)) + except core.ReadError as e: + return core.Detection( + why=f"it answered the first query but not the second: {e}") + + seen, bindings = set(), {} + for reply in replies: + if len(reply) < 13 or reply[1] != proto.MAGIC_READ: + continue + action, layer = reply[2], reply[3] + seen.add(action) + if action not in expected or layer != 1: + continue + decoded = self.decode_config(reply, probe) + if decoded: + bindings[probe.control_for_action[action]] = decoded[2] + + missing = expected - seen + if missing: + return core.Detection( + why=f"it described {keys} keys and {knobs} knobs but didn't " + f"report {len(missing)} of them, so this isn't the " + "protocol it speaks") + + return core.Detection(speaks=True, keys=keys, knobs=knobs, + layers=self.layers, bindings=bindings) diff --git a/macropad_gui/backends/sayo.py b/macropad_gui/backends/sayo.py new file mode 100644 index 0000000..dbe3b13 --- /dev/null +++ b/macropad_gui/backends/sayo.py @@ -0,0 +1,287 @@ +""" +SayoDevice backend. Complete but never run against real hardware. + +Drop this and sayo_proto.py in macropad_gui/backends/ and add one line to +REGISTRY in backends/__init__.py: + + from .sayo import Sayo + REGISTRY = (CH57x(), Sayo()) + +Until it is in REGISTRY the app cannot see it, so this file sitting here +changes nothing for anyone. + +The protocol is in sayo_proto.py, decoded from WebHID captures of +sayodevice.com and checked against them line by line. Three things about +it shape the code here: + + * Reads and writes are the same command, 0x12. A four byte payload asks + for a row of a table, a sixty byte payload is the row coming back or + going in. Table 0x10 is the keys. + * The pad says how many keys it has. Walk the index up and it answers + with a no-such-index flag, so the key count is discovered rather than + configured. It reports where each key physically is, too, so the grid + is known rather than guessed. + * Several bytes of a key's record drift on their own between reads, so + a write is read-modify-write: read the record, change the two binding + bytes, send it back. The vendor's own page does the same, which one + capture caught it doing. + +The pad echoes a write back about 100ms later, which is a real +acknowledgement. The CH57x says nothing, so this backend can confirm a +write where the other one can only hope. +""" + +import errno + +from . import sayo_proto as wire +from .base import Backend + +REPORT_ID = wire.REPORT_ID +MAX_KEYS = 64 # a sanity stop in case a pad never says no + + +class Sayo(Backend): + key = "sayo" + name = "SayoDevice" + vid = "8089" + pids = ("000b",) # the 2x4; other Sayo pids are untested + layers = 1 # the firmware has more, we expose one for now + max_keys = MAX_KEYS + max_knobs = 0 + + # ------------------------------------------------------------ protocol + + def action_byte(self, control): + """Keys are their own index, counting from zero. No knobs.""" + if control.startswith("key"): + return int(control[3:]) - 1 + raise ValueError(f"{self.name} has no knobs: {control}") + + def _request(self, table, index=0): + return wire.report(wire.read_request(table, index)) + + def _ask(self, device, table, index=0): + """ + One row of one table, or None if the pad has no such row. + + Heartbeats arrive unasked about once a second and have to be + stepped over, or the first one to land looks like the answer. + """ + from .. import core + for reply in core._exchange(device.path, self._request(table, index), + want=4): + if len(reply) < 2 or reply[0] != REPORT_ID: + continue + payload = reply[1:] + if wire.is_heartbeat(payload): + continue + try: + got = wire.parse_reply(payload) + except ValueError: + continue + if got["table"] != table or got["index"] != index: + continue + return None if got["end"] else payload + return None + + def _records(self, device, limit=MAX_KEYS): + """Every key's record, in order, stopping where the pad says to.""" + out = [] + for i in range(limit): + rec = self._ask(device, wire.TABLE_KEYS, i) + if rec is None or not wire.is_key_record(rec): + break + out.append(rec) + return out + + def _binding(self, record): + """ + core.Binding for one key, or None if we cannot say what it does. + + None is the honest answer for a key type no capture covers, and + it matters: three keys on one real pad are type 0x80, and reading + those bytes as a modifier made them look like a bare "ctrl+shift" + that nobody had set. The app shows None as unknown, which is true, + and write() then refuses to overwrite it. + """ + from .. import core + kind, a, b = wire.read_binding(record) + + if kind == "consumer": + return core.Binding(media=b) if b else None + + if kind != "keyboard": + return None + + mods, code = a, b + if not mods and not code: + return core.Binding(none=True) + names = [n for n, bit in core._MOD_ORDER if mods & bit] + if code: + name = core._KEYNAME_BY_CODE.get(code) + if name is None: + return None + names.append(name) + return core.Binding(keys="+".join(names)) + + # --------------------------------------------------------------- read + + def info(self, device): + """ + (keys, knobs) as the pad reports them, or None if it goes quiet. + + None matters: it means "did not answer", and the app then treats + the pad as unsupported rather than writing to it. + """ + records = self._records(device) + return (len(records), 0) if records else None + + def model(self, device): + """The name the pad calls itself, for the window title. May be ''.""" + reply = self._ask(device, wire.TABLE_NAME) + return wire.device_name(reply) if reply else "" + + def read_layer(self, device, layer=1, layout=None): + out = {} + for i, rec in enumerate(self._records(device)): + binding = self._binding(rec) + if binding is not None: + out[f"key{i + 1}"] = binding + return out + + # -------------------------------------------------------------- write + + def write(self, device, control, binding, state, layer=1, _write=None): + """ + Put one binding on the pad. + + Read the key's record first, because most of it is the pad's own + and some of it moves between reads. If the read fails nothing has + been sent, which is the one case where the app can promise the old + binding is still there. + """ + from .. import core + import macropad as proto + + index = self.action_byte(control) + + try: + record = self._ask(device, wire.TABLE_KEYS, index) + except core.ReadError as e: + raise core.WriteError(f"{control}: couldn't read the key first, so " + f"nothing was changed ({e})", + pad_touched=False, cause=e) + if record is None or not wire.is_key_record(record): + raise core.WriteError(f"{control}: the pad has no key {index + 1}", + pad_touched=False) + + # Refuse to write over a key whose current type no capture covers. + # Overwriting it would leave a key marked as one kind holding + # another kind's bytes, and what the firmware does then is anyone's + # guess. Better to say so and leave the pad alone. + kind = wire.kind_of(record) + if kind == "unknown": + raise core.WriteError( + f"{control}: this key is set to something this app hasn't " + f"decoded yet (type 0x{record[wire.KIND_AT]:02x}), so it was " + f"left alone. Clear it in the SayoDevice configurator first " + f"and it becomes editable here.", pad_touched=False) + + payload = self._payload(record, binding) + write = _write or proto.write_report + try: + write(device.path, wire.report(payload)) + except OSError as e: + why = core._explain(e) + untouched = e.errno in (errno.ENOENT, errno.ENODEV, + errno.EACCES, errno.EPERM) + if not untouched: + state.mark_unknown(control, f"write interrupted: {why}", layer) + state.save() + raise core.WriteError(f"{control}: {why}", + pad_touched=not untouched, cause=e) + state.record(control, binding, layer) + state.save() + + def _payload(self, record, binding): + """The sealed write for this binding, on this key's own record.""" + from .. import core + + if binding.media: + if binding.media not in wire.CONSUMER_BY_NAME: + known = ", ".join(sorted(wire.CONSUMER_BY_NAME)) + raise core.WriteError( + f"{self.name}: {binding.media} isn't captured yet. This " + f"pad numbers media keys its own way, not by HID code, so " + f"each one needs seeing once. Known so far: {known}.", + pad_touched=False) + return wire.patch_consumer(record, binding.media) + return wire.patch_binding(record, *self._encode(binding)) + + def _encode(self, binding): + """ + (mods, usage) for one keyboard binding, or a WriteError saying why + not. + + Parsing goes through macropad.MODIFIERS and macropad.KEYCODES, the + same tables the CLI uses, so a binding typed once means the same + thing on either family of pad. That is most of the point of having + a backend seam at all. + """ + from .. import core + import macropad as proto + + if binding.none: + return 0, 0 + if "," in binding.keys: + raise core.WriteError( + f"{self.name}: this pad holds one keystroke per key, not a " + "sequence", pad_touched=False) + mods, code = 0, 0 + for part in binding.keys.lower().split("+"): + part = part.strip() + if not part: + continue + if part in proto.MODIFIERS: + mods |= proto.MODIFIERS[part] + elif part in proto.KEYCODES: + if code: + raise core.WriteError( + f"{self.name}: this pad holds one key plus modifiers, " + f"so {binding.keys!r} won't fit", pad_touched=False) + code = proto.KEYCODES[part] + else: + raise core.WriteError(f"{self.name}: don't know the key " + f"{part!r}", pad_touched=False) + return mods, code + + # ------------------------------------------------------------- detect + + def detect(self, device): + """ + Ask an unknown pad what it is, reading only. + + Two things have to line up before we call it a Sayo: a key table + that answers in the right shape, and a model name. A pad sharing + the vendor id but not the protocol fails one of them and is left + alone. + """ + from .. import core + try: + records = self._records(device) + except core.ReadError as e: + return core.Detection(why=f"it didn't answer: {e}") + if not records: + return core.Detection(why="it didn't answer the key table query") + + rows, cols = wire.grid_from_records(records) + if rows * cols != len(records): + rows, cols = 1, len(records) # odd shape; one row is honest + bindings = {} + for i, rec in enumerate(records): + got = self._binding(rec) + if got is not None: + bindings[f"key{i + 1}"] = got + return core.Detection(speaks=True, keys=len(records), knobs=0, + layers=self.layers, bindings=bindings, + grid=(rows, cols), model=self.model(device)) diff --git a/macropad_gui/backends/sayo_proto.py b/macropad_gui/backends/sayo_proto.py new file mode 100644 index 0000000..574b4ee --- /dev/null +++ b/macropad_gui/backends/sayo_proto.py @@ -0,0 +1,631 @@ +""" +SayoDevice 8089:000b wire format. + +Worked out from WebHID captures of sayodevice.com. Everything here is +checked against those captures by the self test at the bottom: run this +file directly and it rebuilds every captured packet byte for byte. + + python3 sayo_proto.py + +A report is 64 bytes: report id 0x21, then 63 bytes of payload. One +command byte, 0x12, covers both directions. A four byte payload is a +question, a sixty byte payload is an answer or a write. + + payload + 0 0x12 the only command we have seen + 1-2 checksum, little endian (see checksum()) + 3 how many bytes from byte 4 on carry meaning. 0x04 for a + read request, 0x3c for a key record + 4 flags. 0x00 asking, 0x40 in a reply means no such index + 5 which table (see TABLES) + 6 index within that table + 7+ the data + +Table 0x10 is the keys, one record per key, and a record looks like this: + + 7 0x01 + 8-10 0x00 + 11-18 x, y, width, height on the pad face, four little endian 16 + bit numbers. Key 1 of an 8 key pad is at 1250,1250 and is + 1800 square, giving a 4 by 2 grid with a pitch of 1900 + 19 0x64 100 + 23 what kind of key this is (see KINDS). It decides what bytes + 27 and 28 mean, and the configurator writes it + 27-28 the binding, read according to byte 23 + 44 a counter of some sort, drifts on its own + 51-52 also drifts on its own, probably a press count or timestamp + +Byte 23 took two captures to pin down. A capture of the page setting +volume and brightness keys shows it going from 0x01 to 0x03 and byte 27 +carrying a small code, while an earlier capture of the page setting a +plain letter left it alone. So it is the key's type, not drifting state: + + 0x00, 0x01 a keyboard shortcut. 27 is the modifier bitmask, + standard HID, and 28 is the usage code. The low bit + appears either way and is something else, so it is + preserved rather than understood + 0x03 volume and brightness. 27 is a code of Sayo's own + (see CONSUMER), 28 is zero + anything a kind we have not captured. On one real pad, 0x80 + else is three media transport keys. Read as unknown and + never written over + +Writing is read-modify-write: read the key's record, set bytes 23, 27 +and 28, reseal. The vendor's own page does exactly that, which one +capture caught in the act. Nothing else in the record is ours to touch, +because bytes 44 and 51-52 really do move on their own. +""" + +from pathlib import Path + +REPORT_ID = 0x21 +CMD = 0x12 # both directions, both reads and writes +KEY_AT = 6 +KIND_AT = 23 # what sort of key it is, which decides 27 and 28 +MOD_AT = 27 +KEYCODE_AT = 28 +CHECK_AT = 1 # payload bytes 1 and 2, little endian +LEN_AT = 3 +FLAG_AT = 4 +TABLE_AT = 5 +DATA_AT = 7 + +RECORD_LEN = 0x3C # byte 3 of a key record +REQUEST_LEN = 0x04 # byte 3 of a read request +NO_SUCH_INDEX = 0x40 # byte 4 of a reply, meaning you ran off the end + +# Byte 5. The pad lists the ones it has in table 0x00; these are the ones +# we have a use for. +TABLE_DIRECTORY = 0x00 +TABLE_NAME = 0x01 # the model name, UTF-16LE +TABLE_INFO = 0x02 # vid and pid at bytes 19 to 22, and uptime +TABLE_KEYS = 0x10 # one record per key, the ones we care about + +# Table 0x01 and 0x19 want 0x0c in the flag byte rather than 0x00. Why is +# not clear, but the page is consistent about it and so are we. +FLAGS = {TABLE_NAME: 0x0C} + +CMD_WRITE_KEY = CMD # old name, kept so nothing outside here breaks + +# Byte 23. KEYBOARD_KINDS are the two values seen alongside a real +# keyboard shortcut; the low bit is some other flag we carry along. +KIND_KEYBOARD = 0x00 +KIND_CONSUMER = 0x03 +KEYBOARD_KINDS = (0x00, 0x01) +KINDS = {KIND_KEYBOARD: "keyboard", 0x01: "keyboard", KIND_CONSUMER: "consumer"} + +# Byte 27 when byte 23 is 0x03. Sayo's own numbering, not HID consumer +# codes, so every one of these comes from a capture and no others are +# guessed. The names match macropad.MEDIA_KEYS so a media binding means +# the same thing on either family of pad. +CONSUMER = { + 0x01: "brightnessup", + 0x02: "brightnessdown", + 0x0A: "volumeup", + 0x0B: "volumedown", +} +CONSUMER_BY_NAME = {v: k for k, v in CONSUMER.items()} + +# Captured records, each the exact 63 bytes sayodevice.com sent, taken from +# CAPTURED below so the two can never drift apart. Byte 6 is the key index, +# so these cover keys 1, 2 and 8 of an 8 key pad. +CAPTURED = [ + "1256683c00100701000000261b4e0c080708076400000000000000000400000000000000000000000000000015000000000000000000000000000000000000", + "1256693c00100701000000261b4e0c080708076400000000000000000500000000000000000000000000000015000000000000000000000000000000000000", + "1256773c00100701000000261b4e0c080708076400000000000000001300000000000000000000000000000015000000000000000000000000000000000000", + "1258643c00100701000000261b4e0c080708076400000000000000020000000000000000000000000000000015000000000000000000000000000000000000", + "125e643c00100701000000261b4e0c080708076400000000000000080000000000000000000000000000000015000000000000000000000000000000000000", + "1256643c00100701000000261b4e0c080708076400000000000000000000000000000000000000000000000015000000000000000000000000000000000000", + "12c17e3c00100001000000e204e20408070807640000000000000000040000030000000e000000000000000000000000000000000000000100000009500000", + "122a863c001001010000004e0ce20408070807640000000000000000040000030000000c0000000000000000000000000000000000000000000000094f0000", +] + +# A second capture, of the page starting up, which is where the read +# command came from. Request then reply, in the order they happened. +STARTUP = [ + # the directory: which tables this pad has + ("out", "122512040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + ("in", "1250e11f00000007007b000000004b000004040102030d0e101112161718191a1e1f0000000000000000000000000000000000000000000000000000000000"), + # the model name + ("out", "12261e040c01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + ("in", "129124340c01005300610079006f00440065007600690063006500200032007800340056002000520047004200000000000000000000000000000000000000"), + # the eight keys, asked for one at a time + ("out", "123512040010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + ("in", "12c2d13c00100001000000e204e20408070807640000000000000001570000030000000e000000000000000000000000000000000000000100000009500000"), + ("in", "12ad823c001001010000004e0ce20408070807640000008000000003000000030000000c0000000000000000000000000000000000000000000000094f0000"), + ("in", "1296863c00100201000000ba13e20408070807640000008000000004000000030000000f000000810000000000000000000000014a00000000000000000000"), + ("in", "126f8f3c00100301000000261be204080708076400000080000000010000000000000000000000000000000000000000000000044b00000000000000000000"), + ("in", "1214a93c00100401000000e2044e0c08070807640000000000000001560000010000000000000000000000001d000000000000000000000000000000000000"), + ("in", "1286883c001005010000004e0c4e0c080708076400000001000000072f0000000000000000000000000000001b000000000000000000000000000000000000"), + ("in", "12f5c83c00100601000000ba134e0c080708076400000000000000073000000000000000000000000000000006000000000000044c00000000000000000000"), + ("in", "125bb23c00100701000000261b4e0c080708076400000001000000000000000000000000000000000000000015000000000000044e00000000000000000000"), + # asking for a ninth key, and being told there isn't one + ("out", "12351a040010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + ("in", "12355a044010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + # and then the page set key 8 to p, by sending back the record it had + # just read with one byte changed. Which is what we do. + ("out", "125bc53c00100701000000261b4e0c080708076400000001000000001300000000000000000000000000000015000000000000044e00000000000000000000"), +] + +LIVE = [bytes.fromhex(h) for d, h in STARTUP if d == "in" and + bytes.fromhex(h)[TABLE_AT] == 0x10 and bytes.fromhex(h)[LEN_AT] == 0x3C] + +# A third capture: the page setting key 8 to each of four volume and +# brightness keys in turn, with the log cleared first. All four differ +# from each other in byte 27 and nothing else, and all four set byte 23 +# to 0x03 where the key had been 0x01. That pair of facts is what makes +# byte 23 the key type rather than drifting state. +CONSUMER_CAPTURE = { + "volumeup": "1267b23c00100701000000261b4e0c0807080764000000030000000a0000000000000000000000000000000015000000000000044e00000000000000000000", + "volumedown": "1268b23c00100701000000261b4e0c0807080764000000030000000b0000000000000000000000000000000015000000000000044e00000000000000000000", + "brightnessup": "125eb23c00100701000000261b4e0c080708076400000003000000010000000000000000000000000000000015000000000000044e00000000000000000000", + "brightnessdown": "125fb23c00100701000000261b4e0c080708076400000003000000020000000000000000000000000000000015000000000000044e00000000000000000000", +} + + +def _records(): + out = {} + for h in CAPTURED: + b = bytes.fromhex(h) + out.setdefault(b[KEY_AT], b) # first sighting of each key + return out + + +RECORDS = _records() + +USAGES = {c: 4 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")} +USAGES.update({str(d): 30 + i for i, d in enumerate("1234567890")}) +USAGES.update({ + "enter": 40, "esc": 41, "backspace": 42, "tab": 43, "space": 44, + "minus": 45, "equal": 46, "leftbracket": 47, "rightbracket": 48, + "backslash": 49, "semicolon": 51, "quote": 52, "grave": 53, + "comma": 54, "period": 55, "slash": 56, "capslock": 57, + "printscreen": 70, "insert": 73, "home": 74, "pageup": 75, + "delete": 76, "end": 77, "pagedown": 78, + "right": 79, "left": 80, "down": 81, "up": 82, + "numlock": 83, "numpadslash": 84, "numpadstar": 85, + "numpadminus": 86, "numpadplus": 87, "numpadenter": 88, "numpaddot": 99, +}) +for _i in range(1, 13): + USAGES[f"f{_i}"] = 57 + _i +for _i in range(1, 10): + USAGES[f"numpad{_i}"] = 0x58 + _i +USAGES["numpad0"] = 0x62 + +# Standard HID modifier bits. The pad uses them unchanged. +MODIFIERS = {"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "meta": 0x08, + "super": 0x08, "win": 0x08, + "rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rmeta": 0x80} + + +def parse_combo(text): + """'ctrl+shift+a' -> (0x03, 0x04). A bare modifier gives keycode 0.""" + mods, usage = 0, 0 + for part in text.lower().replace(" ", "").split("+"): + if not part: + continue + if part in MODIFIERS: + mods |= MODIFIERS[part] + elif part in USAGES: + usage = USAGES[part] + else: + raise KeyError(f"don't know {part!r}") + return mods, usage + + +def checksum(payload): + """ + Sum the whole 64 byte report as little endian 16 bit words with the + checksum field zeroed. No magic constant: this reproduces all ten + captured packets exactly. + """ + raw = bytearray([REPORT_ID]) + bytearray(payload) + raw[2] = raw[3] = 0 # the checksum's own two bytes + if len(raw) % 2: + raw.append(0) + total = sum(raw[i] | (raw[i + 1] << 8) for i in range(0, len(raw), 2)) + return total & 0xFFFF + + +def seal(payload): + """Put the right checksum into a payload and hand it back.""" + out = bytearray(payload) + c = checksum(out) + out[CHECK_AT] = c & 0xFF + out[CHECK_AT + 1] = (c >> 8) & 0xFF + return bytes(out) + + +def verify(payload): + return payload[CHECK_AT] | (payload[CHECK_AT + 1] << 8) == checksum(payload) + + +def kind_of(record): + """What sort of key this is: 'keyboard', 'consumer', or 'unknown'.""" + return KINDS.get(record[KIND_AT], "unknown") + + +def read_binding(record): + """ + (kind, a, b) from a record, where what a and b mean follows the kind. + + keyboard (modifier bitmask, HID usage) + consumer (consumer code, name or None) + unknown (byte 23, byte 27) and nothing else to say + + Returning the kind rather than guessing is the whole point: three keys + on one real pad are type 0x80, and reading those as a modifier made + them look like a bare "ctrl+shift" that nobody had set. + """ + kind = kind_of(record) + if kind == "keyboard": + return kind, record[MOD_AT], record[KEYCODE_AT] + if kind == "consumer": + code = record[MOD_AT] + return kind, code, CONSUMER.get(code) + return kind, record[KIND_AT], record[MOD_AT] + + +def patch_binding(record, mods, usage): + """ + One key's record holding a keyboard shortcut instead, resealed. + + record must be that key's own record, because bytes 11 to 18 are where + that key sits on the pad face and other bytes are its own state. Pass + another key's record and you move this key on top of that one. + + Byte 23 is set to the keyboard kind. If the key was already a keyboard + key its low bit is kept, since that bit shows up either way and the + vendor's page leaves it alone when it changes a letter. + """ + out = bytearray(record) + was = out[KIND_AT] + out[KIND_AT] = (was & 0x01) if was in KEYBOARD_KINDS else KIND_KEYBOARD + out[MOD_AT] = mods + out[KEYCODE_AT] = usage + return seal(out) + + +def patch_consumer(record, code): + """ + One key's record holding a volume or brightness key, resealed. + + code is a key of CONSUMER, or one of its names. Byte 23 becomes 0x03 + and byte 28 goes to zero, which is exactly what the capture of the + page doing this shows. + """ + if isinstance(code, str): + if code not in CONSUMER_BY_NAME: + raise KeyError(f"no captured code for {code!r}; known: " + f"{', '.join(sorted(CONSUMER_BY_NAME))}") + code = CONSUMER_BY_NAME[code] + out = bytearray(record) + out[KIND_AT] = KIND_CONSUMER + out[MOD_AT] = code + out[KEYCODE_AT] = 0 + return seal(out) + + +def patch_keycode(record, usage): + """A plain key with no modifier.""" + return patch_binding(record, 0, usage) + + +def is_heartbeat(payload): + """The status report the pad sends unasked, about once a second.""" + return bool(payload) and payload[0] == 0x00 + + +def report(payload): + """The 64 bytes to hand to a hidraw write.""" + return bytes([REPORT_ID]) + bytes(payload) + + +# ------------------------------------------------------ reading the pad +# +# A capture of the configurator starting up gave this away. Ask with a +# four byte payload naming a table and an index, get the row back. Walk +# the index up from zero and the pad tells you when to stop, so the key +# count does not have to be guessed or configured anywhere. + + +def read_request(table, index=0): + """The payload that asks for one row of one table.""" + out = bytearray(63) + out[0] = CMD + out[LEN_AT] = REQUEST_LEN + out[FLAG_AT] = FLAGS.get(table, 0x00) + out[TABLE_AT] = table + out[KEY_AT] = index + return seal(out) + + +def parse_reply(payload): + """ + {table, index, flags, data, end} from anything the pad sends back. + + data is only the meaningful part: byte 3 says how far it runs. end is + True when the index does not exist, which is how you find out how many + keys a pad has. + """ + p = bytes(payload) + if len(p) < DATA_AT or p[0] != CMD: + raise ValueError("not a 0x12 report") + size = p[LEN_AT] + return {"table": p[TABLE_AT], "index": p[KEY_AT], "flags": p[FLAG_AT], + "data": p[FLAG_AT:FLAG_AT + size], "payload": p, + "end": bool(p[FLAG_AT] & NO_SUCH_INDEX)} + + +def is_key_record(payload): + """Is this 63 bytes a key record, rather than a short reply or noise?""" + p = bytes(payload) + return (len(p) >= DATA_AT and p[0] == CMD and p[TABLE_AT] == TABLE_KEYS + and p[LEN_AT] == RECORD_LEN and not p[FLAG_AT] & NO_SUCH_INDEX) + + +def key_geometry(record): + """ + (x, y, w, h) of one key on the pad face, from its record. + + The unit is arbitrary but consistent. What it is good for is working + out the shape of a pad nobody has told us about: group the keys by y + to get the rows. + """ + u16 = lambda o: record[o] | (record[o + 1] << 8) + return u16(11), u16(13), u16(15), u16(17) + + +def grid_from_records(records): + """ + (rows, cols) worked out from where the keys actually are. + + Beats guessing from the key count, which cannot tell a 4 by 2 from a + 2 by 4 or an 8 by 1. + """ + rows = sorted({key_geometry(r)[1] for r in records}) + cols = sorted({key_geometry(r)[0] for r in records}) + return len(rows) or 1, len(cols) or 1 + + +def device_name(payload): + """ + The model name out of a table 0x01 reply. UTF-16LE, zero padded. + + The declared length can land on an odd byte, which would leave half a + character on the end, so trim to even and then cut at the first NUL + rather than trusting the padding. + """ + p = bytes(payload) + size = max(0, p[LEN_AT] - 3) + raw = p[DATA_AT:DATA_AT + size - (size % 2)] + return raw.decode("utf-16-le", "replace").split("\x00")[0].strip() + + +def tables_offered(payload): + """ + Which tables a pad admits to having, from a table 0x00 reply. + + Only used to sanity check that 0x10 is there before trusting the rest. + """ + p = bytes(payload) + body = p[FLAG_AT:FLAG_AT + p[LEN_AT]] + seen, out = set(), [] + for b in body[10:]: # the list sits after a header + if b and b not in seen: + seen.add(b) + out.append(b) + return out + + +# ------------------------------------------------------- backup files +# +# The configurator's "backup" button writes a .sayobak. Inside it, from +# offset 102, are the pad's eight key records, 62 bytes each. They are the +# same layout as a write payload from byte 11 on, which is the part we +# cannot invent, so a backup is a perfectly good substitute for reading the +# records off the pad. +# +# Rebuilding key 1's captured write from its backup record comes out byte +# for byte identical, which is what makes this trustworthy. + +BACKUP_MAGIC = b"Sayo Device Backup File" +BACKUP_START = 102 +BACKUP_STRIDE = 62 +KEYS_IN_BACKUP = 8 + + +def read_backup(data): + """ + [{key, x, y, w, h, mods, usage, record}] from a .sayobak, one per key. + + x and y are the key's position on the pad face and w and h its size, in + whatever unit the firmware uses. On an 8 key pad they come out as a 4 by + 2 grid with a pitch of 1900 and a size of 1800. + """ + if hasattr(data, "read_bytes"): + data = data.read_bytes() + elif isinstance(data, str): + data = Path(data).read_bytes() + if not data.startswith(BACKUP_MAGIC): + raise ValueError("not a Sayo backup file") + out = [] + for i in range(KEYS_IN_BACKUP): + off = BACKUP_START + i * BACKUP_STRIDE + rec = data[off:off + BACKUP_STRIDE] + if len(rec) < BACKUP_STRIDE: + break + u16 = lambda o: rec[o] | (rec[o + 1] << 8) + out.append({"key": i, "x": u16(11), "y": u16(13), + "w": u16(15), "h": u16(17), + "mods": rec[MOD_AT], "usage": rec[KEYCODE_AT], + "record": rec}) + return out + + +def payload_from_record(record, key, mods, usage): + """ + A write payload for one key, built from that key's backup record. + + The header is fixed, bytes 11 onward come from the record, and only the + binding changes. Checked against the captures: key 1 comes out byte for + byte identical to what the vendor's page sent. + """ + out = bytearray(63) + out[0] = CMD_WRITE_KEY + out[3] = 0x3C + out[5] = 0x10 + out[6] = key + out[7] = 0x01 + out[11:62] = record[11:62] + return patch_binding(out, mods, usage) + + +def describe_record(record): + """What one key does, in words, whatever kind of key it is.""" + kind, a, b = read_binding(record) + if kind == "keyboard": + return describe(a, b) + if kind == "consumer": + return b or f"an uncaptured volume or brightness code, 0x{a:02x}" + return f"a key type we haven't decoded, 0x{a:02x} with 0x{b:02x}" + + +def describe(mods, usage): + """'ctrl+shift+a' from a modifier bitmask and a usage code.""" + names = [n for n, bit in (("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04), + ("meta", 0x08), ("rctrl", 0x10), ("rshift", 0x20), + ("ralt", 0x40), ("rmeta", 0x80)) if mods & bit] + by_code = {v: k for k, v in sorted(USAGES.items())} + if usage: + names.append(by_code.get(usage, f"0x{usage:02x}")) + return "+".join(names) or "nothing" + + +# ------------------------------------------------------------- self test + + +if __name__ == "__main__": + bad = 0 + for h in CAPTURED: + b = bytes.fromhex(h) + if seal(b) != b or not verify(b): + bad += 1 + print(f" MISMATCH key={b[KEY_AT]} stored={b[1]:02x}{b[2]:02x} " + f"computed={seal(b)[1]:02x}{seal(b)[2]:02x}") + print(f"{len(CAPTURED) - bad}/{len(CAPTURED)} captured packets reproduced exactly") + + for key, rec in sorted(RECORDS.items()): + assert len(rec) == 63, f"key {key} record is {len(rec)} bytes, want 63" + assert verify(rec), f"stored record for key {key} fails its own checksum" + got = patch_keycode(rec, USAGES["z"]) + assert verify(got) and got[KEYCODE_AT] == USAGES["z"] + print(f"{len(RECORDS)} stored key records verify and re-seal cleanly") + + # the two modifier captures, rebuilt from key 8's record + for combo, want in (("shift", "1258643c"), ("meta", "125e643c")): + mods, usage = parse_combo(combo) + built = patch_binding(RECORDS[7], mods, usage) + assert built.hex().startswith(want), f"{combo}: {built[:4].hex()} != {want}" + print("left shift and meta captures rebuilt from key 8's record") + + for combo in ("ctrl+a", "ctrl+shift+t", "meta+w", "alt+a"): + mods, usage = parse_combo(combo) + out = patch_binding(RECORDS[0], mods, usage) + assert verify(out) + print("modifier combinations build and verify") + + # ------------------------------------------------ the startup capture + + for direction, h in STARTUP: + b = bytes.fromhex(h) + assert len(b) == 63, f"{h[:8]} is {len(b)} bytes" + assert verify(b), f"{h[:8]} fails its own checksum" + print(f"{len(STARTUP)} startup packets verify") + + # our read_request() against the three the page actually sent + for table, index, want in ((TABLE_DIRECTORY, 0, "122512040000"), + (TABLE_NAME, 0, "12261e040c01"), + (TABLE_KEYS, 0, "123512040010"), + (TABLE_KEYS, 8, "12351a040010")): + got = read_request(table, index).hex() + assert got.startswith(want), f"table {table:#04x}: {got[:12]} != {want}" + print("read requests match the ones the vendor's page sends") + + end = parse_reply(bytes.fromhex( + "12355a044010080000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000"[:126])) + assert end["end"] and end["index"] == 8 + assert len(LIVE) == 8 and not any(parse_reply(r)["end"] for r in LIVE) + print(f"the pad reports {len(LIVE)} keys and says so when asked for a ninth") + + name = device_name(bytes.fromhex( + [h for d, h in STARTUP if d == "in" and bytes.fromhex(h)[TABLE_AT] == 1][0])) + assert name == "SayoDevice 2x4V RGB", repr(name) + print(f"model name reads back as {name!r}") + + assert TABLE_KEYS in tables_offered(bytes.fromhex(STARTUP[1][1])) + rows, cols = grid_from_records(LIVE) + assert (rows, cols) == (2, 4), (rows, cols) + print(f"geometry puts the keys in a {cols} by {rows} grid") + + # the page read key 8 then wrote it. Rebuild that write from that read. + rebuilt = payload_from_record(LIVE[7], 7, 0x00, USAGES["p"]) + assert rebuilt == bytes.fromhex(STARTUP[-1][1]), "read-modify-write diverged" + print("the page's own write rebuilt byte for byte from the record it read") + + # ------------------------------------------- volume and brightness + + before = LIVE[7] + assert before[KIND_AT] == 0x01, f"key 8 started at {before[KIND_AT]:#04x}" + for name, want in CONSUMER_CAPTURE.items(): + built = patch_consumer(before, name) + assert built == bytes.fromhex(want), ( + f"{name}: {[n for n in range(63) if built[n] != bytes.fromhex(want)[n]]}") + print(f"all {len(CONSUMER_CAPTURE)} volume and brightness writes rebuilt exactly") + + kinds = {bytes.fromhex(h)[KIND_AT] for h in CONSUMER_CAPTURE.values()} + assert kinds == {KIND_CONSUMER}, kinds + print("and each one set byte 23 to 0x03 where the key had been 0x01, " + "so byte 23 is the key type") + + for name, h in CONSUMER_CAPTURE.items(): + kind, code, got = read_binding(bytes.fromhex(h)) + assert (kind, got) == ("consumer", name), (kind, got, name) + print("they read back as the names they were set to") + + # a kind we have never captured must not read as a keyboard shortcut + odd = bytearray(LIVE[0]) + odd[KIND_AT] = 0x80 + assert kind_of(odd) == "unknown" + assert "haven't decoded" in describe_record(odd) + print("an uncaptured key type reads as unknown rather than a bare modifier") + + # switching a key back to a shortcut must clear the consumer type + back = patch_binding(bytes.fromhex(CONSUMER_CAPTURE["volumeup"]), 0x01, + USAGES["c"]) + assert back[KIND_AT] == KIND_KEYBOARD and verify(back) + assert read_binding(back) == ("keyboard", 0x01, USAGES["c"]) + print("and setting a shortcut on a volume key clears the type back") + + for i, rec in enumerate(LIVE): + x, y, w, h = key_geometry(rec) + print(f" key {i+1}: at ({x:>5},{y:>5}) size {w}x{h} " + f"[{kind_of(rec)}] -> {describe_record(rec)}") + + # a backup, if one is sitting next to this file + import glob + for path in sorted(glob.glob("*.sayobak")) + sorted(glob.glob("backup.bin")): + keys = read_backup(Path(path)) + print(f"\n{path}: {len(keys)} key records") + cols = len({k["x"] for k in keys}) + print(f" pad is {cols} across by {len(keys)//cols} down") + for k in keys: + print(f" key {k['key']+1}: at ({k['x']:>5},{k['y']:>5}) " + f"size {k['w']}x{k['h']} [{kind_of(k['record'])}] -> " + f"{describe_record(k['record'])}") + rebuilt = payload_from_record(keys[0]["record"], 0, 0, USAGES["a"]) + assert verify(rebuilt) + assert rebuilt == RECORDS[0], "key 1 should rebuild to its captured write" + print(" key 1 rebuilt from the backup matches its captured write exactly") + break diff --git a/macropad_gui/core.py b/macropad_gui/core.py index 987f91b..fcd80ec 100644 --- a/macropad_gui/core.py +++ b/macropad_gui/core.py @@ -46,17 +46,30 @@ DIAL_ACTIONS = ("left", "push", "right") WRITE_GAP = 0.02 # same pause the CLI leaves between reports -def action_byte(control): +def backends(): """ - The byte the pad uses for one control. Keys count from 1; knobs start - at 0x10, three apiece. Confirmed on 1189:8840 both by writing and by - reading back, and it is what the pad reports for its own slots. + The backend registry. Imported late because the backends import this + module for its data types, and a top level import either way would be + circular. """ - control = control.lower() - if control.startswith("key"): - return int(control[3:]) - dial, act = control.split("-") - return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act) + from . import backends as registry + return registry + + +def backend_for(device): + """Whichever family owns this device; the default when there is none.""" + return backends().for_device(device) + + +def action_byte(control, backend=None): + """ + The slot number one control lives in. + + Slot numbering belongs to the protocol, so this is the backend's answer. + The default is the CH57x family, which is what every caller that does + not have a device in hand wants. + """ + return (backend or backends().DEFAULT).action_byte(control) @dataclass(frozen=True) @@ -414,8 +427,13 @@ class State: should call record()/mark_unknown(); the GUI just reads it. """ - def __init__(self, path=None, layout=None): - self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json" + def __init__(self, path=None, layout=None, device_id=None): + # The default name is the one the app has always used, so upgrading + # never orphans somebody's bindings. A second family would pass its + # own id and get its own file. + self.device_id = device_id or DEVICE_ID + default = config_dir() / f"state-{self.device_id.replace(':', '-')}.json" + self.path = Path(path) if path else default self.entries = {} # (layer, control) -> Entry self.layout = layout or DEFAULT_LAYOUT if self.path.exists(): @@ -449,7 +467,7 @@ class State: else: d = {"binding": e.binding.to_json(), "written": e.written} layers.setdefault(str(layer), {})[control] = d - _atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID, + _atomic_write_json(self.path, {"format": FORMAT, "device": self.device_id, "layout": self.layout.to_json(), "layers": layers}) @@ -497,7 +515,26 @@ SUGGESTED_RULE = "60-macropad.rules" # Vendor wide on purpose. Pads in this family have several product ids, and # a rule tied to one of them leaves everybody else unable to read their own # hardware, which is exactly the point at which they give up. -RULE_LINE = f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", TAG+="uaccess"' +def rule_lines(): + """One uaccess rule per vendor we support.""" + return [b.udev_rule() for b in backends().REGISTRY] + + +RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", ' + f'TAG+="uaccess"') + + +def rule_command(): + """ + The shell line that installs access rules for every family we support. + One vendor today, so it reads the same as it always has; a second + backend adds its own line here rather than needing the text edited. + """ + lines = rule_lines() + if len(lines) == 1: + return f"echo '{lines[0]}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}" + body = "\\n".join(lines) + return f"printf '{body}\\n' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}" @@ -552,14 +589,24 @@ def _udev_tags(devpath): return tags +def vendors(): + """Every USB vendor id any backend knows about.""" + return {b.vid.lower() for b in backends().REGISTRY} + + +def discover(): + """Config-capable hidraw nodes belonging to any family we support.""" + return proto.find_devices(vendors()) + + def _on_usb_bus(): + want = vendors() for d in glob.glob("/sys/bus/usb/devices/*"): try: v = open(os.path.join(d, "idVendor")).read().strip().lower() - p = open(os.path.join(d, "idProduct")).read().strip().lower() except OSError: continue - if (v, p) == (VID, PID): + if v in want: return True return False @@ -608,8 +655,12 @@ def diagnose(keyd=True): "the roadmap."]) warnings = [w for w in (_keyd_warning() if keyd else None,) if w] - everything = proto.find_devices() - found = [d for d in everything if d["pid"] == PID] + everything = discover() + # A device is "known" only when some backend is confirmed on its exact + # id. A sibling under the same vendor shows up as unsupported, which is + # what puts the detect button in front of its owner. + found = [d for d in everything + if backends().for_vid_pid(d["vid"], d["pid"])] if not found and everything: other = everything[0] @@ -632,7 +683,7 @@ def diagnose(keyd=True): "may name a single product id; this one covers every " "pad from the same maker.", "Run these, then unplug the pad and plug it back in."] - d.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}", + d.fix = [rule_command(), "sudo udevadm control --reload-rules && sudo udevadm trigger"] return d @@ -695,7 +746,7 @@ def diagnose(keyd=True): diag.detail = ["No udev rule lets you write to the pad yet. Running " "the installer again adds it, or paste these commands " "into a terminal:"] - diag.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}", + diag.fix = [rule_command(), "sudo udevadm control --reload-rules && sudo udevadm trigger", "# then unplug and replug the pad"] return diag @@ -729,88 +780,27 @@ def _explain(err): def write_binding(device, control, binding, state, layer=1, _write=None): """ - Send one binding (config report + commit) and update the shadow state. - Raises WriteError; on success the state file is already saved. + Send one binding and update the shadow state. Raises WriteError; on + success the state file is already saved. """ - write = _write or proto.write_report - reports = binding.reports(device.report_id, action_byte(control), layer) - - sent = 0 - try: - for r in reports: - write(device.path, r) - sent += 1 - time.sleep(WRITE_GAP) - except OSError as e: - why = _explain(e) - # If even the first write failed on open, nothing reached the pad. - untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV, - errno.EACCES, errno.EPERM) - if not untouched: - state.mark_unknown(control, f"write interrupted after {sent} of " - f"{len(reports)} reports: {why}", layer) - state.save() - raise WriteError(f"{control}: {why}", pad_touched=not untouched, cause=e) - - state.record(control, binding, layer) - state.save() + return backend_for(device).write(device, control, binding, state, layer, + _write) # --------------------------------------------------------------- decoding -def decode_info(report): - """(keys, knobs) from a 0xFB reply, or None.""" - if len(report) < 4 or report[1] != proto.MAGIC_INFO: - return None - return report[2], report[3] +def decode_info(report, backend=None): + """(keys, knobs) from an info reply, or None.""" + return (backend or backends().DEFAULT).decode_info(report) -def decode_config(report, layout=None): +def decode_config(report, layout=None, backend=None): """ The inverse of Binding.reports()[0]: (control, layer, Binding) from a - native config report, or None if it isn't one we understand. Used to - import captures and to prove round trips in the tests. - - layout decides which slots count as real controls, and it matters more - than it looks. The firmware answers for 15 keys and 3 knobs whatever - the pad actually has, so the caller has to say how big the pad is. - Decode a 15 key pad against the default 12 key layout and keys 13 to 15 - and the whole third knob are dropped on the floor without a word. + config report, or None if it isn't one we understand. Used to import + captures and to prove round trips in the tests. """ - if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE, proto.MAGIC_READ): - return None - action, layer, kind = report[2], report[3], report[4] - control = (layout or DEFAULT_LAYOUT).control_for_action.get(action) - if control is None: - return None - delay = report[5] | (report[6] << 8) - count = report[10] - - if kind == proto.KeyType.MULTIMEDIA and count: - media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8)) - return (control, layer, Binding(media=media)) if media else None - - if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC): - mods, code = report[11], report[12] - if not mods and not code: - return control, layer, Binding(none=True) - return None - - if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count: - steps = [] - for i in range(count): - off = 11 + 2 * i - if off + 1 >= len(report): - return None - mods, code = report[off], report[off + 1] - names = [n for n, bit in _MOD_ORDER if mods & bit] - if code: - if code not in _KEYNAME_BY_CODE: - return None - names.append(_KEYNAME_BY_CODE[code]) - steps.append("+".join(names)) - return control, layer, Binding(keys=",".join(steps), delay=delay) - return None + return (backend or backends().DEFAULT).decode_config(report, layout) def is_commit(report): @@ -924,24 +914,12 @@ def _exchange(path, request, want=None): def read_info(device): """(keys, knobs) as the pad reports them, or None if it doesn't answer.""" - for reply in _exchange(device.path, proto.native_info(device.report_id), want=1): - got = decode_info(reply) - if got: - return got - return None + return backend_for(device).info(device) def read_layer(device, layer=1, layout=None): """{control: Binding} for one layer, as it is on the pad right now.""" - out = {} - for reply in _exchange(device.path, proto.native_read(device.report_id, layer)): - decoded = decode_config(reply, layout) - if not decoded: - continue - control, got_layer, binding = decoded - if got_layer == layer: - out[control] = binding - return out + return backend_for(device).read_layer(device, layer, layout) def read_into_state(device, state, layer=1, layout=None): @@ -966,85 +944,53 @@ class Detection: layers: int = 0 bindings: dict = field(default_factory=dict) why: str = "" # when speaks is False, what went wrong + grid: tuple = () # (rows, cols) when the pad reports geometry + model: str = "" # what the pad calls itself, if it says @property def layout(self): """ - A first guess at the shape. The pad reports counts, not geometry, - so rows and columns are a guess the owner should confirm. + The shape to draw. Use the pad's own geometry when it reports any, + and guess from the key count when it doesn't. - Pads in this family are wider than they are tall, so the guess is - the squarest split that keeps rows no greater than columns: 6 keys - give 2x3, 12 give 3x4, 15 give 3x5. The old version had this the - wrong way round and drew every pad on its side. + The CH57x only reports counts, so for that family this is a guess + the owner should confirm. Pads in it are wider than they are tall, + so the guess is the squarest split that keeps rows no greater than + columns: 6 keys give 2x3, 12 give 3x4, 15 give 3x5. The old + version had this the wrong way round and drew every pad on its + side. """ if not self.speaks: return None + if self.grid and self.grid[0] * self.grid[1] == self.keys: + rows, cols = self.grid + return Layout(self.keys, self.knobs, rows, cols, self.model) rows = max(r for r in range(1, int(self.keys ** 0.5) + 1) if self.keys % r == 0) - return Layout(self.keys, self.knobs, rows, self.keys // rows) + return Layout(self.keys, self.knobs, rows, self.keys // rows, + self.model) def detect(device): """ - Ask an unknown pad what it is, without configuring anything. - - Both commands are queries. We only conclude the pad speaks this - protocol if it answers both in the right shape: a well formed info - reply, and a layer whose controls are exactly the slots that reply - implies. A pad from a different family will fail one of those and we - leave it alone. + Ask an unknown pad what it is, without configuring anything. Which + questions get asked is the backend's business; what comes back is a + Detection either way. """ - try: - info = read_info(device) - except ReadError as e: - return Detection(why=f"it didn't answer: {e}") - if not info: - return Detection(why="it didn't answer the 'what are you' query") - - keys, knobs = info - if not 1 <= keys <= proto.KEY_SLOTS or not 0 <= knobs <= proto.KNOB_SLOTS: - return Detection(why=f"it reported {keys} keys and {knobs} knobs, " - "which isn't a pad this protocol can describe") - - probe = Layout(keys, knobs, rows=keys, cols=1) # geometry irrelevant here - expected = set(probe.control_for_action) - - try: - replies = _exchange(device.path, proto.native_read(device.report_id, 1)) - except ReadError as e: - return Detection(why=f"it answered the first query but not the second: {e}") - - seen, bindings = set(), {} - for reply in replies: - if len(reply) < 13 or reply[1] != proto.MAGIC_READ: - continue - action, layer = reply[2], reply[3] - seen.add(action) - if action not in expected or layer != 1: - continue - control = probe.control_for_action[action] - decoded = decode_config(reply, probe) - if decoded: - bindings[control] = decoded[2] - - missing = expected - seen - if missing: - return Detection(why=f"it described {keys} keys and {knobs} knobs but " - f"didn't report {len(missing)} of them, so this " - "isn't the protocol it speaks") - - return Detection(speaks=True, keys=keys, knobs=knobs, - layers=proto.LAYERS, bindings=bindings) + return backend_for(device).detect(device) def detection_report(device, detected): """A block of text to paste into an issue when adding a new pad.""" - lines = [f"Device: {device.vid}:{device.pid}", + from . import __version__ + backend = backend_for(device) + lines = [f"MacroPad {__version__}", + f"Device: {device.vid}:{device.pid}", f"Name: {device.name or 'not reported'}", - f"Node: {device.path}, report id {device.report_id}", ""] + f"Node: {device.path}, report id {device.report_id}", + f"Tried: {backend.name} ({backend.key})", ""] if not detected.speaks: - lines += ["This pad did NOT answer the 1189:8840 protocol.", + lines += [f"This pad did NOT answer the {backend.name} protocol.", f"Reason: {detected.why}"] return "\n".join(lines) lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.", diff --git a/macropad_gui/fonts/Harmattan-Bold.ttf b/macropad_gui/fonts/Harmattan-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..10fd96326c587be32bed276095fba16af03d873c GIT binary patch literal 17808 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 literal 0 HcmV?d00001 diff --git a/macropad_gui/fonts/Harmattan-Regular.ttf b/macropad_gui/fonts/Harmattan-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9e2ffbf812ba2bb4c88f9f54889d1a8e1b98eed9 GIT binary patch literal 17876 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 LU0>(Iuf+cWPcHH= literal 0 HcmV?d00001 diff --git a/macropad_gui/fonts/Harmattan-SemiBold.ttf b/macropad_gui/fonts/Harmattan-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..058dd6583b43221cf4035363cde553b71db94d93 GIT binary patch literal 17864 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 literal 0 HcmV?d00001 diff --git a/macropad_gui/fonts/JetBrainsMonoNL-BoldItalic.ttf b/macropad_gui/fonts/JetBrainsMonoNL-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ca22bb8e31211a9b66208110dc1ea737e4636470 GIT binary patch literal 17252 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 literal 0 HcmV?d00001 diff --git a/macropad_gui/fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf b/macropad_gui/fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..625f3000bece6fe9ebd01590592d318bd9f38d58 GIT binary patch literal 17320 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

3D-N_0ob$uaCyfErQ>|m*Vy8h?gK?h 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 literal 0 HcmV?d00001 diff --git a/macropad_gui/fonts/JetBrainsMonoNL-SemiBold.ttf b/macropad_gui/fonts/JetBrainsMonoNL-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..24800da22a37b899d4e2f75e2aa7075e3224dbcb GIT binary patch literal 16096 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 J^ {after}") + w.close() + + def test_the_window_can_still_be_made_small(self): + try: + from macropad_gui import themes # noqa: F401 + except ImportError: + self.skipTest("PySide6 not installed") + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}): + w, qapp = self.window("silkscreen") + w.resize(1000, 800) + w.show() + qapp.processEvents() + w.pad.select("key1") + w._refresh() + qapp.processEvents() + w.resize(800, 520) + qapp.processEvents() + self.assertLessEqual(w.height(), 540, "window refused to shrink") + w.close() + + def test_the_inspector_is_inside_something_that_scrolls(self): + try: + from PySide6.QtWidgets import QScrollArea + except ImportError: + self.skipTest("PySide6 not installed") + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}): + w, _ = self.window("silkscreen") + self.assertIsInstance(w.panel, QScrollArea) + self.assertIs(w.panel.widget(), w.inspector) + self.assertTrue(w.panel.widgetResizable()) + w.close() + + +class VersionIsLabelled(unittest.TestCase): + """ + One version string, in one place, reachable from everywhere that quotes + it. A release should be one line to change, and a bug report should + never be missing it. + """ + + def test_it_is_a_plain_semver(self): + import re + from macropad_gui import __version__ + self.assertRegex(__version__, r"^\d+\.\d+\.\d+$") + + def test_the_cli_reports_it(self): + self.assertEqual(proto.version(), __import__("macropad_gui").__version__) + + def test_the_cli_survives_being_copied_out_on_its_own(self): + """ + macropad.py is meant to work as a single file. With no GUI package + beside it the version is unknown, which is fine, but it must not + raise. + """ + from unittest import mock + real = __import__("builtins").__import__ + + def no_gui(name, *a, **kw): + if name == "macropad_gui": + raise ImportError("not there") + return real(name, *a, **kw) + + with mock.patch("builtins.__import__", no_gui): + self.assertEqual(proto.version(), "unknown") + + def test_a_detection_report_carries_it(self): + from macropad_gui import __version__ + device = core.Device(path="/dev/hidraw9", vid="1189", pid="8840", + report_id=0, writable=True, name="") + text = core.detection_report(device, core.Detection(why="it said nothing")) + self.assertIn(__version__, text) diff --git a/tests/test_sayo.py b/tests/test_sayo.py new file mode 100644 index 0000000..bade4f1 --- /dev/null +++ b/tests/test_sayo.py @@ -0,0 +1,352 @@ +""" +The SayoDevice backend, replayed against real captures. + +No Sayo pad has ever been written to, so these tests stand in for the +hardware: a fake pad answers out of two WebHID captures of the vendor's +own configurator, and the backend has to come out with the same answers +the configurator did. The write test is the important one. It asserts +that our packet is byte for byte the packet the vendor's page sent for +the same change, which is the only check available until somebody runs +it on a desk. + +The backend is deliberately not in backends.REGISTRY, so none of this +affects a real pad. Adding it there is one line, once a write has been +confirmed on hardware. +""" + +import unittest +from unittest import mock + +from macropad_gui import core +from macropad_gui.backends import sayo_proto as wire +from macropad_gui.backends.sayo import Sayo + + +def _payloads(direction): + return [bytes.fromhex(h) for d, h in wire.STARTUP if d == direction] + + +class FakePad: + """ + Answers read requests out of the capture and remembers writes. + + It replies the way the real pad does: the report id on the front, a + heartbeat first so the backend has to step over one, and nothing at + all for an index that does not exist. + """ + + def __init__(self, keys=8, name=True): + self.path = "/dev/hidraw-fake" + self.vid, self.pid, self.report_id = "8089", "000b", wire.REPORT_ID + self.name = "SayoDevice" + self.keys = keys + self.offer_name = name + self.written = [] + self.records = {i: r for i, r in enumerate(wire.LIVE)} + + # the heartbeat the real pad sends unasked, roughly once a second + HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63) + + def exchange(self, path, request, want=None): + assert path == self.path + assert len(request) == 64 and request[0] == wire.REPORT_ID + asked = wire.parse_reply(request[1:]) + table, index = asked["table"], asked["index"] + out = [self.HEARTBEAT] + + if table == wire.TABLE_KEYS: + if index < self.keys: + out.append(bytes([wire.REPORT_ID]) + self.records[index]) + else: + end = bytearray(wire.read_request(table, index)) + end[wire.FLAG_AT] = wire.NO_SUCH_INDEX + out.append(bytes([wire.REPORT_ID]) + wire.seal(end)) + elif table == wire.TABLE_NAME and self.offer_name: + out.append(bytes([wire.REPORT_ID]) + _payloads("in")[1]) + return out + + def write_report(self, path, report): + assert path == self.path + self.written.append(bytes(report)) + + +class Capture(unittest.TestCase): + """The captures themselves, before any backend touches them.""" + + def test_every_captured_packet_verifies(self): + for direction, h in wire.STARTUP: + payload = bytes.fromhex(h) + self.assertEqual(len(payload), 63, h[:8]) + self.assertTrue(wire.verify(payload), f"{h[:8]} checksum") + + def test_the_pad_reported_eight_keys_in_a_four_by_two_grid(self): + self.assertEqual(len(wire.LIVE), 8) + self.assertEqual(wire.grid_from_records(wire.LIVE), (2, 4)) + + def test_read_requests_match_the_vendors_own(self): + asked_for = 0 + for want in _payloads("out"): + if want[wire.LEN_AT] != wire.REQUEST_LEN: + continue # that one is a write + asked = wire.parse_reply(want) + mine = wire.read_request(asked["table"], asked["index"]) + self.assertEqual(mine, want, f"table {asked['table']:#04x}") + asked_for += 1 + self.assertGreater(asked_for, 2, "the capture should hold several reads") + + +class Reading(unittest.TestCase): + + def setUp(self): + self.pad = FakePad() + self.backend = Sayo() + patch = mock.patch.object(core, "_exchange", self.pad.exchange) + patch.start() + self.addCleanup(patch.stop) + + def test_it_discovers_the_key_count_rather_than_assuming_one(self): + self.assertEqual(self.backend.info(self.pad), (8, 0)) + + def test_a_smaller_pad_reports_itself_smaller(self): + self.pad.keys = 4 + self.assertEqual(self.backend.info(self.pad), (4, 0)) + + def test_a_silent_pad_gives_none_not_a_fake_shape(self): + """None is what makes the app refuse to write. It has to stay None.""" + self.pad.keys = 0 + self.assertIsNone(self.backend.info(self.pad)) + + def test_heartbeats_do_not_get_mistaken_for_answers(self): + self.pad.HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63) + self.assertEqual(self.backend.info(self.pad), (8, 0)) + + def test_bindings_read_back_as_the_configurator_shows_them(self): + got = self.backend.read_layer(self.pad) + self.assertEqual(got["key1"].keys, "ctrl+numpadplus") + self.assertEqual(got["key5"].keys, "ctrl+numpadminus") + self.assertEqual(got["key7"].keys, "ctrl+shift+alt+rightbracket") + self.assertTrue(got["key8"].none) + + def test_an_uncaptured_key_type_is_left_out_rather_than_guessed(self): + """ + Keys 2, 3 and 4 of the captured pad are type 0x80, which the owner + says are media transport keys. Read as a modifier they came out as + a bare "ctrl+shift" and "alt" that nobody had set, which is worse + than admitting we do not know. + """ + got = self.backend.read_layer(self.pad) + for control in ("key2", "key3", "key4"): + self.assertNotIn(control, got, control) + for control in ("key1", "key5", "key6", "key7", "key8"): + self.assertIn(control, got, control) + + def test_it_reads_its_own_model_name(self): + self.assertEqual(self.backend.model(self.pad), "SayoDevice 2x4V RGB") + + +class Detecting(unittest.TestCase): + + def setUp(self): + self.pad = FakePad() + self.backend = Sayo() + patch = mock.patch.object(core, "_exchange", self.pad.exchange) + patch.start() + self.addCleanup(patch.stop) + + def test_it_uses_the_pads_geometry_instead_of_guessing(self): + found = self.backend.detect(self.pad) + self.assertTrue(found.speaks) + self.assertEqual((found.keys, found.knobs), (8, 0)) + self.assertEqual(found.grid, (2, 4)) + self.assertEqual(found.layout.rows, 2) + self.assertEqual(found.layout.cols, 4) + self.assertEqual(found.layout.name, "SayoDevice 2x4V RGB") + + def test_a_pad_that_says_nothing_is_left_alone(self): + self.pad.keys = 0 + found = self.backend.detect(self.pad) + self.assertFalse(found.speaks) + self.assertIn("key table", found.why) + + +class Writing(unittest.TestCase): + + def setUp(self): + self.pad = FakePad() + self.backend = Sayo() + self.state = mock.Mock() + patch = mock.patch.object(core, "_exchange", self.pad.exchange) + patch.start() + self.addCleanup(patch.stop) + + def _write(self, control, binding): + self.backend.write(self.pad, control, binding, self.state, + _write=self.pad.write_report) + + def test_our_packet_is_the_one_the_vendors_page_sent(self): + """ + The capture caught the configurator reading key 8 and then setting + it to p. Same starting record, same change, so the bytes have to + match, checksum included. + """ + theirs = _payloads("out")[-1] + self._write("key8", core.Binding(keys="p")) + self.assertEqual(len(self.pad.written), 1) + self.assertEqual(self.pad.written[0], wire.report(theirs)) + + def test_it_keeps_the_pads_own_bytes_and_changes_only_the_binding(self): + before = wire.LIVE[0] + self._write("key1", core.Binding(keys="ctrl+shift+a")) + after = self.pad.written[0][1:] + moved = [i for i in range(63) if after[i] != before[i]] + self.assertEqual(moved, [wire.CHECK_AT, wire.CHECK_AT + 1, + wire.MOD_AT, wire.KEYCODE_AT]) + self.assertEqual(after[wire.MOD_AT], 0x03) + self.assertEqual(after[wire.KEYCODE_AT], 0x04) + + def test_clearing_a_key_zeroes_both_binding_bytes(self): + self._write("key1", core.Binding(none=True)) + after = self.pad.written[0][1:] + self.assertEqual((after[wire.MOD_AT], after[wire.KEYCODE_AT]), (0, 0)) + + def test_a_key_the_pad_does_not_have_is_refused_before_sending(self): + with self.assertRaises(core.WriteError) as caught: + self._write("key9", core.Binding(keys="a")) + self.assertFalse(caught.exception.pad_touched) + self.assertEqual(self.pad.written, []) + + def test_the_four_captured_media_keys_write(self): + """ + Each one has to come out as the exact packet the vendor's page sent + for the same change, which is what the third capture recorded. + """ + for name, want in wire.CONSUMER_CAPTURE.items(): + self.pad.written.clear() + self._write("key8", core.Binding(media=name)) + self.assertEqual(self.pad.written[0], wire.report(bytes.fromhex(want)), + name) + + def test_a_media_key_we_have_not_captured_is_refused(self): + """ + This pad numbers media keys its own way, not by HID code, so a name + we have never seen set cannot be derived. Saying so beats writing a + number we made up. + """ + with self.assertRaises(core.WriteError) as caught: + self._write("key8", core.Binding(media="playpause")) + self.assertIn("captured", str(caught.exception).lower()) + self.assertFalse(caught.exception.pad_touched) + self.assertEqual(self.pad.written, []) + + def test_an_uncaptured_key_type_is_never_overwritten(self): + """ + The real risk this guards: writing a shortcut onto a type 0x80 key + would leave it flagged as one kind of key holding another kind's + bytes. Nothing reaches the pad, so the old binding survives. + """ + with self.assertRaises(core.WriteError) as caught: + self._write("key2", core.Binding(keys="a")) + self.assertFalse(caught.exception.pad_touched) + self.assertEqual(self.pad.written, []) + self.assertIn("0x80", str(caught.exception)) + + def test_switching_a_media_key_to_a_shortcut_clears_the_type(self): + self.pad.records[7] = bytes.fromhex(wire.CONSUMER_CAPTURE["volumeup"]) + self._write("key8", core.Binding(keys="ctrl+c")) + after = self.pad.written[0][1:] + self.assertEqual(after[wire.KIND_AT], wire.KIND_KEYBOARD) + self.assertEqual(wire.read_binding(after), ("keyboard", 0x01, 0x06)) + + def test_a_keyboard_write_keeps_the_low_bit_of_the_type(self): + """ + Key 8 was type 0x01 and the vendor's page set it to p without + touching that bit, so neither do we. + """ + self._write("key8", core.Binding(keys="p")) + self.assertEqual(self.pad.written[0][1:][wire.KIND_AT], 0x01) + + def test_a_sequence_is_refused_because_the_pad_holds_one_keystroke(self): + with self.assertRaises(core.WriteError) as caught: + self._write("key1", core.Binding(keys="a,b,c")) + self.assertFalse(caught.exception.pad_touched) + + def test_knobs_are_refused_since_this_pad_has_none(self): + with self.assertRaises(ValueError): + self.backend.action_byte("dial1left") + + def test_everything_written_reseals_and_round_trips(self): + for combo in ("a", "ctrl+c", "ctrl+shift+t", "meta+l", "super+l", + "win+l", "f5", "space", "numpadminus"): + self.pad.written.clear() + self._write("key1", core.Binding(keys=combo)) + payload = self.pad.written[0][1:] + self.assertTrue(wire.verify(payload), combo) + mods, code = wire.parse_combo(combo) + self.assertEqual(payload[wire.MOD_AT], mods, combo) + self.assertEqual(payload[wire.KEYCODE_AT], code, combo) + + +class Drawing(unittest.TestCase): + """ + Every pad before this one had knobs, so a knobless board is a shape the + drawing code has never been given. It must come out in every theme + rather than dividing by a knob count of zero somewhere. + """ + + def test_a_knobless_pad_draws_in_every_theme(self): + import os + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPixmap + from macropad_gui import padview, themes + + QApplication.instance() or QApplication([]) + shape = core.Layout(8, 0, 2, 4, "SayoDevice 2x4V RGB") + for name, theme in sorted(themes.THEMES.items()): + padview.apply_theme(theme) + view = padview.PadView() + view.layout = shape + view.looks = {c: padview.Look() for c in shape.control_ids} + view.resize(900, 520) + shot = QPixmap(view.size()) + view.render(shot) + self.assertFalse(shot.isNull(), name) + + def test_a_knobless_layout_has_only_keys(self): + shape = core.Layout(8, 0, 2, 4) + self.assertEqual(len(shape.control_ids), 8) + self.assertFalse([c for c in shape.control_ids if c.startswith("dial")]) + + +class Routing(unittest.TestCase): + """ + The two families must not reach into each other. A Sayo pad has to go + to the Sayo backend and a CH57x to the CH57x one, and a Sayo pid we + have never seen must not be claimed outright: it goes through detect() + first, the same as an unknown CH57x sibling. + """ + + def test_a_sayo_pad_routes_to_the_sayo_backend(self): + from macropad_gui import backends + self.assertEqual(backends.for_vid_pid("8089", "000b").key, "sayo") + + def test_a_ch57x_pad_still_routes_to_ch57x(self): + from macropad_gui import backends + self.assertEqual(backends.for_vid_pid("1189", "8840").key, "ch57x") + + def test_the_ch57x_backend_does_not_claim_a_sayo(self): + from macropad_gui import backends + self.assertFalse(backends.DEFAULT.claims("8089", "000b")) + + def test_an_unseen_sayo_pid_is_not_claimed_outright(self): + from macropad_gui import backends + self.assertIsNone(backends.for_vid_pid("8089", "0099")) + self.assertTrue(Sayo().could_claim("8089", "0099")) + + def test_the_installer_offers_a_rule_for_the_sayo_vendor(self): + from macropad_gui import core + self.assertTrue(any("8089" in line for line in core.rule_lines())) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall.sh b/uninstall.sh old mode 100644 new mode 100755