Read the pad, detect unknown ones, layout editor
added a feature that now can read the inputs off the board, meaning if you already have saved settings and you uninstall / reinstall the app or use it on a new device it can auto detect what each key has saved. using the above feature i now also do believe it is possible to auto detect the macros pads rough layout / how many keys and dials it has!. hopfully this means that more people can use the app easily without needing to hard code a bunch of new layouts. its not truly universal but it should open the door to much more pads working out of the box right now! and lastly in case the extra layouts do work i made a little layout editor so you can the app to look like your actual macropad!
167
README.md
@@ -23,7 +23,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#-install-it-the-whole-world-is-counting-on-you">Install</a> |
|
<a href="#-install">Install</a> |
|
||||||
<a href="#-your-first-binding">First binding</a> |
|
<a href="#-your-first-binding">First binding</a> |
|
||||||
<a href="#-when-something-goes-wrong">Help</a> |
|
<a href="#-when-something-goes-wrong">Help</a> |
|
||||||
<a href="#-other-pads-in-this-family">Other pads</a> |
|
<a href="#-other-pads-in-this-family">Other pads</a> |
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
> ```
|
> ```
|
||||||
> curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
|
> curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
|
||||||
> ```
|
> ```
|
||||||
> Never used a terminal? Perfect. [The step by step guide](#-install-it-the-whole-world-is-counting-on-you) was written for you.
|
> Never used a terminal? Perfect. [The step by step guide](#-install) was written for you.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,13 +73,13 @@ Probably, if yours looks like this:
|
|||||||
- Came with software called **`MINI_KEYBOARD.exe`**, or a download link to it.
|
- Came with software called **`MINI_KEYBOARD.exe`**, or a download link to it.
|
||||||
- Plugs in with USB and works as a keyboard straight away.
|
- Plugs in with USB and works as a keyboard straight away.
|
||||||
|
|
||||||
To be sure, open a terminal ([how?](#step-1-open-a-terminal)), type `lsusb` and press Enter. Look for this line:
|
To check, run `lsusb` in a terminal and look for a line like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
|
Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
|
||||||
```
|
```
|
||||||
|
|
||||||
The important bit is **`1189:8840`**. Don't worry about the "Acer" part: it isn't made by Acer, these pads just borrow that ID.
|
The part that matters is **`1189:8840`**. Ignore "Acer": these pads borrow that ID and have nothing to do with Acer.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Different number, like `1189:8890` or `1189:8842`?** That's a sibling of this pad with a different layout. MacroPad will spot it and tell you it isn't supported yet, rather than risk sending it the wrong thing. [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) supports several of them today, and [you can help add yours here](#-other-pads-in-this-family).
|
> **Different number, like `1189:8890` or `1189:8842`?** That's a sibling of this pad with a different layout. MacroPad will spot it and tell you it isn't supported yet, rather than risk sending it the wrong thing. [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) supports several of them today, and [you can help add yours here](#-other-pads-in-this-family).
|
||||||
@@ -95,7 +95,10 @@ The important bit is **`1189:8840`**. Don't worry about the "Acer" part: it isn'
|
|||||||
| 🎵 **Media keys** | Play/pause, next, previous, stop, volume, mute, **screen brightness**, calculator, email, browser, my computer. |
|
| 🎵 **Media keys** | Play/pause, next, previous, stop, volume, mute, **screen brightness**, calculator, email, browser, my computer. |
|
||||||
| 🚫 **Nothing** | Switch a key off completely, so a stray press does nothing. |
|
| 🚫 **Nothing** | Switch a key off completely, so a stray press does nothing. |
|
||||||
| 🔄 **Rotate view** | Stand your pad up or lay it flat. The drawing turns to match. |
|
| 🔄 **Rotate view** | Stand your pad up or lay it flat. The drawing turns to match. |
|
||||||
| 🧠 **Remembers what it wrote** | The pad can't be read back, so the app keeps its own notes and marks anything it doesn't know. |
|
| 📖 **Reads your pad** | Opens and asks the pad what it's actually holding, so you start from the truth. |
|
||||||
|
| 🔎 **Identifies unknown pads** | Asks an unrecognised pad about itself, read only, and refuses to write if it doesn't answer properly. |
|
||||||
|
| 📐 **Layout editor** | Drag the keys around until the drawing matches your pad. |
|
||||||
|
| 🧠 **Remembers what it wrote** | Keeps its own notes too, and marks anything it isn't sure about rather than guessing. |
|
||||||
| 🩺 **Tells you what's wrong** | Unplugged? No permission? Wrong pad? You get a plain explanation and the exact fix, with a copy button. |
|
| 🩺 **Tells you what's wrong** | Unplugged? No permission? Wrong pad? You get a plain explanation and the exact fix, with a copy button. |
|
||||||
| 📥 **Imports from the vendor app** | Set your pad up on Windows before? Bring that setup across. |
|
| 📥 **Imports from the vendor app** | Set your pad up on Windows before? Bring that setup across. |
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ Both write straight to the pad's own memory, so you can even use both. Pick whic
|
|||||||
| Key sequences with delays | Not yet | Yes |
|
| Key sequences with delays | Not yet | Yes |
|
||||||
| Mouse actions, LED modes, extra layers | Not yet | Yes |
|
| Mouse actions, LED modes, extra layers | Not yet | Yes |
|
||||||
| Record a shortcut by pressing it | Yes | No |
|
| Record a shortcut by pressing it | Yes | No |
|
||||||
|
| Read what's on the pad | Yes | No |
|
||||||
| Install and permissions | One line does both | AUR, a download or `cargo install`; rule by hand |
|
| Install and permissions | One line does both | AUR, a download or `cargo install`; rule by hand |
|
||||||
|
|
||||||
**Short version:** new to Linux, or just want to click things? MacroPad. Want every feature, a different pad, or a config file you can keep in git? ch57x-keyboard-tool.
|
**Short version:** new to Linux, or just want to click things? MacroPad. Want every feature, a different pad, or a config file you can keep in git? ch57x-keyboard-tool.
|
||||||
@@ -133,26 +137,20 @@ Other people have built GUIs for these pads too, mostly for the smaller 3 and 6
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Install it (the whole world is counting on you)
|
## 🚀 Install
|
||||||
|
|
||||||
Welcome. Maybe this is your first week on Linux. Maybe it's your first *hour*. Either way, you are about to do something brave and important, and I'm going to walk you through every single click. Nothing here can break your computer. Deep breath. Let's save the world.
|
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.
|
||||||
|
|
||||||
### Step 0: Plug in your pad
|
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.
|
||||||
|
|
||||||
Plug the macro pad into a USB port. That's it. Step zero complete. You're doing amazing.
|
|
||||||
|
|
||||||
### Step 1: Open a terminal
|
### Step 1: Open a terminal
|
||||||
|
|
||||||
A **terminal** is a window where you type instructions instead of clicking. It looks scary. It isn't. Think of it as a very literal assistant who does exactly what you type.
|
A terminal is a window where you type commands instead of clicking. Nothing below can damage your system.
|
||||||
|
|
||||||
How to open one:
|
|
||||||
|
|
||||||
- **KDE Plasma** (CachyOS, Bazzite, Kubuntu, Fedora KDE, KDE neon): press the **Meta key** (the one with the Windows logo), type **`Konsole`**, press **Enter**.
|
- **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**.
|
- **GNOME** (Ubuntu, Fedora, Pop!_OS): press the **Meta key**, type **`Terminal`**, press **Enter**.
|
||||||
- **Almost anywhere**: try pressing **`Ctrl` + `Alt` + `T`** together.
|
- **Almost anywhere**: try pressing **`Ctrl` + `Alt` + `T`** together.
|
||||||
|
|
||||||
A window with a blinking cursor appears. Hello, terminal. 👋
|
|
||||||
|
|
||||||
### Step 2: Copy this line
|
### Step 2: Copy this line
|
||||||
|
|
||||||
Click the copy button on the right side of this box (or select the text and press `Ctrl+C`):
|
Click the copy button on the right side of this box (or select the text and press `Ctrl+C`):
|
||||||
@@ -168,46 +166,40 @@ In plain words: "download the MacroPad installer from GitHub (`curl`), and run i
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Step 3: Paste it into the terminal
|
### Step 3: Paste it and press Enter
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **In a terminal, pasting is `Ctrl` + `Shift` + `V`**, not `Ctrl+V`. This catches out everyone coming from Windows. (Right click, then **Paste** works too.)
|
> Pasting into a terminal is **`Ctrl` + `Shift` + `V`**, not `Ctrl+V`. Right click, then Paste, also works.
|
||||||
|
|
||||||
Paste it, then press **Enter**.
|
|
||||||
|
|
||||||
### Step 4: Answer the questions
|
### Step 4: Answer the questions
|
||||||
|
|
||||||
The installer explains what it's doing as it goes. It will ask a couple of questions like **"Add it now? [Y/n]"**. Just press **Enter** to say yes.
|
The installer says what it's doing as it goes, and asks before anything that needs your password. Pressing Enter accepts the default.
|
||||||
|
|
||||||
When it asks for your **password**, type the password you use to log in to your computer and press **Enter**.
|
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.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!NOTE]
|
||||||
> **Nothing appears while you type your password.** No dots, no stars, nothing. That's normal! Linux hides it completely so nobody can even count the letters. Just type it and press Enter. If you get it wrong it'll simply ask again.
|
> Nothing appears on screen while you type a password in a terminal. No dots, no stars. That's deliberate, not a frozen prompt.
|
||||||
|
|
||||||
It needs your password for two small things: installing Qt (the toolkit that draws the window) on some systems, and adding one file that lets the app talk to your pad. Everything else goes in your own home folder.
|
First run downloads around 100 MB on some systems. You'll see **All done.** when it's finished.
|
||||||
|
|
||||||
The first install downloads about 100 MB on some systems, so give it a minute. You'll see **All done.** in green when it's finished.
|
### Step 5: Replug the pad
|
||||||
|
|
||||||
### Step 5: Unplug the pad and plug it back in
|
Unplug it and plug it back in, so Linux applies the new permission.
|
||||||
|
|
||||||
This makes Linux notice the new permission. Unplug. Count to two. Plug back in.
|
|
||||||
|
|
||||||
### Step 6: Open MacroPad
|
### Step 6: Open MacroPad
|
||||||
|
|
||||||
Open your app menu (press the **Meta key**), type **`MacroPad`**, and click it. 🎉
|
Open your app menu, type **MacroPad**, and click it.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/first-launch.png" width="820" alt="MacroPad on its first launch: every key is striped because the app hasn't written anything yet">
|
<img src="docs/images/first-launch.png" width="820" alt="MacroPad on its first launch: every key is striped because the app hasn't written anything yet">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Everything is striped the first time. That's correct.** The app hasn't written anything to your pad yet, and the pad has no way of telling it what's already there. You'll fix that in about thirty seconds.
|
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).
|
||||||
|
|
||||||
Look at the bottom left. A **green dot** and **Pad ready** means everything worked. If you see something else, the panel on the right will tell you exactly what to do, or jump to [When something goes wrong](#-when-something-goes-wrong).
|
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.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Want it on your taskbar? Right click **MacroPad** in your app menu and choose **Pin to Task Manager** (Plasma) or **Pin to Dash** (GNOME).
|
> To keep it handy: right click **MacroPad** in your app menu and choose **Pin to Task Manager** (Plasma) or **Pin to Dash** (GNOME).
|
||||||
|
|
||||||
**You did it.** The world is saved. Probably. Let's make sure by setting up a key.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -215,7 +207,7 @@ Look at the bottom left. A **green dot** and **Pad ready** means everything work
|
|||||||
|
|
||||||
### 1. Click a key on the drawing
|
### 1. Click a key on the drawing
|
||||||
|
|
||||||
Pick any key. It gets an orange outline, and the panel on the right shows what it does now.
|
It gets an orange outline, and the panel on the right shows what it currently does.
|
||||||
|
|
||||||
### 2. Type what you want it to do
|
### 2. Type what you want it to do
|
||||||
|
|
||||||
@@ -246,7 +238,7 @@ Click **Media key** and pick from the list. Volume and brightness on the knobs i
|
|||||||
|
|
||||||
### Or make it do nothing
|
### Or make it do nothing
|
||||||
|
|
||||||
Bound something you don't want any more? Click **Nothing**, then write it. The key goes quiet: pressing it does nothing at all. Keys set this way show **Nothing** in grey on the drawing.
|
To clear a binding, click **Nothing** and write it. Keys set this way show **Nothing** in grey.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/step-nothing.png" width="820" alt="Key 5 set to Nothing, waiting to be written, with key 1 already showing Nothing in grey">
|
<img src="docs/images/step-nothing.png" width="820" alt="Key 5 set to Nothing, waiting to be written, with key 1 already showing Nothing in grey">
|
||||||
@@ -266,9 +258,9 @@ The bottom bar tells you exactly what happened. Green means it worked.
|
|||||||
<img src="docs/images/step-written.png" width="820" alt="After writing: the keys are solid white and the bottom bar says Wrote 3 changes to the pad">
|
<img src="docs/images/step-written.png" width="820" alt="After writing: the keys are solid white and the bottom bar says Wrote 3 changes to the pad">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 4. Press the key on your pad
|
### 4. Press the key
|
||||||
|
|
||||||
It does the thing. You can close the app now. **Your bindings live on the pad itself**, so they keep working after a reboot, on another computer, even on Windows. Nothing needs to stay running.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,6 +281,18 @@ You can also click the three lines of text under each knob. Every click of a tur
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📐 If the drawing doesn't match your pad
|
||||||
|
|
||||||
|
Press **Edit layout** at the bottom. The keys start wobbling, and you can drag them around until the picture matches the pad in front of you. Two keys swap when you drop one onto the other, so nothing is ever lost.
|
||||||
|
|
||||||
|
The boxes along the bottom set the grid size and how many keys and knobs there are. **Done** saves it, **Cancel** puts it back.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/edit-layout.png" width="820" alt="Edit layout mode: keys outlined and being dragged into a new position">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
This only changes the picture. It never writes anything to the pad. It's there because the pad reports how many keys it has but not how they're arranged, so on a pad we haven't seen before the first guess can be wrong.
|
||||||
|
|
||||||
## 🔄 Rotate view
|
## 🔄 Rotate view
|
||||||
|
|
||||||
Some people stand the pad up with the knobs on top. Some lay it flat with the knobs on the right. Click **Rotate view** at the bottom and the drawing turns to match. It remembers your choice.
|
Some people stand the pad up with the knobs on top. Some lay it flat with the knobs on the right. Click **Rotate view** at the bottom and the drawing turns to match. It remembers your choice.
|
||||||
@@ -307,17 +311,24 @@ Only do this if nothing has changed on the pad since that capture.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❓ Why does it say "Unknown"?
|
## 📖 Reading your pad
|
||||||
|
|
||||||
Because **these pads are write only.** You can send them new bindings, but there's no known way to ask one what it's currently set to. Even the official Windows app opens with every key blank.
|
MacroPad asks the pad what it's holding when it opens, so the drawing shows what's really on the hardware rather than what some file thinks. There's a **Read pad** button at the bottom to ask again at any time.
|
||||||
|
|
||||||
So the app keeps its own notes of everything it has successfully written, and it's careful about it:
|
Anything you've changed but not written yet is left alone when it reads, since that's your intent, not the pad's state.
|
||||||
|
|
||||||
- If a write finishes, the key is marked with what was written.
|
### So what does "Unknown" mean now?
|
||||||
- If a write fails before anything reaches the pad, nothing changes.
|
|
||||||
- If a write fails **halfway**, that key goes back to **Unknown**, because the honest answer is "no idea". Just write it again.
|
|
||||||
|
|
||||||
Striped keys aren't broken. They're just keys this app hasn't touched yet.
|
Hatched keys are ones the app hasn't been told about: either the pad didn't answer, or you haven't pressed Read pad. Press it and they fill in.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/read-pad.png" width="820" alt="The app after reading the pad: every key and dial filled in from the hardware">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
The app still keeps its own notes, because a write can fail halfway. If that happens, that one control goes back to Unknown rather than claiming either the old or the new binding, and reading the pad clears it up.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This took a while to work out. The project was built assuming these pads were write only, because nothing documented a way to read them and the first captures only recorded traffic in one direction. They can be read. The story is in [For the nerds](#-for-the-nerds).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -429,7 +440,24 @@ Siblings come in other sizes, and several share vendor ID `1189`. **They don't a
|
|||||||
<img src="docs/images/unsupported.png" width="820" alt="The app explaining that it found a 1189:8890 pad that it doesn't support yet">
|
<img src="docs/images/unsupported.png" width="820" alt="The app explaining that it found a 1189:8890 pad that it doesn't support yet">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### You can add yours
|
### Detecting a pad automatically
|
||||||
|
|
||||||
|
If MacroPad sees a pad it doesn't recognise, it offers to **ask the pad what it is**. That's read only, and it needs the pad 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. On the command line:
|
||||||
|
|
||||||
|
```
|
||||||
|
macropad detect
|
||||||
|
```
|
||||||
|
|
||||||
|
The pad reports how many keys and knobs it has, but not how they're arranged, so the app's first guess at rows and columns may be wrong. Check the drawing against the real thing.
|
||||||
|
|
||||||
|
If your pad passes, please [open an issue](https://github.com/Probler-Yt/MacroPad/issues) with the output so it can be added to the known list and everyone else gets it working first time.
|
||||||
|
|
||||||
|
### Adding one by hand
|
||||||
|
|
||||||
If you'd like your pad in MacroPad too, you're exactly who this project needs. It's the same process used for this one. You'll need the vendor software (`MINI_KEYBOARD.exe`) and Wine.
|
If you'd like your pad in MacroPad too, you're exactly who this project needs. It's the same process used for this one. You'll need the vendor software (`MINI_KEYBOARD.exe`) and Wine.
|
||||||
|
|
||||||
@@ -544,6 +572,41 @@ rOzzy1987's format is for different product IDs, which is why porting it to this
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Reading the pad, and how we nearly missed it</b></summary>
|
||||||
|
|
||||||
|
The project ran for weeks on the assumption that these pads were write only. Two things caused that:
|
||||||
|
|
||||||
|
- Nothing documented a read. Neither rOzzy1987's project nor ch57x-keyboard-tool implements one.
|
||||||
|
- The capture tool only recorded **outgoing** transfers. So even though the pad had been answering all along, nothing was ever written down.
|
||||||
|
|
||||||
|
What settled it was noticing that the vendor software has a "view settings" button, and that it showed bindings written by MacroPad. It couldn't have known those from a file, so it was asking the pad. Capturing both directions gave the answer.
|
||||||
|
|
||||||
|
Two query commands, both answered on the interrupt IN endpoint of the same report id:
|
||||||
|
|
||||||
|
```
|
||||||
|
03 fb ... what are you?
|
||||||
|
03 fb 0c 02 reply: 12 keys, 2 knobs
|
||||||
|
|
||||||
|
03 fa 0f 03 01 02 ... send me layer 1
|
||||||
|
(15 key slots, 3 layers, layer 1, 2 knobs)
|
||||||
|
03 fa 02 01 01 00 ... 01 05 17 key 2 is ctrl+alt+t
|
||||||
|
03 fa 10 01 02 00 ... 01 ea 00 dial 1 left is volume down
|
||||||
|
... one report per control, then silence
|
||||||
|
```
|
||||||
|
|
||||||
|
Replies use the same layout as a config write, with `0xFA` in place of the `0xFD` magic. Media replies carry a count of 1 where writes send 2; both are accepted.
|
||||||
|
|
||||||
|
Three things fell out of it:
|
||||||
|
|
||||||
|
- **The pad reports its own layout**, so key and knob counts don't have to be guessed from the USB ID.
|
||||||
|
- **There are three layers**, and the firmware keeps 15 key slots and 3 knobs whatever the hardware has. Ours answers for 6 controls that don't physically exist.
|
||||||
|
- **The dial mapping is confirmed from the other direction.** Reading back what we had written returns exactly the mapping we assumed, which is stronger evidence than a write that appeared to work.
|
||||||
|
|
||||||
|
The vendor's request has trailing bytes that change between runs and look like uninitialised memory from the Windows program. Zeros work fine.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>usbmon only shows you 32 bytes</b></summary>
|
<summary><b>usbmon only shows you 32 bytes</b></summary>
|
||||||
|
|
||||||
@@ -572,7 +635,7 @@ The app's permission check reads `udevadm info` to see whether the device got th
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Where the app keeps its notes</b></summary>
|
<summary><b>Where the app keeps its notes</b></summary>
|
||||||
|
|
||||||
`~/.config/macropad/state-1189-8840.json` holds everything the app has successfully written. Every entry is exactly a command the CLI understands:
|
`~/.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
|
```json
|
||||||
{
|
{
|
||||||
@@ -646,14 +709,18 @@ tests/ real captures and the tests that replay them
|
|||||||
- [x] Keys, shortcuts and media keys on `1189:8840`
|
- [x] Keys, shortcuts and media keys on `1189:8840`
|
||||||
- [x] Both knobs, all three actions each
|
- [x] Both knobs, all three actions each
|
||||||
- [x] Upright and flat views
|
- [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
|
- [x] One line installer for any distro
|
||||||
- [ ] **Key sequences with delays** (ch57x-keyboard-tool shows the likely format; one capture to confirm)
|
- [ ] **Key sequences with delays**. Reading the pad shows two-key sequences stored in the layout we assumed, so this is close
|
||||||
- [ ] Layers 2 and 3, and LED modes
|
- [ ] **Layers 2 and 3**, which the pad turns out to have
|
||||||
|
- [ ] LED modes
|
||||||
- [ ] AUR package, so Arch users can install it like anything else
|
- [ ] AUR package, so Arch users can install it like anything else
|
||||||
- [ ] Knob press to toggle brightness between 0% and 100%
|
- [ ] Knob press to toggle brightness between 0% and 100%
|
||||||
- [ ] Save and switch between named profiles (gaming, editing, streaming)
|
- [ ] Save and switch between named profiles (gaming, editing, streaming)
|
||||||
- [ ] **Windows and macOS** via a cross platform USB layer
|
- [ ] **Windows and macOS** via a cross platform USB layer
|
||||||
- [ ] **More pads**: every capture sent in gets us closer
|
- [x] Detecting a pad's key and knob counts by asking it
|
||||||
|
- [x] A layout editor, since the pad doesn't report how its keys are arranged
|
||||||
|
- [ ] **More pads**: every report sent in gets us closer
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
docs/images/edit-layout.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 308 KiB |
BIN
docs/images/read-pad.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 361 KiB |
@@ -139,6 +139,13 @@ def scene_first_launch():
|
|||||||
save(w, "first-launch")
|
save(w, "first-launch")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_read():
|
||||||
|
w = window(bindings=DEMO)
|
||||||
|
w._say("Read 18 controls from the pad.", A.GOOD)
|
||||||
|
select(w, "dial1-left")
|
||||||
|
save(w, "read-pad")
|
||||||
|
|
||||||
|
|
||||||
def scene_select_and_type():
|
def scene_select_and_type():
|
||||||
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
|
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
|
||||||
select(w, "key2")
|
select(w, "key2")
|
||||||
@@ -191,6 +198,19 @@ def scene_written():
|
|||||||
save(w, "step-written")
|
save(w, "step-written")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_edit_layout():
|
||||||
|
w = window(bindings=DEMO)
|
||||||
|
w._edit_layout(True)
|
||||||
|
spots = {k: (r, c) for k, r, c
|
||||||
|
in __import__("macropad_gui.padview", fromlist=["padview"])
|
||||||
|
._key_grid(w.pad.layout, w.pad.orientation)}
|
||||||
|
w.pad.drag = "key2"
|
||||||
|
w.pad.drag_at = w.pad.keys["key11"].center()
|
||||||
|
w.pad.drop = spots["key11"]
|
||||||
|
save(w, "edit-layout")
|
||||||
|
w._edit_layout(False, keep=False)
|
||||||
|
|
||||||
|
|
||||||
def scene_flat():
|
def scene_flat():
|
||||||
w = window(bindings=DEMO)
|
w = window(bindings=DEMO)
|
||||||
w.pad.set_orientation("flat")
|
w.pad.set_orientation("flat")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
macropad-capture - watch what the vendor's software actually sends.
|
macropad-capture - watch what the vendor's software sends, and what the pad
|
||||||
|
says back.
|
||||||
|
|
||||||
Uses the kernel's usbmon facility to record USB traffic going to your macro
|
Uses the kernel's usbmon facility to record USB traffic to and from your
|
||||||
pad, then decodes the outgoing reports so we can see the real wire format.
|
macro pad, then decodes the reports so we can see the real wire format.
|
||||||
|
|
||||||
This is passive. It only observes - it never sends anything itself.
|
This is passive. It only observes - it never sends anything itself.
|
||||||
|
|
||||||
@@ -16,15 +17,16 @@ How to use it:
|
|||||||
under Wine:
|
under Wine:
|
||||||
wine MINI_KEYBOARD.exe
|
wine MINI_KEYBOARD.exe
|
||||||
|
|
||||||
3. In that software, change ONE key to something distinctive - key 1 to
|
3. Do ONE thing in that software, then come back and press Ctrl+C.
|
||||||
the letter 'a' is ideal, because we know 'a' is HID code 0x04 and it
|
|
||||||
will be easy to spot in the bytes.
|
|
||||||
|
|
||||||
4. Click whatever it uses to apply or save.
|
To learn how a binding is written: change ONE key to the letter 'a'
|
||||||
|
and apply it. ('a' is HID code 0x04, easy to spot in the bytes.)
|
||||||
|
|
||||||
5. Come back here and press Ctrl+C.
|
To learn whether the pad can be read back: press whatever the
|
||||||
|
software calls "view settings" or "read". Anything the pad sends is
|
||||||
|
recorded and marked with a <- arrow.
|
||||||
|
|
||||||
It writes macropad-capture.txt, which has everything we need.
|
4. It writes macropad-capture.txt, which has everything we need.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -57,63 +59,135 @@ def find_pad():
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_has_usbmon():
|
||||||
|
"""
|
||||||
|
'y' built into the kernel, 'm' a loadable module, None not built at all.
|
||||||
|
Read from the running kernel's own config where it's available.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import gzip
|
||||||
|
with gzip.open("/proc/config.gz", "rt") as f:
|
||||||
|
text = f.read()
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
text = open(f"/boot/config-{os.uname().release}").read()
|
||||||
|
except OSError:
|
||||||
|
return "?"
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("CONFIG_USB_MON="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def debugfs_mounted():
|
||||||
|
try:
|
||||||
|
return " /sys/kernel/debug " in open("/proc/mounts").read()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ensure_usbmon():
|
def ensure_usbmon():
|
||||||
|
"""
|
||||||
|
Get /sys/kernel/debug/usb/usbmon to exist, and explain it if we can't.
|
||||||
|
|
||||||
|
Two separate things have to be true: the usbmon facility has to be in
|
||||||
|
the kernel, and debugfs has to be mounted for it to appear. A missing
|
||||||
|
module and an unmounted debugfs look identical until you check both.
|
||||||
|
"""
|
||||||
if os.path.isdir(DEBUGFS):
|
if os.path.isdir(DEBUGFS):
|
||||||
return True
|
return True
|
||||||
print(f"{DIM}Loading usbmon module...{O}")
|
|
||||||
subprocess.run(["modprobe", "usbmon"], capture_output=True)
|
built = kernel_has_usbmon()
|
||||||
|
|
||||||
|
if built == "m":
|
||||||
|
print(f"{DIM}Loading usbmon module...{O}")
|
||||||
|
subprocess.run(["modprobe", "usbmon"], capture_output=True)
|
||||||
|
|
||||||
|
if not debugfs_mounted():
|
||||||
|
print(f"{DIM}Mounting debugfs...{O}")
|
||||||
|
subprocess.run(["mount", "-t", "debugfs", "none", "/sys/kernel/debug"],
|
||||||
|
capture_output=True)
|
||||||
|
|
||||||
if os.path.isdir(DEBUGFS):
|
if os.path.isdir(DEBUGFS):
|
||||||
return True
|
return True
|
||||||
# debugfs may simply not be mounted yet
|
|
||||||
subprocess.run(["mount", "-t", "debugfs", "none", "/sys/kernel/debug"],
|
# Still nothing. Say which of the two is actually missing.
|
||||||
capture_output=True)
|
print(f"\n{R}Can't get at usbmon.{O} {DIM}kernel {os.uname().release}{O}")
|
||||||
return os.path.isdir(DEBUGFS)
|
if built is None:
|
||||||
|
print("Your kernel was built without usbmon (CONFIG_USB_MON is not set),")
|
||||||
|
print("so USB capture isn't available on it at all.")
|
||||||
|
print(f"\n{B}What to do{O}")
|
||||||
|
print(" Boot a kernel that has it. On Arch and CachyOS the LTS kernel does:")
|
||||||
|
print(" sudo pacman -S linux-lts linux-lts-headers")
|
||||||
|
print(" Then pick it in the boot menu and run this again.")
|
||||||
|
elif built == "m":
|
||||||
|
mod = f"/lib/modules/{os.uname().release}/kernel/drivers/usb/mon"
|
||||||
|
print("Your kernel lists usbmon as a module, but it wouldn't load.")
|
||||||
|
if not os.path.isdir(mod):
|
||||||
|
print(f"{mod} is missing, which usually means the kernel was")
|
||||||
|
print("updated and not rebooted into yet.")
|
||||||
|
print(f"\n{B}What to do{O}\n Reboot, then run this again.")
|
||||||
|
else:
|
||||||
|
print(f"\n{B}What to do{O}")
|
||||||
|
print(" sudo depmod -a && sudo modprobe usbmon")
|
||||||
|
else:
|
||||||
|
print("usbmon should be built in, but the folder still isn't there.")
|
||||||
|
print(f"\n{B}What to do{O}")
|
||||||
|
print(" sudo mount -t debugfs none /sys/kernel/debug")
|
||||||
|
print(" sudo ls /sys/kernel/debug/usb")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def decode(line, want_dev):
|
def decode(line, want_dev):
|
||||||
"""
|
"""
|
||||||
usbmon text format, roughly:
|
usbmon text format, roughly:
|
||||||
<tag> <ts> <S|C> <type>:<bus>:<dev>:<ep> <flags> <len> = <hex data>
|
<tag> <ts> <S|C|E> <type><dir>:<bus>:<dev>:<ep> <flags> <len> = <hex>
|
||||||
|
|
||||||
We want submissions (S) carrying data toward our device: control
|
Direction is the second letter: 'o' toward the device, 'i' from it.
|
||||||
transfers (Co) and interrupt out (Io).
|
Data rides on the submission (S) of an OUT transfer and on the
|
||||||
|
completion (C) of an IN transfer, so we want both.
|
||||||
"""
|
"""
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) < 5:
|
if len(parts) < 5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
event = parts[2]
|
event, addr = parts[2], parts[3]
|
||||||
addr = parts[3]
|
|
||||||
if event != "S":
|
|
||||||
return None
|
|
||||||
|
|
||||||
bits = addr.split(":")
|
bits = addr.split(":")
|
||||||
if len(bits) < 4:
|
if len(bits) < 4:
|
||||||
return None
|
return None
|
||||||
xfer, bus, dev, ep = bits[0], bits[1], bits[2], bits[3]
|
xfer, ep = bits[0], bits[3]
|
||||||
|
|
||||||
if int(dev) != want_dev:
|
try:
|
||||||
return None
|
if int(bits[2]) != want_dev:
|
||||||
if not xfer.endswith("o"): # 'o' = out, toward the device
|
return None
|
||||||
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if "=" not in line:
|
outgoing = xfer.endswith("o")
|
||||||
|
if outgoing and event != "S": # OUT data rides on the submission
|
||||||
|
return None
|
||||||
|
if not outgoing and event != "C": # IN data rides on the completion
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "=" not in line: # no payload on this line
|
||||||
return None
|
return None
|
||||||
data = line.split("=", 1)[1].replace(" ", "").strip()
|
data = line.split("=", 1)[1].replace(" ", "").strip()
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
kind = {"Co": "control out", "Io": "interrupt out",
|
kind = {"Co": "control out", "Ci": "control in",
|
||||||
"Bo": "bulk out"}.get(xfer, xfer)
|
"Io": "interrupt out", "Ii": "interrupt in",
|
||||||
|
"Bo": "bulk out", "Bi": "bulk in"}.get(xfer, xfer)
|
||||||
|
|
||||||
setup = ""
|
setup = ""
|
||||||
if xfer == "Co":
|
if xfer.startswith("C"):
|
||||||
# Control transfers carry an 8-byte setup packet in the flags field.
|
# Control transfers carry an 8-byte setup packet in the flags field.
|
||||||
for p in parts[4:9]:
|
for p in parts[4:9]:
|
||||||
if re.fullmatch(r"[0-9a-f]{2,4}", p):
|
if re.fullmatch(r"[0-9a-f]{2,4}", p):
|
||||||
setup += p + " "
|
setup += p + " "
|
||||||
|
|
||||||
return {"kind": kind, "ep": ep, "setup": setup.strip(), "data": data}
|
return {"kind": kind, "ep": ep, "setup": setup.strip(), "data": data,
|
||||||
|
"outgoing": outgoing}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -154,8 +228,9 @@ def main():
|
|||||||
|
|
||||||
print(f"{G}Capturing.{O} Now, in another terminal:")
|
print(f"{G}Capturing.{O} Now, in another terminal:")
|
||||||
print(f" {B}wine MINI_KEYBOARD.exe{O}")
|
print(f" {B}wine MINI_KEYBOARD.exe{O}")
|
||||||
print("\nChange ONE key to the letter 'a', then apply it.")
|
print("\nDo ONE thing in it, then come back here.")
|
||||||
print(f"{DIM}('a' is HID code 0x04 - easy to spot in the bytes.){O}")
|
print(f"{DIM} -> the software talking to the pad")
|
||||||
|
print(f" <- the pad talking back{O}")
|
||||||
print(f"\nPress {B}Ctrl+C{O} when you're done.\n")
|
print(f"\nPress {B}Ctrl+C{O} when you're done.\n")
|
||||||
print("-" * 62)
|
print("-" * 62)
|
||||||
|
|
||||||
@@ -171,7 +246,8 @@ def main():
|
|||||||
n = len(packets)
|
n = len(packets)
|
||||||
hexstr = rec["data"]
|
hexstr = rec["data"]
|
||||||
pretty = " ".join(hexstr[i:i + 2] for i in range(0, min(len(hexstr), 40), 2))
|
pretty = " ".join(hexstr[i:i + 2] for i in range(0, min(len(hexstr), 40), 2))
|
||||||
print(f"{n:3} {rec['kind']:<14} {pretty}"
|
arrow = f"{DIM}->{O}" if rec["outgoing"] else f"{G}<-{O}"
|
||||||
|
print(f"{n:3} {arrow} {rec['kind']:<14} {pretty}"
|
||||||
+ (" ..." if len(hexstr) > 40 else ""))
|
+ (" ..." if len(hexstr) > 40 else ""))
|
||||||
out.write(f"[{n}] {rec['kind']} ep={rec['ep']}"
|
out.write(f"[{n}] {rec['kind']} ep={rec['ep']}"
|
||||||
+ (f" setup={rec['setup']}" if rec["setup"] else "")
|
+ (f" setup={rec['setup']}" if rec["setup"] else "")
|
||||||
@@ -186,15 +262,15 @@ def main():
|
|||||||
print("\n" + "-" * 62)
|
print("\n" + "-" * 62)
|
||||||
if not packets:
|
if not packets:
|
||||||
print(f"{Y}Nothing captured.{O}")
|
print(f"{Y}Nothing captured.{O}")
|
||||||
print("The software may not have written, or it may be talking to a")
|
print("The software may not have talked to the pad, or it may be on a")
|
||||||
print("different device number. Check lsusb while Wine is running.")
|
print("different device number. Check lsusb while Wine is running.")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"{G}Captured {len(packets)} outgoing transfer(s){O} -> {OUTFILE}")
|
sent = sum(1 for p in packets if p["outgoing"])
|
||||||
|
got = len(packets) - sent
|
||||||
hits = [p for p in packets if "04" in p["data"]]
|
print(f"{G}Captured {sent} sent, {got} received{O} -> {OUTFILE}")
|
||||||
if hits:
|
if got:
|
||||||
print(f"{DIM}{len(hits)} contain byte 0x04, which may be your 'a' keycode.{O}")
|
print(f"{G}The pad answered back.{O} It can be read, not only written.")
|
||||||
|
|
||||||
print(f"\nSend me {B}{OUTFILE}{O} and I'll decode the format.")
|
print(f"\nSend me {B}{OUTFILE}{O} and I'll decode the format.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
184
macropad-read.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
macropad-read - find out what the pad will tell us about itself.
|
||||||
|
|
||||||
|
Two ways of listening, neither of which writes a configuration:
|
||||||
|
|
||||||
|
1. Feature reports. A HID "get feature" is a read. We ask for each
|
||||||
|
report id in turn and print anything that comes back.
|
||||||
|
|
||||||
|
2. Passive listening. Several programs can have a hidraw device open at
|
||||||
|
once, and input reports go to all of them. So we can sit and watch
|
||||||
|
while the vendor software talks to the pad, and see the pad's
|
||||||
|
replies, with no kernel module and no root.
|
||||||
|
|
||||||
|
Neither of these changes your keybinds.
|
||||||
|
|
||||||
|
python3 macropad-read.py # find the pad, do both
|
||||||
|
python3 macropad-read.py /dev/hidraw10 # a specific node
|
||||||
|
python3 macropad-read.py --listen 60 # listen for 60 seconds
|
||||||
|
|
||||||
|
To catch the pad answering the vendor software:
|
||||||
|
|
||||||
|
1. Start: python3 macropad-read.py --listen 60
|
||||||
|
2. In another window: wine MINI_KEYBOARD.exe
|
||||||
|
3. Press whatever it calls "view settings" or "read".
|
||||||
|
4. Come back here. Anything the pad said is printed and saved.
|
||||||
|
|
||||||
|
It writes macropad-read.txt in the same format as macropad-capture.py,
|
||||||
|
so the same tools can read it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import macropad as proto # noqa: E402
|
||||||
|
|
||||||
|
OUTFILE = "macropad-read.txt"
|
||||||
|
REPORT_IDS = (0, 1, 2, 3, 4)
|
||||||
|
LEN = 65
|
||||||
|
|
||||||
|
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"
|
||||||
|
DIM = "\033[2m"; O = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def hidiocgfeature(size):
|
||||||
|
"""_IOC(READ|WRITE, 'H', 0x07, size) - hidraw's GET_FEATURE ioctl."""
|
||||||
|
return (3 << 30) | (size << 16) | (ord("H") << 8) | 0x07
|
||||||
|
|
||||||
|
|
||||||
|
def pretty(data, width=16):
|
||||||
|
lines = []
|
||||||
|
for off in range(0, len(data), width):
|
||||||
|
chunk = data[off:off + width]
|
||||||
|
hexs = " ".join(f"{b:02x}" for b in chunk)
|
||||||
|
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||||
|
lines.append(f" {off:04x} {hexs:<{width * 3}} {text}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def try_features(path, out):
|
||||||
|
"""Ask the pad for each report id. Reading can't change anything."""
|
||||||
|
print(f"{B}Feature reports{O} {DIM}asking the pad for each report id{O}")
|
||||||
|
found = 0
|
||||||
|
for rid in REPORT_IDS:
|
||||||
|
buf = bytearray(LEN)
|
||||||
|
buf[0] = rid
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDWR)
|
||||||
|
except OSError as e:
|
||||||
|
print(f" {R}can't open {path}: {e.strerror}{O}")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(fd, hidiocgfeature(len(buf)), buf, True)
|
||||||
|
except OSError as e:
|
||||||
|
print(f" id {rid}: {DIM}nothing ({e.strerror}){O}")
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
if not any(buf[1:]):
|
||||||
|
print(f" id {rid}: {DIM}all zeros{O}")
|
||||||
|
continue
|
||||||
|
found += 1
|
||||||
|
print(f" id {rid}: {G}answered{O}")
|
||||||
|
print(pretty(bytes(buf)))
|
||||||
|
out.write(f"[feature] report id {rid}\n {bytes(buf).hex()}\n")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def listen(path, seconds, out):
|
||||||
|
print(f"\n{B}Listening for {seconds}s{O} "
|
||||||
|
f"{DIM}anything the pad sends shows up here{O}")
|
||||||
|
print(f"{DIM}Now use the vendor software and press its read or view "
|
||||||
|
f"settings button.{O}\n")
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"{R}Can't open {path}: {e.strerror}{O}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
seen = 0
|
||||||
|
end = time.time() + seconds
|
||||||
|
try:
|
||||||
|
while time.time() < end:
|
||||||
|
ready, _, _ = select.select([fd], [], [], 0.5)
|
||||||
|
if not ready:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = os.read(fd, 128)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
except OSError as e:
|
||||||
|
print(f"{R}read failed: {e.strerror}{O}")
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
seen += 1
|
||||||
|
left = int(end - time.time())
|
||||||
|
print(f"{G}<-{O} {len(data)} bytes {DIM}({left}s left){O}")
|
||||||
|
print(pretty(data))
|
||||||
|
out.write(f"[{seen}] interrupt in\n {data.hex()}\n")
|
||||||
|
out.flush()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n{DIM}stopped early{O}")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
seconds = 30
|
||||||
|
if "--listen" in sys.argv:
|
||||||
|
i = sys.argv.index("--listen")
|
||||||
|
if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit():
|
||||||
|
seconds = int(sys.argv[i + 1])
|
||||||
|
|
||||||
|
if args:
|
||||||
|
path = args[0]
|
||||||
|
else:
|
||||||
|
pads = proto.find_devices()
|
||||||
|
if not pads:
|
||||||
|
print(f"{R}No pad found.{O} Run macropad-probe.py to see what's there,")
|
||||||
|
print("or pass the node yourself: python3 macropad-read.py /dev/hidrawN")
|
||||||
|
return 1
|
||||||
|
if len(pads) > 1:
|
||||||
|
print(f"{B}Several candidates:{O}")
|
||||||
|
for p in pads:
|
||||||
|
print(f" {p['path']} {p['vid']}:{p['pid']}")
|
||||||
|
print("\nPick one and pass it as an argument.")
|
||||||
|
return 1
|
||||||
|
path = pads[0]["path"]
|
||||||
|
|
||||||
|
if not os.access(path, os.R_OK):
|
||||||
|
print(f"{R}{path} isn't readable.{O} Add the udev rule, or use sudo.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{B}Reading from {path}{O}")
|
||||||
|
print(f"{DIM}Nothing here writes a configuration.{O}\n")
|
||||||
|
|
||||||
|
with open(OUTFILE, "w") as out:
|
||||||
|
out.write(f"# read attempt on {path}\n\n")
|
||||||
|
found = try_features(path, out)
|
||||||
|
heard = listen(path, seconds, out)
|
||||||
|
|
||||||
|
print("\n" + "-" * 62)
|
||||||
|
if found or heard:
|
||||||
|
print(f"{G}Got something.{O} "
|
||||||
|
f"{found} feature report(s), {heard} message(s) while listening.")
|
||||||
|
print(f"Saved to {B}{OUTFILE}{O} - send that over.")
|
||||||
|
else:
|
||||||
|
print(f"{Y}The pad said nothing.{O}")
|
||||||
|
print("That doesn't prove it can't be read, only that it wasn't")
|
||||||
|
print("answering us. The next thing to try is capturing the vendor")
|
||||||
|
print("software with macropad-capture.py.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
41
macropad.py
@@ -223,6 +223,47 @@ def native_media(report_id, action, usage, layer=1):
|
|||||||
return [bytes([report_id]) + bytes(data), native_commit(report_id)]
|
return [bytes([report_id]) + bytes(data), native_commit(report_id)]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- reading back
|
||||||
|
#
|
||||||
|
# The pad can be read, which we only found by capturing the vendor software's
|
||||||
|
# "view settings" button. Two commands, both answered on the interrupt IN
|
||||||
|
# endpoint of the same report id:
|
||||||
|
#
|
||||||
|
# 0xFB what are you? reply: 03 fb <keys> <knobs>
|
||||||
|
# 0xFA send me a layer reply: one report per control, magic 0xFA, then
|
||||||
|
# nothing more
|
||||||
|
#
|
||||||
|
# Observed, reading layer 1:
|
||||||
|
# 03 fa 0f 03 01 02 ... request (trailing bytes were uninitialised
|
||||||
|
# memory in the vendor program; zeros work)
|
||||||
|
# 03 fa 02 01 01 00 ... 01 05 17 key2 is ctrl+alt+t
|
||||||
|
# 03 fa 10 01 02 00 ... 01 ea 00 dial 1 left is volume down
|
||||||
|
|
||||||
|
MAGIC_INFO = 0xFB
|
||||||
|
MAGIC_READ = 0xFA
|
||||||
|
KEY_SLOTS = 0x0F # the firmware keeps 15 key slots whatever the pad has
|
||||||
|
KNOB_SLOTS = 3
|
||||||
|
LAYERS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def native_info(report_id):
|
||||||
|
"""Ask the pad what it is. Reply carries the key and knob counts."""
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0] = MAGIC_INFO
|
||||||
|
return bytes([report_id]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def native_read(report_id, layer=1):
|
||||||
|
"""Ask the pad for every control in one layer."""
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0] = MAGIC_READ
|
||||||
|
data[1] = KEY_SLOTS
|
||||||
|
data[2] = LAYERS
|
||||||
|
data[3] = layer
|
||||||
|
data[4] = 2
|
||||||
|
return bytes([report_id]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- legacy protocol
|
# ------------------------------------------------------- legacy protocol
|
||||||
#
|
#
|
||||||
# The older format. Unlike Extended, a single binding is a *sequence* of
|
# The older format. Unlike Extended, a single binding is a *sequence* of
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ def cmd_doctor(_):
|
|||||||
def cmd_state(_):
|
def cmd_state(_):
|
||||||
s = core.State()
|
s = core.State()
|
||||||
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
|
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
|
||||||
for c in core.CONTROL_IDS:
|
print(f"{DIM}layout: {s.layout.describe()}{O}\n")
|
||||||
|
for c in s.layout.control_ids:
|
||||||
e = s.get(c)
|
e = s.get(c)
|
||||||
byte = f"0x{core.action_byte(c):02x}"
|
byte = f"0x{core.action_byte(c):02x}"
|
||||||
if e.unknown:
|
if e.unknown:
|
||||||
@@ -46,6 +47,26 @@ def cmd_state(_):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_detect(_):
|
||||||
|
d = core.diagnose(keyd=False)
|
||||||
|
if not d.device:
|
||||||
|
print(f"{R}No pad found at all.{O}")
|
||||||
|
return 1
|
||||||
|
print(f"{B}Asking {d.device.vid}:{d.device.pid} on {d.device.path}{O}")
|
||||||
|
print(f"{DIM}This only reads. Nothing is configured.{O}\n")
|
||||||
|
found = core.detect(d.device)
|
||||||
|
print(core.detection_report(d.device, found))
|
||||||
|
if found.speaks:
|
||||||
|
lay = found.layout
|
||||||
|
print(f"\n{G}It speaks this protocol.{O}")
|
||||||
|
print(f"Best guess at the shape: {lay.describe()}. The pad reports how "
|
||||||
|
f"many\nkeys it has, not how they're arranged, so check that "
|
||||||
|
f"against yours.")
|
||||||
|
return 0
|
||||||
|
print(f"\n{Y}It didn't answer properly, so the app won't write to it.{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def cmd_write(a):
|
def cmd_write(a):
|
||||||
if a.control not in core.CONTROL_IDS:
|
if a.control not in core.CONTROL_IDS:
|
||||||
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||||
@@ -78,6 +99,7 @@ def main():
|
|||||||
sub = ap.add_subparsers(dest="cmd")
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
sub.add_parser("doctor", help="check the pad is connected and writable")
|
sub.add_parser("doctor", help="check the pad is connected and writable")
|
||||||
sub.add_parser("state", help="show what this tool believes is on the pad")
|
sub.add_parser("state", help="show what this tool believes is on the pad")
|
||||||
|
sub.add_parser("detect", help="ask an unrecognised pad what it is (read only)")
|
||||||
w = sub.add_parser("write", help="write one binding and record it")
|
w = sub.add_parser("write", help="write one binding and record it")
|
||||||
w.add_argument("control")
|
w.add_argument("control")
|
||||||
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
||||||
@@ -92,7 +114,8 @@ def main():
|
|||||||
print("Install Qt for Python: sudo pacman -S pyside6")
|
print("Install Qt for Python: sudo pacman -S pyside6")
|
||||||
return 1
|
return 1
|
||||||
return run()
|
return run()
|
||||||
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write}[a.cmd](a)
|
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write,
|
||||||
|
"detect": cmd_detect}[a.cmd](a)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from PySide6.QtGui import (QColor, QFont, QFontDatabase, QGuiApplication, QIcon,
|
|||||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||||
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QMainWindow, QMessageBox, QPlainTextEdit,
|
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||||
QPushButton, QVBoxLayout, QWidget)
|
QPushButton, QSpinBox, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from . import core, keymap
|
from . import core, keymap
|
||||||
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||||
@@ -57,6 +57,10 @@ QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
|
|||||||
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
|
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
|
||||||
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
|
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
|
||||||
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
|
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
|
||||||
|
QFrame[role="editbar"] {{ border-top: 1px solid {TAPE.name()}; background: #1c1a17; }}
|
||||||
|
QFrame[role="editbar"] QLabel {{ background: transparent; }}
|
||||||
|
QSpinBox {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||||
|
padding: 4px 6px; }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -144,12 +148,14 @@ class Inspector(QWidget):
|
|||||||
changed = Signal(str, object) # control, Binding or None (revert)
|
changed = Signal(str, object) # control, Binding or None (revert)
|
||||||
write = Signal(str)
|
write = Signal(str)
|
||||||
revert = Signal(str)
|
revert = Signal(str)
|
||||||
|
action = Signal()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setFixedWidth(360)
|
self.setFixedWidth(360)
|
||||||
self.control = None
|
self.control = None
|
||||||
self._loading = False
|
self._loading = False
|
||||||
|
self.editing_layout = False
|
||||||
|
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
v.setContentsMargins(24, 24, 24, 16)
|
v.setContentsMargins(24, 24, 24, 16)
|
||||||
@@ -170,7 +176,10 @@ class Inspector(QWidget):
|
|||||||
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
|
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||||
self.banner_copy = _button("Copy commands")
|
self.banner_copy = _button("Copy commands")
|
||||||
self.banner_copy.clicked.connect(self._copy)
|
self.banner_copy.clicked.connect(self._copy)
|
||||||
for w in (self.banner_head, self.banner_text, self.banner_fix, self.banner_copy):
|
self.banner_action = _button("")
|
||||||
|
self.banner_action.clicked.connect(lambda: self.action.emit())
|
||||||
|
for w in (self.banner_head, self.banner_text, self.banner_fix,
|
||||||
|
self.banner_copy, self.banner_action):
|
||||||
bl.addWidget(w)
|
bl.addWidget(w)
|
||||||
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
|
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
|
||||||
self.banner_copy.sizePolicy().verticalPolicy())
|
self.banner_copy.sizePolicy().verticalPolicy())
|
||||||
@@ -290,6 +299,8 @@ class Inspector(QWidget):
|
|||||||
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
|
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
|
||||||
self.banner_fix.setVisible(bool(cmds))
|
self.banner_fix.setVisible(bool(cmds))
|
||||||
self.banner_copy.setVisible(bool(cmds))
|
self.banner_copy.setVisible(bool(cmds))
|
||||||
|
self.banner_action.setVisible(diag.status == "unsupported")
|
||||||
|
self.banner_action.setText("Ask the pad what it is")
|
||||||
if diag.fix and diag.fix[-1].startswith("#"):
|
if diag.fix and diag.fix[-1].startswith("#"):
|
||||||
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
|
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
|
||||||
self.banner.show()
|
self.banner.show()
|
||||||
@@ -307,11 +318,15 @@ class Inspector(QWidget):
|
|||||||
if control is None:
|
if control is None:
|
||||||
self.editor.hide()
|
self.editor.hide()
|
||||||
self.empty.setText(
|
self.empty.setText(
|
||||||
"Click a key or dial to change what it does.\n\n"
|
("Drag the keys around until the drawing matches your pad, "
|
||||||
+ ("The pad can't report what's on it, so this app only knows "
|
"then press Done.\n\nUse the boxes below to change the "
|
||||||
"what it has written itself. Hatched controls are unknown. "
|
"grid or the number of keys and knobs. This only changes "
|
||||||
"If you set the pad up with the vendor software, you can "
|
"the picture, never the pad."
|
||||||
"import that capture instead." if all_unknown else ""))
|
if self.editing_layout else
|
||||||
|
"Click a key or dial to change what it does.\n\n")
|
||||||
|
+ ("Hatched controls are ones this app hasn't been told about. "
|
||||||
|
"Press Read pad to ask the pad itself what it's holding."
|
||||||
|
if all_unknown and not self.editing_layout else ""))
|
||||||
self.empty.show()
|
self.empty.show()
|
||||||
self._loading = False
|
self._loading = False
|
||||||
return
|
return
|
||||||
@@ -422,6 +437,9 @@ class Window(QMainWindow):
|
|||||||
self.state = core.State()
|
self.state = core.State()
|
||||||
self.desired = {} # control -> Binding, unwritten edits
|
self.desired = {} # control -> Binding, unwritten edits
|
||||||
self.diag = None
|
self.diag = None
|
||||||
|
# Pads the user has identified by asking them. Remembered so the app
|
||||||
|
# doesn't go back to calling them unsupported on the next check.
|
||||||
|
self.adopted = set(self.settings.value("adopted", []) or [])
|
||||||
|
|
||||||
root = QWidget()
|
root = QWidget()
|
||||||
outer = QVBoxLayout(root)
|
outer = QVBoxLayout(root)
|
||||||
@@ -431,12 +449,41 @@ class Window(QMainWindow):
|
|||||||
body = QHBoxLayout()
|
body = QHBoxLayout()
|
||||||
body.setSpacing(0)
|
body.setSpacing(0)
|
||||||
self.pad = PadView()
|
self.pad = PadView()
|
||||||
|
self.pad.set_layout(self.state.layout)
|
||||||
self.pad.set_orientation(self.settings.value("orientation", "upright"))
|
self.pad.set_orientation(self.settings.value("orientation", "upright"))
|
||||||
self.inspector = Inspector()
|
self.inspector = Inspector()
|
||||||
body.addWidget(self.pad, 1)
|
body.addWidget(self.pad, 1)
|
||||||
body.addWidget(self.inspector)
|
body.addWidget(self.inspector)
|
||||||
outer.addLayout(body, 1)
|
outer.addLayout(body, 1)
|
||||||
|
|
||||||
|
# editor bar, hidden unless you're rearranging the pad
|
||||||
|
self.editbar = QFrame()
|
||||||
|
self.editbar.setProperty("role", "editbar")
|
||||||
|
eb = QHBoxLayout(self.editbar)
|
||||||
|
eb.setContentsMargins(16, 8, 16, 8)
|
||||||
|
eb.setSpacing(8)
|
||||||
|
eb.addWidget(_label("Drag the keys to match your pad.", "dim"))
|
||||||
|
eb.addSpacing(12)
|
||||||
|
self.spin = {}
|
||||||
|
for name, label, lo, hi in (("rows", "Rows", 1, 15),
|
||||||
|
("cols", "Columns", 1, 15),
|
||||||
|
("keys", "Keys", 1, 15),
|
||||||
|
("knobs", "Knobs", 0, 3)):
|
||||||
|
eb.addWidget(_label(label, "dim"))
|
||||||
|
box = QSpinBox()
|
||||||
|
box.setRange(lo, hi)
|
||||||
|
box.setFixedWidth(56)
|
||||||
|
box.valueChanged.connect(self._resize_layout)
|
||||||
|
self.spin[name] = box
|
||||||
|
eb.addWidget(box)
|
||||||
|
eb.addStretch()
|
||||||
|
self.edit_done = _button("Done", "primary")
|
||||||
|
self.edit_cancel = _button("Cancel", "flat")
|
||||||
|
eb.addWidget(self.edit_cancel)
|
||||||
|
eb.addWidget(self.edit_done)
|
||||||
|
self.editbar.hide()
|
||||||
|
outer.addWidget(self.editbar)
|
||||||
|
|
||||||
footer = QFrame()
|
footer = QFrame()
|
||||||
footer.setProperty("role", "footer")
|
footer.setProperty("role", "footer")
|
||||||
fl = QHBoxLayout(footer)
|
fl = QHBoxLayout(footer)
|
||||||
@@ -445,6 +492,10 @@ class Window(QMainWindow):
|
|||||||
self.dot.setFixedSize(9, 9)
|
self.dot.setFixedSize(9, 9)
|
||||||
self.status = _label("", "dim")
|
self.status = _label("", "dim")
|
||||||
self.message = _label("")
|
self.message = _label("")
|
||||||
|
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.rotate = _button("Rotate view", "flat")
|
self.rotate = _button("Rotate view", "flat")
|
||||||
self.import_btn = _button("Import capture…", "flat")
|
self.import_btn = _button("Import capture…", "flat")
|
||||||
self.write_all = _button("", "primary")
|
self.write_all = _button("", "primary")
|
||||||
@@ -452,6 +503,8 @@ class Window(QMainWindow):
|
|||||||
fl.addWidget(self.status)
|
fl.addWidget(self.status)
|
||||||
fl.addSpacing(16)
|
fl.addSpacing(16)
|
||||||
fl.addWidget(self.message, 1)
|
fl.addWidget(self.message, 1)
|
||||||
|
fl.addWidget(self.edit_btn)
|
||||||
|
fl.addWidget(self.read_btn)
|
||||||
fl.addWidget(self.rotate)
|
fl.addWidget(self.rotate)
|
||||||
fl.addWidget(self.import_btn)
|
fl.addWidget(self.import_btn)
|
||||||
fl.addWidget(self.write_all)
|
fl.addWidget(self.write_all)
|
||||||
@@ -461,8 +514,14 @@ class Window(QMainWindow):
|
|||||||
self.pad.selected.connect(self._select)
|
self.pad.selected.connect(self._select)
|
||||||
self.inspector.changed.connect(self._changed)
|
self.inspector.changed.connect(self._changed)
|
||||||
self.inspector.revert.connect(self._revert)
|
self.inspector.revert.connect(self._revert)
|
||||||
|
self.inspector.action.connect(self._identify)
|
||||||
self.inspector.write.connect(lambda c: self._write([c]))
|
self.inspector.write.connect(lambda c: self._write([c]))
|
||||||
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
||||||
|
self.edit_btn.clicked.connect(lambda: self._edit_layout(True))
|
||||||
|
self.edit_done.clicked.connect(lambda: self._edit_layout(False, keep=True))
|
||||||
|
self.edit_cancel.clicked.connect(lambda: self._edit_layout(False, keep=False))
|
||||||
|
self.pad.rearranged.connect(self._rearranged)
|
||||||
|
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
|
||||||
self.rotate.clicked.connect(self._rotate)
|
self.rotate.clicked.connect(self._rotate)
|
||||||
self.import_btn.clicked.connect(self._import)
|
self.import_btn.clicked.connect(self._import)
|
||||||
|
|
||||||
@@ -473,12 +532,15 @@ class Window(QMainWindow):
|
|||||||
self.timer.start(2000)
|
self.timer.start(2000)
|
||||||
self._poll()
|
self._poll()
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
# The pad can tell us what it holds, so ask rather than assume.
|
||||||
|
if self.diag and self.diag.ready:
|
||||||
|
QTimer.singleShot(0, lambda: self._read(quiet=True))
|
||||||
|
|
||||||
# ------------------------------------------------------------- model
|
# ------------------------------------------------------------- model
|
||||||
|
|
||||||
def _pending(self):
|
def _pending(self):
|
||||||
out = []
|
out = []
|
||||||
for c in core.CONTROL_IDS:
|
for c in self.state.layout.control_ids:
|
||||||
b = self.desired.get(c)
|
b = self.desired.get(c)
|
||||||
if b is None:
|
if b is None:
|
||||||
continue
|
continue
|
||||||
@@ -490,7 +552,7 @@ class Window(QMainWindow):
|
|||||||
def _refresh(self, editor=True):
|
def _refresh(self, editor=True):
|
||||||
pending = set(self._pending())
|
pending = set(self._pending())
|
||||||
looks = {}
|
looks = {}
|
||||||
for c in core.CONTROL_IDS:
|
for c in self.state.layout.control_ids:
|
||||||
e = self.state.get(c)
|
e = self.state.get(c)
|
||||||
if c in pending:
|
if c in pending:
|
||||||
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
||||||
@@ -510,7 +572,8 @@ class Window(QMainWindow):
|
|||||||
if not editor:
|
if not editor:
|
||||||
self.inspector.update_buttons(c in pending, can_write)
|
self.inspector.update_buttons(c in pending, can_write)
|
||||||
return
|
return
|
||||||
all_unknown = all(self.state.get(x).unknown for x in core.CONTROL_IDS)
|
all_unknown = all(self.state.get(x).unknown
|
||||||
|
for x in self.state.layout.control_ids)
|
||||||
self.inspector.show_control(
|
self.inspector.show_control(
|
||||||
c, self.state.get(c) if c else None,
|
c, self.state.get(c) if c else None,
|
||||||
self.desired.get(c) if c in pending else None,
|
self.desired.get(c) if c in pending else None,
|
||||||
@@ -539,8 +602,15 @@ class Window(QMainWindow):
|
|||||||
except Exception as e: # never let polling kill the window
|
except Exception as e: # never let polling kill the window
|
||||||
self._say(f"Couldn't check the pad: {e}", BAD)
|
self._say(f"Couldn't check the pad: {e}", BAD)
|
||||||
return
|
return
|
||||||
|
d = self.diag
|
||||||
|
if d.status == "unsupported" and d.device and \
|
||||||
|
f"{d.device.vid}:{d.device.pid}" in self.adopted:
|
||||||
|
self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.",
|
||||||
|
device=d.device, warnings=d.warnings)
|
||||||
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
||||||
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
||||||
|
if self.diag.device:
|
||||||
|
self.pad.device_id = f"{self.diag.device.vid}:{self.diag.device.pid}"
|
||||||
self.status.setText({
|
self.status.setText({
|
||||||
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
|
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
|
||||||
"unplugged": "No pad connected",
|
"unplugged": "No pad connected",
|
||||||
@@ -551,6 +621,8 @@ class Window(QMainWindow):
|
|||||||
self.inspector.show_problem(self.diag, self.note)
|
self.inspector.show_problem(self.diag, self.note)
|
||||||
if self.diag.status != old:
|
if self.diag.status != old:
|
||||||
self._refresh(editor=False)
|
self._refresh(editor=False)
|
||||||
|
if self.diag.ready and old in ("unplugged", "no-interface", None):
|
||||||
|
self._read(quiet=True)
|
||||||
|
|
||||||
# ------------------------------------------------------------- write
|
# ------------------------------------------------------------- write
|
||||||
|
|
||||||
@@ -590,12 +662,122 @@ class Window(QMainWindow):
|
|||||||
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
|
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
|
def _read(self, quiet=False):
|
||||||
|
"""
|
||||||
|
Ask the pad what it holds and believe the answer. Anything you have
|
||||||
|
changed but not written is left alone, since that is your intent
|
||||||
|
rather than the pad's state.
|
||||||
|
"""
|
||||||
|
if not (self.diag and self.diag.ready):
|
||||||
|
if not quiet:
|
||||||
|
self._say(self.diag.headline if self.diag else "No pad.", BAD)
|
||||||
|
return
|
||||||
|
self.read_btn.setEnabled(False)
|
||||||
|
self.read_btn.setText("Reading...")
|
||||||
|
QApplication.processEvents()
|
||||||
|
try:
|
||||||
|
got = core.read_into_state(self.diag.device, self.state)
|
||||||
|
except core.ReadError as e:
|
||||||
|
if not quiet:
|
||||||
|
self._say(f"Couldn't read the pad: {e}", BAD)
|
||||||
|
else:
|
||||||
|
n = len(got)
|
||||||
|
self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD)
|
||||||
|
finally:
|
||||||
|
self.read_btn.setEnabled(True)
|
||||||
|
self.read_btn.setText("Read pad")
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _identify(self):
|
||||||
|
"""
|
||||||
|
Interrogate a pad we don't recognise. Reads only. If it answers
|
||||||
|
properly we adopt its shape and carry on; if not we say so and
|
||||||
|
leave it alone.
|
||||||
|
"""
|
||||||
|
dev = self.diag.device if self.diag else None
|
||||||
|
if not dev:
|
||||||
|
return
|
||||||
|
self.inspector.banner_action.setEnabled(False)
|
||||||
|
self.inspector.banner_action.setText("Asking...")
|
||||||
|
QApplication.processEvents()
|
||||||
|
try:
|
||||||
|
found = core.detect(dev)
|
||||||
|
finally:
|
||||||
|
self.inspector.banner_action.setEnabled(True)
|
||||||
|
self.inspector.banner_action.setText("Ask the pad what it is")
|
||||||
|
|
||||||
|
if not found.speaks:
|
||||||
|
self._say(f"That pad doesn't speak this protocol: {found.why}", BAD)
|
||||||
|
return
|
||||||
|
|
||||||
|
layout = found.layout
|
||||||
|
self.state = core.State(layout=layout)
|
||||||
|
self.state.layout = layout
|
||||||
|
for control, binding in found.bindings.items():
|
||||||
|
self.state.record(control, binding)
|
||||||
|
self.state.save()
|
||||||
|
self.pad.set_layout(layout)
|
||||||
|
self.desired.clear()
|
||||||
|
self.adopted.add(f"{dev.vid}:{dev.pid}")
|
||||||
|
self.settings.setValue("adopted", sorted(self.adopted))
|
||||||
|
self._poll()
|
||||||
|
self._say(f"It says {layout.keys} keys and {layout.knobs} "
|
||||||
|
f"knob{'s' if layout.knobs != 1 else ''}. Check the drawing "
|
||||||
|
f"matches your pad.", GOOD)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
def _say(self, text, colour=None):
|
def _say(self, text, colour=None):
|
||||||
self.message.setText(text)
|
self.message.setText(text)
|
||||||
self.message.setStyleSheet(f"color: {colour};" if colour else "")
|
self.message.setStyleSheet(f"color: {colour};" if colour else "")
|
||||||
|
|
||||||
# ------------------------------------------------------------- misc
|
# ------------------------------------------------------------- misc
|
||||||
|
|
||||||
|
def _edit_layout(self, on, keep=False):
|
||||||
|
if on:
|
||||||
|
self.editing_from = self.state.layout
|
||||||
|
for name, value in (("rows", self.state.layout.rows),
|
||||||
|
("cols", self.state.layout.cols),
|
||||||
|
("keys", self.state.layout.keys),
|
||||||
|
("knobs", self.state.layout.knobs)):
|
||||||
|
self.spin[name].blockSignals(True)
|
||||||
|
self.spin[name].setValue(value)
|
||||||
|
self.spin[name].blockSignals(False)
|
||||||
|
self._say("")
|
||||||
|
elif not keep:
|
||||||
|
self._set_layout(self.editing_from)
|
||||||
|
else:
|
||||||
|
self.state.save()
|
||||||
|
lay = self.state.layout
|
||||||
|
self._say(f"Layout saved: {lay.describe()}.", GOOD)
|
||||||
|
self.editbar.setVisible(on)
|
||||||
|
self.edit_btn.setEnabled(not on)
|
||||||
|
self.read_btn.setEnabled(not on)
|
||||||
|
self.inspector.editing_layout = on
|
||||||
|
self.pad.select(None if on else self.pad.current)
|
||||||
|
self.pad.set_editing(on)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _set_layout(self, layout):
|
||||||
|
self.state.layout = layout
|
||||||
|
self.pad.set_layout(layout)
|
||||||
|
if self.pad.current not in layout.control_ids:
|
||||||
|
self.pad.current = None
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _rearranged(self, layout):
|
||||||
|
self.state.layout = layout
|
||||||
|
|
||||||
|
def _resize_layout(self):
|
||||||
|
lay = self.state.layout.resized(
|
||||||
|
rows=self.spin["rows"].value(), cols=self.spin["cols"].value(),
|
||||||
|
keys=self.spin["keys"].value(), knobs=self.spin["knobs"].value())
|
||||||
|
if lay.rows != self.spin["rows"].value(): # it had to grow to fit
|
||||||
|
self.spin["rows"].blockSignals(True)
|
||||||
|
self.spin["rows"].setValue(lay.rows)
|
||||||
|
self.spin["rows"].blockSignals(False)
|
||||||
|
self._set_layout(lay)
|
||||||
|
self.pad.set_editing(True)
|
||||||
|
|
||||||
def _rotate(self):
|
def _rotate(self):
|
||||||
o = "flat" if self.pad.orientation == "upright" else "upright"
|
o = "flat" if self.pad.orientation == "upright" else "upright"
|
||||||
self.pad.set_orientation(o)
|
self.pad.set_orientation(o)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -42,23 +42,155 @@ except ImportError: # running from inside the package dir
|
|||||||
VID, PID = "1189", "8840"
|
VID, PID = "1189", "8840"
|
||||||
DEVICE_ID = f"{VID}:{PID}"
|
DEVICE_ID = f"{VID}:{PID}"
|
||||||
|
|
||||||
KEYS = tuple(f"key{i}" for i in range(1, 13))
|
|
||||||
DIALS = ("dial1", "dial2")
|
|
||||||
DIAL_ACTIONS = ("left", "push", "right")
|
DIAL_ACTIONS = ("left", "push", "right")
|
||||||
CONTROL_IDS = KEYS + tuple(f"{d}-{a}" for d in DIALS for a in DIAL_ACTIONS)
|
|
||||||
|
|
||||||
# Action bytes come from macropad.CONTROLS, never from here.
|
|
||||||
for _cid in CONTROL_IDS:
|
|
||||||
assert _cid in proto.CONTROLS, f"macropad.py has no control {_cid}"
|
|
||||||
|
|
||||||
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
|
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
|
||||||
|
|
||||||
|
|
||||||
def action_byte(control):
|
def action_byte(control):
|
||||||
return proto.CONTROLS[control]
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
CONTROL_FOR_ACTION = {action_byte(c): c for c in CONTROL_IDS}
|
@dataclass(frozen=True)
|
||||||
|
class Layout:
|
||||||
|
"""
|
||||||
|
The shape of one pad: how many keys and knobs, and how the keys are
|
||||||
|
arranged when you look at it.
|
||||||
|
|
||||||
|
keys and knobs come from the pad itself (the 0xFB query). Rows and
|
||||||
|
columns do not: the pad knows how many keys it has, not how they are
|
||||||
|
laid out, so that part is either known from the model or chosen by
|
||||||
|
whoever owns it.
|
||||||
|
|
||||||
|
Rows and columns describe the pad lying flat, the way its keys are
|
||||||
|
printed. Standing it upright just turns the drawing.
|
||||||
|
"""
|
||||||
|
keys: int = 12
|
||||||
|
knobs: int = 2
|
||||||
|
rows: int = 3
|
||||||
|
cols: int = 4
|
||||||
|
name: str = ""
|
||||||
|
places: tuple = () # ((row, col), ...) per key; empty means left to right
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not 1 <= self.keys <= proto.KEY_SLOTS:
|
||||||
|
raise ValueError(f"a pad can have 1 to {proto.KEY_SLOTS} keys")
|
||||||
|
if not 0 <= self.knobs <= proto.KNOB_SLOTS:
|
||||||
|
raise ValueError(f"a pad can have up to {proto.KNOB_SLOTS} knobs")
|
||||||
|
if self.rows * self.cols < self.keys:
|
||||||
|
raise ValueError(f"{self.rows}x{self.cols} can't hold "
|
||||||
|
f"{self.keys} keys")
|
||||||
|
if self.places:
|
||||||
|
if len(self.places) != self.keys:
|
||||||
|
raise ValueError("places must give a spot for every key")
|
||||||
|
if len(set(self.places)) != len(self.places):
|
||||||
|
raise ValueError("two keys can't share a spot")
|
||||||
|
for r, c in self.places:
|
||||||
|
if not (0 <= r < self.rows and 0 <= c < self.cols):
|
||||||
|
raise ValueError(f"({r}, {c}) is outside {self.rows}x{self.cols}")
|
||||||
|
|
||||||
|
def spots(self):
|
||||||
|
"""[(key_id, row, col)] with the pad lying flat."""
|
||||||
|
if self.places:
|
||||||
|
return [(f"key{i + 1}", r, c) for i, (r, c) in enumerate(self.places)]
|
||||||
|
return [(f"key{i + 1}", i // self.cols, i % self.cols)
|
||||||
|
for i in range(self.keys)]
|
||||||
|
|
||||||
|
def rearranged(self, places):
|
||||||
|
"""The same pad with its keys in different spots."""
|
||||||
|
return replace(self, places=tuple((int(r), int(c)) for r, c in places))
|
||||||
|
|
||||||
|
def resized(self, rows=None, cols=None, keys=None, knobs=None):
|
||||||
|
"""
|
||||||
|
Change the grid, keeping any arrangement that still fits and
|
||||||
|
falling back to left to right for keys that no longer do.
|
||||||
|
"""
|
||||||
|
rows = self.rows if rows is None else rows
|
||||||
|
cols = self.cols if cols is None else cols
|
||||||
|
keys = self.keys if keys is None else keys
|
||||||
|
knobs = self.knobs if knobs is None else knobs
|
||||||
|
if rows * cols < keys: # grow to fit rather than refuse
|
||||||
|
rows = -(-keys // cols)
|
||||||
|
|
||||||
|
taken, places = set(), []
|
||||||
|
old = {i: p for i, p in enumerate(self.places)}
|
||||||
|
free = [(r, c) for r in range(rows) for c in range(cols)]
|
||||||
|
for i in range(keys):
|
||||||
|
spot = old.get(i)
|
||||||
|
if spot and spot[0] < rows and spot[1] < cols and spot not in taken:
|
||||||
|
places.append(spot)
|
||||||
|
taken.add(spot)
|
||||||
|
else:
|
||||||
|
places.append(None)
|
||||||
|
for i, spot in enumerate(places):
|
||||||
|
if spot is None:
|
||||||
|
nxt = next(p for p in free if p not in taken)
|
||||||
|
places[i] = nxt
|
||||||
|
taken.add(nxt)
|
||||||
|
return replace(self, rows=rows, cols=cols, keys=keys, knobs=knobs,
|
||||||
|
places=tuple(places) if self.places else ())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_ids(self):
|
||||||
|
return tuple(f"key{i}" for i in range(1, self.keys + 1))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dial_ids(self):
|
||||||
|
return tuple(f"dial{i}" for i in range(1, self.knobs + 1))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_ids(self):
|
||||||
|
return self.key_ids + tuple(f"{d}-{a}" for d in self.dial_ids
|
||||||
|
for a in DIAL_ACTIONS)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_for_action(self):
|
||||||
|
return {action_byte(c): c for c in self.control_ids}
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
knobs = (f" and {self.knobs} knob{'s' if self.knobs != 1 else ''}"
|
||||||
|
if self.knobs else "")
|
||||||
|
return f"{self.keys} keys in {self.rows}x{self.cols}{knobs}"
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
d = {"keys": self.keys, "knobs": self.knobs,
|
||||||
|
"rows": self.rows, "cols": self.cols, "name": self.name}
|
||||||
|
if self.places:
|
||||||
|
d["places"] = [list(p) for p in self.places]
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, d):
|
||||||
|
return cls(keys=int(d["keys"]), knobs=int(d["knobs"]),
|
||||||
|
rows=int(d["rows"]), cols=int(d["cols"]),
|
||||||
|
name=d.get("name", ""),
|
||||||
|
places=tuple(tuple(p) for p in d.get("places", ())))
|
||||||
|
|
||||||
|
|
||||||
|
# Pads we have laid eyes on. Anything else has to be detected or described
|
||||||
|
# by its owner, because a USB id alone does not pin down a layout: 1189:8840
|
||||||
|
# covers several different pads.
|
||||||
|
KNOWN_LAYOUTS = {
|
||||||
|
("1189", "8840"): Layout(12, 2, 3, 4, "12 keys, 2 knobs"),
|
||||||
|
}
|
||||||
|
DEFAULT_LAYOUT = KNOWN_LAYOUTS[(VID, PID)]
|
||||||
|
|
||||||
|
# Kept so older code and the CLI keep working; the app uses a Layout.
|
||||||
|
KEYS = DEFAULT_LAYOUT.key_ids
|
||||||
|
DIALS = DEFAULT_LAYOUT.dial_ids
|
||||||
|
CONTROL_IDS = DEFAULT_LAYOUT.control_ids
|
||||||
|
CONTROL_FOR_ACTION = DEFAULT_LAYOUT.control_for_action
|
||||||
|
|
||||||
|
for _cid in CONTROL_IDS:
|
||||||
|
assert action_byte(_cid) == proto.CONTROLS[_cid], _cid
|
||||||
|
|
||||||
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
|
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
|
||||||
|
|
||||||
@@ -246,7 +378,7 @@ def _atomic_write_json(path, data):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
FORMAT = 1
|
FORMAT = 2 # 1 had no layout, and one build saved rows and cols swapped
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -264,16 +396,23 @@ class State:
|
|||||||
should call record()/mark_unknown(); the GUI just reads it.
|
should call record()/mark_unknown(); the GUI just reads it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path=None):
|
def __init__(self, path=None, layout=None):
|
||||||
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
||||||
self.entries = {} # (layer, control) -> Entry
|
self.entries = {} # (layer, control) -> Entry
|
||||||
|
self.layout = layout or DEFAULT_LAYOUT
|
||||||
if self.path.exists():
|
if self.path.exists():
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
data = json.loads(self.path.read_text())
|
data = json.loads(self.path.read_text())
|
||||||
if data.get("format") != FORMAT:
|
version = data.get("format")
|
||||||
raise ValueError(f"{self.path}: unsupported format {data.get('format')}")
|
if version not in (1, FORMAT):
|
||||||
|
raise ValueError(f"{self.path}: unsupported format {version}")
|
||||||
|
# Format 1 files either have no layout or have one from the build that
|
||||||
|
# stored rows and columns the wrong way round. Ignore it either way;
|
||||||
|
# the bindings are still good.
|
||||||
|
if version == FORMAT and data.get("layout"):
|
||||||
|
self.layout = Layout.from_json(data["layout"])
|
||||||
for layer, controls in data.get("layers", {}).items():
|
for layer, controls in data.get("layers", {}).items():
|
||||||
for control, e in controls.items():
|
for control, e in controls.items():
|
||||||
if e.get("unknown"):
|
if e.get("unknown"):
|
||||||
@@ -293,6 +432,7 @@ class State:
|
|||||||
d = {"binding": e.binding.to_json(), "written": e.written}
|
d = {"binding": e.binding.to_json(), "written": e.written}
|
||||||
layers.setdefault(str(layer), {})[control] = d
|
layers.setdefault(str(layer), {})[control] = d
|
||||||
_atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID,
|
_atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID,
|
||||||
|
"layout": self.layout.to_json(),
|
||||||
"layers": layers})
|
"layers": layers})
|
||||||
|
|
||||||
def get(self, control, layer=1):
|
def get(self, control, layer=1):
|
||||||
@@ -315,6 +455,8 @@ class Device:
|
|||||||
report_id: int
|
report_id: int
|
||||||
writable: bool
|
writable: bool
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
vid: str = VID
|
||||||
|
pid: str = PID
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -435,17 +577,19 @@ def diagnose(keyd=True):
|
|||||||
|
|
||||||
if not found and everything:
|
if not found and everything:
|
||||||
other = everything[0]
|
other = everything[0]
|
||||||
|
dev = Device(path=other["path"], report_id=other["report_id"],
|
||||||
|
writable=other["writable"], name=other.get("name", ""),
|
||||||
|
vid=other["vid"], pid=other["pid"])
|
||||||
return Diagnosis(
|
return Diagnosis(
|
||||||
"unsupported",
|
"unsupported",
|
||||||
f"Found a {other['vid']}:{other['pid']} pad, which this app doesn't know yet.",
|
f"Found a {other['vid']}:{other['pid']} pad, which isn't one this "
|
||||||
detail=[f"So far only {DEVICE_ID} (12 keys, 2 dials) has been "
|
"app knows.",
|
||||||
"worked out. Pads in this family differ in layout and "
|
device=dev,
|
||||||
"protocol, so writing to yours with the wrong one could "
|
detail=["Pads in this family differ in both layout and protocol, "
|
||||||
"fail or scramble it. The app won't try.",
|
"so the app won't write to one it hasn't identified.",
|
||||||
"ch57x-keyboard-tool, a command line tool, supports several "
|
"It can ask the pad about itself first. That only reads, "
|
||||||
"of these pads today. To get yours into this app, the "
|
"and if the pad doesn't answer properly nothing else "
|
||||||
"README's \"Other pads\" section shows how to capture what "
|
"happens."],
|
||||||
"the vendor software sends."],
|
|
||||||
warnings=warnings)
|
warnings=warnings)
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
@@ -464,7 +608,8 @@ def diagnose(keyd=True):
|
|||||||
|
|
||||||
d = found[0]
|
d = found[0]
|
||||||
dev = Device(path=d["path"], report_id=d["report_id"],
|
dev = Device(path=d["path"], report_id=d["report_id"],
|
||||||
writable=d["writable"], name=d.get("name", ""))
|
writable=d["writable"], name=d.get("name", ""),
|
||||||
|
vid=d["vid"], pid=d["pid"])
|
||||||
|
|
||||||
if dev.writable:
|
if dev.writable:
|
||||||
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
|
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
|
||||||
@@ -569,13 +714,20 @@ def write_binding(device, control, binding, state, layer=1, _write=None):
|
|||||||
|
|
||||||
# --------------------------------------------------------------- decoding
|
# --------------------------------------------------------------- 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_config(report):
|
def decode_config(report):
|
||||||
"""
|
"""
|
||||||
The inverse of Binding.reports()[0]: (control, layer, Binding) from a
|
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
|
native config report, or None if it isn't one we understand. Used to
|
||||||
import captures and to prove round trips in the tests.
|
import captures and to prove round trips in the tests.
|
||||||
"""
|
"""
|
||||||
if len(report) < 13 or report[1] != proto.MAGIC_NATIVE:
|
if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE, proto.MAGIC_READ):
|
||||||
return None
|
return None
|
||||||
action, layer, kind = report[2], report[3], report[4]
|
action, layer, kind = report[2], report[3], report[4]
|
||||||
control = CONTROL_FOR_ACTION.get(action)
|
control = CONTROL_FOR_ACTION.get(action)
|
||||||
@@ -584,17 +736,17 @@ def decode_config(report):
|
|||||||
delay = report[5] | (report[6] << 8)
|
delay = report[5] | (report[6] << 8)
|
||||||
count = report[10]
|
count = report[10]
|
||||||
|
|
||||||
if kind == proto.KeyType.MULTIMEDIA:
|
if kind == proto.KeyType.MULTIMEDIA and count:
|
||||||
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
||||||
return (control, layer, Binding(media=media)) if media else None
|
return (control, layer, Binding(media=media)) if media else None
|
||||||
|
|
||||||
if kind == proto.KeyType.BASIC and count == 0:
|
if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC):
|
||||||
mods, code = report[11], report[12]
|
mods, code = report[11], report[12]
|
||||||
if not mods and not code:
|
if not mods and not code:
|
||||||
return control, layer, Binding(none=True)
|
return control, layer, Binding(none=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if kind == proto.KeyType.BASIC and count:
|
if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count:
|
||||||
steps = []
|
steps = []
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
off = 11 + 2 * i
|
off = 11 + 2 * i
|
||||||
@@ -616,11 +768,21 @@ def is_commit(report):
|
|||||||
(report[2], report[3]) == proto.TERMINATOR
|
(report[2], report[3]) == proto.TERMINATOR
|
||||||
|
|
||||||
|
|
||||||
def read_capture(path):
|
def read_capture(path, incoming=False):
|
||||||
"""Raw report bytes from a macropad-capture.py output file, in order."""
|
"""
|
||||||
|
Reports from a macropad-capture.py output file, in order.
|
||||||
|
|
||||||
|
Captures can now hold both directions. By default we return only what
|
||||||
|
was sent TO the pad, since that is what a binding looks like; pass
|
||||||
|
incoming=True for what the pad sent back.
|
||||||
|
"""
|
||||||
out = []
|
out = []
|
||||||
|
want = " in " if incoming else " out "
|
||||||
|
keep = False
|
||||||
for line in Path(path).read_text().splitlines():
|
for line in Path(path).read_text().splitlines():
|
||||||
if line.startswith(" "):
|
if line.startswith("["):
|
||||||
|
keep = want in f" {line.split(']', 1)[-1].strip()} "
|
||||||
|
elif line.startswith(" ") and keep:
|
||||||
try:
|
try:
|
||||||
out.append(bytes.fromhex(line.strip()))
|
out.append(bytes.fromhex(line.strip()))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -648,3 +810,192 @@ def import_capture(path, state):
|
|||||||
if done:
|
if done:
|
||||||
state.save()
|
state.save()
|
||||||
return done
|
return done
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- reading
|
||||||
|
|
||||||
|
READ_TIMEOUT = 1.5 # total, per request
|
||||||
|
READ_QUIET = 0.25 # replies have stopped once this much silence passes
|
||||||
|
|
||||||
|
|
||||||
|
class ReadError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _exchange(path, request, want=None):
|
||||||
|
"""
|
||||||
|
Send one request and collect replies until the pad goes quiet.
|
||||||
|
|
||||||
|
Reading is the one place we write to the pad without changing it: both
|
||||||
|
commands here are queries, byte for byte what the vendor software sends.
|
||||||
|
"""
|
||||||
|
import select
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
|
||||||
|
except OSError as e:
|
||||||
|
raise ReadError(_explain(e)) from e
|
||||||
|
|
||||||
|
replies = []
|
||||||
|
try:
|
||||||
|
while select.select([fd], [], [], 0)[0]: # drain anything stale
|
||||||
|
try:
|
||||||
|
os.read(fd, 128)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
os.write(fd, request)
|
||||||
|
except OSError as e:
|
||||||
|
raise ReadError(_explain(e)) from e
|
||||||
|
|
||||||
|
deadline = time.monotonic() + READ_TIMEOUT
|
||||||
|
last = time.monotonic()
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not select.select([fd], [], [], 0.05)[0]:
|
||||||
|
if replies and time.monotonic() - last > READ_QUIET:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = os.read(fd, 128)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
except OSError as e:
|
||||||
|
raise ReadError(_explain(e)) from e
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
last = time.monotonic()
|
||||||
|
replies.append(data)
|
||||||
|
if want is not None and len(replies) >= want:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def read_layer(device, layer=1):
|
||||||
|
"""{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)
|
||||||
|
if not decoded:
|
||||||
|
continue
|
||||||
|
control, got_layer, binding = decoded
|
||||||
|
if got_layer == layer:
|
||||||
|
out[control] = binding
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_into_state(device, state, layer=1):
|
||||||
|
"""Read the pad and make the shadow state match it. Returns what was read."""
|
||||||
|
bindings = read_layer(device, layer)
|
||||||
|
if not bindings:
|
||||||
|
raise ReadError("the pad didn't answer")
|
||||||
|
for control, binding in bindings.items():
|
||||||
|
state.record(control, binding, layer)
|
||||||
|
state.save()
|
||||||
|
return bindings
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- detection
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Detection:
|
||||||
|
"""What a read-only interrogation of an unrecognised pad turned up."""
|
||||||
|
speaks: bool = False # it answered our protocol properly
|
||||||
|
keys: int = 0
|
||||||
|
knobs: int = 0
|
||||||
|
layers: int = 0
|
||||||
|
bindings: dict = field(default_factory=dict)
|
||||||
|
why: str = "" # when speaks is False, what went wrong
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
if not self.speaks:
|
||||||
|
return None
|
||||||
|
cols = 3 if self.keys % 3 == 0 else (4 if self.keys % 4 == 0 else self.keys)
|
||||||
|
rows = -(-self.keys // cols)
|
||||||
|
return Layout(self.keys, self.knobs, rows, cols)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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}",
|
||||||
|
f"Name: {device.name or 'not reported'}",
|
||||||
|
f"Node: {device.path}, report id {device.report_id}", ""]
|
||||||
|
if not detected.speaks:
|
||||||
|
lines += ["This pad did NOT answer the 1189:8840 protocol.",
|
||||||
|
f"Reason: {detected.why}"]
|
||||||
|
return "\n".join(lines)
|
||||||
|
lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.",
|
||||||
|
f"It answered a layer read for every slot that implies.", "",
|
||||||
|
"Layer 1 as read from the pad:"]
|
||||||
|
for control, binding in sorted(detected.bindings.items(),
|
||||||
|
key=lambda kv: action_byte(kv[0])):
|
||||||
|
lines.append(f" 0x{action_byte(control):02x} {control:<12} {binding.label()}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Visual states:
|
|||||||
import math
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
||||||
QPainter, QPainterPath, QPen, QTransform)
|
QPainter, QPainterPath, QPen, QTransform)
|
||||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||||
@@ -33,11 +33,6 @@ R_OUT, R_IN, R_KNOB = 54.0, 40.0, 30.0
|
|||||||
ROW_H, ROW_GAP = 22.0, 12.0
|
ROW_H, ROW_GAP = 22.0, 12.0
|
||||||
DIAL_W = 160.0
|
DIAL_W = 160.0
|
||||||
|
|
||||||
# Which dial is dial 1 (action bytes 0x10-0x12). Upright, dials on top:
|
|
||||||
# assumed left. Flat (the vendor app's view): assumed top.
|
|
||||||
DIALS_IN_ORDER = ("dial1", "dial2")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Look:
|
class Look:
|
||||||
text: str = "" # what's on the pad, or will be once written
|
text: str = "" # what's on the pad, or will be once written
|
||||||
@@ -46,22 +41,28 @@ class Look:
|
|||||||
quiet: bool = False # known, but does nothing: legend drawn dimmed
|
quiet: bool = False # known, but does nothing: legend drawn dimmed
|
||||||
|
|
||||||
|
|
||||||
def _key_grid(orientation):
|
def _key_grid(layout, orientation):
|
||||||
"""[(key_id, row, col)] with row 0 at the top."""
|
"""
|
||||||
out = []
|
[(key_id, row, col)] as drawn, with row 0 at the top.
|
||||||
if orientation == "upright": # 4 rows x 3 cols, dials above
|
|
||||||
for r in range(4):
|
The layout describes the pad lying flat. Upright is the same pad turned
|
||||||
for c in range(3):
|
a quarter turn anticlockwise, so a key at (r, c) in an R by C grid ends
|
||||||
out.append((f"key{(3 - r) + 1 + c * 4}", r, c))
|
up at (C - 1 - c, r) in a C by R one.
|
||||||
else: # 3 rows x 4 cols, dials right
|
"""
|
||||||
for r in range(3):
|
flat = layout.spots()
|
||||||
for c in range(4):
|
if orientation != "upright":
|
||||||
out.append((f"key{r * 4 + c + 1}", r, c))
|
return flat
|
||||||
return out
|
return [(k, layout.cols - 1 - c, r) for k, r, c in flat]
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_size(layout, orientation):
|
||||||
|
return ((layout.cols, layout.rows) if orientation == "upright"
|
||||||
|
else (layout.rows, layout.cols))
|
||||||
|
|
||||||
|
|
||||||
class PadView(QWidget):
|
class PadView(QWidget):
|
||||||
selected = Signal(str)
|
selected = Signal(str)
|
||||||
|
rearranged = Signal(object)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -69,9 +70,19 @@ class PadView(QWidget):
|
|||||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
self.setMinimumSize(360, 420)
|
self.setMinimumSize(360, 420)
|
||||||
self.orientation = "upright"
|
self.orientation = "upright"
|
||||||
self.looks = {c: Look() for c in core.CONTROL_IDS}
|
self.layout = core.DEFAULT_LAYOUT
|
||||||
|
self.device_id = core.DEVICE_ID
|
||||||
|
self.looks = {c: Look() for c in self.layout.control_ids}
|
||||||
self.current = None
|
self.current = None
|
||||||
self.hover = None
|
self.hover = None
|
||||||
|
self.editing = False
|
||||||
|
self.drag = None # key being moved
|
||||||
|
self.drag_at = None # where the pointer is, in board units
|
||||||
|
self.drop = None # (row, col) it would land in
|
||||||
|
self._wobble = 0
|
||||||
|
self._wobbler = QTimer(self)
|
||||||
|
self._wobbler.setInterval(130)
|
||||||
|
self._wobbler.timeout.connect(self._tick)
|
||||||
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
self._layout()
|
self._layout()
|
||||||
|
|
||||||
@@ -82,6 +93,14 @@ class PadView(QWidget):
|
|||||||
self._layout()
|
self._layout()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def set_layout(self, layout):
|
||||||
|
"""Redraw for a pad of a different shape."""
|
||||||
|
self.layout = layout
|
||||||
|
if self.current not in layout.control_ids:
|
||||||
|
self.current = None
|
||||||
|
self._layout()
|
||||||
|
self.update()
|
||||||
|
|
||||||
def set_looks(self, looks):
|
def set_looks(self, looks):
|
||||||
self.looks = looks
|
self.looks = looks
|
||||||
self.update()
|
self.update()
|
||||||
@@ -90,6 +109,21 @@ class PadView(QWidget):
|
|||||||
self.current = control
|
self.current = control
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def set_editing(self, on):
|
||||||
|
"""
|
||||||
|
Edit mode: keys can be dragged between cells. They wobble, which is
|
||||||
|
the clearest way anyone has found to say "these can be moved".
|
||||||
|
"""
|
||||||
|
self.editing = on
|
||||||
|
self.drag = self.drop = self.drag_at = None
|
||||||
|
self._wobbler.start() if on else self._wobbler.stop()
|
||||||
|
self.setCursor(Qt.ArrowCursor)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
self._wobble = 1 - self._wobble
|
||||||
|
self.update()
|
||||||
|
|
||||||
# ------------------------------------------------------------ layout
|
# ------------------------------------------------------------ layout
|
||||||
|
|
||||||
def _layout(self):
|
def _layout(self):
|
||||||
@@ -97,23 +131,35 @@ class PadView(QWidget):
|
|||||||
self.dials = {} # dial -> centre QPointF
|
self.dials = {} # dial -> centre QPointF
|
||||||
self.rows = {} # dial action -> legend row QRectF
|
self.rows = {} # dial action -> legend row QRectF
|
||||||
|
|
||||||
|
lay = self.layout
|
||||||
|
grid_rows, grid_cols = _grid_size(lay, self.orientation)
|
||||||
|
# from a knob's centre down to the bottom of its legend rows
|
||||||
|
stack = R_OUT + ROW_GAP + len(core.DIAL_ACTIONS) * ROW_H
|
||||||
|
|
||||||
if self.orientation == "upright":
|
if self.orientation == "upright":
|
||||||
dial_centres = [QPointF(M + DIAL_W / 2, M + R_OUT),
|
# knobs in a row above the keys
|
||||||
QPointF(M + DIAL_W * 1.5, M + R_OUT)]
|
span = max(grid_cols * (K + G) - G, lay.knobs * (DIAL_W + G))
|
||||||
grid_top = M + 2 * R_OUT + ROW_GAP + 3 * ROW_H + 28
|
step = span / max(lay.knobs, 1)
|
||||||
|
dial_centres = [QPointF(M + step * (i + 0.5), M + R_OUT)
|
||||||
|
for i in range(lay.knobs)]
|
||||||
|
grid_top = M + (R_OUT + stack + 28 if lay.knobs else 0)
|
||||||
grid_left = M
|
grid_left = M
|
||||||
else:
|
else:
|
||||||
|
# knobs in a column down the right
|
||||||
grid_top, grid_left = M, M
|
grid_top, grid_left = M, M
|
||||||
x = M + 4 * K + 3 * G + 30 + DIAL_W / 2
|
x = M + grid_cols * (K + G) + 30 + DIAL_W / 2
|
||||||
first = M + R_OUT
|
dial_centres = [QPointF(x, M + R_OUT + i * (stack + 20 + R_OUT))
|
||||||
second = first + R_OUT + ROW_GAP + 3 * ROW_H + 20 + R_OUT
|
for i in range(lay.knobs)]
|
||||||
dial_centres = [QPointF(x, first), QPointF(x, second)]
|
|
||||||
|
|
||||||
for key, r, c in _key_grid(self.orientation):
|
self.cells = {}
|
||||||
self.keys[key] = QRectF(grid_left + c * (K + G),
|
for r in range(grid_rows):
|
||||||
grid_top + r * (K + G), K, K)
|
for c in range(grid_cols):
|
||||||
|
self.cells[(r, c)] = QRectF(grid_left + c * (K + G),
|
||||||
|
grid_top + r * (K + G), K, K)
|
||||||
|
for key, r, c in _key_grid(lay, self.orientation):
|
||||||
|
self.keys[key] = self.cells[(r, c)]
|
||||||
|
|
||||||
for dial, centre in zip(DIALS_IN_ORDER, dial_centres):
|
for dial, centre in zip(lay.dial_ids, dial_centres):
|
||||||
self.dials[dial] = centre
|
self.dials[dial] = centre
|
||||||
top = centre.y() + R_OUT + ROW_GAP
|
top = centre.y() + R_OUT + ROW_GAP
|
||||||
for i, act in enumerate(core.DIAL_ACTIONS):
|
for i, act in enumerate(core.DIAL_ACTIONS):
|
||||||
@@ -121,10 +167,9 @@ class PadView(QWidget):
|
|||||||
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H,
|
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H,
|
||||||
DIAL_W - 12, ROW_H)
|
DIAL_W - 12, ROW_H)
|
||||||
|
|
||||||
right = max([r.right() for r in self.keys.values()] +
|
boxes = list(self.cells.values()) + list(self.rows.values())
|
||||||
[r.right() for r in self.rows.values()])
|
right = max(r.right() for r in boxes)
|
||||||
bottom = max([r.bottom() for r in self.keys.values()] +
|
bottom = max(r.bottom() for r in boxes)
|
||||||
[r.bottom() for r in self.rows.values()])
|
|
||||||
self.board = QRectF(0, 0, right + M, bottom + 46)
|
self.board = QRectF(0, 0, right + M, bottom + 46)
|
||||||
|
|
||||||
def _transform(self):
|
def _transform(self):
|
||||||
@@ -172,7 +217,27 @@ class PadView(QWidget):
|
|||||||
return f"{dial}-{act}"
|
return f"{dial}-{act}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _board_pos(self, pos):
|
||||||
|
inv, ok = self._transform().inverted()
|
||||||
|
return inv.map(QPointF(pos)) if ok else None
|
||||||
|
|
||||||
|
def _cell_at(self, point):
|
||||||
|
for spot, rect in self.cells.items():
|
||||||
|
if rect.adjusted(-G / 2, -G / 2, G / 2, G / 2).contains(point):
|
||||||
|
return spot
|
||||||
|
return None
|
||||||
|
|
||||||
def mouseMoveEvent(self, e):
|
def mouseMoveEvent(self, e):
|
||||||
|
if self.editing:
|
||||||
|
if self.drag:
|
||||||
|
self.drag_at = self._board_pos(e.position())
|
||||||
|
self.drop = self._cell_at(self.drag_at) if self.drag_at else None
|
||||||
|
self.update()
|
||||||
|
else:
|
||||||
|
over = self._hit(e.position())
|
||||||
|
key = over if over and over.startswith("key") else None
|
||||||
|
self.setCursor(Qt.OpenHandCursor if key else Qt.ArrowCursor)
|
||||||
|
return
|
||||||
h = self._hit(e.position())
|
h = self._hit(e.position())
|
||||||
if h != self.hover:
|
if h != self.hover:
|
||||||
self.hover = h
|
self.hover = h
|
||||||
@@ -184,12 +249,52 @@ class PadView(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def mousePressEvent(self, e):
|
def mousePressEvent(self, e):
|
||||||
|
if e.button() != Qt.LeftButton:
|
||||||
|
return
|
||||||
h = self._hit(e.position())
|
h = self._hit(e.position())
|
||||||
if h and e.button() == Qt.LeftButton:
|
if self.editing:
|
||||||
|
if h and h.startswith("key"):
|
||||||
|
self.drag = h
|
||||||
|
self.drag_at = self._board_pos(e.position())
|
||||||
|
self.setCursor(Qt.ClosedHandCursor)
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
|
if h:
|
||||||
self.current = h
|
self.current = h
|
||||||
self.selected.emit(h)
|
self.selected.emit(h)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
if not (self.editing and self.drag):
|
||||||
|
return
|
||||||
|
moved, target = self.drag, self.drop
|
||||||
|
self.drag = self.drag_at = self.drop = None
|
||||||
|
self.setCursor(Qt.OpenHandCursor)
|
||||||
|
if target:
|
||||||
|
self._place(moved, target)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _place(self, key, target):
|
||||||
|
"""
|
||||||
|
Drop a key into a cell. If another key is already there the two
|
||||||
|
swap, so a drag never loses one.
|
||||||
|
"""
|
||||||
|
spots = {k: (r, c) for k, r, c in _key_grid(self.layout, self.orientation)}
|
||||||
|
if spots.get(key) == target:
|
||||||
|
return
|
||||||
|
for other, spot in spots.items():
|
||||||
|
if spot == target:
|
||||||
|
spots[other] = spots[key]
|
||||||
|
break
|
||||||
|
spots[key] = target
|
||||||
|
|
||||||
|
if self.orientation == "upright": # back to the flat arrangement
|
||||||
|
spots = {k: (c, self.layout.cols - 1 - r) for k, (r, c) in spots.items()}
|
||||||
|
places = [spots[f"key{i + 1}"] for i in range(self.layout.keys)]
|
||||||
|
self.layout = self.layout.rearranged(places)
|
||||||
|
self._layout()
|
||||||
|
self.rearranged.emit(self.layout)
|
||||||
|
|
||||||
# ------------------------------------------------------------ paint
|
# ------------------------------------------------------------ paint
|
||||||
|
|
||||||
def paintEvent(self, _):
|
def paintEvent(self, _):
|
||||||
@@ -204,17 +309,54 @@ class PadView(QWidget):
|
|||||||
p.setPen(QPen(INK_DIM, 1.2))
|
p.setPen(QPen(INK_DIM, 1.2))
|
||||||
p.drawPath(board)
|
p.drawPath(board)
|
||||||
|
|
||||||
|
if self.editing:
|
||||||
|
for spot, rect in self.cells.items():
|
||||||
|
if spot == self.drop or not any(r is rect for r in self.keys.values()):
|
||||||
|
path = QPainterPath()
|
||||||
|
path.addRoundedRect(rect, 5, 5)
|
||||||
|
pen = QPen(TAPE if spot == self.drop else HATCH, 1.4)
|
||||||
|
pen.setStyle(Qt.DashLine)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.setBrush(Qt.NoBrush)
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
for key, rect in self.keys.items():
|
for key, rect in self.keys.items():
|
||||||
|
if key == self.drag:
|
||||||
|
continue
|
||||||
|
p.save()
|
||||||
|
if self.editing:
|
||||||
|
# a small alternating tilt, the way icons shake when movable
|
||||||
|
lean = 0.55 if (hash(key) + self._wobble) % 2 else -0.55
|
||||||
|
p.translate(rect.center())
|
||||||
|
p.rotate(lean)
|
||||||
|
p.translate(-rect.center())
|
||||||
self._paint_key(p, key, rect)
|
self._paint_key(p, key, rect)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
for dial, centre in self.dials.items():
|
for dial, centre in self.dials.items():
|
||||||
self._paint_dial(p, dial, centre)
|
self._paint_dial(p, dial, centre)
|
||||||
|
|
||||||
|
if self.drag and self.drag_at:
|
||||||
|
rect = QRectF(0, 0, K, K)
|
||||||
|
rect.moveCenter(self.drag_at)
|
||||||
|
p.save()
|
||||||
|
p.setOpacity(0.92)
|
||||||
|
shadow = QPainterPath()
|
||||||
|
shadow.addRoundedRect(rect.adjusted(2, 4, 2, 4), 5, 5)
|
||||||
|
p.fillPath(shadow, QColor(0, 0, 0, 90))
|
||||||
|
p.fillPath(shadow, QColor(0, 0, 0, 60))
|
||||||
|
fill = QPainterPath()
|
||||||
|
fill.addRoundedRect(rect, 5, 5)
|
||||||
|
p.fillPath(fill, BOARD)
|
||||||
|
self._paint_key(p, self.drag, rect)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
f = QFont(self.mono)
|
f = QFont(self.mono)
|
||||||
f.setPixelSize(10)
|
f.setPixelSize(10)
|
||||||
p.setFont(f)
|
p.setFont(f)
|
||||||
p.setPen(INK_DIM)
|
p.setPen(INK_DIM)
|
||||||
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
|
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
|
||||||
Qt.AlignLeft | Qt.AlignVCenter, core.DEVICE_ID)
|
Qt.AlignLeft | Qt.AlignVCenter, self.device_id)
|
||||||
|
|
||||||
def _outline_pen(self, control, known):
|
def _outline_pen(self, control, known):
|
||||||
if control == self.current:
|
if control == self.current:
|
||||||
|
|||||||
160
tests/captures/1189-8840-read.txt
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# capture of Bus 001 Device 005: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
|
||||||
|
# the vendor software's "view settings" button, reading all three layers.
|
||||||
|
# Trailing bytes of the two outgoing requests are uninitialised stack memory
|
||||||
|
# from the Windows program, not part of the protocol.
|
||||||
|
# bus 1 device 5
|
||||||
|
|
||||||
|
[1] interrupt out ep=4
|
||||||
|
03fbfbfb3200488f2f0218c82102b25676003cd03200b2834c005002f201488f
|
||||||
|
[2] interrupt in ep=4
|
||||||
|
03fb0c0200000000000000000000000000000000000000000000000000000000
|
||||||
|
[3] interrupt out ep=4
|
||||||
|
03fa0f03010230cc3200f0cc32007cf20269000000004d004402f0cc32005d01
|
||||||
|
[4] interrupt in ep=4
|
||||||
|
03fa010101000000000000000000000000000000000000000000000000000000
|
||||||
|
[5] interrupt in ep=4
|
||||||
|
03fa020101000000000001051700000000000000000000000000000000000000
|
||||||
|
[6] interrupt in ep=4
|
||||||
|
03fa030102000000000001b60000000000000000000000000000000000000000
|
||||||
|
[7] interrupt in ep=4
|
||||||
|
03fa040101000000000000000000000000000000000000000000000000000000
|
||||||
|
[8] interrupt in ep=4
|
||||||
|
03fa050101000000000000000000000000000000000000000000000000000000
|
||||||
|
[9] interrupt in ep=4
|
||||||
|
03fa0601010000000000010f4500000000000000000000000000000000000000
|
||||||
|
[10] interrupt in ep=4
|
||||||
|
03fa070102000000000001cd0000000000000000000000000000000000000000
|
||||||
|
[11] interrupt in ep=4
|
||||||
|
03fa080101000000000000000000000000000000000000000000000000000000
|
||||||
|
[12] interrupt in ep=4
|
||||||
|
03fa090101000000000000000000000000000000000000000000000000000000
|
||||||
|
[13] interrupt in ep=4
|
||||||
|
03fa0a0101000000000000000000000000000000000000000000000000000000
|
||||||
|
[14] interrupt in ep=4
|
||||||
|
03fa0b0102000000000001b50000000000000000000000000000000000000000
|
||||||
|
[15] interrupt in ep=4
|
||||||
|
03fa0c0101000000000000000000000000000000000000000000000000000000
|
||||||
|
[16] interrupt in ep=4
|
||||||
|
03fa0d01000000000000020059005b0000000000000000000000000000000000
|
||||||
|
[17] interrupt in ep=4
|
||||||
|
03fa0e01000000000000020059005c0000000000000000000000000000000000
|
||||||
|
[18] interrupt in ep=4
|
||||||
|
03fa0f01010000000000020059005d0000000000000000000000000000000000
|
||||||
|
[19] interrupt in ep=4
|
||||||
|
03fa100102000000000001ea0000000000000000000000000000000000000000
|
||||||
|
[20] interrupt in ep=4
|
||||||
|
03fa110102000000000001e20000000000000000000000000000000000000000
|
||||||
|
[21] interrupt in ep=4
|
||||||
|
03fa120102000000000001e90000000000000000000000000000000000000000
|
||||||
|
[22] interrupt in ep=4
|
||||||
|
03fa130102000000000001700000000000000000000000000000000000000000
|
||||||
|
[23] interrupt in ep=4
|
||||||
|
03fa140102000000000001920100000000000000000000000000000000000000
|
||||||
|
[24] interrupt in ep=4
|
||||||
|
03fa1501020000000000016f0000000000000000000000000000000000000000
|
||||||
|
[25] interrupt in ep=4
|
||||||
|
03fa180101000000000001002400000000000000000000000000000000000000
|
||||||
|
[26] interrupt in ep=4
|
||||||
|
03fa170101000000000001005200000000000000000000000000000000000000
|
||||||
|
[27] interrupt in ep=4
|
||||||
|
03fa160101000000000001002600000000000000000000000000000000000000
|
||||||
|
[28] interrupt out ep=4
|
||||||
|
03fa0f03020230cc3200f0cc32007cf20269000000004d004402f0cc32005d01
|
||||||
|
[29] interrupt in ep=4
|
||||||
|
03fa010201000000000001000400000000000000000000000000000000000000
|
||||||
|
[30] interrupt in ep=4
|
||||||
|
03fa020201000000000001000500000000000000000000000000000000000000
|
||||||
|
[31] interrupt in ep=4
|
||||||
|
03fa030201000000000001000600000000000000000000000000000000000000
|
||||||
|
[32] interrupt in ep=4
|
||||||
|
03fa040201000000000001000700000000000000000000000000000000000000
|
||||||
|
[33] interrupt in ep=4
|
||||||
|
03fa050201000000000001000800000000000000000000000000000000000000
|
||||||
|
[34] interrupt in ep=4
|
||||||
|
03fa060201000000000001000900000000000000000000000000000000000000
|
||||||
|
[35] interrupt in ep=4
|
||||||
|
03fa070201000000000001000a00000000000000000000000000000000000000
|
||||||
|
[36] interrupt in ep=4
|
||||||
|
03fa080201000000000001000b00000000000000000000000000000000000000
|
||||||
|
[37] interrupt in ep=4
|
||||||
|
03fa090201000000000001000c00000000000000000000000000000000000000
|
||||||
|
[38] interrupt in ep=4
|
||||||
|
03fa0a0201000000000001000d00000000000000000000000000000000000000
|
||||||
|
[39] interrupt in ep=4
|
||||||
|
03fa0b0201000000000001000e00000000000000000000000000000000000000
|
||||||
|
[40] interrupt in ep=4
|
||||||
|
03fa0c0201000000000001000f00000000000000000000000000000000000000
|
||||||
|
[41] interrupt in ep=4
|
||||||
|
03fa0d0201000000000001001000000000000000000000000000000000000000
|
||||||
|
[42] interrupt in ep=4
|
||||||
|
03fa0e0201000000000001001100000000000000000000000000000000000000
|
||||||
|
[43] interrupt in ep=4
|
||||||
|
03fa0f0201000000000001001200000000000000000000000000000000000000
|
||||||
|
[44] interrupt in ep=4
|
||||||
|
03fa120201000000000001001e00000000000000000000000000000000000000
|
||||||
|
[45] interrupt in ep=4
|
||||||
|
03fa110201000000000001001f00000000000000000000000000000000000000
|
||||||
|
[46] interrupt in ep=4
|
||||||
|
03fa100201000000000001002000000000000000000000000000000000000000
|
||||||
|
[47] interrupt in ep=4
|
||||||
|
03fa150201000000000001002100000000000000000000000000000000000000
|
||||||
|
[48] interrupt in ep=4
|
||||||
|
03fa140201000000000001002200000000000000000000000000000000000000
|
||||||
|
[49] interrupt in ep=4
|
||||||
|
03fa130201000000000001002300000000000000000000000000000000000000
|
||||||
|
[50] interrupt in ep=4
|
||||||
|
03fa180201000000000001002400000000000000000000000000000000000000
|
||||||
|
[51] interrupt in ep=4
|
||||||
|
03fa170201000000000001002500000000000000000000000000000000000000
|
||||||
|
[52] interrupt in ep=4
|
||||||
|
03fa160201000000000001002600000000000000000000000000000000000000
|
||||||
|
[53] interrupt out ep=4
|
||||||
|
03fa0f03030230cc3200f0cc32007cf20269000000004d004402f0cc32005d01
|
||||||
|
[54] interrupt in ep=4
|
||||||
|
03fa010301000000000001000400000000000000000000000000000000000000
|
||||||
|
[55] interrupt in ep=4
|
||||||
|
03fa020301000000000001000500000000000000000000000000000000000000
|
||||||
|
[56] interrupt in ep=4
|
||||||
|
03fa030301000000000001000600000000000000000000000000000000000000
|
||||||
|
[57] interrupt in ep=4
|
||||||
|
03fa040301000000000001000700000000000000000000000000000000000000
|
||||||
|
[58] interrupt in ep=4
|
||||||
|
03fa050301000000000001000800000000000000000000000000000000000000
|
||||||
|
[59] interrupt in ep=4
|
||||||
|
03fa060301000000000001000900000000000000000000000000000000000000
|
||||||
|
[60] interrupt in ep=4
|
||||||
|
03fa070301000000000001000a00000000000000000000000000000000000000
|
||||||
|
[61] interrupt in ep=4
|
||||||
|
03fa080301000000000001000b00000000000000000000000000000000000000
|
||||||
|
[62] interrupt in ep=4
|
||||||
|
03fa090301000000000001000c00000000000000000000000000000000000000
|
||||||
|
[63] interrupt in ep=4
|
||||||
|
03fa0a0301000000000001000d00000000000000000000000000000000000000
|
||||||
|
[64] interrupt in ep=4
|
||||||
|
03fa0b0301000000000001000e00000000000000000000000000000000000000
|
||||||
|
[65] interrupt in ep=4
|
||||||
|
03fa0c0301000000000001000f00000000000000000000000000000000000000
|
||||||
|
[66] interrupt in ep=4
|
||||||
|
03fa0d0301000000000001001000000000000000000000000000000000000000
|
||||||
|
[67] interrupt in ep=4
|
||||||
|
03fa0e0301000000000001001100000000000000000000000000000000000000
|
||||||
|
[68] interrupt in ep=4
|
||||||
|
03fa0f0301000000000001001200000000000000000000000000000000000000
|
||||||
|
[69] interrupt in ep=4
|
||||||
|
03fa100302000000000001700000000000000000000000000000000000000000
|
||||||
|
[70] interrupt in ep=4
|
||||||
|
03fa110301000000000001001f00000000000000000000000000000000000000
|
||||||
|
[71] interrupt in ep=4
|
||||||
|
03fa1203020000000000016f0000000000000000000000000000000000000000
|
||||||
|
[72] interrupt in ep=4
|
||||||
|
03fa150301000000000001002100000000000000000000000000000000000000
|
||||||
|
[73] interrupt in ep=4
|
||||||
|
03fa140301000000000001002200000000000000000000000000000000000000
|
||||||
|
[74] interrupt in ep=4
|
||||||
|
03fa130301000000000001002300000000000000000000000000000000000000
|
||||||
|
[75] interrupt in ep=4
|
||||||
|
03fa180301000000000001002400000000000000000000000000000000000000
|
||||||
|
[76] interrupt in ep=4
|
||||||
|
03fa170301000000000001002500000000000000000000000000000000000000
|
||||||
|
[77] interrupt in ep=4
|
||||||
|
03fa160301000000000001002600000000000000000000000000000000000000
|
||||||
@@ -77,6 +77,8 @@ class EveryCapture(unittest.TestCase):
|
|||||||
seen = 0
|
seen = 0
|
||||||
for path in sorted(CAPTURES.glob("*.txt")):
|
for path in sorted(CAPTURES.glob("*.txt")):
|
||||||
for report in core.read_capture(path):
|
for report in core.read_capture(path):
|
||||||
|
if report[1] in (proto.MAGIC_INFO, proto.MAGIC_READ):
|
||||||
|
continue # a query, not a binding
|
||||||
if core.is_commit(report):
|
if core.is_commit(report):
|
||||||
self.assertEqual(proto.native_commit(3)[:len(report)], report)
|
self.assertEqual(proto.native_commit(3)[:len(report)], report)
|
||||||
continue
|
continue
|
||||||
@@ -105,6 +107,22 @@ class EveryCapture(unittest.TestCase):
|
|||||||
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
|
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
|
||||||
self.assertTrue(state.get("dial2-left").unknown)
|
self.assertTrue(state.get("dial2-left").unknown)
|
||||||
|
|
||||||
|
def test_capture_directions_are_separate(self):
|
||||||
|
"""A reply from the pad must never be mistaken for a binding."""
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
cap = Path(d) / "both.txt"
|
||||||
|
cap.write_text(
|
||||||
|
"[1] interrupt out ep=4\n 03fd010101000000000001000400\n"
|
||||||
|
"[2] interrupt in ep=3\n 03fd020101000000000001001500\n"
|
||||||
|
"[3] interrupt out ep=4\n 03fdfeff00000000\n")
|
||||||
|
self.assertEqual(len(core.read_capture(cap)), 2)
|
||||||
|
self.assertEqual(len(core.read_capture(cap, incoming=True)), 1)
|
||||||
|
|
||||||
|
state = core.State(Path(d) / "s.json")
|
||||||
|
got = core.import_capture(cap, state)
|
||||||
|
self.assertEqual([(c, b.keys) for c, _, b in got], [("key1", "a")])
|
||||||
|
self.assertTrue(state.get("key2").unknown)
|
||||||
|
|
||||||
def test_uncommitted_configs_are_not_imported(self):
|
def test_uncommitted_configs_are_not_imported(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
cap = Path(d) / "c.txt"
|
cap = Path(d) / "c.txt"
|
||||||
@@ -115,6 +133,281 @@ class EveryCapture(unittest.TestCase):
|
|||||||
self.assertTrue(state.get("key1").unknown)
|
self.assertTrue(state.get("key1").unknown)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingThePad(unittest.TestCase):
|
||||||
|
"""The vendor software's "view settings", captured and decoded."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cap = CAPTURES / "1189-8840-read.txt"
|
||||||
|
self.sent = core.read_capture(self.cap)
|
||||||
|
self.got = core.read_capture(self.cap, incoming=True)
|
||||||
|
|
||||||
|
def test_our_requests_match_the_vendors(self):
|
||||||
|
info, read1, read2, read3 = self.sent
|
||||||
|
self.assertEqual(proto.native_info(3)[:2], info[:2])
|
||||||
|
for layer, sent in ((1, read1), (2, read2), (3, read3)):
|
||||||
|
# the vendor's trailing bytes are uninitialised memory; ours are zero
|
||||||
|
self.assertEqual(proto.native_read(3, layer)[:6], sent[:6])
|
||||||
|
|
||||||
|
def test_info_reply(self):
|
||||||
|
self.assertEqual(core.decode_info(self.got[0]), (12, 2))
|
||||||
|
self.assertEqual(len(core.KEYS), 12)
|
||||||
|
self.assertEqual(len(core.DIALS), 2)
|
||||||
|
|
||||||
|
def test_layer_1_is_what_the_pad_holds(self):
|
||||||
|
layer1 = {}
|
||||||
|
for r in self.got:
|
||||||
|
d = core.decode_config(r)
|
||||||
|
if d and d[1] == 1:
|
||||||
|
layer1[d[0]] = d[2]
|
||||||
|
# dials, which is the mapping we could never confirm by writing alone
|
||||||
|
self.assertEqual(layer1["dial1-left"], core.Binding(media="volumedown"))
|
||||||
|
self.assertEqual(layer1["dial1-push"], core.Binding(media="mute"))
|
||||||
|
self.assertEqual(layer1["dial1-right"], core.Binding(media="volumeup"))
|
||||||
|
self.assertEqual(layer1["dial2-left"], core.Binding(media="brightnessdown"))
|
||||||
|
self.assertEqual(layer1["dial2-push"], core.Binding(media="calculator"))
|
||||||
|
self.assertEqual(layer1["dial2-right"], core.Binding(media="brightnessup"))
|
||||||
|
# keys, including modifiers and an empty one
|
||||||
|
self.assertEqual(layer1["key2"], core.Binding(keys="ctrl+alt+t"))
|
||||||
|
self.assertEqual(layer1["key6"], core.Binding(keys="ctrl+shift+alt+super+f12"))
|
||||||
|
self.assertEqual(layer1["key3"], core.Binding(media="prev"))
|
||||||
|
self.assertTrue(layer1["key1"].none)
|
||||||
|
|
||||||
|
def test_a_read_binding_re_encodes_to_the_same_write(self):
|
||||||
|
"""Whatever we read, writing it back must produce the same meaning."""
|
||||||
|
for r in self.got:
|
||||||
|
d = core.decode_config(r)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
control, layer, binding = d
|
||||||
|
again = core.decode_config(
|
||||||
|
binding.reports(3, core.action_byte(control), layer)[0])
|
||||||
|
self.assertIsNotNone(again, binding)
|
||||||
|
self.assertEqual(again[0], control)
|
||||||
|
self.assertEqual(again[2], binding)
|
||||||
|
|
||||||
|
def test_the_pad_stores_two_key_sequences(self):
|
||||||
|
"""
|
||||||
|
Slots 0x0d to 0x0f hold two keystrokes each, in the layout we had
|
||||||
|
assumed for sequences: count at byte 10, then (modifier, keycode)
|
||||||
|
pairs. This is factory data for a 15 key sibling rather than
|
||||||
|
something we watched being written, so it supports the encoding
|
||||||
|
without proving the pad accepts it from us.
|
||||||
|
"""
|
||||||
|
multi = [r for r in self.got
|
||||||
|
if r[1] == proto.MAGIC_READ and r[10] == 2 and r[4] != 2]
|
||||||
|
self.assertTrue(multi)
|
||||||
|
r = multi[0]
|
||||||
|
self.assertEqual((r[11], r[13]), (0, 0)) # no modifiers
|
||||||
|
self.assertTrue(r[12] and r[14]) # two real keycodes
|
||||||
|
|
||||||
|
def test_replies_cover_the_firmware_not_the_hardware(self):
|
||||||
|
"""
|
||||||
|
The pad answers for 24 slots per layer: 15 keys and 3 knobs, which
|
||||||
|
is the most this firmware supports. Ours has 12 keys and 2 knobs,
|
||||||
|
so 6 of those slots are for hardware that isn't there. Those decode
|
||||||
|
to no control, which is what we want: the app must not offer them.
|
||||||
|
"""
|
||||||
|
configs = [r for r in self.got if r[1] == proto.MAGIC_READ]
|
||||||
|
self.assertEqual(len(configs), 24 * 3)
|
||||||
|
|
||||||
|
real = [r for r in configs if core.decode_config(r)]
|
||||||
|
self.assertEqual(len(real), len(core.CONTROL_IDS) * 3)
|
||||||
|
|
||||||
|
phantom = {r[2] for r in configs if not core.decode_config(r)}
|
||||||
|
self.assertEqual(phantom, {0x0d, 0x0e, 0x0f, # keys 13 to 15
|
||||||
|
0x16, 0x17, 0x18}) # a third knob
|
||||||
|
|
||||||
|
def test_reading_fills_in_the_state(self):
|
||||||
|
from unittest import mock
|
||||||
|
replies = [r for r in self.got if r[1] == proto.MAGIC_READ and r[3] == 1]
|
||||||
|
dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
state = core.State(Path(d) / "s.json")
|
||||||
|
with mock.patch.object(core, "_exchange", return_value=replies):
|
||||||
|
got = core.read_into_state(dev, state)
|
||||||
|
self.assertEqual(len(got), len(core.CONTROL_IDS))
|
||||||
|
fresh = core.State(Path(d) / "s.json")
|
||||||
|
self.assertFalse(any(fresh.get(c).unknown for c in core.CONTROL_IDS))
|
||||||
|
self.assertEqual(fresh.get("dial2-push").binding,
|
||||||
|
core.Binding(media="calculator"))
|
||||||
|
|
||||||
|
|
||||||
|
class Detection(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Detecting an unknown pad, using the real capture as the stand-in for a
|
||||||
|
pad that speaks this protocol, and mangled versions for ones that don't.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True,
|
||||||
|
name="Some Other Pad", vid="1189", pid="8890")
|
||||||
|
got = core.read_capture(CAPTURES / "1189-8840-read.txt", incoming=True)
|
||||||
|
self.info = [r for r in got if r[1] == proto.MAGIC_INFO]
|
||||||
|
self.layer1 = [r for r in got if r[1] == proto.MAGIC_READ and r[3] == 1]
|
||||||
|
|
||||||
|
def run_detect(self, info=None, layer=None):
|
||||||
|
from unittest import mock
|
||||||
|
calls = [info if info is not None else self.info,
|
||||||
|
layer if layer is not None else self.layer1]
|
||||||
|
with mock.patch.object(core, "_exchange", side_effect=calls):
|
||||||
|
return core.detect(self.dev)
|
||||||
|
|
||||||
|
def test_a_pad_that_speaks_is_recognised(self):
|
||||||
|
d = self.run_detect()
|
||||||
|
self.assertTrue(d.speaks, d.why)
|
||||||
|
self.assertEqual((d.keys, d.knobs), (12, 2))
|
||||||
|
self.assertEqual(d.layout, core.Layout(12, 2, 4, 3))
|
||||||
|
self.assertEqual(d.bindings["dial1-left"], core.Binding(media="volumedown"))
|
||||||
|
|
||||||
|
def test_silence_is_not_taken_as_yes(self):
|
||||||
|
d = self.run_detect(info=[])
|
||||||
|
self.assertFalse(d.speaks)
|
||||||
|
self.assertIn("didn't answer", d.why)
|
||||||
|
|
||||||
|
def test_a_reply_that_is_not_ours_is_rejected(self):
|
||||||
|
d = self.run_detect(info=[bytes.fromhex("03aa5501") + bytes(60)])
|
||||||
|
self.assertFalse(d.speaks)
|
||||||
|
|
||||||
|
def test_nonsense_counts_are_rejected(self):
|
||||||
|
bad = bytearray(self.info[0])
|
||||||
|
bad[2] = 99 # 99 keys
|
||||||
|
d = self.run_detect(info=[bytes(bad)])
|
||||||
|
self.assertFalse(d.speaks)
|
||||||
|
self.assertIn("99 keys", d.why)
|
||||||
|
|
||||||
|
def test_a_pad_that_answers_one_query_but_not_the_other(self):
|
||||||
|
"""The dangerous case: sounds right, then doesn't describe itself."""
|
||||||
|
d = self.run_detect(layer=self.layer1[:4])
|
||||||
|
self.assertFalse(d.speaks)
|
||||||
|
self.assertIn("didn't report", d.why)
|
||||||
|
|
||||||
|
def test_a_six_key_pad(self):
|
||||||
|
info = bytearray(self.info[0])
|
||||||
|
info[2], info[3] = 6, 1
|
||||||
|
keep = {1, 2, 3, 4, 5, 6, 0x10, 0x11, 0x12}
|
||||||
|
layer = [r for r in self.layer1 if r[2] in keep]
|
||||||
|
d = self.run_detect(info=[bytes(info)], layer=layer)
|
||||||
|
self.assertTrue(d.speaks, d.why)
|
||||||
|
self.assertEqual(d.layout, core.Layout(6, 1, 2, 3))
|
||||||
|
self.assertEqual(len(d.layout.control_ids), 9)
|
||||||
|
|
||||||
|
def test_report_is_pasteable(self):
|
||||||
|
text = core.detection_report(self.dev, self.run_detect())
|
||||||
|
self.assertIn("1189:8890", text)
|
||||||
|
self.assertIn("0x10 dial1-left", text)
|
||||||
|
refused = core.detection_report(self.dev, self.run_detect(info=[]))
|
||||||
|
self.assertIn("did NOT answer", refused)
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutIsRemembered(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_state_keeps_the_pad_shape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d) / "s.json"
|
||||||
|
lay = core.Layout(9, 2, 3, 3, "nine")
|
||||||
|
st = core.State(path, layout=lay)
|
||||||
|
st.record("key9", core.Binding(keys="f5"))
|
||||||
|
st.save()
|
||||||
|
again = core.State(path)
|
||||||
|
self.assertEqual(again.layout, lay)
|
||||||
|
self.assertEqual(len(again.layout.control_ids), 15)
|
||||||
|
self.assertEqual(again.get("key9").binding, core.Binding(keys="f5"))
|
||||||
|
|
||||||
|
def test_an_old_state_file_still_loads(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d) / "s.json"
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"format": core.FORMAT, "device": core.DEVICE_ID,
|
||||||
|
"layers": {"1": {"key1": {"binding": {"keys": "a"},
|
||||||
|
"written": "2026-01-01T00:00:00"}}}}))
|
||||||
|
st = core.State(path)
|
||||||
|
self.assertEqual(st.layout, core.DEFAULT_LAYOUT)
|
||||||
|
self.assertEqual(st.get("key1").binding, core.Binding(keys="a"))
|
||||||
|
|
||||||
|
|
||||||
|
class Arranging(unittest.TestCase):
|
||||||
|
"""Moving keys around the grid, and keeping it consistent when rotated."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lay = core.DEFAULT_LAYOUT
|
||||||
|
|
||||||
|
def test_the_default_matches_the_printed_pad(self):
|
||||||
|
flat = {k: (r, c) for k, r, c in self.lay.spots()}
|
||||||
|
self.assertEqual(flat["key1"], (0, 0)) # top left, reading across
|
||||||
|
self.assertEqual(flat["key4"], (0, 3))
|
||||||
|
self.assertEqual(flat["key12"], (2, 3))
|
||||||
|
|
||||||
|
def test_upright_is_the_same_pad_turned(self):
|
||||||
|
from macropad_gui import padview
|
||||||
|
up = {k: (r, c) for k, r, c in padview._key_grid(self.lay, "upright")}
|
||||||
|
self.assertEqual(up["key1"], (3, 0)) # bottom left when stood up
|
||||||
|
self.assertEqual(up["key4"], (0, 0))
|
||||||
|
self.assertEqual(up["key12"], (0, 2))
|
||||||
|
self.assertEqual(len(set(up.values())), self.lay.keys)
|
||||||
|
|
||||||
|
def test_swapping_two_keys(self):
|
||||||
|
places = list(self.lay.spots())
|
||||||
|
moved = self.lay.rearranged(
|
||||||
|
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
|
||||||
|
for k, r, c in places])
|
||||||
|
flat = {k: (r, c) for k, r, c in moved.spots()}
|
||||||
|
self.assertEqual(flat["key1"], (2, 3))
|
||||||
|
self.assertEqual(flat["key12"], (0, 0))
|
||||||
|
self.assertEqual(len(set(flat.values())), 12)
|
||||||
|
|
||||||
|
def test_resizing_keeps_what_still_fits(self):
|
||||||
|
moved = self.lay.rearranged(
|
||||||
|
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
|
||||||
|
for k, r, c in self.lay.spots()])
|
||||||
|
self.assertEqual(moved.resized(cols=6).cols, 6)
|
||||||
|
smaller = moved.resized(rows=2, cols=2, keys=4)
|
||||||
|
self.assertEqual(len(smaller.spots()), 4)
|
||||||
|
for _, r, c in smaller.spots():
|
||||||
|
self.assertLess(r, 2)
|
||||||
|
self.assertLess(c, 2)
|
||||||
|
|
||||||
|
def test_a_grid_too_small_grows_instead_of_failing(self):
|
||||||
|
grown = self.lay.resized(rows=1, cols=3)
|
||||||
|
self.assertEqual((grown.rows, grown.cols), (4, 3))
|
||||||
|
self.assertEqual(len(grown.spots()), 12)
|
||||||
|
|
||||||
|
def test_layout_survives_a_save(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d) / "s.json"
|
||||||
|
moved = self.lay.rearranged(
|
||||||
|
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
|
||||||
|
for k, r, c in self.lay.spots()])
|
||||||
|
st = core.State(path, layout=moved)
|
||||||
|
st.save()
|
||||||
|
self.assertEqual(core.State(path).layout, moved)
|
||||||
|
|
||||||
|
|
||||||
|
class Layouts(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_action_bytes_agree_with_the_protocol_module(self):
|
||||||
|
for c in core.DEFAULT_LAYOUT.control_ids:
|
||||||
|
self.assertEqual(core.action_byte(c), proto.CONTROLS[c], c)
|
||||||
|
|
||||||
|
def test_layouts_of_other_shapes(self):
|
||||||
|
three = core.Layout(3, 1, 1, 3)
|
||||||
|
self.assertEqual(three.control_ids,
|
||||||
|
("key1", "key2", "key3",
|
||||||
|
"dial1-left", "dial1-push", "dial1-right"))
|
||||||
|
self.assertEqual(core.action_byte("dial1-left"), 0x10)
|
||||||
|
fifteen = core.Layout(15, 3, 5, 3)
|
||||||
|
self.assertEqual(core.action_byte("dial3-right"), 0x18)
|
||||||
|
self.assertEqual(len(fifteen.control_ids), 24)
|
||||||
|
|
||||||
|
def test_impossible_layouts_are_refused(self):
|
||||||
|
for bad in ((0, 2, 4, 3), (99, 2, 40, 3), (12, 9, 4, 3), (12, 2, 2, 3)):
|
||||||
|
with self.assertRaises(ValueError, msg=bad):
|
||||||
|
core.Layout(*bad)
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
l = core.Layout(9, 2, 3, 3, "nine")
|
||||||
|
self.assertEqual(core.Layout.from_json(l.to_json()), l)
|
||||||
|
|
||||||
|
|
||||||
class BindingModel(unittest.TestCase):
|
class BindingModel(unittest.TestCase):
|
||||||
|
|
||||||
def test_round_trip(self):
|
def test_round_trip(self):
|
||||||
@@ -244,8 +537,9 @@ class PermissionDiagnosis(unittest.TestCase):
|
|||||||
|
|
||||||
def diag(self, rules, tags):
|
def diag(self, rules, tags):
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
dev = [{"path": "/dev/hidraw10", "pid": "8840", "report_id": 3,
|
dev = [{"path": "/dev/hidraw10", "vid": "1189", "pid": "8840",
|
||||||
"writable": False, "name": "USB Composite Device"}]
|
"report_id": 3, "writable": False,
|
||||||
|
"name": "USB Composite Device"}]
|
||||||
with mock.patch.object(proto, "find_devices", return_value=dev), \
|
with mock.patch.object(proto, "find_devices", return_value=dev), \
|
||||||
mock.patch.object(core, "_matching_rules", return_value=rules), \
|
mock.patch.object(core, "_matching_rules", return_value=rules), \
|
||||||
mock.patch.object(core, "_udev_tags", return_value=tags), \
|
mock.patch.object(core, "_udev_tags", return_value=tags), \
|
||||||
@@ -278,7 +572,9 @@ class PermissionDiagnosis(unittest.TestCase):
|
|||||||
d = core.diagnose()
|
d = core.diagnose()
|
||||||
self.assertEqual(d.status, "unsupported")
|
self.assertEqual(d.status, "unsupported")
|
||||||
self.assertIn("1189:8890", d.headline)
|
self.assertIn("1189:8890", d.headline)
|
||||||
self.assertIsNone(d.device)
|
# the device comes with it now, so the app can offer to interrogate it
|
||||||
|
self.assertEqual(d.device.path, "/dev/hidraw4")
|
||||||
|
self.assertEqual((d.device.vid, d.device.pid), ("1189", "8890"))
|
||||||
|
|
||||||
def test_rule_fine_but_not_local_session(self):
|
def test_rule_fine_but_not_local_session(self):
|
||||||
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
|
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
|
||||||
@@ -306,6 +602,30 @@ class GuiSmoke(unittest.TestCase):
|
|||||||
w.pad.select(c)
|
w.pad.select(c)
|
||||||
w._refresh()
|
w._refresh()
|
||||||
self.assertEqual(w.inspector.title.text(), core.control_name(c))
|
self.assertEqual(w.inspector.title.text(), core.control_name(c))
|
||||||
|
# the drawing must cope with any shape the protocol allows
|
||||||
|
for lay in (core.Layout(3, 1, 1, 3), core.Layout(15, 3, 5, 3),
|
||||||
|
core.Layout(4, 0, 1, 4)):
|
||||||
|
w.pad.set_layout(lay)
|
||||||
|
for orientation in ("upright", "flat"):
|
||||||
|
w.pad.set_orientation(orientation)
|
||||||
|
w.pad.grab()
|
||||||
|
for c in lay.control_ids:
|
||||||
|
self.assertIsNotNone(core.action_byte(c))
|
||||||
|
# dragging a key in edit mode swaps it with whatever is there
|
||||||
|
w.pad.set_layout(core.DEFAULT_LAYOUT)
|
||||||
|
w._edit_layout(True)
|
||||||
|
from macropad_gui import padview
|
||||||
|
before = {k: (r, c) for k, r, c
|
||||||
|
in padview._key_grid(w.pad.layout, w.pad.orientation)}
|
||||||
|
w.pad.drag = "key1"
|
||||||
|
w.pad._place("key1", before["key12"])
|
||||||
|
after = {k: (r, c) for k, r, c
|
||||||
|
in padview._key_grid(w.pad.layout, w.pad.orientation)}
|
||||||
|
self.assertEqual(after["key1"], before["key12"])
|
||||||
|
self.assertEqual(after["key12"], before["key1"])
|
||||||
|
w._edit_layout(False, keep=False)
|
||||||
|
self.assertEqual(w.state.layout, core.DEFAULT_LAYOUT)
|
||||||
|
self.assertFalse(w.pad.editing)
|
||||||
w._changed("key1", core.Binding(media="mute"))
|
w._changed("key1", core.Binding(media="mute"))
|
||||||
self.assertEqual(w._pending(), ["key1"])
|
self.assertEqual(w._pending(), ["key1"])
|
||||||
# choosing Nothing marks the control as changed straight away
|
# choosing Nothing marks the control as changed straight away
|
||||||
|
|||||||