Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	README.md
#	macropad_gui/app.py
This commit is contained in:
Oaken
2026-10-08 23:06:28 +01:00
33 changed files with 3332 additions and 234 deletions

58
docs/adding-a-pad.md Normal file
View File

@@ -0,0 +1,58 @@
# Adding a pad the app doesn't know yet
If MacroPad finds your pad but says it isn't supported, this is the whole
process. It takes one command and one issue.
## Start here: ask the pad
Install MacroPad, plug your pad in, and run:
```
macropad detect
```
On most pads in this family that's the whole job. It asks the pad how many keys and knobs it has, reads back what's currently bound, and prints a report. Paste that into [an issue](https://github.com/Probler-Yt/MacroPad/issues) and your pad gets added to the known list.
The app offers the same thing: when it sees a pad it doesn't recognise, the panel has an **Ask the pad what it is** button.
## How it decides
Detection is read only, and the pad has to pass two tests before the app will write anything to it:
1. It answers the `0xFB` query with a sensible key and knob count.
2. It then reports a full layer, describing exactly the control slots that count implies.
A pad from a different family fails one of those, and the app leaves it alone rather than guessing.
The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware.
## If it doesn't answer
Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine.
#### Capture what the vendor software sends
1. **See what your pad is:**
```
python3 ~/.local/share/macropad/app/macropad-probe.py
```
This only reads. It lists your pad's ID and its USB interfaces.
2. **Start the capture** (it watches USB traffic; it never sends anything):
```
cd ~/.local/share/macropad/app
sudo python3 macropad-capture.py
```
3. **In a second terminal**, open the vendor app with Wine:
```
wine MINI_KEYBOARD.exe
```
4. In the vendor app, set **key 1 to the letter `a`** and click its save/apply button. Then set **each knob's turn left, press and turn right** to different letters, and save again.
5. Go back to the first terminal and press **`Ctrl+C`**. You now have a file called `macropad-capture.txt`.
6. **[Open an issue](https://github.com/Probler-Yt/MacroPad/issues)** with that file, your `macropad-probe.py` output, and a photo of your pad. That's genuinely all it takes to get your pad supported.
---

209
docs/protocol.md Normal file
View File

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

0
install.sh Normal file → Executable file
View File

View File

@@ -42,6 +42,9 @@ MODIFIERS = {
"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "win": 0x08, "super": 0x08, "ctrl": 0x01, "shift": 0x02, "alt": 0x04, "win": 0x08, "super": 0x08,
"lctrl": 0x01, "lshift": 0x02, "lalt": 0x04, "lwin": 0x08, "lctrl": 0x01, "lshift": 0x02, "lalt": 0x04, "lwin": 0x08,
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rwin": 0x80, "rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rwin": 0x80,
# The GUI labels this key Meta, which is what it is called on Linux, so
# the CLI accepts that spelling too rather than only win and super.
"meta": 0x08, "lmeta": 0x08, "rmeta": 0x80,
} }
# Standard USB HID Keyboard/Keypad usage IDs. Verified against upstream: # Standard USB HID Keyboard/Keypad usage IDs. Verified against upstream:
@@ -347,8 +350,14 @@ def legacy_media_sequence(report_id, action, usage, layer=0):
KNOWN_VENDORS = ("1189",) KNOWN_VENDORS = ("1189",)
def find_devices(): def find_devices(vendors=None):
"""Find hidraw nodes that expose a vendor-defined 64-byte config interface.""" """
Find hidraw nodes that expose a vendor-defined 64-byte config interface.
vendors limits the scan to those USB vendor ids; the default is this
module's own list. Nothing about the wire format depends on it.
"""
vendors = {v.lower() for v in (vendors or KNOWN_VENDORS)}
out = [] out = []
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")): for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
dev = os.path.join(node, "device") dev = os.path.join(node, "device")
@@ -363,7 +372,7 @@ def find_devices():
continue continue
_, v, p = hid_id.split(":") _, v, p = hid_id.split(":")
vid, pid = v[-4:].lower(), p[-4:].lower() vid, pid = v[-4:].lower(), p[-4:].lower()
if vid not in KNOWN_VENDORS: if vid not in vendors:
continue continue
try: try:
@@ -403,8 +412,24 @@ def hexdump(b):
return "\n".join(lines) return "\n".join(lines)
def version():
"""
The app's version, or 'unknown' if the CLI was copied out on its own.
macropad.py is meant to work as a single file with nothing beside it, so
a missing GUI package is not an error here.
"""
try:
from macropad_gui import __version__
return __version__
except ImportError:
return "unknown"
def main(): def main():
ap = argparse.ArgumentParser(description="Configure a Holtek-family macro pad.") ap = argparse.ArgumentParser(description="Configure a Holtek-family macro pad.")
ap.add_argument("--version", action="version",
version=f"macropad {version()}")
sub = ap.add_subparsers(dest="cmd") sub = ap.add_subparsers(dest="cmd")
sub.add_parser("list", help="show detected pads") sub.add_parser("list", help="show detected pads")

View File

@@ -1 +1,13 @@
"""Linux GUI for Holtek-family USB macro pads. GPL-3.0.""" """Linux GUI for Holtek-family USB macro pads. GPL-3.0."""
# The one place the version is written down. The CLI's --version, the app's
# About box and the bug report template all read it from here, so a release
# is one line to change and the tag can never disagree with what ships.
#
# Semver, and the rule used so far: major when the idea of how the app works
# changes, minor for new hardware or features, patch for fixes.
#
# 1.x write only, because these pads were assumed to be unreadable
# 2.0.0 they can be read, plus detection, actions and themes
# 2.1.0 a second protocol family (SayoDevice), 15 key pads, theme styles
__version__ = "2.1.0"

View File

@@ -30,16 +30,19 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
try:
import fcntl
except ImportError:
# Windows has none. Everything that needs it is Linux only anyway, and
# each use checks. Importing this module must not fail, because app.py
# imports it to build the window.
fcntl = None
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from . import core from . import core
try:
import fcntl
except ImportError: # Windows: no flock, no uinput ioctl
fcntl = None
proto = core.proto proto = core.proto
TRIGGERS = tuple(f"f{i}" for i in range(13, 25)) TRIGGERS = tuple(f"f{i}" for i in range(13, 25))
@@ -272,9 +275,16 @@ class VirtualKeyboard:
def __init__(self, path=UINPUT, _open=os.open, _ioctl=None, def __init__(self, path=UINPUT, _open=os.open, _ioctl=None,
_write=os.write, _sleep=time.sleep): _write=os.write, _sleep=time.sleep):
import struct import struct
# Resolved here, not in the signature: a default argument is
# evaluated when the module is imported, which on Windows would
# crash before the window ever got a chance to open.
if _ioctl is None:
if fcntl is None:
raise OSError("a virtual keyboard needs /dev/uinput, which "
"this system hasn't got")
_ioctl = fcntl.ioctl
self._struct, self._write, self._sleep = struct, _write, _sleep self._struct, self._write, self._sleep = struct, _write, _sleep
self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK) self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK)
_ioctl = _ioctl or fcntl.ioctl
_ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY) _ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY)
for code in _KEY.values(): for code in _KEY.values():
_ioctl(self.fd, _UI_SET_KEYBIT, code) _ioctl(self.fd, _UI_SET_KEYBIT, code)
@@ -573,11 +583,12 @@ def listen(debug=False, log=print):
Run until stopped. Survives the pad being unplugged, and picks up edits Run until stopped. Survives the pad being unplugged, and picks up edits
to actions.json without a restart. to actions.json without a restart.
""" """
if fcntl is None:
log("The actions listener needs /dev/hidraw and uinput, which only exist on Linux.")
return 1
lock_path = runtime_dir() / LOCK lock_path = runtime_dir() / LOCK
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
if fcntl is None:
log("Background actions are Linux only for now: they need /dev/uinput "
"to type, and a file lock to keep one copy running.")
return 1
lock = open(lock_path, "a+") lock = open(lock_path, "a+")
try: try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)

View File

@@ -13,12 +13,13 @@ from PySide6.QtGui import (QActionGroup, QColor, QFont, QFontDatabase,
QGuiApplication, QIcon, QGuiApplication, QIcon,
QPalette) QPalette)
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame, from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
QScrollArea, QStyle,
QGridLayout, QHBoxLayout, QLabel, QLineEdit, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit, QMainWindow, QMessageBox, QPlainTextEdit,
QPushButton, QSpinBox, QVBoxLayout, QWidget, QPushButton, QSpinBox, QVBoxLayout, QWidget,
QCheckBox, QComboBox, QMenu) QCheckBox, QComboBox, QMenu)
from . import actions as act, core, keymap, padview, themes from . import __version__, actions as act, core, keymap, padview, themes
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
# Set by apply_theme; functions read these when they run, so they follow a # Set by apply_theme; functions read these when they run, so they follow a
@@ -31,29 +32,71 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
("brightnessdown", "brightnessup"), ("brightnessdown", "brightnessup"),
("calculator", "mail"), ("browser", "mycomputer")] ("calculator", "mail"), ("browser", "mycomputer")]
def _status(t):
"""The footer's device line and its last message."""
if t.mono_status:
return (f'QLabel[role="status"] {{ color: {t.ink}; '
f'font-family: "{t.mono_font}"; font-size: 12px; }}\n'
f'QLabel[role="message"] {{ font-family: "{t.mono_font}"; '
f'font-size: 12px; font-weight: 600; }}')
return f'QLabel[role="status"] {{ color: {t.ink_dim}; }}'
def _primary(t, tint):
"""The write button: filled by default, outlined where a theme says so."""
if t.outline_primary:
return f"""
QPushButton[role="primary"] {{ background: {t.board}; color: {t.accent};
border: 1px solid {t.accent}; border-radius: {t.button_radius}px;
font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: {tint(t.board, t.accent, 0.12)}; }}
QPushButton[role="primary"]:disabled {{ color: {tint(t.window, t.ink_dim, 0.7)};
border-color: {tint(t.window, t.line, 0.7)}; }}"""
return f"""
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
border: none; border-radius: {t.button_radius}px;
font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}"""
def _flat(t):
"""Footer buttons: bare text by default, outlined pills where asked."""
if t.pill_buttons:
return f"""
QPushButton[role="flat"] {{ background: {t.board}; color: {t.ink};
border: 1px solid {t.ink_dim}; border-radius: {t.button_radius}px;
padding: 6px 14px; }}
QPushButton[role="flat"]:hover {{ border-color: {t.ink}; }}
QPushButton[role="flat"]:disabled {{ color: {t.ink_dim}; border-color: {t.line}; }}"""
return f"""
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}"""
INSPECTOR_W = 400 # the side panel's width, without its scrollbar
def stylesheet(t): def stylesheet(t):
"""The whole window's look, from one theme.""" """The whole window's look, from one theme."""
tint = themes.mix tint = themes.mix
return f""" return f"""
QWidget {{ color: {t.ink}; background: {t.window}; }} QWidget {{ color: {t.ink}; background: {t.window}; }}
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }} QLabel[role="title"] {{ font-size: {t.title_pt}px; font-weight: 600; }}
{_status(t)}
QLabel[role="dim"] {{ color: {t.ink_dim}; }} QLabel[role="dim"] {{ color: {t.ink_dim}; }}
QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }} QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }}
QLabel[role="value"] {{ font-size: 15px; }} QLabel[role="value"] {{ font-size: 15px; }}
QPushButton {{ background: transparent; border: 1px solid {t.line}; QPushButton {{ background: transparent; border: 1px solid {t.line};
border-radius: 4px; padding: 6px 12px; }} border-radius: {t.button_radius}px; padding: 6px 12px; }}
QPushButton:hover {{ border-color: {t.ink_dim}; }} QPushButton:hover {{ border-color: {t.ink_dim}; }}
QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }} QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }}
QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }} QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }}
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text}; {_primary(t, tint)}
border: none; font-weight: 600; padding: 8px 16px; }} {_flat(t)}
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }} QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
QLineEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; QLineEdit {{ background: {t.field}; border: 1px solid {t.line};
padding: 7px 8px; }} border-radius: {t.button_radius}px; padding: 7px 8px; }}
QLineEdit:focus {{ border-color: {t.ink_dim}; }} QLineEdit:focus {{ border-color: {t.ink_dim}; }}
QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }} QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }}
QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }} QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }}
@@ -62,6 +105,8 @@ QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.b
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {t.ink_dim}; }} QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {t.ink_dim}; }}
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 {t.line}; }} QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }}
QScrollArea[role="panel"], QWidget[role="panel"] {{ background: {t.window};
border: none; }}
QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }} QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }}
QFrame[role="editbar"] QLabel {{ background: transparent; }} QFrame[role="editbar"] QLabel {{ background: transparent; }}
QSpinBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; QSpinBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
@@ -169,7 +214,7 @@ class Inspector(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setFixedWidth(400) self.setFixedWidth(INSPECTOR_W)
self.setFixedHeight(800) self.setFixedHeight(800)
self.control = None self.control = None
self._loading = False self._loading = False
@@ -769,8 +814,22 @@ class Window(QMainWindow):
self.pad.set_layout(self.state.layout) 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()
# The inspector can get taller than the window: a long banner, a full
# media list, a theme with bigger type. Left to itself it pushes the
# window's minimum height past the screen, and clicking the first key
# makes the window jump to full height. In a scroll area it keeps its
# own height to itself and everything stays reachable.
self.panel = QScrollArea()
self.panel.setWidget(self.inspector)
self.panel.setWidgetResizable(True)
self.panel.setFrameShape(QFrame.NoFrame)
self.panel.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.panel.setProperty("role", "panel")
self.panel.viewport().setProperty("role", "panel")
bar = self.style().pixelMetric(QStyle.PM_ScrollBarExtent)
self.panel.setFixedWidth(INSPECTOR_W + bar)
body.addWidget(self.pad, 1) body.addWidget(self.pad, 1)
body.addWidget(self.inspector) body.addWidget(self.panel)
outer.addLayout(body, 1) outer.addLayout(body, 1)
# editor bar, hidden unless you're rearranging the pad # editor bar, hidden unless you're rearranging the pad
@@ -813,26 +872,53 @@ class Window(QMainWindow):
fl.setContentsMargins(16, 8, 16, 8) fl.setContentsMargins(16, 8, 16, 8)
self.dot = QLabel() self.dot = QLabel()
self.dot.setFixedSize(9, 9) self.dot.setFixedSize(9, 9)
self.status = _label("", "dim") self.status = _label("", "status")
self.message = _label("") self.message = _label("", "message")
self.edit_btn = _button("Edit layout", "flat") self.edit_btn = _button("Edit layout", "flat")
self.edit_btn.setToolTip("Rearrange the drawing to match your pad") self.edit_btn.setToolTip("Rearrange the drawing to match your pad")
self.read_btn = _button("Read pad", "flat") self.read_btn = _button("Read pad", "flat")
self.read_btn.setToolTip("Ask the pad what's on it right now") self.read_btn.setToolTip("Ask the pad what's on it right now")
self.theme_btn = _button("Theme", "flat") self.theme_btn = _button("Theme", "flat")
self.theme_btn.setToolTip("Change how MacroPad looks") self.theme_btn.setToolTip("Change how MacroPad looks")
# Style decides how the pad is drawn, colours only recolour it, so
# the menu offers them separately rather than as one long list.
self.theme_menu = QMenu(self) self.theme_menu = QMenu(self)
self.theme_group = QActionGroup(self) self.style_group = QActionGroup(self)
self.theme_group.setExclusive(True) self.style_group.setExclusive(True)
self.colour_group = QActionGroup(self)
self.colour_group.setExclusive(True)
self.colour_actions = {}
current = self.settings.value("theme", themes.DEFAULT) current = self.settings.value("theme", themes.DEFAULT)
for t in themes.THEMES.values(): style_now, palette_now = themes.parts(current)
a = self.theme_menu.addAction(t.name)
head = self.theme_menu.addAction("Style")
head.setEnabled(False)
for key, (name, _base, _allowed) in themes.STYLES.items():
a = self.theme_menu.addAction(" " + name)
a.setCheckable(True) a.setCheckable(True)
a.setChecked(t.key == current) a.setChecked(key == style_now)
a.setData(t.key) a.setData(("style", key))
self.theme_group.addAction(a) self.style_group.addAction(a)
self.theme_menu.addSeparator()
head = self.theme_menu.addAction("Colours")
head.setEnabled(False)
for key, (name, _colours) in themes.PALETTES.items():
a = self.theme_menu.addAction(" " + name)
a.setCheckable(True)
a.setChecked(key == palette_now)
a.setData(("palette", key))
self.colour_group.addAction(a)
self.colour_actions[key] = a
self._sync_theme_menu(style_now, palette_now)
# The version sits at the foot of the Theme menu rather than in a
# menu bar, because there isn't one. It is here to be read off and
# quoted in a bug report, so it is deliberately selectable text in
# the one menu everybody opens.
self.theme_menu.addSeparator()
stamp = self.theme_menu.addAction(f"MacroPad {__version__}")
stamp.setEnabled(False)
self.theme_btn.setMenu(self.theme_menu) self.theme_btn.setMenu(self.theme_menu)
self.rotate = _button("Rotate view", "flat") self.rotate = _button("Rotate pad", "flat")
self.import_btn = _button("Import capture…", "flat") self.import_btn = _button("Import capture…", "flat")
self.write_all = _button("", "primary") self.write_all = _button("", "primary")
fl.addWidget(self.dot) fl.addWidget(self.dot)
@@ -863,7 +949,8 @@ class Window(QMainWindow):
self.pad.rearranged.connect(self._rearranged) self.pad.rearranged.connect(self._rearranged)
self.read_btn.clicked.connect(lambda: self._read(quiet=False)) self.read_btn.clicked.connect(lambda: self._read(quiet=False))
self.rotate.clicked.connect(self._rotate) self.rotate.clicked.connect(self._rotate)
self.theme_group.triggered.connect(lambda a: self._set_theme(a.data())) self.style_group.triggered.connect(lambda a: self._set_theme(a.data()))
self.colour_group.triggered.connect(lambda a: self._set_theme(a.data()))
self.import_btn.clicked.connect(self._import) self.import_btn.clicked.connect(self._import)
self.note = core._keyd_warning() self.note = core._keyd_warning()
@@ -1304,9 +1391,30 @@ class Window(QMainWindow):
self._set_layout(lay) self._set_layout(lay)
self.pad.set_editing(True) self.pad.set_editing(True)
def _set_theme(self, key): def _sync_theme_menu(self, style, palette):
t = apply_theme(QApplication.instance(), key) """Grey out colours the chosen style does not offer."""
allowed = themes.STYLES[style][2]
for key, action in self.colour_actions.items():
action.setEnabled(key in allowed)
action.setChecked(key == palette)
def _set_theme(self, data):
"""data is a theme key, or ("style"|"palette", key) from the menu."""
current = self.settings.value("theme", themes.DEFAULT)
style, palette = themes.parts(current)
if isinstance(data, tuple):
which, key = data
if which == "style":
style = key
if palette not in themes.STYLES[style][2]:
palette = themes.STYLES[style][2][0]
else:
palette = key
else:
style, palette = themes.parts(data)
t = apply_theme(QApplication.instance(), themes.compose(style, palette).key)
self.settings.setValue("theme", t.key) self.settings.setValue("theme", t.key)
self._sync_theme_menu(style, palette)
self._poll() # redraws the status dot in the new colours self._poll() # redraws the status dot in the new colours
self._refresh() self._refresh()
self.pad.update() self.pad.update()
@@ -1365,6 +1473,13 @@ def apply_theme(app, key=None):
padview.apply_theme(t) padview.apply_theme(t)
GOOD, BAD, LINE, FIELD = t.good, t.bad, t.line, t.field GOOD, BAD, LINE, FIELD = t.good, t.bad, t.line, t.field
app.setStyle("Fusion") app.setStyle("Fusion")
if t.ui_font and t.ui_font in padview._families():
f = QFont(t.ui_font)
if t.ui_pt:
f.setPixelSize(t.ui_pt)
app.setFont(f)
else:
app.setFont(QFont())
pal = QPalette() pal = QPalette()
for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field), for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field),
(QPalette.Text, t.ink), (QPalette.WindowText, t.ink), (QPalette.Text, t.ink), (QPalette.WindowText, t.ink),

View File

@@ -0,0 +1,34 @@
"""
One device family per backend.
Everything above this line works in terms of a Layout and a set of control
ids and doesn't care what the hardware speaks. Everything below it is one
pad family's protocol. Adding a family means writing the six methods in
base.py and listing the class here.
"""
from .ch57x import CH57x
from .sayo import Sayo
REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins
DEFAULT = REGISTRY[0]
def for_vid_pid(vid, pid):
for backend in REGISTRY:
if backend.claims(str(vid).lower(), str(pid).lower()):
return backend
return None
def for_device(device):
# Falls back rather than returning None, so callers that only ever see
# one family stay simple. Use for_vid_pid when "nobody owns this" is an
# answer you need to act on.
if device is None:
return DEFAULT
return for_vid_pid(device.vid, device.pid) or DEFAULT
def known_ids():
return [(b.vid, b.pids, b.name) for b in REGISTRY]

View File

@@ -0,0 +1,64 @@
"""
What a device family has to provide.
These raise rather than returning something vague, so a half written backend
fails at the call it can't answer instead of writing wrong bytes to somebody's
hardware.
"""
class Backend:
key = "" # short id, used in settings and messages
name = "" # what a person would call this family
vid = "" # USB vendor id, lowercase hex, no 0x
pids = () # product ids this backend is CONFIRMED on
layers = 1
max_keys = 0
max_knobs = 0
def claims(self, vid, pid):
"""True only for ids this backend has been confirmed on."""
if vid.lower() != self.vid.lower():
return False
return not self.pids or pid.lower() in {p.lower() for p in self.pids}
def could_claim(self, vid, pid):
# Siblings share a vendor id but not always a protocol. The scan and
# the udev rule cover the whole vendor so an unconfirmed sibling is
# still found and offered detection; claims() stays strict so it is
# never written to on the strength of its vendor id alone.
return vid.lower() == self.vid.lower()
def udev_rule(self):
return (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{self.vid}", '
f'TAG+="uaccess"')
def adopt(self, layout):
"""Where a family can say more about a shape than detection found."""
return layout
def supports(self, keys, knobs):
return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs
# The six a family must answer.
def action_byte(self, control):
"""The slot number this family uses for a control id."""
raise NotImplementedError
def info(self, device):
"""(keys, knobs) as the pad reports them, or None."""
raise NotImplementedError
def read_layer(self, device, layer=1, layout=None):
raise NotImplementedError
def write(self, device, control, binding, state, layer=1, _write=None):
raise NotImplementedError
def detect(self, device):
"""Read only. Must say no rather than guess."""
raise NotImplementedError
def __repr__(self):
return f"<{type(self).__name__} {self.vid}:{'/'.join(self.pids) or '*'}>"

View File

@@ -0,0 +1,216 @@
"""
The CH57x family: 1189:8840 and its relatives.
Reverse engineered from USB captures of the vendor's own software, and every
packet in tests/captures is replayed by the test suite. The wire format lives
in macropad.py and is not to be edited without a capture to back it up.
Two things about this firmware shape the code:
* It keeps 15 key slots and 3 knob slots whatever hardware is soldered on,
so a layer read always answers 24 slots. Only the 0xFB query says how
many of those are real.
* There is no addressing by name. A control is a slot number: keys are
1 to 15, knobs start at 0x10 and take three apiece.
"""
import errno
import time
import macropad as proto
from .base import Backend
WRITE_GAP = 0.02 # the same pause the CLI leaves between reports
DIAL_ACTIONS = ("left", "push", "right")
class CH57x(Backend):
key = "ch57x"
name = "CH57x macro pad"
vid = "1189"
pids = ("8840",) # confirmed; siblings share the vendor id, not the
# protocol, so they go through detect() first
layers = proto.LAYERS
max_keys = proto.KEY_SLOTS
max_knobs = proto.KNOB_SLOTS
# ------------------------------------------------------------ protocol
def action_byte(self, control):
"""
Keys count from 1; knobs start at 0x10, three apiece. Confirmed on
1189:8840 both by writing and by reading back, and it matches the
slots the pad reports for itself.
"""
control = control.lower()
if control.startswith("key"):
return int(control[3:])
dial, act = control.split("-")
return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act)
# -------------------------------------------------------------- decode
def decode_info(self, report):
"""(keys, knobs) from a 0xFB reply, or None."""
if len(report) < 4 or report[1] != proto.MAGIC_INFO:
return None
return report[2], report[3]
def decode_config(self, report, layout=None):
"""
(control, layer, Binding) from a config report, or None.
layout decides which slots count as real controls, and it matters
more than it looks: decode a 15 key pad against a 12 key layout and
keys 13 to 15 and the third knob vanish without a word.
"""
from .. import core
if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE,
proto.MAGIC_READ):
return None
action, layer, kind = report[2], report[3], report[4]
controls = (layout or core.DEFAULT_LAYOUT).control_for_action
control = controls.get(action)
if control is None:
return None
delay = report[5] | (report[6] << 8)
count = report[10]
if kind == proto.KeyType.MULTIMEDIA and count:
media = core._MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
return (control, layer, core.Binding(media=media)) if media else None
if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC):
mods, code = report[11], report[12]
if not mods and not code:
return control, layer, core.Binding(none=True)
return None
if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count:
steps = []
for i in range(count):
off = 11 + 2 * i
if off + 1 >= len(report):
return None
mods, code = report[off], report[off + 1]
names = [n for n, bit in core._MOD_ORDER if mods & bit]
if code:
if code not in core._KEYNAME_BY_CODE:
return None
names.append(core._KEYNAME_BY_CODE[code])
steps.append("+".join(names))
return control, layer, core.Binding(keys=",".join(steps), delay=delay)
return None
# ---------------------------------------------------------------- read
def info(self, device):
from .. import core
for reply in core._exchange(device.path,
proto.native_info(device.report_id), want=1):
got = self.decode_info(reply)
if got:
return got
return None
def read_layer(self, device, layer=1, layout=None):
from .. import core
out = {}
request = proto.native_read(device.report_id, layer)
for reply in core._exchange(device.path, request):
decoded = self.decode_config(reply, layout)
if not decoded:
continue
control, got_layer, binding = decoded
if got_layer == layer:
out[control] = binding
return out
# --------------------------------------------------------------- write
def write(self, device, control, binding, state, layer=1, _write=None):
"""
Send one binding (config report, then commit) and update the shadow
state. On success the state file is already saved.
"""
from .. import core
write = _write or proto.write_report
reports = binding.reports(device.report_id, self.action_byte(control),
layer)
sent = 0
try:
for r in reports:
write(device.path, r)
sent += 1
time.sleep(WRITE_GAP)
except OSError as e:
why = core._explain(e)
# if the very first write failed on open, nothing reached the pad
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted after {sent} of "
f"{len(reports)} reports: {why}", layer)
state.save()
raise core.WriteError(f"{control}: {why}",
pad_touched=not untouched, cause=e)
state.record(control, binding, layer)
state.save()
# -------------------------------------------------------------- detect
def detect(self, device):
"""
Ask an unknown pad what it is, without configuring anything.
Both commands are queries. We only conclude the pad speaks this
protocol if it answers both in the right shape: a well formed info
reply, and a layer whose controls are exactly the slots that reply
implies. A pad from another family fails one of those and we leave
it alone.
"""
from .. import core
try:
info = self.info(device)
except core.ReadError as e:
return core.Detection(why=f"it didn't answer: {e}")
if not info:
return core.Detection(why="it didn't answer the 'what are you' query")
keys, knobs = info
if not self.supports(keys, knobs):
return core.Detection(why=f"it reported {keys} keys and {knobs} "
"knobs, which isn't a pad this protocol "
"can describe")
probe = core.Layout(keys, knobs, rows=keys, cols=1) # geometry unused
expected = set(probe.control_for_action)
try:
replies = core._exchange(device.path,
proto.native_read(device.report_id, 1))
except core.ReadError as e:
return core.Detection(
why=f"it answered the first query but not the second: {e}")
seen, bindings = set(), {}
for reply in replies:
if len(reply) < 13 or reply[1] != proto.MAGIC_READ:
continue
action, layer = reply[2], reply[3]
seen.add(action)
if action not in expected or layer != 1:
continue
decoded = self.decode_config(reply, probe)
if decoded:
bindings[probe.control_for_action[action]] = decoded[2]
missing = expected - seen
if missing:
return core.Detection(
why=f"it described {keys} keys and {knobs} knobs but didn't "
f"report {len(missing)} of them, so this isn't the "
"protocol it speaks")
return core.Detection(speaks=True, keys=keys, knobs=knobs,
layers=self.layers, bindings=bindings)

View File

@@ -0,0 +1,287 @@
"""
SayoDevice backend. Complete but never run against real hardware.
Drop this and sayo_proto.py in macropad_gui/backends/ and add one line to
REGISTRY in backends/__init__.py:
from .sayo import Sayo
REGISTRY = (CH57x(), Sayo())
Until it is in REGISTRY the app cannot see it, so this file sitting here
changes nothing for anyone.
The protocol is in sayo_proto.py, decoded from WebHID captures of
sayodevice.com and checked against them line by line. Three things about
it shape the code here:
* Reads and writes are the same command, 0x12. A four byte payload asks
for a row of a table, a sixty byte payload is the row coming back or
going in. Table 0x10 is the keys.
* The pad says how many keys it has. Walk the index up and it answers
with a no-such-index flag, so the key count is discovered rather than
configured. It reports where each key physically is, too, so the grid
is known rather than guessed.
* Several bytes of a key's record drift on their own between reads, so
a write is read-modify-write: read the record, change the two binding
bytes, send it back. The vendor's own page does the same, which one
capture caught it doing.
The pad echoes a write back about 100ms later, which is a real
acknowledgement. The CH57x says nothing, so this backend can confirm a
write where the other one can only hope.
"""
import errno
from . import sayo_proto as wire
from .base import Backend
REPORT_ID = wire.REPORT_ID
MAX_KEYS = 64 # a sanity stop in case a pad never says no
class Sayo(Backend):
key = "sayo"
name = "SayoDevice"
vid = "8089"
pids = ("000b",) # the 2x4; other Sayo pids are untested
layers = 1 # the firmware has more, we expose one for now
max_keys = MAX_KEYS
max_knobs = 0
# ------------------------------------------------------------ protocol
def action_byte(self, control):
"""Keys are their own index, counting from zero. No knobs."""
if control.startswith("key"):
return int(control[3:]) - 1
raise ValueError(f"{self.name} has no knobs: {control}")
def _request(self, table, index=0):
return wire.report(wire.read_request(table, index))
def _ask(self, device, table, index=0):
"""
One row of one table, or None if the pad has no such row.
Heartbeats arrive unasked about once a second and have to be
stepped over, or the first one to land looks like the answer.
"""
from .. import core
for reply in core._exchange(device.path, self._request(table, index),
want=4):
if len(reply) < 2 or reply[0] != REPORT_ID:
continue
payload = reply[1:]
if wire.is_heartbeat(payload):
continue
try:
got = wire.parse_reply(payload)
except ValueError:
continue
if got["table"] != table or got["index"] != index:
continue
return None if got["end"] else payload
return None
def _records(self, device, limit=MAX_KEYS):
"""Every key's record, in order, stopping where the pad says to."""
out = []
for i in range(limit):
rec = self._ask(device, wire.TABLE_KEYS, i)
if rec is None or not wire.is_key_record(rec):
break
out.append(rec)
return out
def _binding(self, record):
"""
core.Binding for one key, or None if we cannot say what it does.
None is the honest answer for a key type no capture covers, and
it matters: three keys on one real pad are type 0x80, and reading
those bytes as a modifier made them look like a bare "ctrl+shift"
that nobody had set. The app shows None as unknown, which is true,
and write() then refuses to overwrite it.
"""
from .. import core
kind, a, b = wire.read_binding(record)
if kind == "consumer":
return core.Binding(media=b) if b else None
if kind != "keyboard":
return None
mods, code = a, b
if not mods and not code:
return core.Binding(none=True)
names = [n for n, bit in core._MOD_ORDER if mods & bit]
if code:
name = core._KEYNAME_BY_CODE.get(code)
if name is None:
return None
names.append(name)
return core.Binding(keys="+".join(names))
# --------------------------------------------------------------- read
def info(self, device):
"""
(keys, knobs) as the pad reports them, or None if it goes quiet.
None matters: it means "did not answer", and the app then treats
the pad as unsupported rather than writing to it.
"""
records = self._records(device)
return (len(records), 0) if records else None
def model(self, device):
"""The name the pad calls itself, for the window title. May be ''."""
reply = self._ask(device, wire.TABLE_NAME)
return wire.device_name(reply) if reply else ""
def read_layer(self, device, layer=1, layout=None):
out = {}
for i, rec in enumerate(self._records(device)):
binding = self._binding(rec)
if binding is not None:
out[f"key{i + 1}"] = binding
return out
# -------------------------------------------------------------- write
def write(self, device, control, binding, state, layer=1, _write=None):
"""
Put one binding on the pad.
Read the key's record first, because most of it is the pad's own
and some of it moves between reads. If the read fails nothing has
been sent, which is the one case where the app can promise the old
binding is still there.
"""
from .. import core
import macropad as proto
index = self.action_byte(control)
try:
record = self._ask(device, wire.TABLE_KEYS, index)
except core.ReadError as e:
raise core.WriteError(f"{control}: couldn't read the key first, so "
f"nothing was changed ({e})",
pad_touched=False, cause=e)
if record is None or not wire.is_key_record(record):
raise core.WriteError(f"{control}: the pad has no key {index + 1}",
pad_touched=False)
# Refuse to write over a key whose current type no capture covers.
# Overwriting it would leave a key marked as one kind holding
# another kind's bytes, and what the firmware does then is anyone's
# guess. Better to say so and leave the pad alone.
kind = wire.kind_of(record)
if kind == "unknown":
raise core.WriteError(
f"{control}: this key is set to something this app hasn't "
f"decoded yet (type 0x{record[wire.KIND_AT]:02x}), so it was "
f"left alone. Clear it in the SayoDevice configurator first "
f"and it becomes editable here.", pad_touched=False)
payload = self._payload(record, binding)
write = _write or proto.write_report
try:
write(device.path, wire.report(payload))
except OSError as e:
why = core._explain(e)
untouched = e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted: {why}", layer)
state.save()
raise core.WriteError(f"{control}: {why}",
pad_touched=not untouched, cause=e)
state.record(control, binding, layer)
state.save()
def _payload(self, record, binding):
"""The sealed write for this binding, on this key's own record."""
from .. import core
if binding.media:
if binding.media not in wire.CONSUMER_BY_NAME:
known = ", ".join(sorted(wire.CONSUMER_BY_NAME))
raise core.WriteError(
f"{self.name}: {binding.media} isn't captured yet. This "
f"pad numbers media keys its own way, not by HID code, so "
f"each one needs seeing once. Known so far: {known}.",
pad_touched=False)
return wire.patch_consumer(record, binding.media)
return wire.patch_binding(record, *self._encode(binding))
def _encode(self, binding):
"""
(mods, usage) for one keyboard binding, or a WriteError saying why
not.
Parsing goes through macropad.MODIFIERS and macropad.KEYCODES, the
same tables the CLI uses, so a binding typed once means the same
thing on either family of pad. That is most of the point of having
a backend seam at all.
"""
from .. import core
import macropad as proto
if binding.none:
return 0, 0
if "," in binding.keys:
raise core.WriteError(
f"{self.name}: this pad holds one keystroke per key, not a "
"sequence", pad_touched=False)
mods, code = 0, 0
for part in binding.keys.lower().split("+"):
part = part.strip()
if not part:
continue
if part in proto.MODIFIERS:
mods |= proto.MODIFIERS[part]
elif part in proto.KEYCODES:
if code:
raise core.WriteError(
f"{self.name}: this pad holds one key plus modifiers, "
f"so {binding.keys!r} won't fit", pad_touched=False)
code = proto.KEYCODES[part]
else:
raise core.WriteError(f"{self.name}: don't know the key "
f"{part!r}", pad_touched=False)
return mods, code
# ------------------------------------------------------------- detect
def detect(self, device):
"""
Ask an unknown pad what it is, reading only.
Two things have to line up before we call it a Sayo: a key table
that answers in the right shape, and a model name. A pad sharing
the vendor id but not the protocol fails one of them and is left
alone.
"""
from .. import core
try:
records = self._records(device)
except core.ReadError as e:
return core.Detection(why=f"it didn't answer: {e}")
if not records:
return core.Detection(why="it didn't answer the key table query")
rows, cols = wire.grid_from_records(records)
if rows * cols != len(records):
rows, cols = 1, len(records) # odd shape; one row is honest
bindings = {}
for i, rec in enumerate(records):
got = self._binding(rec)
if got is not None:
bindings[f"key{i + 1}"] = got
return core.Detection(speaks=True, keys=len(records), knobs=0,
layers=self.layers, bindings=bindings,
grid=(rows, cols), model=self.model(device))

View File

@@ -0,0 +1,631 @@
"""
SayoDevice 8089:000b wire format.
Worked out from WebHID captures of sayodevice.com. Everything here is
checked against those captures by the self test at the bottom: run this
file directly and it rebuilds every captured packet byte for byte.
python3 sayo_proto.py
A report is 64 bytes: report id 0x21, then 63 bytes of payload. One
command byte, 0x12, covers both directions. A four byte payload is a
question, a sixty byte payload is an answer or a write.
payload
0 0x12 the only command we have seen
1-2 checksum, little endian (see checksum())
3 how many bytes from byte 4 on carry meaning. 0x04 for a
read request, 0x3c for a key record
4 flags. 0x00 asking, 0x40 in a reply means no such index
5 which table (see TABLES)
6 index within that table
7+ the data
Table 0x10 is the keys, one record per key, and a record looks like this:
7 0x01
8-10 0x00
11-18 x, y, width, height on the pad face, four little endian 16
bit numbers. Key 1 of an 8 key pad is at 1250,1250 and is
1800 square, giving a 4 by 2 grid with a pitch of 1900
19 0x64 100
23 what kind of key this is (see KINDS). It decides what bytes
27 and 28 mean, and the configurator writes it
27-28 the binding, read according to byte 23
44 a counter of some sort, drifts on its own
51-52 also drifts on its own, probably a press count or timestamp
Byte 23 took two captures to pin down. A capture of the page setting
volume and brightness keys shows it going from 0x01 to 0x03 and byte 27
carrying a small code, while an earlier capture of the page setting a
plain letter left it alone. So it is the key's type, not drifting state:
0x00, 0x01 a keyboard shortcut. 27 is the modifier bitmask,
standard HID, and 28 is the usage code. The low bit
appears either way and is something else, so it is
preserved rather than understood
0x03 volume and brightness. 27 is a code of Sayo's own
(see CONSUMER), 28 is zero
anything a kind we have not captured. On one real pad, 0x80
else is three media transport keys. Read as unknown and
never written over
Writing is read-modify-write: read the key's record, set bytes 23, 27
and 28, reseal. The vendor's own page does exactly that, which one
capture caught in the act. Nothing else in the record is ours to touch,
because bytes 44 and 51-52 really do move on their own.
"""
from pathlib import Path
REPORT_ID = 0x21
CMD = 0x12 # both directions, both reads and writes
KEY_AT = 6
KIND_AT = 23 # what sort of key it is, which decides 27 and 28
MOD_AT = 27
KEYCODE_AT = 28
CHECK_AT = 1 # payload bytes 1 and 2, little endian
LEN_AT = 3
FLAG_AT = 4
TABLE_AT = 5
DATA_AT = 7
RECORD_LEN = 0x3C # byte 3 of a key record
REQUEST_LEN = 0x04 # byte 3 of a read request
NO_SUCH_INDEX = 0x40 # byte 4 of a reply, meaning you ran off the end
# Byte 5. The pad lists the ones it has in table 0x00; these are the ones
# we have a use for.
TABLE_DIRECTORY = 0x00
TABLE_NAME = 0x01 # the model name, UTF-16LE
TABLE_INFO = 0x02 # vid and pid at bytes 19 to 22, and uptime
TABLE_KEYS = 0x10 # one record per key, the ones we care about
# Table 0x01 and 0x19 want 0x0c in the flag byte rather than 0x00. Why is
# not clear, but the page is consistent about it and so are we.
FLAGS = {TABLE_NAME: 0x0C}
CMD_WRITE_KEY = CMD # old name, kept so nothing outside here breaks
# Byte 23. KEYBOARD_KINDS are the two values seen alongside a real
# keyboard shortcut; the low bit is some other flag we carry along.
KIND_KEYBOARD = 0x00
KIND_CONSUMER = 0x03
KEYBOARD_KINDS = (0x00, 0x01)
KINDS = {KIND_KEYBOARD: "keyboard", 0x01: "keyboard", KIND_CONSUMER: "consumer"}
# Byte 27 when byte 23 is 0x03. Sayo's own numbering, not HID consumer
# codes, so every one of these comes from a capture and no others are
# guessed. The names match macropad.MEDIA_KEYS so a media binding means
# the same thing on either family of pad.
CONSUMER = {
0x01: "brightnessup",
0x02: "brightnessdown",
0x0A: "volumeup",
0x0B: "volumedown",
}
CONSUMER_BY_NAME = {v: k for k, v in CONSUMER.items()}
# Captured records, each the exact 63 bytes sayodevice.com sent, taken from
# CAPTURED below so the two can never drift apart. Byte 6 is the key index,
# so these cover keys 1, 2 and 8 of an 8 key pad.
CAPTURED = [
"1256683c00100701000000261b4e0c080708076400000000000000000400000000000000000000000000000015000000000000000000000000000000000000",
"1256693c00100701000000261b4e0c080708076400000000000000000500000000000000000000000000000015000000000000000000000000000000000000",
"1256773c00100701000000261b4e0c080708076400000000000000001300000000000000000000000000000015000000000000000000000000000000000000",
"1258643c00100701000000261b4e0c080708076400000000000000020000000000000000000000000000000015000000000000000000000000000000000000",
"125e643c00100701000000261b4e0c080708076400000000000000080000000000000000000000000000000015000000000000000000000000000000000000",
"1256643c00100701000000261b4e0c080708076400000000000000000000000000000000000000000000000015000000000000000000000000000000000000",
"12c17e3c00100001000000e204e20408070807640000000000000000040000030000000e000000000000000000000000000000000000000100000009500000",
"122a863c001001010000004e0ce20408070807640000000000000000040000030000000c0000000000000000000000000000000000000000000000094f0000",
]
# A second capture, of the page starting up, which is where the read
# command came from. Request then reply, in the order they happened.
STARTUP = [
# the directory: which tables this pad has
("out", "122512040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
("in", "1250e11f00000007007b000000004b000004040102030d0e101112161718191a1e1f0000000000000000000000000000000000000000000000000000000000"),
# the model name
("out", "12261e040c01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
("in", "129124340c01005300610079006f00440065007600690063006500200032007800340056002000520047004200000000000000000000000000000000000000"),
# the eight keys, asked for one at a time
("out", "123512040010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
("in", "12c2d13c00100001000000e204e20408070807640000000000000001570000030000000e000000000000000000000000000000000000000100000009500000"),
("in", "12ad823c001001010000004e0ce20408070807640000008000000003000000030000000c0000000000000000000000000000000000000000000000094f0000"),
("in", "1296863c00100201000000ba13e20408070807640000008000000004000000030000000f000000810000000000000000000000014a00000000000000000000"),
("in", "126f8f3c00100301000000261be204080708076400000080000000010000000000000000000000000000000000000000000000044b00000000000000000000"),
("in", "1214a93c00100401000000e2044e0c08070807640000000000000001560000010000000000000000000000001d000000000000000000000000000000000000"),
("in", "1286883c001005010000004e0c4e0c080708076400000001000000072f0000000000000000000000000000001b000000000000000000000000000000000000"),
("in", "12f5c83c00100601000000ba134e0c080708076400000000000000073000000000000000000000000000000006000000000000044c00000000000000000000"),
("in", "125bb23c00100701000000261b4e0c080708076400000001000000000000000000000000000000000000000015000000000000044e00000000000000000000"),
# asking for a ninth key, and being told there isn't one
("out", "12351a040010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
("in", "12355a044010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
# and then the page set key 8 to p, by sending back the record it had
# just read with one byte changed. Which is what we do.
("out", "125bc53c00100701000000261b4e0c080708076400000001000000001300000000000000000000000000000015000000000000044e00000000000000000000"),
]
LIVE = [bytes.fromhex(h) for d, h in STARTUP if d == "in" and
bytes.fromhex(h)[TABLE_AT] == 0x10 and bytes.fromhex(h)[LEN_AT] == 0x3C]
# A third capture: the page setting key 8 to each of four volume and
# brightness keys in turn, with the log cleared first. All four differ
# from each other in byte 27 and nothing else, and all four set byte 23
# to 0x03 where the key had been 0x01. That pair of facts is what makes
# byte 23 the key type rather than drifting state.
CONSUMER_CAPTURE = {
"volumeup": "1267b23c00100701000000261b4e0c0807080764000000030000000a0000000000000000000000000000000015000000000000044e00000000000000000000",
"volumedown": "1268b23c00100701000000261b4e0c0807080764000000030000000b0000000000000000000000000000000015000000000000044e00000000000000000000",
"brightnessup": "125eb23c00100701000000261b4e0c080708076400000003000000010000000000000000000000000000000015000000000000044e00000000000000000000",
"brightnessdown": "125fb23c00100701000000261b4e0c080708076400000003000000020000000000000000000000000000000015000000000000044e00000000000000000000",
}
def _records():
out = {}
for h in CAPTURED:
b = bytes.fromhex(h)
out.setdefault(b[KEY_AT], b) # first sighting of each key
return out
RECORDS = _records()
USAGES = {c: 4 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}
USAGES.update({str(d): 30 + i for i, d in enumerate("1234567890")})
USAGES.update({
"enter": 40, "esc": 41, "backspace": 42, "tab": 43, "space": 44,
"minus": 45, "equal": 46, "leftbracket": 47, "rightbracket": 48,
"backslash": 49, "semicolon": 51, "quote": 52, "grave": 53,
"comma": 54, "period": 55, "slash": 56, "capslock": 57,
"printscreen": 70, "insert": 73, "home": 74, "pageup": 75,
"delete": 76, "end": 77, "pagedown": 78,
"right": 79, "left": 80, "down": 81, "up": 82,
"numlock": 83, "numpadslash": 84, "numpadstar": 85,
"numpadminus": 86, "numpadplus": 87, "numpadenter": 88, "numpaddot": 99,
})
for _i in range(1, 13):
USAGES[f"f{_i}"] = 57 + _i
for _i in range(1, 10):
USAGES[f"numpad{_i}"] = 0x58 + _i
USAGES["numpad0"] = 0x62
# Standard HID modifier bits. The pad uses them unchanged.
MODIFIERS = {"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "meta": 0x08,
"super": 0x08, "win": 0x08,
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rmeta": 0x80}
def parse_combo(text):
"""'ctrl+shift+a' -> (0x03, 0x04). A bare modifier gives keycode 0."""
mods, usage = 0, 0
for part in text.lower().replace(" ", "").split("+"):
if not part:
continue
if part in MODIFIERS:
mods |= MODIFIERS[part]
elif part in USAGES:
usage = USAGES[part]
else:
raise KeyError(f"don't know {part!r}")
return mods, usage
def checksum(payload):
"""
Sum the whole 64 byte report as little endian 16 bit words with the
checksum field zeroed. No magic constant: this reproduces all ten
captured packets exactly.
"""
raw = bytearray([REPORT_ID]) + bytearray(payload)
raw[2] = raw[3] = 0 # the checksum's own two bytes
if len(raw) % 2:
raw.append(0)
total = sum(raw[i] | (raw[i + 1] << 8) for i in range(0, len(raw), 2))
return total & 0xFFFF
def seal(payload):
"""Put the right checksum into a payload and hand it back."""
out = bytearray(payload)
c = checksum(out)
out[CHECK_AT] = c & 0xFF
out[CHECK_AT + 1] = (c >> 8) & 0xFF
return bytes(out)
def verify(payload):
return payload[CHECK_AT] | (payload[CHECK_AT + 1] << 8) == checksum(payload)
def kind_of(record):
"""What sort of key this is: 'keyboard', 'consumer', or 'unknown'."""
return KINDS.get(record[KIND_AT], "unknown")
def read_binding(record):
"""
(kind, a, b) from a record, where what a and b mean follows the kind.
keyboard (modifier bitmask, HID usage)
consumer (consumer code, name or None)
unknown (byte 23, byte 27) and nothing else to say
Returning the kind rather than guessing is the whole point: three keys
on one real pad are type 0x80, and reading those as a modifier made
them look like a bare "ctrl+shift" that nobody had set.
"""
kind = kind_of(record)
if kind == "keyboard":
return kind, record[MOD_AT], record[KEYCODE_AT]
if kind == "consumer":
code = record[MOD_AT]
return kind, code, CONSUMER.get(code)
return kind, record[KIND_AT], record[MOD_AT]
def patch_binding(record, mods, usage):
"""
One key's record holding a keyboard shortcut instead, resealed.
record must be that key's own record, because bytes 11 to 18 are where
that key sits on the pad face and other bytes are its own state. Pass
another key's record and you move this key on top of that one.
Byte 23 is set to the keyboard kind. If the key was already a keyboard
key its low bit is kept, since that bit shows up either way and the
vendor's page leaves it alone when it changes a letter.
"""
out = bytearray(record)
was = out[KIND_AT]
out[KIND_AT] = (was & 0x01) if was in KEYBOARD_KINDS else KIND_KEYBOARD
out[MOD_AT] = mods
out[KEYCODE_AT] = usage
return seal(out)
def patch_consumer(record, code):
"""
One key's record holding a volume or brightness key, resealed.
code is a key of CONSUMER, or one of its names. Byte 23 becomes 0x03
and byte 28 goes to zero, which is exactly what the capture of the
page doing this shows.
"""
if isinstance(code, str):
if code not in CONSUMER_BY_NAME:
raise KeyError(f"no captured code for {code!r}; known: "
f"{', '.join(sorted(CONSUMER_BY_NAME))}")
code = CONSUMER_BY_NAME[code]
out = bytearray(record)
out[KIND_AT] = KIND_CONSUMER
out[MOD_AT] = code
out[KEYCODE_AT] = 0
return seal(out)
def patch_keycode(record, usage):
"""A plain key with no modifier."""
return patch_binding(record, 0, usage)
def is_heartbeat(payload):
"""The status report the pad sends unasked, about once a second."""
return bool(payload) and payload[0] == 0x00
def report(payload):
"""The 64 bytes to hand to a hidraw write."""
return bytes([REPORT_ID]) + bytes(payload)
# ------------------------------------------------------ reading the pad
#
# A capture of the configurator starting up gave this away. Ask with a
# four byte payload naming a table and an index, get the row back. Walk
# the index up from zero and the pad tells you when to stop, so the key
# count does not have to be guessed or configured anywhere.
def read_request(table, index=0):
"""The payload that asks for one row of one table."""
out = bytearray(63)
out[0] = CMD
out[LEN_AT] = REQUEST_LEN
out[FLAG_AT] = FLAGS.get(table, 0x00)
out[TABLE_AT] = table
out[KEY_AT] = index
return seal(out)
def parse_reply(payload):
"""
{table, index, flags, data, end} from anything the pad sends back.
data is only the meaningful part: byte 3 says how far it runs. end is
True when the index does not exist, which is how you find out how many
keys a pad has.
"""
p = bytes(payload)
if len(p) < DATA_AT or p[0] != CMD:
raise ValueError("not a 0x12 report")
size = p[LEN_AT]
return {"table": p[TABLE_AT], "index": p[KEY_AT], "flags": p[FLAG_AT],
"data": p[FLAG_AT:FLAG_AT + size], "payload": p,
"end": bool(p[FLAG_AT] & NO_SUCH_INDEX)}
def is_key_record(payload):
"""Is this 63 bytes a key record, rather than a short reply or noise?"""
p = bytes(payload)
return (len(p) >= DATA_AT and p[0] == CMD and p[TABLE_AT] == TABLE_KEYS
and p[LEN_AT] == RECORD_LEN and not p[FLAG_AT] & NO_SUCH_INDEX)
def key_geometry(record):
"""
(x, y, w, h) of one key on the pad face, from its record.
The unit is arbitrary but consistent. What it is good for is working
out the shape of a pad nobody has told us about: group the keys by y
to get the rows.
"""
u16 = lambda o: record[o] | (record[o + 1] << 8)
return u16(11), u16(13), u16(15), u16(17)
def grid_from_records(records):
"""
(rows, cols) worked out from where the keys actually are.
Beats guessing from the key count, which cannot tell a 4 by 2 from a
2 by 4 or an 8 by 1.
"""
rows = sorted({key_geometry(r)[1] for r in records})
cols = sorted({key_geometry(r)[0] for r in records})
return len(rows) or 1, len(cols) or 1
def device_name(payload):
"""
The model name out of a table 0x01 reply. UTF-16LE, zero padded.
The declared length can land on an odd byte, which would leave half a
character on the end, so trim to even and then cut at the first NUL
rather than trusting the padding.
"""
p = bytes(payload)
size = max(0, p[LEN_AT] - 3)
raw = p[DATA_AT:DATA_AT + size - (size % 2)]
return raw.decode("utf-16-le", "replace").split("\x00")[0].strip()
def tables_offered(payload):
"""
Which tables a pad admits to having, from a table 0x00 reply.
Only used to sanity check that 0x10 is there before trusting the rest.
"""
p = bytes(payload)
body = p[FLAG_AT:FLAG_AT + p[LEN_AT]]
seen, out = set(), []
for b in body[10:]: # the list sits after a header
if b and b not in seen:
seen.add(b)
out.append(b)
return out
# ------------------------------------------------------- backup files
#
# The configurator's "backup" button writes a .sayobak. Inside it, from
# offset 102, are the pad's eight key records, 62 bytes each. They are the
# same layout as a write payload from byte 11 on, which is the part we
# cannot invent, so a backup is a perfectly good substitute for reading the
# records off the pad.
#
# Rebuilding key 1's captured write from its backup record comes out byte
# for byte identical, which is what makes this trustworthy.
BACKUP_MAGIC = b"Sayo Device Backup File"
BACKUP_START = 102
BACKUP_STRIDE = 62
KEYS_IN_BACKUP = 8
def read_backup(data):
"""
[{key, x, y, w, h, mods, usage, record}] from a .sayobak, one per key.
x and y are the key's position on the pad face and w and h its size, in
whatever unit the firmware uses. On an 8 key pad they come out as a 4 by
2 grid with a pitch of 1900 and a size of 1800.
"""
if hasattr(data, "read_bytes"):
data = data.read_bytes()
elif isinstance(data, str):
data = Path(data).read_bytes()
if not data.startswith(BACKUP_MAGIC):
raise ValueError("not a Sayo backup file")
out = []
for i in range(KEYS_IN_BACKUP):
off = BACKUP_START + i * BACKUP_STRIDE
rec = data[off:off + BACKUP_STRIDE]
if len(rec) < BACKUP_STRIDE:
break
u16 = lambda o: rec[o] | (rec[o + 1] << 8)
out.append({"key": i, "x": u16(11), "y": u16(13),
"w": u16(15), "h": u16(17),
"mods": rec[MOD_AT], "usage": rec[KEYCODE_AT],
"record": rec})
return out
def payload_from_record(record, key, mods, usage):
"""
A write payload for one key, built from that key's backup record.
The header is fixed, bytes 11 onward come from the record, and only the
binding changes. Checked against the captures: key 1 comes out byte for
byte identical to what the vendor's page sent.
"""
out = bytearray(63)
out[0] = CMD_WRITE_KEY
out[3] = 0x3C
out[5] = 0x10
out[6] = key
out[7] = 0x01
out[11:62] = record[11:62]
return patch_binding(out, mods, usage)
def describe_record(record):
"""What one key does, in words, whatever kind of key it is."""
kind, a, b = read_binding(record)
if kind == "keyboard":
return describe(a, b)
if kind == "consumer":
return b or f"an uncaptured volume or brightness code, 0x{a:02x}"
return f"a key type we haven't decoded, 0x{a:02x} with 0x{b:02x}"
def describe(mods, usage):
"""'ctrl+shift+a' from a modifier bitmask and a usage code."""
names = [n for n, bit in (("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04),
("meta", 0x08), ("rctrl", 0x10), ("rshift", 0x20),
("ralt", 0x40), ("rmeta", 0x80)) if mods & bit]
by_code = {v: k for k, v in sorted(USAGES.items())}
if usage:
names.append(by_code.get(usage, f"0x{usage:02x}"))
return "+".join(names) or "nothing"
# ------------------------------------------------------------- self test
if __name__ == "__main__":
bad = 0
for h in CAPTURED:
b = bytes.fromhex(h)
if seal(b) != b or not verify(b):
bad += 1
print(f" MISMATCH key={b[KEY_AT]} stored={b[1]:02x}{b[2]:02x} "
f"computed={seal(b)[1]:02x}{seal(b)[2]:02x}")
print(f"{len(CAPTURED) - bad}/{len(CAPTURED)} captured packets reproduced exactly")
for key, rec in sorted(RECORDS.items()):
assert len(rec) == 63, f"key {key} record is {len(rec)} bytes, want 63"
assert verify(rec), f"stored record for key {key} fails its own checksum"
got = patch_keycode(rec, USAGES["z"])
assert verify(got) and got[KEYCODE_AT] == USAGES["z"]
print(f"{len(RECORDS)} stored key records verify and re-seal cleanly")
# the two modifier captures, rebuilt from key 8's record
for combo, want in (("shift", "1258643c"), ("meta", "125e643c")):
mods, usage = parse_combo(combo)
built = patch_binding(RECORDS[7], mods, usage)
assert built.hex().startswith(want), f"{combo}: {built[:4].hex()} != {want}"
print("left shift and meta captures rebuilt from key 8's record")
for combo in ("ctrl+a", "ctrl+shift+t", "meta+w", "alt+a"):
mods, usage = parse_combo(combo)
out = patch_binding(RECORDS[0], mods, usage)
assert verify(out)
print("modifier combinations build and verify")
# ------------------------------------------------ the startup capture
for direction, h in STARTUP:
b = bytes.fromhex(h)
assert len(b) == 63, f"{h[:8]} is {len(b)} bytes"
assert verify(b), f"{h[:8]} fails its own checksum"
print(f"{len(STARTUP)} startup packets verify")
# our read_request() against the three the page actually sent
for table, index, want in ((TABLE_DIRECTORY, 0, "122512040000"),
(TABLE_NAME, 0, "12261e040c01"),
(TABLE_KEYS, 0, "123512040010"),
(TABLE_KEYS, 8, "12351a040010")):
got = read_request(table, index).hex()
assert got.startswith(want), f"table {table:#04x}: {got[:12]} != {want}"
print("read requests match the ones the vendor's page sends")
end = parse_reply(bytes.fromhex(
"12355a044010080000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000000000000000000000000000000000"[:126]))
assert end["end"] and end["index"] == 8
assert len(LIVE) == 8 and not any(parse_reply(r)["end"] for r in LIVE)
print(f"the pad reports {len(LIVE)} keys and says so when asked for a ninth")
name = device_name(bytes.fromhex(
[h for d, h in STARTUP if d == "in" and bytes.fromhex(h)[TABLE_AT] == 1][0]))
assert name == "SayoDevice 2x4V RGB", repr(name)
print(f"model name reads back as {name!r}")
assert TABLE_KEYS in tables_offered(bytes.fromhex(STARTUP[1][1]))
rows, cols = grid_from_records(LIVE)
assert (rows, cols) == (2, 4), (rows, cols)
print(f"geometry puts the keys in a {cols} by {rows} grid")
# the page read key 8 then wrote it. Rebuild that write from that read.
rebuilt = payload_from_record(LIVE[7], 7, 0x00, USAGES["p"])
assert rebuilt == bytes.fromhex(STARTUP[-1][1]), "read-modify-write diverged"
print("the page's own write rebuilt byte for byte from the record it read")
# ------------------------------------------- volume and brightness
before = LIVE[7]
assert before[KIND_AT] == 0x01, f"key 8 started at {before[KIND_AT]:#04x}"
for name, want in CONSUMER_CAPTURE.items():
built = patch_consumer(before, name)
assert built == bytes.fromhex(want), (
f"{name}: {[n for n in range(63) if built[n] != bytes.fromhex(want)[n]]}")
print(f"all {len(CONSUMER_CAPTURE)} volume and brightness writes rebuilt exactly")
kinds = {bytes.fromhex(h)[KIND_AT] for h in CONSUMER_CAPTURE.values()}
assert kinds == {KIND_CONSUMER}, kinds
print("and each one set byte 23 to 0x03 where the key had been 0x01, "
"so byte 23 is the key type")
for name, h in CONSUMER_CAPTURE.items():
kind, code, got = read_binding(bytes.fromhex(h))
assert (kind, got) == ("consumer", name), (kind, got, name)
print("they read back as the names they were set to")
# a kind we have never captured must not read as a keyboard shortcut
odd = bytearray(LIVE[0])
odd[KIND_AT] = 0x80
assert kind_of(odd) == "unknown"
assert "haven't decoded" in describe_record(odd)
print("an uncaptured key type reads as unknown rather than a bare modifier")
# switching a key back to a shortcut must clear the consumer type
back = patch_binding(bytes.fromhex(CONSUMER_CAPTURE["volumeup"]), 0x01,
USAGES["c"])
assert back[KIND_AT] == KIND_KEYBOARD and verify(back)
assert read_binding(back) == ("keyboard", 0x01, USAGES["c"])
print("and setting a shortcut on a volume key clears the type back")
for i, rec in enumerate(LIVE):
x, y, w, h = key_geometry(rec)
print(f" key {i+1}: at ({x:>5},{y:>5}) size {w}x{h} "
f"[{kind_of(rec)}] -> {describe_record(rec)}")
# a backup, if one is sitting next to this file
import glob
for path in sorted(glob.glob("*.sayobak")) + sorted(glob.glob("backup.bin")):
keys = read_backup(Path(path))
print(f"\n{path}: {len(keys)} key records")
cols = len({k["x"] for k in keys})
print(f" pad is {cols} across by {len(keys)//cols} down")
for k in keys:
print(f" key {k['key']+1}: at ({k['x']:>5},{k['y']:>5}) "
f"size {k['w']}x{k['h']} [{kind_of(k['record'])}] -> "
f"{describe_record(k['record'])}")
rebuilt = payload_from_record(keys[0]["record"], 0, 0, USAGES["a"])
assert verify(rebuilt)
assert rebuilt == RECORDS[0], "key 1 should rebuild to its captured write"
print(" key 1 rebuilt from the backup matches its captured write exactly")
break

View File

@@ -46,17 +46,30 @@ DIAL_ACTIONS = ("left", "push", "right")
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 backends():
""" """
The byte the pad uses for one control. Keys count from 1; knobs start The backend registry. Imported late because the backends import this
at 0x10, three apiece. Confirmed on 1189:8840 both by writing and by module for its data types, and a top level import either way would be
reading back, and it is what the pad reports for its own slots. circular.
""" """
control = control.lower() from . import backends as registry
if control.startswith("key"): return registry
return int(control[3:])
dial, act = control.split("-")
return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act) def backend_for(device):
"""Whichever family owns this device; the default when there is none."""
return backends().for_device(device)
def action_byte(control, backend=None):
"""
The slot number one control lives in.
Slot numbering belongs to the protocol, so this is the backend's answer.
The default is the CH57x family, which is what every caller that does
not have a device in hand wants.
"""
return (backend or backends().DEFAULT).action_byte(control)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -414,8 +427,13 @@ 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, layout=None): def __init__(self, path=None, layout=None, device_id=None):
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json" # The default name is the one the app has always used, so upgrading
# never orphans somebody's bindings. A second family would pass its
# own id and get its own file.
self.device_id = device_id or DEVICE_ID
default = config_dir() / f"state-{self.device_id.replace(':', '-')}.json"
self.path = Path(path) if path else default
self.entries = {} # (layer, control) -> Entry self.entries = {} # (layer, control) -> Entry
self.layout = layout or DEFAULT_LAYOUT self.layout = layout or DEFAULT_LAYOUT
if self.path.exists(): if self.path.exists():
@@ -449,7 +467,7 @@ class State:
else: else:
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": self.device_id,
"layout": self.layout.to_json(), "layout": self.layout.to_json(),
"layers": layers}) "layers": layers})
@@ -497,7 +515,26 @@ SUGGESTED_RULE = "60-macropad.rules"
# Vendor wide on purpose. Pads in this family have several product ids, and # Vendor wide on purpose. Pads in this family have several product ids, and
# a rule tied to one of them leaves everybody else unable to read their own # a rule tied to one of them leaves everybody else unable to read their own
# hardware, which is exactly the point at which they give up. # hardware, which is exactly the point at which they give up.
RULE_LINE = f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", TAG+="uaccess"' def rule_lines():
"""One uaccess rule per vendor we support."""
return [b.udev_rule() for b in backends().REGISTRY]
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
f'TAG+="uaccess"')
def rule_command():
"""
The shell line that installs access rules for every family we support.
One vendor today, so it reads the same as it always has; a second
backend adds its own line here rather than needing the text edited.
"""
lines = rule_lines()
if len(lines) == 1:
return f"echo '{lines[0]}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}"
body = "\\n".join(lines)
return f"printf '{body}\\n' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}"
@@ -552,14 +589,24 @@ def _udev_tags(devpath):
return tags return tags
def vendors():
"""Every USB vendor id any backend knows about."""
return {b.vid.lower() for b in backends().REGISTRY}
def discover():
"""Config-capable hidraw nodes belonging to any family we support."""
return proto.find_devices(vendors())
def _on_usb_bus(): def _on_usb_bus():
want = vendors()
for d in glob.glob("/sys/bus/usb/devices/*"): for d in glob.glob("/sys/bus/usb/devices/*"):
try: try:
v = open(os.path.join(d, "idVendor")).read().strip().lower() v = open(os.path.join(d, "idVendor")).read().strip().lower()
p = open(os.path.join(d, "idProduct")).read().strip().lower()
except OSError: except OSError:
continue continue
if (v, p) == (VID, PID): if v in want:
return True return True
return False return False
@@ -608,8 +655,12 @@ def diagnose(keyd=True):
"the roadmap."]) "the roadmap."])
warnings = [w for w in (_keyd_warning() if keyd else None,) if w] warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
everything = proto.find_devices() everything = discover()
found = [d for d in everything if d["pid"] == PID] # A device is "known" only when some backend is confirmed on its exact
# id. A sibling under the same vendor shows up as unsupported, which is
# what puts the detect button in front of its owner.
found = [d for d in everything
if backends().for_vid_pid(d["vid"], d["pid"])]
if not found and everything: if not found and everything:
other = everything[0] other = everything[0]
@@ -632,7 +683,7 @@ def diagnose(keyd=True):
"may name a single product id; this one covers every " "may name a single product id; this one covers every "
"pad from the same maker.", "pad from the same maker.",
"Run these, then unplug the pad and plug it back in."] "Run these, then unplug the pad and plug it back in."]
d.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}", d.fix = [rule_command(),
"sudo udevadm control --reload-rules && sudo udevadm trigger"] "sudo udevadm control --reload-rules && sudo udevadm trigger"]
return d return d
@@ -695,7 +746,7 @@ def diagnose(keyd=True):
diag.detail = ["No udev rule lets you write to the pad yet. Running " diag.detail = ["No udev rule lets you write to the pad yet. Running "
"the installer again adds it, or paste these commands " "the installer again adds it, or paste these commands "
"into a terminal:"] "into a terminal:"]
diag.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}", diag.fix = [rule_command(),
"sudo udevadm control --reload-rules && sudo udevadm trigger", "sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"] "# then unplug and replug the pad"]
return diag return diag
@@ -729,88 +780,27 @@ def _explain(err):
def write_binding(device, control, binding, state, layer=1, _write=None): def write_binding(device, control, binding, state, layer=1, _write=None):
""" """
Send one binding (config report + commit) and update the shadow state. Send one binding and update the shadow state. Raises WriteError; on
Raises WriteError; on success the state file is already saved. success the state file is already saved.
""" """
write = _write or proto.write_report return backend_for(device).write(device, control, binding, state, layer,
reports = binding.reports(device.report_id, action_byte(control), layer) _write)
sent = 0
try:
for r in reports:
write(device.path, r)
sent += 1
time.sleep(WRITE_GAP)
except OSError as e:
why = _explain(e)
# If even the first write failed on open, nothing reached the pad.
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted after {sent} of "
f"{len(reports)} reports: {why}", layer)
state.save()
raise WriteError(f"{control}: {why}", pad_touched=not untouched, cause=e)
state.record(control, binding, layer)
state.save()
# --------------------------------------------------------------- decoding # --------------------------------------------------------------- decoding
def decode_info(report): def decode_info(report, backend=None):
"""(keys, knobs) from a 0xFB reply, or None.""" """(keys, knobs) from an info reply, or None."""
if len(report) < 4 or report[1] != proto.MAGIC_INFO: return (backend or backends().DEFAULT).decode_info(report)
return None
return report[2], report[3]
def decode_config(report, layout=None): def decode_config(report, layout=None, backend=None):
""" """
The inverse of Binding.reports()[0]: (control, layer, Binding) from a 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 config report, or None if it isn't one we understand. Used to import
import captures and to prove round trips in the tests. captures and to prove round trips in the tests.
layout decides which slots count as real controls, and it matters more
than it looks. The firmware answers for 15 keys and 3 knobs whatever
the pad actually has, so the caller has to say how big the pad is.
Decode a 15 key pad against the default 12 key layout and keys 13 to 15
and the whole third knob are dropped on the floor without a word.
""" """
if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE, proto.MAGIC_READ): return (backend or backends().DEFAULT).decode_config(report, layout)
return None
action, layer, kind = report[2], report[3], report[4]
control = (layout or DEFAULT_LAYOUT).control_for_action.get(action)
if control is None:
return None
delay = report[5] | (report[6] << 8)
count = report[10]
if kind == proto.KeyType.MULTIMEDIA and count:
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
return (control, layer, Binding(media=media)) if media else None
if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC):
mods, code = report[11], report[12]
if not mods and not code:
return control, layer, Binding(none=True)
return None
if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count:
steps = []
for i in range(count):
off = 11 + 2 * i
if off + 1 >= len(report):
return None
mods, code = report[off], report[off + 1]
names = [n for n, bit in _MOD_ORDER if mods & bit]
if code:
if code not in _KEYNAME_BY_CODE:
return None
names.append(_KEYNAME_BY_CODE[code])
steps.append("+".join(names))
return control, layer, Binding(keys=",".join(steps), delay=delay)
return None
def is_commit(report): def is_commit(report):
@@ -924,24 +914,12 @@ def _exchange(path, request, want=None):
def read_info(device): def read_info(device):
"""(keys, knobs) as the pad reports them, or None if it doesn't answer.""" """(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): return backend_for(device).info(device)
got = decode_info(reply)
if got:
return got
return None
def read_layer(device, layer=1, layout=None): def read_layer(device, layer=1, layout=None):
"""{control: Binding} for one layer, as it is on the pad right now.""" """{control: Binding} for one layer, as it is on the pad right now."""
out = {} return backend_for(device).read_layer(device, layer, layout)
for reply in _exchange(device.path, proto.native_read(device.report_id, layer)):
decoded = decode_config(reply, layout)
if not decoded:
continue
control, got_layer, binding = decoded
if got_layer == layer:
out[control] = binding
return out
def read_into_state(device, state, layer=1, layout=None): def read_into_state(device, state, layer=1, layout=None):
@@ -966,85 +944,53 @@ class Detection:
layers: int = 0 layers: int = 0
bindings: dict = field(default_factory=dict) bindings: dict = field(default_factory=dict)
why: str = "" # when speaks is False, what went wrong why: str = "" # when speaks is False, what went wrong
grid: tuple = () # (rows, cols) when the pad reports geometry
model: str = "" # what the pad calls itself, if it says
@property @property
def layout(self): def layout(self):
""" """
A first guess at the shape. The pad reports counts, not geometry, The shape to draw. Use the pad's own geometry when it reports any,
so rows and columns are a guess the owner should confirm. and guess from the key count when it doesn't.
Pads in this family are wider than they are tall, so the guess is The CH57x only reports counts, so for that family this is a guess
the squarest split that keeps rows no greater than columns: 6 keys the owner should confirm. Pads in it are wider than they are tall,
give 2x3, 12 give 3x4, 15 give 3x5. The old version had this the so the guess is the squarest split that keeps rows no greater than
wrong way round and drew every pad on its side. columns: 6 keys give 2x3, 12 give 3x4, 15 give 3x5. The old
version had this the wrong way round and drew every pad on its
side.
""" """
if not self.speaks: if not self.speaks:
return None return None
if self.grid and self.grid[0] * self.grid[1] == self.keys:
rows, cols = self.grid
return Layout(self.keys, self.knobs, rows, cols, self.model)
rows = max(r for r in range(1, int(self.keys ** 0.5) + 1) rows = max(r for r in range(1, int(self.keys ** 0.5) + 1)
if self.keys % r == 0) if self.keys % r == 0)
return Layout(self.keys, self.knobs, rows, self.keys // rows) return Layout(self.keys, self.knobs, rows, self.keys // rows,
self.model)
def detect(device): def detect(device):
""" """
Ask an unknown pad what it is, without configuring anything. Ask an unknown pad what it is, without configuring anything. Which
questions get asked is the backend's business; what comes back is a
Both commands are queries. We only conclude the pad speaks this Detection either way.
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: return backend_for(device).detect(device)
info = read_info(device)
except ReadError as e:
return Detection(why=f"it didn't answer: {e}")
if not info:
return Detection(why="it didn't answer the 'what are you' query")
keys, knobs = info
if not 1 <= keys <= proto.KEY_SLOTS or not 0 <= knobs <= proto.KNOB_SLOTS:
return Detection(why=f"it reported {keys} keys and {knobs} knobs, "
"which isn't a pad this protocol can describe")
probe = Layout(keys, knobs, rows=keys, cols=1) # geometry irrelevant here
expected = set(probe.control_for_action)
try:
replies = _exchange(device.path, proto.native_read(device.report_id, 1))
except ReadError as e:
return Detection(why=f"it answered the first query but not the second: {e}")
seen, bindings = set(), {}
for reply in replies:
if len(reply) < 13 or reply[1] != proto.MAGIC_READ:
continue
action, layer = reply[2], reply[3]
seen.add(action)
if action not in expected or layer != 1:
continue
control = probe.control_for_action[action]
decoded = decode_config(reply, probe)
if decoded:
bindings[control] = decoded[2]
missing = expected - seen
if missing:
return Detection(why=f"it described {keys} keys and {knobs} knobs but "
f"didn't report {len(missing)} of them, so this "
"isn't the protocol it speaks")
return Detection(speaks=True, keys=keys, knobs=knobs,
layers=proto.LAYERS, bindings=bindings)
def detection_report(device, detected): def detection_report(device, detected):
"""A block of text to paste into an issue when adding a new pad.""" """A block of text to paste into an issue when adding a new pad."""
lines = [f"Device: {device.vid}:{device.pid}", from . import __version__
backend = backend_for(device)
lines = [f"MacroPad {__version__}",
f"Device: {device.vid}:{device.pid}",
f"Name: {device.name or 'not reported'}", f"Name: {device.name or 'not reported'}",
f"Node: {device.path}, report id {device.report_id}", ""] f"Node: {device.path}, report id {device.report_id}",
f"Tried: {backend.name} ({backend.key})", ""]
if not detected.speaks: if not detected.speaks:
lines += ["This pad did NOT answer the 1189:8840 protocol.", lines += [f"This pad did NOT answer the {backend.name} protocol.",
f"Reason: {detected.why}"] f"Reason: {detected.why}"]
return "\n".join(lines) return "\n".join(lines)
lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.", lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,103 @@
Copyright (c) 2007-2008, The C&MA Guinea Fulbe Team;
Copyright renewed 2011-2012, George W. Nuss (http://www.fulbefouta.com),
with the Reserved Font Name "Fouta".
Copyright (c) 2004-2025, SIL Global (https://www.sil.org),
with Reserved Font Names 'Andika' and 'SIL'.
Copyright (c) 2014-2025, SIL Global (https://www.sil.org/).
with Reserved Font Names "Harmattan" and "SIL".
This Font Software is licensed under the SIL Open Font License,
Version 1.1. This license is copied below, and is also available with
an FAQ at: https://openfontlicense.org/
----------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
----------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -16,11 +16,12 @@ import zlib
from dataclasses import dataclass from dataclasses import dataclass
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF, from PySide6.QtGui import (QRadialGradient, QBrush, QColor, QFont, QFontDatabase, QFontMetricsF,
QPainter, QPainterPath, QPen, QTransform) QPainter, QPainterPath, QPen, QTransform)
from PySide6.QtWidgets import QSizePolicy, QWidget from PySide6.QtWidgets import QSizePolicy, QWidget
from . import core, themes from . import core, themes
from .vectors import KNOB_ARC
THEME = themes.get(themes.DEFAULT) THEME = themes.get(themes.DEFAULT)
@@ -35,12 +36,24 @@ TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
def apply_theme(theme): def apply_theme(theme):
global THEME global THEME, K, G, GY, M, R_OUT, R_IN, R_KNOB, ROW_H, ROW_GAP, ROW_SEP
global DIAL_W, KEY_R, LEGEND_R, RULE_X, LABEL_X
global BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT, BADGE
THEME = theme THEME = theme
for colour, value in ((WINDOW, theme.window), (BOARD, theme.board), for colour, value in ((WINDOW, theme.window), (BOARD, theme.board),
(INK, theme.ink), (INK_DIM, theme.ink_dim), (INK, theme.ink), (INK_DIM, theme.ink_dim),
(HATCH, theme.hatch), (TAPE, theme.accent)): (HATCH, theme.hatch), (TAPE, theme.accent)):
colour.setRgba(QColor(value).rgba()) colour.setRgba(QColor(value).rgba())
m = theme.metrics
K, G, GY, M = m.key, m.gap_x, m.gap_y, m.margin
R_OUT, R_IN, R_KNOB = m.r_out, m.r_in, m.r_knob
ROW_H, ROW_GAP, ROW_SEP = m.row_h, m.row_gap, m.row_sep
DIAL_W = m.dial_w
KEY_R, LEGEND_R = m.key_radius, m.legend_radius
RULE_X, LABEL_X = m.rule_x, m.label_x
BYTE_PT, LABEL_PT = m.byte_pt, m.label_pt
ROW_PT, ROW_BYTE_PT = m.row_pt, m.row_byte_pt
BADGE = m.badge
_FAMILIES = None _FAMILIES = None
@@ -92,11 +105,19 @@ def _wobble_path(path, amount, seed, step=4.0):
out.lineTo(q) out.lineTo(q)
return out return out
# Geometry in board units; the view scales it to fit. # Geometry in board units; the view scales it to fit. These come from the
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin # active theme's Metrics and are refreshed by apply_theme, so a theme can
R_OUT, R_IN, R_KNOB = 54.0, 40.0, 30.0 # change the pad's proportions and not just its colours.
ROW_H, ROW_GAP = 22.0, 12.0 _M = themes.Metrics()
DIAL_W = 160.0 K, G, GY, M = _M.key, _M.gap_x, _M.gap_y, _M.margin
R_OUT, R_IN, R_KNOB = _M.r_out, _M.r_in, _M.r_knob
ROW_H, ROW_GAP, ROW_SEP = _M.row_h, _M.row_gap, _M.row_sep
DIAL_W = _M.dial_w
KEY_R, LEGEND_R = _M.key_radius, _M.legend_radius
RULE_X, LABEL_X = _M.rule_x, _M.label_x
BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT = (_M.byte_pt, _M.label_pt,
_M.row_pt, _M.row_byte_pt)
BADGE = _M.badge
@dataclass @dataclass
class Look: class Look:
@@ -149,9 +170,16 @@ class PadView(QWidget):
self._wobbler = QTimer(self) self._wobbler = QTimer(self)
self._wobbler.setInterval(130) self._wobbler.setInterval(130)
self._wobbler.timeout.connect(self._tick) self._wobbler.timeout.connect(self._tick)
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) self._sys_mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._layout() self._layout()
@property
def mono(self):
"""The theme's monospace face when it ships one, else the system's."""
if THEME.mono_font and THEME.mono_font in _families():
return QFont(THEME.mono_font)
return self._sys_mono
# ------------------------------------------------------------ public # ------------------------------------------------------------ public
def set_orientation(self, orientation): def set_orientation(self, orientation):
@@ -209,7 +237,7 @@ class PadView(QWidget):
left = lay.knob_side == "left" and bool(lay.knobs) left = lay.knob_side == "left" and bool(lay.knobs)
keys_w = grid_cols * (K + G) - G keys_w = grid_cols * (K + G) - G
keys_h = grid_rows * (K + G) - G keys_h = grid_rows * (K + GY) - GY
# how much room the knobs need across, and down from the first centre # how much room the knobs need across, and down from the first centre
dials_w = lay.knobs * (DIAL_W + G) - G if lay.knobs else 0 dials_w = lay.knobs * (DIAL_W + G) - G if lay.knobs else 0
dials_h = (R_OUT + (lay.knobs - 1) * (stack + 20 + R_OUT) + stack dials_h = (R_OUT + (lay.knobs - 1) * (stack + 20 + R_OUT) + stack
@@ -243,17 +271,21 @@ class PadView(QWidget):
for r in range(grid_rows): for r in range(grid_rows):
for c in range(grid_cols): for c in range(grid_cols):
self.cells[(r, c)] = QRectF(grid_left + c * (K + G), self.cells[(r, c)] = QRectF(grid_left + c * (K + G),
grid_top + r * (K + G), K, K) grid_top + r * (K + GY), K, K)
for key, r, c in _key_grid(lay, self.orientation): for key, r, c in _key_grid(lay, self.orientation):
self.keys[key] = self.cells[(r, c)] self.keys[key] = self.cells[(r, c)]
for dial, centre in zip(lay.dial_ids, 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
# With a boxed legend the rows butt together and the block's own
# colour shows through as a hairline; otherwise they are loose.
inset = 0.0 if THEME.legend_box else 6.0
step = ROW_H + ROW_SEP
for i, act in enumerate(core.DIAL_ACTIONS): for i, act in enumerate(core.DIAL_ACTIONS):
self.rows[f"{dial}-{act}"] = QRectF( self.rows[f"{dial}-{act}"] = QRectF(
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H, centre.x() - DIAL_W / 2 + inset, top + i * step,
DIAL_W - 12, ROW_H) DIAL_W - 2 * inset, ROW_H)
boxes = list(self.cells.values()) + list(self.rows.values()) boxes = list(self.cells.values()) + list(self.rows.values())
right = max(r.right() for r in boxes) right = max(r.right() for r in boxes)
@@ -274,13 +306,41 @@ class PadView(QWidget):
# ----------------------------------------------------------- hit test # ----------------------------------------------------------- hit test
def _arc_outline(self, c, mirror):
"""
One turn arc, built from the outline in the Figma file. Coordinates
there are multiples of the knob's radius measured from its centre,
so this works at any size. The clockwise arc is the same shape
mirrored.
"""
path = QPainterPath()
s = R_KNOB
for op, a in KNOB_ARC:
pts = [QPointF(c.x() + (-a[i] if mirror else a[i]) * s,
c.y() + a[i + 1] * s)
for i in range(0, len(a), 2)]
if op == "M":
path.moveTo(pts[0])
elif op == "L":
path.lineTo(pts[0])
elif op == "Q":
path.quadTo(pts[0], pts[1])
elif op == "C":
path.cubicTo(pts[0], pts[1], pts[2])
else:
path.closeSubpath()
return path
def _segment(self, dial, act): def _segment(self, dial, act):
"""Clickable area for one dial action, in board units.""" """Clickable area for one dial action, in board units."""
c = self.dials[dial] c = self.dials[dial]
path = QPainterPath() path = QPainterPath()
if act == "push": if act == "push":
path.addEllipse(c, R_IN - 2, R_IN - 2) path.addEllipse(c, (R_KNOB if THEME.knob_art else R_IN) - 2,
(R_KNOB if THEME.knob_art else R_IN) - 2)
return path return path
if THEME.knob_art == "figma":
return self._arc_outline(c, mirror=(act == "right"))
start, span = (100, 160) if act == "left" else (-80, 160) start, span = (100, 160) if act == "left" else (-80, 160)
outer = QRectF(c.x() - R_OUT, c.y() - R_OUT, 2 * R_OUT, 2 * R_OUT) outer = QRectF(c.x() - R_OUT, c.y() - R_OUT, 2 * R_OUT, 2 * R_OUT)
inner = QRectF(c.x() - R_IN, c.y() - R_IN, 2 * R_IN, 2 * R_IN) inner = QRectF(c.x() - R_IN, c.y() - R_IN, 2 * R_IN, 2 * R_IN)
@@ -494,7 +554,8 @@ class PadView(QWidget):
"""roomy: there's space to spare, as on a keycap; not on a dial legend.""" """roomy: there's space to spare, as on a keycap; not on a dial legend."""
if THEME.font and THEME.font in _families(): if THEME.font and THEME.font in _families():
f = QFont(THEME.font) f = QFont(THEME.font)
f.setPixelSize(px + (2 if roomy else 0)) # handwriting reads small # handwriting reads small; a normal face does not need the bump
f.setPixelSize(px + (2 if roomy and THEME.wobble else 0))
elif THEME.mono_legends: elif THEME.mono_legends:
f = QFont(self.mono) f = QFont(self.mono)
f.setPixelSize(px - 1) f.setPixelSize(px - 1)
@@ -616,8 +677,58 @@ class PadView(QWidget):
i += 1 i += 1
p.restore() p.restore()
# ----------------------------------------------------------- surfaces
@staticmethod
def _well_brush(rect, radial=False):
"""
The tile and knob gradients. Figma calls the tile one a diamond
gradient, which Qt has no equivalent for; a radial one reaching the
corners is the closest honest approximation and reads the same, as a
face that darkens towards its edge.
"""
if not THEME.well:
return QColor(THEME.board)
c = rect.center()
r = (math.hypot(rect.width(), rect.height()) / 2 if not radial
else max(rect.width(), rect.height()) / 2)
g = QRadialGradient(c, r)
g.setColorAt(0.0, QColor(THEME.board))
g.setColorAt(0.75 if radial else 0.745, QColor(THEME.board))
g.setColorAt(0.9 if radial else 1.0, QColor(THEME.well))
g.setColorAt(1.0, QColor(THEME.well))
return QBrush(g)
def _glow(self, p, path, colour):
"""
A few wide, nearly transparent passes under a stroke, so an accent
edge blooms the way it does in the design.
"""
if not THEME.glow:
return
p.save()
p.setBrush(Qt.NoBrush)
for i, (width, alpha) in enumerate(((THEME.glow, 26),
(THEME.glow * 0.6, 44),
(THEME.glow * 0.3, 70))):
c = QColor(colour)
c.setAlpha(alpha)
pen = QPen(c, width)
pen.setJoinStyle(Qt.RoundJoin)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.drawPath(path)
p.restore()
def _tape(self, p, at, length=30.0, width=9.0): def _tape(self, p, at, length=30.0, width=9.0):
"""A strip of Kapton stuck across a corner.""" """
The marker for an edit that hasn't reached the pad yet. Normally a
strip of Kapton across the corner; "star" themes stamp a little
asterisk tile instead.
"""
if THEME.badge == "star":
self._star_badge(p, at, max(11.0, width * 1.6))
return
p.save() p.save()
p.translate(at) p.translate(at)
p.rotate(45) p.rotate(45)
@@ -628,37 +739,123 @@ class PadView(QWidget):
p.drawRect(QRectF(-length / 2, -width / 2, length, width)) p.drawRect(QRectF(-length / 2, -width / 2, length, width))
p.restore() p.restore()
@staticmethod
def _corner_path(rect, tl=0.0, tr=0.0, br=0.0, bl=0.0):
"""
A rectangle with each corner rounded by its own radius, built as one
continuous outline. Adding overlapping sub-paths instead would cancel
under the odd-even fill rule and leave the shape hollow.
"""
path = QPainterPath()
path.moveTo(rect.left() + tl, rect.top())
path.lineTo(rect.right() - tr, rect.top())
if tr:
path.quadTo(rect.right(), rect.top(), rect.right(), rect.top() + tr)
path.lineTo(rect.right(), rect.bottom() - br)
if br:
path.quadTo(rect.right(), rect.bottom(),
rect.right() - br, rect.bottom())
path.lineTo(rect.left() + bl, rect.bottom())
if bl:
path.quadTo(rect.left(), rect.bottom(), rect.left(), rect.bottom() - bl)
path.lineTo(rect.left(), rect.top() + tl)
if tl:
path.quadTo(rect.left(), rect.top(), rect.left() + tl, rect.top())
path.closeSubpath()
return path
def _star_badge(self, p, at, size=16.0):
"""A filled tile with an asterisk on it, for a knob row."""
box = QRectF(at.x() - size / 2, at.y() - size / 2, size, size)
self._flag_shape(p, box, THEME.badge_glyph or "*", QColor(TAPE),
QColor(THEME.accent_text))
def _corner_flag(self, p, rect, glyph, fill, pen_colour, italic=False):
"""
The tab tucked into a key's top right corner: square, with only the
inner corner rounded, so it reads as folded into the tile.
"""
size = min(BADGE, rect.width() * 0.26)
box = QRectF(rect.right() - size - KEY_R * 0.35,
rect.top() + KEY_R * 0.18, size, size)
self._flag_shape(p, box, glyph, fill, pen_colour, italic)
def _flag_shape(self, p, box, glyph, fill, pen_colour, italic=False):
p.save()
r = box.width() * 0.28
# square against the tile's own corner, rounded on the two inner ones
path = self._corner_path(box, tl=r, tr=0.0, br=r, bl=0.0)
p.setPen(Qt.NoPen)
p.setBrush(fill)
p.drawPath(path)
f = QFont(self.mono)
f.setPixelSize(max(7, round(box.height() * 0.95)))
f.setWeight(QFont.ExtraBold)
f.setItalic(italic)
p.setFont(f)
p.setPen(pen_colour)
# the asterisk sits high in the em box; nudge it back to the middle
lift = box.height() * (0.18 if glyph == "*" else 0.0)
p.drawText(box.translated(0, lift), Qt.AlignCenter, glyph)
p.restore()
def _paint_key(self, p, key, rect): def _paint_key(self, p, key, rect):
look = self.looks.get(key, Look()) look = self.looks.get(key, Look())
path = QPainterPath() path = QPainterPath()
path.addRoundedRect(rect, 5, 5) path.addRoundedRect(rect, KEY_R, KEY_R)
unknown = not look.known and not look.pending unknown = not look.known and not look.pending
if THEME.well:
p.fillPath(path, self._well_brush(rect))
if unknown: if unknown:
self._unknown_fill(p, path, rect, key) self._unknown_fill(p, path, rect, key)
if key == self.current or look.pending:
self._glow(p, path, TAPE)
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key) self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
f = QFont(self.mono) f = QFont(self.mono)
f.setPixelSize(10) f.setPixelSize(max(6, round(BYTE_PT)))
if THEME.mono_font:
f.setItalic(True)
f.setWeight(QFont.Bold)
p.setFont(f) p.setFont(f)
p.setPen(INK_DIM) p.setPen(QColor(THEME.byte_ink) if THEME.byte_ink else INK_DIM)
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop, bi = THEME.metrics.byte_inset
byte_at = (rect.adjusted(bi[0] * rect.width(), bi[1] * rect.height(),
-bi[0] * rect.width(), 0)
if bi else rect.adjusted(6, 4, -6, -4))
p.drawText(byte_at, Qt.AlignLeft | Qt.AlignTop,
f"0x{core.action_byte(key):02x}") f"0x{core.action_byte(key):02x}")
f = self._legend_font(13) f = self._legend_font(round(LABEL_PT))
p.setFont(f) p.setFont(f)
if look.pending: if key == self.current:
p.setPen(TAPE) p.setPen(TAPE)
elif look.pending:
# the design marks a change with the corner flag, not the words
p.setPen(TAPE if not THEME.badge_glyph else INK)
else: else:
p.setPen(INK if look.known and not look.quiet else INK_DIM) p.setPen(INK if look.known and not look.quiet else INK_DIM)
text = self._lettering(look.text if (look.known or look.pending) else "Unknown") text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
body = rect.adjusted(8, 22, -8, -10) lb = THEME.metrics.label_band
body = (QRectF(rect.left() + lb[0] * rect.width(),
rect.top() + lb[1] * rect.height(),
rect.width() * (1 - lb[0] - lb[2]),
rect.height() * (1 - lb[1] - lb[3]))
if lb else rect.adjusted(8, 22, -8, -10))
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off. # Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
text = text.replace("+", "+\u200b") text = text.replace("+", "+\u200b")
p.drawText(body, Qt.AlignCenter | Qt.TextWordWrap, p.drawText(body, Qt.AlignCenter | Qt.TextWordWrap,
self._fit(text, f, body)) self._fit(text, f, body))
if look.pending: if look.pending:
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7)) if THEME.badge == "star":
self._corner_flag(p, rect, THEME.badge_glyph or "*",
QColor(TAPE), QColor(THEME.accent_text))
else:
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7))
elif unknown and THEME.query_badge:
self._corner_flag(p, rect, "?", QColor(THEME.hatch), INK_DIM,
italic=True)
def _paint_turn_icon(self, p, centre, clockwise, colour): def _paint_turn_icon(self, p, centre, clockwise, colour):
r = 5.5 r = 5.5
@@ -688,12 +885,16 @@ class PadView(QWidget):
look = self.looks.get(control, Look()) look = self.looks.get(control, Look())
seg = self._segment(dial, act) seg = self._segment(dial, act)
if act != "push": if act != "push":
if THEME.well:
p.fillPath(seg, self._well_brush(seg.boundingRect()))
if not look.known and not look.pending: if not look.known and not look.pending:
self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0) self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0)
if control == self.current: if control == self.current:
fill = QColor(TAPE) fill = QColor(TAPE)
fill.setAlpha(55) fill.setAlpha(55 if not THEME.glow else 28)
p.fillPath(seg, fill) p.fillPath(seg, fill)
if control == self.current or look.pending:
self._glow(p, seg, TAPE)
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending), self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
control) control)
@@ -701,12 +902,16 @@ class PadView(QWidget):
look = self.looks.get(push, Look()) look = self.looks.get(push, Look())
knob = QPainterPath() knob = QPainterPath()
knob.addEllipse(c, R_KNOB, R_KNOB) knob.addEllipse(c, R_KNOB, R_KNOB)
if THEME.well:
p.fillPath(knob, self._well_brush(knob.boundingRect(), radial=True))
if not look.known and not look.pending: if not look.known and not look.pending:
self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0) self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0)
if push == self.current: if push == self.current:
fill = QColor(TAPE) fill = QColor(TAPE)
fill.setAlpha(55) fill.setAlpha(55 if not THEME.glow else 28)
p.fillPath(knob, fill) p.fillPath(knob, fill)
if push == self.current or look.pending:
self._glow(p, knob, TAPE)
self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push) self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
# knurling, the way a footprint marks a rotary encoder # knurling, the way a footprint marks a rotary encoder
if not THEME.wobble: if not THEME.wobble:
@@ -726,13 +931,31 @@ class PadView(QWidget):
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}")) p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
# Legend rows: the silkscreen text next to the part. # Legend rows: the silkscreen text next to the part.
if THEME.legend_box:
self._paint_legend_box(p, dial)
for act in core.DIAL_ACTIONS: for act in core.DIAL_ACTIONS:
control = f"{dial}-{act}" control = f"{dial}-{act}"
self._paint_row(p, control, act, self.rows[control]) self._paint_row(p, control, act, self.rows[control])
def _paint_legend_box(self, p, dial):
"""
One knob's three rows sit on a plate the colour of the dividing
lines. The rows are painted over it leaving the plate showing as a
hairline between them, which is how the design draws its separators.
"""
boxes = [self.rows[f"{dial}-{a}"] for a in core.DIAL_ACTIONS]
rect = boxes[0].united(boxes[-1]).adjusted(-ROW_SEP, -ROW_SEP,
ROW_SEP, ROW_SEP)
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
p.setBrush(QColor(THEME.line))
p.drawRoundedRect(rect, LEGEND_R, LEGEND_R)
def _paint_row(self, p, control, act, rect): def _paint_row(self, p, control, act, rect):
look = self.looks.get(control, Look()) look = self.looks.get(control, Look())
is_cur = control == self.current is_cur = control == self.current
if THEME.legend_box:
self._paint_boxed_row(p, control, act, rect, look, is_cur)
return
if is_cur or control == self.hover: if is_cur or control == self.hover:
bg = QColor(TAPE if is_cur else INK) bg = QColor(TAPE if is_cur else INK)
bg.setAlpha(40 if is_cur else 18) bg.setAlpha(40 if is_cur else 18)
@@ -753,20 +976,85 @@ class PadView(QWidget):
mono.setPixelSize(10) mono.setPixelSize(10)
p.setFont(mono) p.setFont(mono)
p.setPen(INK_DIM) p.setPen(INK_DIM)
prefix = "0x" if THEME.byte_prefix else ""
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter, p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
f"{core.action_byte(control):02x}") f"{prefix}{core.action_byte(control):02x}")
f = self._legend_font(12, roomy=False) f = self._legend_font(12, roomy=False)
p.setFont(f) p.setFont(f)
p.setPen(TAPE if look.pending else p.setPen(TAPE if look.pending else
(INK if look.known and not look.quiet else INK_DIM)) (INK if look.known and not look.quiet else INK_DIM))
text = self._lettering(look.text if (look.known or look.pending) else "Unknown") text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
body = rect.adjusted(22, 0, -26, 0) # the action byte sits on the right; leave room for the wider 0x form
body = rect.adjusted(22, 0, -36 if THEME.byte_prefix else -26, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter, p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width())) QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
if look.pending: if look.pending:
self._tape(p, QPointF(rect.left() - 4, rect.top() + 3), 14, 6) self._tape(p, QPointF(rect.left() - 4, rect.top() + 3), 14, 6)
def _paint_boxed_row(self, p, control, act, rect, look, is_cur):
"""
One row of a boxed legend: a filled tile, a turn or press icon, a
vertical rule, the legend, and the action byte on the right.
"""
first = act == core.DIAL_ACTIONS[0]
last = act == core.DIAL_ACTIONS[-1]
r = LEGEND_R
path = self._corner_path(rect,
tl=r if first else 0.0, tr=r if first else 0.0,
br=r if last else 0.0, bl=r if last else 0.0)
p.setPen(Qt.NoPen)
p.setBrush(self._well_brush(rect) if THEME.well else QColor(THEME.board))
p.drawPath(path)
if not look.known and not look.pending:
self._unknown_fill(p, path, rect, control, 7.0)
if is_cur or look.pending:
self._glow(p, path, TAPE)
pen = QPen(QColor(TAPE), ROW_SEP * 1.6)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
p.drawPath(path)
elif control == self.hover:
glow = QColor(INK)
glow.setAlpha(18)
p.setPen(Qt.NoPen)
p.setBrush(glow)
p.drawPath(path)
colour = (QColor(TAPE) if (look.pending or is_cur)
else (INK if look.known and not look.quiet else INK_DIM))
icon_c = QPointF(rect.left() + RULE_X / 2, rect.center().y())
if act == "push":
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawEllipse(icon_c, rect.height() * 0.11, rect.height() * 0.11)
else:
self._paint_turn_icon(p, icon_c, act == "right", colour)
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
p.drawLine(QPointF(rect.left() + RULE_X, rect.top()),
QPointF(rect.left() + RULE_X, rect.bottom()))
mono = QFont(self.mono)
mono.setPixelSize(max(6, round(ROW_BYTE_PT)))
mono.setItalic(True)
mono.setWeight(QFont.Bold)
p.setFont(mono)
p.setPen(QColor(THEME.byte_ink or THEME.ink_dim))
p.drawText(rect.adjusted(0, 0, -RULE_X * 0.25, 0),
Qt.AlignRight | Qt.AlignVCenter,
f"0x{core.action_byte(control):02x}")
f = self._legend_font(round(ROW_PT), roomy=False)
p.setFont(f)
p.setPen(colour)
text = self._lettering(look.text if (look.known or look.pending)
else "Unknown")
body = rect.adjusted(LABEL_X, 0, -RULE_X * 1.1, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
@staticmethod @staticmethod
def _fit(text, font, rect): def _fit(text, font, rect):
"""Elide to at most two wrapped lines.""" """Elide to at most two wrapped lines."""

View File

@@ -12,10 +12,46 @@ Most themes only change colours. Two change how the pad is drawn:
Nord and Catppuccin use their published palettes, both MIT licensed. Nord and Catppuccin use their published palettes, both MIT licensed.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
@dataclass(frozen=True)
class Metrics:
"""
The pad's proportions, in board units. The view scales the whole board
to fit, so only the ratios between these matter.
Knobs North's values come from its Figma file: each one is the design's
measurement divided by the design's 291.67 unit key and multiplied by
our 96, so the proportions are exactly the designer's.
"""
key: float = 96.0 # a key tile, square
gap_x: float = 16.0 # between key columns
gap_y: float = 16.0 # between key rows
margin: float = 30.0 # board edge to anything
key_radius: float = 5.0
r_out: float = 54.0 # outer reach of a knob's arcs
r_in: float = 40.0
r_knob: float = 30.0 # the knob body
row_h: float = 22.0 # one legend row
row_gap: float = 12.0 # knob bottom to first legend row
row_sep: float = 0.0 # hairline between rows, 0 for none
dial_w: float = 160.0 # legend block width
legend_radius: float = 3.0
rule_x: float = 0.0 # vertical rule inside the legend block
label_x: float = 22.0 # where a legend's text starts
byte_pt: float = 10.0 # action byte type size
label_pt: float = 13.0 # key legend type size
row_pt: float = 12.0 # knob legend type size
row_byte_pt: float = 10.0
badge: float = 13.0 # the changed / unknown corner flag
# Where a key's legend sits, as fractions of the tile: left, top, right,
# bottom. Empty keeps the fixed insets that suit a 96 unit key.
label_band: tuple = ()
byte_inset: tuple = () # same, for the action byte
@dataclass(frozen=True) @dataclass(frozen=True)
class Theme: class Theme:
key: str key: str
@@ -44,6 +80,24 @@ class Theme:
caps: bool = False # legends in capitals, like drawing lettering caps: bool = False # legends in capitals, like drawing lettering
font: str = "" # legend font; empty means the system font font: str = "" # legend font; empty means the system font
mono_legends: bool = False # draw legends in the monospace font mono_legends: bool = False # draw legends in the monospace font
badge: str = "tape" # pending marker: "tape" strip or "star"
legend_box: bool = False # a border around each knob's three rows
query_badge: bool = False # a "?" in the corner of an unknown key
byte_prefix: bool = False # knob rows read 0x13 rather than 13
badge_glyph: str = "" # a character stamped in the corner flag
byte_ink: str = "" # action byte grey; empty means ink_dim
well: str = "" # gradient's far colour; empty means flat
glow: float = 0.0 # neon bloom on accent strokes, board units
knob_art: str = "" # "figma" draws the outline from vectors.py
button_radius: int = 4 # window buttons and fields
pill_buttons: bool = False # footer buttons outlined rather than bare
outline_primary: bool = False # the write button outlined, not filled
ui_pt: int = 0 # base type size; 0 keeps Qt's own
title_pt: int = 18 # the inspector's heading
mono_status: bool = False # footer status in the mono face
metrics: Metrics = field(default_factory=Metrics)
ui_font: str = "" # window chrome; empty means the system font
mono_font: str = "" # window chrome, monospace parts
notes: tuple = field(default=()) notes: tuple = field(default=())
@@ -92,15 +146,141 @@ LATTE = Theme(
hatch="#ccd0da", accent="#8839ef", accent_text="#eff1f5", hatch="#ccd0da", accent="#8839ef", accent_text="#eff1f5",
good="#40a02b", bad="#d20f39", line="#bcc0cc", field="#dce0e8") good="#40a02b", bad="#d20f39", line="#bcc0cc", field="#dce0e8")
THEMES = {t.key: t for t in (SILKSCREEN, WHITE_BOARD, BLUEPRINT, SKETCH, # Designed in Figma by a member of the community. The palette is Silkscreen's
NORD, MOCHA, LATTE)} # own window and board with a hotter orange and neutral rather than warm greys,
DEFAULT = SILKSCREEN.key # so what makes it its own theme is mostly the drawing switches below.
# Scale factor from the Figma file: its key tile is 291.67 units, ours is 96.
_F = 96.0 / 291.67
KNOBS_NORTH = Theme(
"knobs-north", "Knobs North", True,
window="#17191b", board="#202326", ink="#ffffff", ink_dim="#b5b5b5",
hatch="#2f2f2f", accent="#ff7700", accent_text="#ffffff",
good="#87df9a", bad="#e0584f", line="#6e6e6e", field="#202326",
badge="star", badge_glyph="*", legend_box=True, query_badge=True,
byte_prefix=True, byte_ink="#505050",
# tile and knob gradients, both #202326 falling to #131619 in the file
well="#131619", glow=3.2, knob_art="figma",
font="Harmattan", mono_font="JetBrains Mono NL", ui_font="Harmattan",
button_radius=7, pill_buttons=True, outline_primary=True, ui_pt=15,
title_pt=28, mono_status=True,
metrics=Metrics(
key=96.0,
gap_x=88.0 * _F, gap_y=60.58 * _F, margin=74.0 * _F,
key_radius=20.0 * _F,
r_out=128.58 * _F, r_in=110.0 * _F, r_knob=92.5 * _F,
row_h=75.0 * _F, row_gap=40.0 * _F, row_sep=2.0 * _F,
dial_w=512.0 * _F, legend_radius=15.0 * _F,
rule_x=73.0 * _F, label_x=94.0 * _F,
byte_pt=24.0 * _F, label_pt=40.0 * _F,
row_pt=40.0 * _F, row_byte_pt=20.0 * _F,
badge=64.29 * _F,
# The design sets line height to exactly the type size; Qt's is
# looser, so the band is taller than the design's 100/300 while
# staying centred on the same line.
label_band=(38 / 300, 0.18, 37 / 300, 0.18),
byte_inset=(19 / 300, 10 / 300)),
notes=("Designed in Figma by a member of the community.",
"Knobs along the top. Use Rotate view if the drawing is sideways."))
# --------------------------------------------------------------- palettes
#
# A palette is colour and nothing else. Anything that decides how the pad is
# drawn - proportions, badges, knob art, typefaces - belongs to the style.
PALETTE_FIELDS = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field", "well",
"byte_ink", "dark")
def _palette(theme, **over):
"""Pull a palette out of a theme written the old way."""
out = {f: getattr(theme, f) for f in PALETTE_FIELDS}
out.update(over)
return out
PALETTES = {
"graphite": ("Graphite", _palette(SILKSCREEN)),
"paper": ("Paper", _palette(WHITE_BOARD)),
"nord": ("Nord", _palette(NORD)),
"mocha": ("Catppuccin Mocha", _palette(MOCHA)),
"latte": ("Catppuccin Latte", _palette(LATTE)),
"neon": ("Neon", _palette(KNOBS_NORTH)),
"blueprint": ("Blueprint", _palette(BLUEPRINT)),
"graph": ("Graph paper", _palette(SKETCH)),
}
# ----------------------------------------------------------------- styles
#
# recolour lists the palettes a style is offered with. Blueprint and Sketch
# are left alone: a blueprint that isn't blue, or a pencil sketch that isn't
# on paper, is not the same idea.
OPEN = ("graphite", "paper", "nord", "mocha", "latte", "neon")
STYLES = {
"silkscreen": ("Silkscreen", SILKSCREEN, OPEN),
"knobs-north": ("Knobs North", KNOBS_NORTH, OPEN),
"blueprint": ("Blueprint", BLUEPRINT, ("blueprint",)),
"sketch": ("Hand drawn", SKETCH, ("graph",)),
}
# Keys as they were before styles and palettes were separated, so a saved
# setting from an older build still finds its theme.
LEGACY = {
"silkscreen": ("silkscreen", "graphite"),
"white-board": ("silkscreen", "paper"),
"nord": ("silkscreen", "nord"),
"catppuccin-mocha": ("silkscreen", "mocha"),
"catppuccin-latte": ("silkscreen", "latte"),
"blueprint": ("blueprint", "blueprint"),
"sketch": ("sketch", "graph"),
"knobs-north": ("knobs-north", "neon"),
}
_LEGACY_BY_PAIR = {v: k for k, v in LEGACY.items()}
def compose(style_key, palette_key):
"""One style wearing one palette."""
style_name, base, _allowed = STYLES[style_key]
palette_name, colours = PALETTES[palette_key]
key = _LEGACY_BY_PAIR.get((style_key, palette_key),
f"{style_key}/{palette_key}")
name = (style_name if len(STYLES[style_key][2]) == 1
else f"{style_name} / {palette_name}")
return replace(base, key=key, name=name, **colours)
THEMES = {}
for _s, (_sn, _base, _allowed) in STYLES.items():
for _p in _allowed:
_t = compose(_s, _p)
THEMES[_t.key] = _t
DEFAULT = "silkscreen"
def parts(key):
"""(style, palette) for a theme key, old style or new."""
if key in LEGACY:
return LEGACY[key]
if "/" in key:
s, p = key.split("/", 1)
if s in STYLES and p in PALETTES:
return s, p
return LEGACY[DEFAULT]
FONTS = Path(__file__).resolve().parent / "fonts" FONTS = Path(__file__).resolve().parent / "fonts"
def get(key): def get(key):
return THEMES.get(key, THEMES[DEFAULT]) if key in THEMES:
return THEMES[key]
style, palette = parts(key or DEFAULT)
if palette not in STYLES[style][2]:
palette = STYLES[style][2][0]
return THEMES[compose(style, palette).key]
def mix(a, b, t): def mix(a, b, t):

26
macropad_gui/vectors.py Normal file
View File

@@ -0,0 +1,26 @@
"""Knob arc outline, lifted from Mo-MacroPad.fig (frame "Knob: Written").
Coordinates are multiples of the knob's radius, measured from its
centre, so the shape survives any scaling. The clockwise arc is this
same outline mirrored in x.
"""
KNOB_ARC = (
("M", (-0.48235, -1.28433)),
("C", (-0.47805, -1.30123, -0.49448, -1.31592, -0.51064, -1.30935)),
("C", (-0.75489, -1.21012, -0.96658, -1.04405, -1.1212, -0.82982)),
("C", (-1.28387, -0.60444, -1.37606, -0.33587, -1.3861, -0.05809)),
("C", (-1.39614, 0.21969, -1.32359, 0.49421, -1.17761, 0.73076)),
("C", (-1.03163, 0.96731, -0.81879, 1.15525, -0.566, 1.27083)),
("L", (-0.35379, 1.23286)),
("C", (-0.33893, 1.23021, -0.3313, 1.21352, -0.33901, 1.20054)),
("L", (-0.44912, 1.0152)),
("C", (-0.65136, 0.92274, -0.82163, 0.77238, -0.93841, 0.58314)),
("C", (-1.05519, 0.39391, -1.11324, 0.17429, -1.10521, -0.04793)),
("C", (-1.09717, -0.27016, -1.02342, -0.48501, -0.89328, -0.66532)),
("C", (-0.7712, -0.83446, -0.60464, -0.96608, -0.41247, -1.04581)),
("C", (-0.39636, -1.0525, -0.39361, -1.07455, -0.40792, -1.08452)),
("L", (-0.5143, -1.15866)),
("L", (-0.48235, -1.28433)),
("Z", ()),
)

View File

@@ -345,3 +345,40 @@ class Themes(unittest.TestCase):
from macropad_gui import themes from macropad_gui import themes
self.assertTrue((themes.FONTS / "ArchitectsDaughter-Regular.ttf").exists()) self.assertTrue((themes.FONTS / "ArchitectsDaughter-Regular.ttf").exists())
self.assertIn("Open Font License", (themes.FONTS / "OFL.txt").read_text()) self.assertIn("Open Font License", (themes.FONTS / "OFL.txt").read_text())
class WindowsImport(unittest.TestCase):
"""
app.py imports actions to build the window, so actions must import on a
system with no fcntl. It used to fail twice: the import itself, and
VirtualKeyboard's _ioctl default, which is evaluated at import time.
"""
def test_actions_imports_without_fcntl(self):
import builtins
import sys
real = builtins.__import__
def no_fcntl(name, *a, **k):
if name == "fcntl":
raise ImportError("No module named 'fcntl'")
return real(name, *a, **k)
saved = {m: sys.modules[m] for m in list(sys.modules)
if m.startswith("macropad_gui.actions") or m == "fcntl"}
for m in saved:
del sys.modules[m]
builtins.__import__ = no_fcntl
try:
import importlib
mod = importlib.import_module("macropad_gui.actions")
self.assertIsNone(mod.fcntl)
with self.assertRaises(OSError):
mod.VirtualKeyboard()
self.assertEqual(mod.listen(log=lambda _s: None), 1)
finally:
builtins.__import__ = real
for m in list(sys.modules):
if m.startswith("macropad_gui.actions"):
del sys.modules[m]
sys.modules.update(saved)

195
tests/test_backends.py Normal file
View File

@@ -0,0 +1,195 @@
"""
The backend seam.
A split you cannot exercise is not a split, so these tests register a second,
imaginary family and check that the app routes to it: different vendor id,
different slot numbering, different everything. Nothing here touches real
hardware and the fake backend is removed again afterwards.
"""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from macropad_gui import backends, core
from macropad_gui.backends.base import Backend
import macropad as proto
class Imaginary(Backend):
"""
Stands in for a second device family. Eight keys, no knobs, slots
numbered from 0x20 so nothing it returns could be mistaken for a CH57x
answer that leaked through.
"""
key = "imaginary"
name = "Imaginary pad"
vid = "c0de"
pids = ("0001",)
layers = 1
max_keys = 8
max_knobs = 0
def __init__(self):
self.calls = []
def action_byte(self, control):
self.calls.append(("action_byte", control))
return 0x20 + int(control[3:]) - 1
def info(self, device):
self.calls.append(("info", device.path))
return 8, 0
def read_layer(self, device, layer=1, layout=None):
self.calls.append(("read_layer", layer))
return {f"key{i}": core.Binding(keys="a") for i in range(1, 9)}
def write(self, device, control, binding, state, layer=1, _write=None):
self.calls.append(("write", control))
state.record(control, binding, layer)
state.save()
def detect(self, device):
self.calls.append(("detect", device.path))
return core.Detection(speaks=True, keys=8, knobs=0, layers=1,
bindings={"key1": core.Binding(keys="a")})
def fake_device(vid="c0de", pid="0001", path="/dev/null-pad"):
return core.Device(path=path, report_id=3, writable=True, vid=vid, pid=pid)
class Registry(unittest.TestCase):
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
def tearDown(self):
backends.REGISTRY = self._saved
def test_each_family_gets_its_own_device(self):
self.assertIs(backends.for_vid_pid("c0de", "0001"), self.fake)
self.assertIsInstance(backends.for_vid_pid("1189", "8840"),
backends.CH57x)
def test_an_unknown_id_is_claimed_by_nobody(self):
self.assertIsNone(backends.for_vid_pid("dead", "beef"))
def test_a_sibling_under_a_known_vendor_is_not_claimed(self):
"""
1189:8890 exists and is not ours. It must not be claimed on the
strength of its vendor id, or the app would write to a pad whose
protocol it has never confirmed.
"""
self.assertIsNone(backends.for_vid_pid("1189", "8890"))
ch = backends.CH57x()
self.assertTrue(ch.could_claim("1189", "8890")) # still worth finding
def test_for_device_falls_back_rather_than_returning_none(self):
self.assertIsInstance(backends.for_device(None), backends.CH57x)
self.assertIsInstance(backends.for_device(fake_device("1189", "8890")),
backends.CH57x)
class Routing(unittest.TestCase):
"""core's functions must land on the backend that owns the device."""
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
self.dir = tempfile.TemporaryDirectory()
self.state = core.State(Path(self.dir.name) / "s.json")
def tearDown(self):
backends.REGISTRY = self._saved
self.dir.cleanup()
def test_detect_routes(self):
found = core.detect(fake_device())
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (8, 0))
self.assertIn(("detect", "/dev/null-pad"), self.fake.calls)
def test_read_routes(self):
got = core.read_layer(fake_device())
self.assertEqual(len(got), 8)
self.assertIn(("read_layer", 1), self.fake.calls)
def test_info_routes(self):
self.assertEqual(core.read_info(fake_device()), (8, 0))
def test_write_routes(self):
core.write_binding(fake_device(), "key1", core.Binding(keys="a"),
self.state)
self.assertIn(("write", "key1"), self.fake.calls)
self.assertEqual(self.state.get("key1").binding, core.Binding(keys="a"))
def test_slot_numbering_is_per_family(self):
self.assertEqual(self.fake.action_byte("key1"), 0x20)
self.assertEqual(core.action_byte("key1"), 1) # still CH57x
self.assertEqual(core.action_byte("key1", self.fake), 0x20)
def test_the_real_backend_is_untouched_by_the_fake(self):
ch = backends.for_vid_pid("1189", "8840")
self.assertEqual(ch.action_byte("dial2-right"), 0x15)
self.assertEqual(ch.max_keys, proto.KEY_SLOTS)
def test_discovery_covers_every_vendor(self):
self.assertEqual(core.vendors(), {"1189", "8089", "c0de"})
with mock.patch.object(proto, "find_devices", return_value=[]) as f:
core.discover()
f.assert_called_once()
self.assertEqual(f.call_args[0][0], {"1189", "8089", "c0de"})
def test_one_udev_rule_per_vendor(self):
lines = core.rule_lines()
self.assertEqual(len(lines), len(backends.REGISTRY))
self.assertTrue(any('"1189"' in l for l in lines))
self.assertTrue(any('"c0de"' in l for l in lines))
for line in lines:
self.assertIn('TAG+="uaccess"', line)
self.assertIn('KERNEL=="hidraw*"', line)
class HalfWritten(unittest.TestCase):
"""A backend that forgets a method should fail loudly, not silently."""
def test_the_base_class_refuses_to_guess(self):
bare = Backend()
dev = fake_device()
for call in (lambda: bare.action_byte("key1"),
lambda: bare.info(dev),
lambda: bare.read_layer(dev),
lambda: bare.write(dev, "key1", None, None),
lambda: bare.detect(dev)):
with self.assertRaises(NotImplementedError):
call()
class StateFiles(unittest.TestCase):
def test_the_default_file_name_has_not_changed(self):
"""Renaming it would orphan every existing user's bindings."""
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
self.assertEqual(core.State().path.name, "state-1189-8840.json")
def test_a_second_family_gets_its_own_file(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
s = core.State(device_id="c0de:0001")
self.assertEqual(s.path.name, "state-c0de-0001.json")
s.record("key1", core.Binding(keys="a"))
s.save()
import json
self.assertEqual(json.loads(s.path.read_text())["device"],
"c0de:0001")
if __name__ == "__main__":
unittest.main()

View File

@@ -13,6 +13,7 @@ import os
import re import re
import tempfile import tempfile
import unittest import unittest
from unittest import mock
from pathlib import Path from pathlib import Path
from macropad_gui import core from macropad_gui import core
@@ -306,6 +307,94 @@ class KnobSide(unittest.TestCase):
self.assertLess(knob_l, keys_l, "knobs should sit left of the keys") self.assertLess(knob_l, keys_l, "knobs should sit left of the keys")
class ThemeCatalogue(unittest.TestCase):
"""Every theme has to be complete and has to draw without blowing up."""
def test_knobs_north_is_registered(self):
from macropad_gui import themes
t = themes.get("knobs-north")
self.assertEqual(t.key, "knobs-north")
self.assertEqual(t.accent, "#ff7700")
self.assertEqual(t.badge, "star")
self.assertTrue(t.legend_box and t.query_badge and t.byte_prefix)
def test_the_other_styles_are_untouched(self):
"""Drawing switches belong to the style, so recolouring never moves them."""
from macropad_gui import themes
for key, t in themes.THEMES.items():
if themes.parts(key)[0] == "knobs-north":
continue
self.assertEqual(t.badge, "tape", key)
self.assertFalse(t.legend_box, key)
self.assertFalse(t.query_badge, key)
self.assertFalse(t.byte_prefix, key)
self.assertEqual(t.metrics, themes.Metrics(), key)
def test_the_old_keys_still_resolve(self):
from macropad_gui import themes
for old in ("silkscreen", "white-board", "nord", "catppuccin-mocha",
"catppuccin-latte", "blueprint", "sketch", "knobs-north"):
self.assertEqual(themes.get(old).key, old, old)
def test_the_grid_multiplies_out(self):
from macropad_gui import themes
want = sum(len(allowed) for _n, _b, allowed in themes.STYLES.values())
self.assertEqual(len(themes.THEMES), want)
# every style keeps its drawing switches whatever colours it wears
for style, (_n, base, allowed) in themes.STYLES.items():
for pal in allowed:
t = themes.get(themes.compose(style, pal).key)
self.assertEqual(t.metrics, base.metrics, f"{style}/{pal}")
self.assertEqual(t.badge, base.badge, f"{style}/{pal}")
self.assertEqual(t.accent, themes.PALETTES[pal][1]["accent"])
def test_a_style_refuses_colours_it_does_not_offer(self):
from macropad_gui import themes
# blueprint is only itself; asking for nord gives blueprint back
self.assertEqual(themes.get("blueprint/nord").key, "blueprint")
def test_junk_keys_fall_back(self):
from macropad_gui import themes
for junk in ("", "nonsense", "nope/also-nope", "silkscreen/nope"):
self.assertIn(themes.get(junk).key, themes.THEMES)
def test_every_theme_has_a_full_palette(self):
from macropad_gui import themes
fields = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field")
for key, t in themes.THEMES.items():
for f in fields:
value = getattr(t, f)
self.assertRegex(value, r"^#[0-9a-fA-F]{6}$", f"{key}.{f}")
def test_every_theme_paints(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import padview, themes
except ImportError:
self.skipTest("PySide6 not installed")
QApplication.instance() or QApplication([])
lay = core.Layout(12, 2, 3, 4)
try:
for key, theme in themes.THEMES.items():
padview.apply_theme(theme)
for orientation in ("flat", "upright"):
v = padview.PadView()
v.set_layout(lay)
v.set_orientation(orientation)
v.resize(700, 600)
v.looks = {c: padview.Look("Ctrl+Z", known=True)
for c in lay.control_ids}
v.looks["key2"] = padview.Look("Ctrl+S", pending=True)
v.looks["key3"] = padview.Look() # unknown
v.current = "dial1-push"
shot = v.grab().toImage()
self.assertFalse(shot.isNull(), f"{key} / {orientation}")
finally:
padview.apply_theme(themes.get(themes.DEFAULT))
class BoardBalance(unittest.TestCase): class BoardBalance(unittest.TestCase):
""" """
Three knobs' worth of legends is taller than three rows of keys. The Three knobs' worth of legends is taller than three rows of keys. The
@@ -768,9 +857,17 @@ class PermissionDiagnosis(unittest.TestCase):
self.assertFalse(any(c.startswith("sudo mv") for c in d.fix)) self.assertFalse(any(c.startswith("sudo mv") for c in d.fix))
def test_no_rule(self): def test_no_rule(self):
"""
The fix has to write a rule for every vendor the app can talk to,
into the file that loads early enough. How it gets written there
is the rule writer's business: one vendor is an echo, several need
a printf, and pinning the command shape here just breaks the test
the next time a pad family is added.
"""
d = self.diag([], set()) d = self.diag([], set())
self.assertTrue(d.fix[0].startswith("echo 'KERNEL==\"hidraw*\""))
self.assertIn("60-macropad.rules", d.fix[0]) self.assertIn("60-macropad.rules", d.fix[0])
for vendor in core.vendors():
self.assertIn(f'=="{vendor}"', d.fix[0], vendor)
def test_other_family_pad_is_refused_not_ignored(self): def test_other_family_pad_is_refused_not_ignored(self):
from unittest import mock from unittest import mock
@@ -915,3 +1012,122 @@ class ThemedDrawing(unittest.TestCase):
# it wanders, but only a little # it wanders, but only a little
box = a.boundingRect() box = a.boundingRect()
self.assertLess(abs(box.width() - 96), 5) self.assertLess(abs(box.width() - 96), 5)
class WindowStaysPutWhenYouClick(unittest.TestCase):
"""
Clicking the first control used to make the window jump to full height,
on Linux and on Windows. The inspector's content is taller than the pad,
and with nothing to contain it the window's minimum height grew to fit,
which the window manager then obeyed. It lives in a scroll area now.
"""
def window(self, theme):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from unittest import mock
from PySide6.QtWidgets import QApplication
from macropad_gui import app as A
ready = core.Diagnosis("ready", "Ready.", device=core.Device(
path="/dev/null-pad", report_id=3, writable=True,
vid="1189", pid="8840"))
QApplication.instance() or QApplication([])
A.apply_theme(QApplication.instance(), theme)
with mock.patch.object(core, "_keyd_warning", return_value=None), \
mock.patch.object(core, "diagnose", return_value=ready):
w = A.Window()
w.timer.stop()
w.diag = ready
return w, QApplication
def test_minimum_height_does_not_grow_on_the_first_click(self):
try:
from macropad_gui import themes
except ImportError:
self.skipTest("PySide6 not installed")
for key in ("silkscreen", "knobs-north", "blueprint"):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, qapp = self.window(key)
w.resize(1000, 800)
w.show()
qapp.processEvents()
before = w.centralWidget().minimumSizeHint().height()
for control in ("key1", "dial1-left", "key7"):
w.pad.select(control)
w._refresh()
qapp.processEvents()
after = w.centralWidget().minimumSizeHint().height()
self.assertEqual(before, after, f"{key}: {before} -> {after}")
w.close()
def test_the_window_can_still_be_made_small(self):
try:
from macropad_gui import themes # noqa: F401
except ImportError:
self.skipTest("PySide6 not installed")
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, qapp = self.window("silkscreen")
w.resize(1000, 800)
w.show()
qapp.processEvents()
w.pad.select("key1")
w._refresh()
qapp.processEvents()
w.resize(800, 520)
qapp.processEvents()
self.assertLessEqual(w.height(), 540, "window refused to shrink")
w.close()
def test_the_inspector_is_inside_something_that_scrolls(self):
try:
from PySide6.QtWidgets import QScrollArea
except ImportError:
self.skipTest("PySide6 not installed")
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, _ = self.window("silkscreen")
self.assertIsInstance(w.panel, QScrollArea)
self.assertIs(w.panel.widget(), w.inspector)
self.assertTrue(w.panel.widgetResizable())
w.close()
class VersionIsLabelled(unittest.TestCase):
"""
One version string, in one place, reachable from everywhere that quotes
it. A release should be one line to change, and a bug report should
never be missing it.
"""
def test_it_is_a_plain_semver(self):
import re
from macropad_gui import __version__
self.assertRegex(__version__, r"^\d+\.\d+\.\d+$")
def test_the_cli_reports_it(self):
self.assertEqual(proto.version(), __import__("macropad_gui").__version__)
def test_the_cli_survives_being_copied_out_on_its_own(self):
"""
macropad.py is meant to work as a single file. With no GUI package
beside it the version is unknown, which is fine, but it must not
raise.
"""
from unittest import mock
real = __import__("builtins").__import__
def no_gui(name, *a, **kw):
if name == "macropad_gui":
raise ImportError("not there")
return real(name, *a, **kw)
with mock.patch("builtins.__import__", no_gui):
self.assertEqual(proto.version(), "unknown")
def test_a_detection_report_carries_it(self):
from macropad_gui import __version__
device = core.Device(path="/dev/hidraw9", vid="1189", pid="8840",
report_id=0, writable=True, name="")
text = core.detection_report(device, core.Detection(why="it said nothing"))
self.assertIn(__version__, text)

352
tests/test_sayo.py Normal file
View File

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

0
uninstall.sh Normal file → Executable file
View File