Add files via upload

This commit is contained in:
Mohamed Mahjoub
2026-09-11 20:30:34 +01:00
committed by GitHub
parent 07f3af3441
commit c7c02edb60
35 changed files with 3975 additions and 13 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
macropad-capture.txt
.venv/

View File

@@ -1,8 +1,8 @@
<p align="center">
<img src="docs/images/icon.png" width="112" alt="Macropad app icon: a macro pad with two dials and one orange key">
<img src="docs/images/icon.png" width="112" alt="MacroPad app icon: a macro pad with two dials and one orange key">
</p>
<h1 align="center">Macropad</h1>
<h1 align="center">MacroPad</h1>
<p align="center">
<b>Set up your cheap 12 key, 2 knob USB macro pad on Linux.</b><br>
@@ -19,7 +19,7 @@
</p>
<p align="center">
<img src="docs/images/hero.png" width="860" alt="Macropad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key">
<img src="docs/images/hero.png" width="860" alt="MacroPad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key">
</p>
<p align="center">
@@ -35,7 +35,7 @@
> [!TIP]
> **In a hurry?** Open a terminal and paste this. It does everything, asks before changing anything, and is safe to run again.
> ```
> curl -fsSL https://raw.githubusercontent.com/Probler/macropad/main/install.sh | bash
> curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
> ```
> Never used a terminal? Perfect. [The step by step guide](#-install-it-the-whole-world-is-counting-on-you) was written for you.
@@ -134,13 +134,13 @@ A window with a blinking cursor appears. Hello, terminal. 👋
Click the copy button on the right side of this box (or select the text and press `Ctrl+C`):
```
curl -fsSL https://raw.githubusercontent.com/Probler/macropad/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
```
<details>
<summary>What does that line actually do? (optional reading)</summary>
In plain words: "download the Macropad installer from GitHub (`curl`), and run it (`| bash`)". The installer is a normal text file, and you can [read it here](install.sh) before running it if you like. Being suspicious of random commands from the internet is a healthy instinct, and I respect it.
In plain words: "download the MacroPad installer from GitHub (`curl`), and run it (`| bash`)". The installer is a normal text file, and you can [read it here](install.sh) before running it if you like. Being suspicious of random commands from the internet is a healthy instinct, and I respect it.
</details>
@@ -168,12 +168,12 @@ The first install downloads about 100 MB on some systems, so give it a minute. Y
This makes Linux notice the new permission. Unplug. Count to two. Plug back in.
### Step 6: Open Macropad
### Step 6: Open MacroPad
Open your app menu (press the **Meta key**), type **`Macropad`**, and click it. 🎉
Open your app menu (press the **Meta key**), type **`MacroPad`**, and click it. 🎉
<p align="center">
<img src="docs/images/first-launch.png" width="820" alt="Macropad on its first launch: every key is striped because the app hasn't written anything yet">
<img src="docs/images/first-launch.png" width="820" alt="MacroPad on its first launch: every key is striped because the app hasn't written anything yet">
</p>
**Everything is striped the first time. That's correct.** The app hasn't written anything to your pad yet, and the pad has no way of telling it what's already there. You'll fix that in about thirty seconds.
@@ -181,7 +181,7 @@ Open your app menu (press the **Meta key**), type **`Macropad`**, and click it.
Look at the bottom left. A **green dot** and **Pad ready** means everything worked. If you see something else, the panel on the right will tell you exactly what to do, or jump to [When something goes wrong](#-when-something-goes-wrong).
> [!TIP]
> Want it on your taskbar? Right click **Macropad** in your app menu and choose **Pin to Task Manager** (Plasma) or **Pin to Dash** (GNOME).
> Want it on your taskbar? Right click **MacroPad** in your app menu and choose **Pin to Task Manager** (Plasma) or **Pin to Dash** (GNOME).
**You did it.** The world is saved. Probably. Let's make sure by setting up a key.
@@ -347,7 +347,7 @@ Harmless. It only appears if you run the app before its launcher is installed. T
### The window looks wrong or won't open
Run `macropad` in a terminal and read the last few lines it prints. If it mentions `xcb` or a "platform plugin", run the installer again, which adds the missing library. If you're still stuck, [open an issue](https://github.com/Probler/macropad/issues) and paste what it printed.
Run `macropad` in a terminal and read the last few lines it prints. If it mentions `xcb` or a "platform plugin", run the installer again, which adds the missing library. If you're still stuck, [open an issue](https://github.com/Probler-Yt/MacroPad/issues) and paste what it printed.
---
@@ -421,7 +421,7 @@ If you have a different pad, you're exactly who this project needs, and it's the
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/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.
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.
---
@@ -520,7 +520,7 @@ udev reads every rules file in one lexical order by filename. The tag is only tu
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`.
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

BIN
docs/images/flat-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
docs/images/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
docs/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
docs/images/keyd-note.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
docs/images/legend.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/images/permission.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
docs/images/step-media.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
docs/images/step-record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
docs/images/step-type.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
docs/images/unsupported.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

347
docs/make_screenshots.py Normal file
View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Regenerate every image in docs/images from the real app, offscreen.
QT_QPA_PLATFORM=offscreen QT_SCALE_FACTOR=2 python3 docs/make_screenshots.py
Uses a fake pad and a throwaway config folder, so it never touches your real
pad or your saved state. Looks best with Noto Sans and Hack installed, which
are Plasma's defaults.
"""
import os
import sys
import tempfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs" / "images"
sys.path.insert(0, str(ROOT))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["XDG_CONFIG_HOME"] = tempfile.mkdtemp()
from PySide6.QtCore import QPointF, QRectF, Qt # noqa: E402
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QImage, # noqa: E402
QLinearGradient, QPainter, QPen)
from PySide6.QtSvg import QSvgRenderer # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from macropad_gui import app as A, core, padview # noqa: E402
try:
from PIL import Image, ImageDraw, ImageFilter
except ImportError:
Image = None
B = core.Binding
DEMO = {
"key4": B(keys="super+ctrl+left"), "key8": B(keys="super+w"),
"key12": B(keys="super+ctrl+right"),
"key3": B(media="prev"), "key7": B(media="playpause"), "key11": B(media="next"),
"key2": B(keys="ctrl+z"), "key6": B(keys="ctrl+shift+z"), "key10": B(keys="ctrl+s"),
"key1": B(keys="printscreen"), "key5": B(keys="ctrl+alt+t"), "key9": B(media="mycomputer"),
"dial1-left": B(media="volumedown"), "dial1-push": B(media="mute"),
"dial1-right": B(media="volumeup"),
"dial2-left": B(media="brightnessdown"), "dial2-push": B(media="calculator"),
"dial2-right": B(media="brightnessup"),
}
READY = core.Diagnosis("ready", "Ready on /dev/hidraw10.",
device=core.Device("/dev/hidraw10", 3, True))
def fonts(app):
ui = QFont("Noto Sans", 10)
if "Noto Sans" in QFontDatabase.families():
app.setFont(ui)
if "Hack" in QFontDatabase.families():
hack = QFont("Hack", 10)
real = QFontDatabase.systemFont
QFontDatabase.systemFont = staticmethod(
lambda which: hack if which == QFontDatabase.FixedFont else real(which))
def window(diag=READY, bindings=None, note=None):
with mock.patch.object(core, "_keyd_warning", return_value=note), \
mock.patch.object(core, "diagnose", return_value=diag):
w = A.Window()
w.timer.stop()
w.diag = diag
w.note = note
w.state = core.State(Path(tempfile.mkdtemp()) / "state.json")
for c, b in (bindings or {}).items():
w.state.record(c, b)
w.resize(1000, 800)
w._poll_fake = diag
w.inspector.show_problem(diag, note)
status_colour = {"ready": A.GOOD, "unplugged": padview.INK_DIM.name()}.get(diag.status, A.BAD)
w.dot.setStyleSheet(f"background: {status_colour}; border-radius: 4px;")
w.status.setText({"ready": "Pad ready on /dev/hidraw10",
"unplugged": "No pad connected",
"no-permission": "No permission to write to the pad",
"unsupported": "Unsupported pad"}[diag.status])
w._refresh()
return w
def select(w, control):
w.pad.select(control)
w._refresh()
def save(w, name, frame=True):
w.show()
QApplication.processEvents()
img = w.grab().toImage()
path = OUT / f"{name}.png"
img.save(str(path))
if frame and Image:
_frame(path)
w.desired.clear()
w.close()
print("wrote", path.relative_to(ROOT))
def _frame(path, radius=20, pad=56):
"""Rounded corners, a hairline edge and a soft shadow, on transparency."""
shot = Image.open(path).convert("RGBA")
w, h = shot.size
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius, fill=255)
shot.putalpha(mask)
edge = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(edge).rounded_rectangle([0, 0, w - 1, h - 1], radius,
outline=(255, 255, 255, 34), width=2)
shot = Image.alpha_composite(shot, edge)
canvas = Image.new("RGBA", (w + 2 * pad, h + 2 * pad), (0, 0, 0, 0))
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
[pad, pad + 14, pad + w, pad + h + 14], radius, fill=(0, 0, 0, 120))
shadow = shadow.filter(ImageFilter.GaussianBlur(22))
canvas = Image.alpha_composite(canvas, shadow)
canvas.alpha_composite(shot, (pad, pad))
canvas.save(path, optimize=True)
# ------------------------------------------------------------------ scenes
def scene_hero():
w = window(bindings=DEMO)
select(w, "key12")
w._say("Wrote 3 changes to the pad.", A.GOOD)
save(w, "hero")
def scene_first_launch():
w = window()
save(w, "first-launch")
def scene_select_and_type():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
select(w, "key2")
w.inspector.keys_edit.setText("ctrl+z")
w.inspector._keys_typed("ctrl+z")
save(w, "step-type")
def scene_record():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
select(w, "key6")
w.inspector.record.setChecked(True)
w.inspector.keys_edit.setText("ctrl+shift+…")
save(w, "step-record")
def scene_media():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("key")})
select(w, "dial1-left")
w.inspector.mode_media.click()
w.inspector.media_buttons["volumedown"].click()
save(w, "step-media")
def scene_pending():
w = window(bindings={k: v for k, v in DEMO.items()
if k not in ("key2", "key6", "key10")})
for k in ("key2", "key6", "key10"):
w.desired[k] = DEMO[k]
select(w, "key10")
w.inspector.keys_hint.setText("Sends Ctrl+S")
w._refresh(editor=False)
save(w, "step-pending")
def scene_written():
w = window(bindings=DEMO)
select(w, "key10")
w._say("Wrote 3 changes to the pad.", A.GOOD)
save(w, "step-written")
def scene_flat():
w = window(bindings=DEMO)
w.pad.set_orientation("flat")
select(w, "dial2-push")
save(w, "flat-view")
def scene_permission():
d = core.Diagnosis(
"no-permission", "/dev/hidraw10 isn't writable.",
device=core.Device("/dev/hidraw10", 3, False),
detail=["No udev rule lets you write to the pad yet. Running the installer "
"again adds it, or paste these commands into a terminal:"],
fix=[f"echo '{core.RULE_LINE}' | sudo tee /etc/udev/rules.d/60-macropad.rules",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"])
w = window(diag=d, bindings=DEMO)
select(w, "key4")
save(w, "permission")
def scene_keyd():
note = ("keyd, a key remapping service, is running and /etc/keyd/default.conf "
"lists this pad. It can change keys on their way from the pad to your "
"desktop, so a key may not do exactly what you set here. If you only "
"set keyd up for this pad, you can switch it off with: "
"sudo systemctl disable --now keyd")
w = window(bindings=DEMO, note=note)
save(w, "keyd-note")
def scene_unsupported():
d = core.Diagnosis(
"unsupported", "Found a 1189:8890 pad, which this app doesn't know yet.",
detail=["So far only 1189:8840 (12 keys, 2 dials) has been worked out. "
"Pads in this family differ in layout and protocol, so writing to "
"yours with the wrong one could fail or scramble it. The app won't try.",
"You can help add it: the README's \"Other pads\" section shows how "
"to capture what the vendor software sends."])
w = window(diag=d)
save(w, "unsupported")
def scene_legend():
"""Four keycaps showing every state the drawing uses."""
scale = 2
names = [("Written", "on the pad", padview.Look("Ctrl+C", known=True), None),
("Unknown", "could be anything", padview.Look(), None),
("Changed", "not written yet", padview.Look("Mute", known=True, pending=True), None),
("Selected", "being edited", padview.Look("Ctrl+S", known=True), "sel")]
cell, gap, top = 132, 26, 24
width = len(names) * cell + (len(names) - 1) * gap + 2 * 30
img = QImage(width * scale, 210 * scale, QImage.Format_ARGB32)
img.fill(padview.BOARD)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
p.scale(scale, scale)
pv = padview.PadView()
for i, (title, sub, look, sel) in enumerate(names):
x = 30 + i * (cell + gap)
rect = QRectF(x + (cell - 96) / 2, top, 96, 96)
pv.looks = {f"key{i + 1}": look}
pv.current = f"key{i + 1}" if sel else None
pv._paint_key(p, f"key{i + 1}", rect)
f = QFont(QApplication.font())
f.setPixelSize(15)
f.setWeight(QFont.DemiBold)
p.setFont(f)
p.setPen(padview.TAPE if look.pending or sel else padview.INK)
p.drawText(QRectF(x, top + 110, cell, 22), Qt.AlignHCenter, title)
f.setWeight(QFont.Normal)
f.setPixelSize(12)
p.setFont(f)
p.setPen(padview.INK_DIM)
p.drawText(QRectF(x, top + 134, cell, 20), Qt.AlignHCenter, sub)
p.end()
path = OUT / "legend.png"
img.save(str(path))
if Image:
_frame(path, radius=24, pad=40)
print("wrote", path.relative_to(ROOT))
def scene_social():
"""1280x640: GitHub's social preview, which Reddit and Discord show for links."""
W, H = 1280, 640
img = QImage(W, H, QImage.Format_ARGB32)
img.fill(padview.WINDOW)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
# faint board-grid, like a PCB's copper pour
p.setPen(QPen(QColor(255, 255, 255, 10), 1))
for x in range(0, W, 32):
p.drawLine(x, 0, x, H)
for y in range(0, H, 32):
p.drawLine(0, y, W, y)
pv = padview.PadView()
pv.resize(460, 640)
pv.looks = {c: padview.Look(b.label(), known=True) for c, b in DEMO.items()}
pv.looks["key12"] = padview.Look("Meta+Ctrl+Right", known=True, pending=True)
pv.current = "key12"
p.save()
p.translate(70, 0)
p.setTransform(pv._transform(), True)
board = QRectF(pv.board)
path = __import__("PySide6.QtGui", fromlist=["QPainterPath"]).QPainterPath()
path.addRoundedRect(board, 18, 18)
p.fillPath(path, padview.BOARD)
p.setPen(QPen(padview.INK_DIM, 1.2))
p.drawPath(path)
for key, rect in pv.keys.items():
pv._paint_key(p, key, rect)
for dial, centre in pv.dials.items():
pv._paint_dial(p, dial, centre)
p.restore()
x0 = 600
f = QFont(QApplication.font())
f.setPixelSize(92)
f.setWeight(QFont.Bold)
p.setFont(f)
p.setPen(padview.INK)
p.drawText(QRectF(x0, 150, 640, 110), Qt.AlignLeft | Qt.AlignVCenter, "MacroPad")
p.fillRect(QRectF(x0 + 4, 268, 120, 6), padview.TAPE)
f.setPixelSize(34)
f.setWeight(QFont.Normal)
p.setFont(f)
p.drawText(QRectF(x0, 300, 640, 140), Qt.AlignLeft | Qt.TextWordWrap,
"Set up your 12 key, 2 knob USB macro pad on Linux.")
f.setPixelSize(26)
p.setFont(f)
p.setPen(padview.INK_DIM)
p.drawText(QRectF(x0, 430, 640, 40), Qt.AlignLeft, "No Windows. No VM. No Wine.")
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
mono.setPixelSize(22)
p.setFont(mono)
p.drawText(QRectF(x0, 540, 640, 30), Qt.AlignLeft, "github.com/Probler-Yt/MacroPad")
p.end()
img.save(str(OUT / "social-preview.png"))
print("wrote docs/images/social-preview.png")
def scene_icon():
r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg"))
img = QImage(256, 256, QImage.Format_ARGB32)
img.fill(Qt.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
r.render(p, QRectF(0, 0, 256, 256))
p.end()
img.save(str(OUT / "icon.png"))
print("wrote docs/images/icon.png")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
app = QApplication(sys.argv)
fonts(app)
A.apply_theme(app)
for name, fn in list(globals().items()):
if name.startswith("scene_"):
fn()

226
install.sh Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env bash
# MacroPad installer.
#
# curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
#
# or, from a downloaded copy: ./install.sh
#
# Safe to run again at any time: it updates in place and keeps your bindings.
# Everything goes in your home folder except one small permission file in
# /etc/udev/rules.d, which is the only step that asks for your password.
set -euo pipefail
NEED_REPLUG=""
REPO="Probler-Yt/MacroPad"
BRANCH="main"
DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
HOME_DIR="$DATA/macropad" # app + optional private Python
APP="$HOME_DIR/app"
BIN="$HOME/.local/bin"
DESKTOP="$DATA/applications/macropad.desktop"
ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg"
RULE="/etc/udev/rules.d/60-macropad.rules"
RULE_LINE='KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess"'
if [ -t 1 ]; then
O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m'
else
O=""; G=""; R=""; B=""; D=""; X=""
fi
say() { printf '%s\n' "$*"; }
step() { printf '\n%s==>%s %s%s%s\n' "$O" "$X" "$B" "$*" "$X"; }
ok() { printf ' %sok%s %s\n' "$G" "$X" "$*"; }
die() { printf '\n%sStopped:%s %s\n' "$R" "$X" "$*" >&2; exit 1; }
# When piped from curl, stdin is this script, so questions read the terminal.
ask() {
local answer=""
if { exec 3</dev/tty; } 2>/dev/null; then
read -r -p " $1 [Y/n] " answer <&3 || true
exec 3<&-
fi
case "$answer" in [nN]*) return 1 ;; *) return 0 ;; esac
}
cat <<EOF
${O} ┌────────────────┐${X}
${O} │ ◯ ◯ │${X} ${B}MacroPad${X}
${O} │ ▢ ▢ ▢ │${X} Set up your 12 key, 2 dial macro pad on Linux.
${O} │ ▢ ▢ ${X}▣${O} │${X}
${O} └────────────────┘${X} ${D}github.com/${REPO}${X}
EOF
[ "$(id -u)" -ne 0 ] || die "Run this as your normal user, not with sudo. It will ask for your password itself when it needs it."
command -v python3 >/dev/null || die "Python 3 isn't installed. Install it with your distro's software centre, then run this again."
python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' \
|| die "Python 3.9 or newer is needed. You have $(python3 -V)."
. /etc/os-release 2>/dev/null || true
FAMILY=" ${ID:-} ${ID_LIKE:-} "
# -------------------------------------------------------------- 1. files
step "Getting the app"
SRC=""
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" 2>/dev/null && pwd || true)"
if [ -n "$HERE" ] && [ -d "$HERE/macropad_gui" ] && [ -f "$HERE/macropad.py" ]; then
SRC="$HERE"
ok "using the copy in $SRC"
else
command -v curl >/dev/null || die "curl is needed to download the app."
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -fsSL "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" \
| tar -xz -C "$TMP" || die "Couldn't download the app. Check your internet connection."
SRC="$(find "$TMP" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -d "$SRC/macropad_gui" ] || die "The download didn't contain the app. Please report this at github.com/${REPO}/issues"
ok "downloaded the latest version"
fi
mkdir -p "$HOME_DIR"
rm -rf "$APP.new"
mkdir -p "$APP.new"
for f in macropad.py macropad-probe.py macropad-capture.py macropad-handshake.py \
macropad_gui packaging install.sh uninstall.sh LICENSE README.md; do
if [ -e "$SRC/$f" ]; then cp -r "$SRC/$f" "$APP.new/"; fi
done
find "$APP.new" -name '__pycache__' -type d -prune -exec rm -rf {} +
rm -rf "$APP.old"
if [ -d "$APP" ]; then mv "$APP" "$APP.old"; fi
mv "$APP.new" "$APP"
rm -rf "$APP.old"
ok "installed to $APP"
# -------------------------------------------------------------- 2. Qt
step "Checking for Qt, the toolkit that draws the window"
PY=""
if python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then
PY="$(command -v python3)"
ok "already installed"
elif command -v pacman >/dev/null && [[ "$FAMILY" == *" arch "* ]] && [ ! -e /run/ostree-booted ]; then
say " Your system can install it directly (package: pyside6)."
if ask "Install it now? This asks for your password."; then
sudo pacman -S --needed --noconfirm pyside6 \
&& python3 -c 'import PySide6.QtWidgets' && PY="$(command -v python3)" \
&& ok "installed pyside6"
fi
fi
if [ -z "$PY" ]; then
# Anything else gets a private copy, so nothing system-wide changes.
say " Setting up a private copy of Qt just for this app (about 100 MB, one time)."
VENV="$HOME_DIR/venv"
if [ ! -x "$VENV/bin/python" ]; then
if ! python3 -m venv "$VENV" 2>/dev/null; then
rm -rf "$VENV"
if command -v apt-get >/dev/null; then
say " Python needs its 'venv' add-on first. This asks for your password."
sudo apt-get install -y python3-venv >/dev/null
python3 -m venv "$VENV" || die "Couldn't create the private Python."
else
die "Couldn't create a private Python. Install your distro's python3-venv package and run this again."
fi
fi
fi
"$VENV/bin/python" -m pip install --quiet --upgrade pip
"$VENV/bin/python" -m pip install --quiet --upgrade PySide6-Essentials \
|| die "Couldn't download Qt. Check your internet connection and run this again."
PY="$VENV/bin/python"
ok "Qt ready"
# Qt's X11 backend needs one small system library some distros leave out.
if [ "${XDG_SESSION_TYPE:-}" = "x11" ]; then
if command -v apt-get >/dev/null && ! ldconfig -p | grep -q libxcb-cursor.so.0; then
sudo apt-get install -y libxcb-cursor0 >/dev/null && ok "added libxcb-cursor0"
elif command -v dnf >/dev/null && [ ! -e /run/ostree-booted ] && ! ldconfig -p | grep -q libxcb-cursor.so.0; then
sudo dnf install -y xcb-util-cursor >/dev/null && ok "added xcb-util-cursor"
fi
fi
fi
# -------------------------------------------------------------- 3. launcher
step "Adding MacroPad to your app menu"
mkdir -p "$BIN" "$(dirname "$DESKTOP")" "$(dirname "$ICON")"
cat > "$BIN/macropad" <<EOF
#!/bin/sh
# Opens MacroPad. Anything after 'macropad' is passed on, e.g. 'macropad doctor'.
cd "$APP" && exec "$PY" -m macropad_gui "\$@"
EOF
chmod +x "$BIN/macropad"
cp "$APP/packaging/macropad.svg" "$ICON"
cat > "$DESKTOP" <<EOF
[Desktop Entry]
Type=Application
Name=MacroPad
GenericName=Macro pad configurator
Comment=Change what your macro pad's keys and dials do
Exec=$BIN/macropad
Icon=macropad
Terminal=false
Categories=Utility;Settings;HardwareSettings;
Keywords=macro;keyboard;keypad;knob;dial;shortcut;hid;
StartupWMClass=macropad
EOF
command -v update-desktop-database >/dev/null && update-desktop-database -q "$(dirname "$DESKTOP")" 2>/dev/null || true
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 >/dev/null 2>&1 || true
ok "MacroPad is in your app menu"
# -------------------------------------------------------------- 4. permission
step "Letting the app talk to the pad"
if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then
ok "permission rule already in place"
else
say " Linux doesn't let apps write to USB devices by default. This adds one"
say " small rule so MacroPad can, for whoever is logged in at the computer."
say " ${D}It writes: $RULE${X}"
if ask "Add it now? This asks for your password."; then
sudo mkdir -p "$(dirname "$RULE")"
printf '%s\n' "# MacroPad: let the logged-in user configure 1189:8840 macro pads." \
"# Must sort before 73-seat-late.rules, which is where uaccess takes effect." \
"$RULE_LINE" | sudo tee "$RULE" >/dev/null
if command -v udevadm >/dev/null; then
sudo udevadm control --reload-rules || true
sudo udevadm trigger --subsystem-match=hidraw || true
fi
ok "rule added"
NEED_REPLUG=1
else
say " Skipped. The app will show you how to add it later."
fi
fi
# A rule named 99-something (or with no number) is read too late to work.
for f in /etc/udev/rules.d/*.rules; do
[ -e "$f" ] || continue
[ "$f" = "$RULE" ] && continue
if grep -qiE 'idVendor\}=="1189"' "$f" 2>/dev/null && grep -q uaccess "$f"; then
base="$(basename "$f")"
if [[ ! "$base" < "73-seat-late.rules" ]]; then
say " ${D}Note: $f also mentions this pad but is read too late to do"
say " anything. It's harmless. Remove it with: sudo rm $f${X}"
fi
fi
done
# -------------------------------------------------------------- done
printf '\n%s%sAll done.%s\n\n' "$G" "$B" "$X"
n=1
if [ -n "$NEED_REPLUG" ]; then
say " ${B}$n.${X} Unplug your macro pad and plug it back in."
n=$((n + 1))
fi
say " ${B}$n.${X} Open ${B}MacroPad${X} from your app menu."
say " ${D}(or type${X} macropad ${D}in a terminal)${X}"
say ""
say " To update later, run the same command again."
say " To remove it: ${D}$APP/uninstall.sh${X}"
say ""
case ":$PATH:" in *":$BIN:"*) ;; *)
say " ${D}Tip: $BIN isn't on your PATH, so the 'macropad' command won't work"
say " in a terminal yet. The app menu entry works regardless.${X}"
say ""
;;
esac

204
macropad-capture.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
macropad-capture - watch what the vendor's software actually sends.
Uses the kernel's usbmon facility to record USB traffic going to your macro
pad, then decodes the outgoing reports so we can see the real wire format.
This is passive. It only observes - it never sends anything itself.
How to use it:
1. Start this script:
sudo python3 macropad-capture.py
2. Leave it running. In another window, launch the vendor software
under Wine:
wine MINI_KEYBOARD.exe
3. In that software, change ONE key to something distinctive - key 1 to
the letter 'a' is ideal, because we know 'a' is HID code 0x04 and it
will be easy to spot in the bytes.
4. Click whatever it uses to apply or save.
5. Come back here and press Ctrl+C.
It writes macropad-capture.txt, which has everything we need.
"""
import os
import re
import subprocess
import sys
import time
DEBUGFS = "/sys/kernel/debug/usb/usbmon"
OUTFILE = "macropad-capture.txt"
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
def find_pad():
"""Locate the pad on the USB bus via lsusb. Returns (bus, device, vid, pid)."""
try:
out = subprocess.run(["lsusb"], capture_output=True, text=True).stdout
except FileNotFoundError:
return None
candidates = []
for line in out.splitlines():
m = re.match(r"Bus (\d+) Device (\d+): ID ([0-9a-f]{4}):([0-9a-f]{4})", line)
if not m:
continue
bus, dev, vid, pid = m.groups()
if vid.lower() in ("1189", "1a81"):
candidates.append((int(bus), int(dev), vid, pid, line.strip()))
return candidates
def ensure_usbmon():
if os.path.isdir(DEBUGFS):
return True
print(f"{DIM}Loading usbmon module...{O}")
subprocess.run(["modprobe", "usbmon"], capture_output=True)
if os.path.isdir(DEBUGFS):
return True
# debugfs may simply not be mounted yet
subprocess.run(["mount", "-t", "debugfs", "none", "/sys/kernel/debug"],
capture_output=True)
return os.path.isdir(DEBUGFS)
def decode(line, want_dev):
"""
usbmon text format, roughly:
<tag> <ts> <S|C> <type>:<bus>:<dev>:<ep> <flags> <len> = <hex data>
We want submissions (S) carrying data toward our device: control
transfers (Co) and interrupt out (Io).
"""
parts = line.split()
if len(parts) < 5:
return None
event = parts[2]
addr = parts[3]
if event != "S":
return None
bits = addr.split(":")
if len(bits) < 4:
return None
xfer, bus, dev, ep = bits[0], bits[1], bits[2], bits[3]
if int(dev) != want_dev:
return None
if not xfer.endswith("o"): # 'o' = out, toward the device
return None
if "=" not in line:
return None
data = line.split("=", 1)[1].replace(" ", "").strip()
if not data:
return None
kind = {"Co": "control out", "Io": "interrupt out",
"Bo": "bulk out"}.get(xfer, xfer)
setup = ""
if xfer == "Co":
# Control transfers carry an 8-byte setup packet in the flags field.
for p in parts[4:9]:
if re.fullmatch(r"[0-9a-f]{2,4}", p):
setup += p + " "
return {"kind": kind, "ep": ep, "setup": setup.strip(), "data": data}
def main():
if os.geteuid() != 0:
print(f"{R}Needs root{O} - usbmon is a kernel facility.")
print(f" sudo python3 {sys.argv[0]}")
return 1
cands = find_pad()
if not cands:
print(f"{R}No Holtek-family device found in lsusb.{O}")
return 1
if len(cands) > 1:
print(f"{B}Several candidates:{O}")
for i, c in enumerate(cands):
print(f" [{i}] {c[4]}")
try:
pick = int(input("Which one? ") or 0)
except (ValueError, EOFError):
pick = 0
cands = [cands[pick]]
bus, dev, vid, pid, desc = cands[0]
print(f"{B}Watching{O} {desc}")
print(f"{DIM}bus {bus}, device {dev}{O}\n")
if not ensure_usbmon():
print(f"{R}Couldn't set up usbmon.{O}")
print("Try: sudo modprobe usbmon")
print(" sudo mount -t debugfs none /sys/kernel/debug")
return 1
path = os.path.join(DEBUGFS, f"{bus}u")
if not os.path.exists(path):
print(f"{R}{path} doesn't exist.{O} Is the bus number right?")
return 1
print(f"{G}Capturing.{O} Now, in another terminal:")
print(f" {B}wine MINI_KEYBOARD.exe{O}")
print("\nChange ONE key to the letter 'a', then apply it.")
print(f"{DIM}('a' is HID code 0x04 - easy to spot in the bytes.){O}")
print(f"\nPress {B}Ctrl+C{O} when you're done.\n")
print("-" * 62)
packets = []
try:
with open(path, "r") as mon, open(OUTFILE, "w") as out:
out.write(f"# capture of {desc}\n# bus {bus} device {dev}\n\n")
for line in mon:
rec = decode(line, dev)
if not rec:
continue
packets.append(rec)
n = len(packets)
hexstr = rec["data"]
pretty = " ".join(hexstr[i:i + 2] for i in range(0, min(len(hexstr), 40), 2))
print(f"{n:3} {rec['kind']:<14} {pretty}"
+ (" ..." if len(hexstr) > 40 else ""))
out.write(f"[{n}] {rec['kind']} ep={rec['ep']}"
+ (f" setup={rec['setup']}" if rec["setup"] else "")
+ f"\n {hexstr}\n")
out.flush()
except KeyboardInterrupt:
pass
except PermissionError:
print(f"{R}Permission denied reading {path}.{O}")
return 1
print("\n" + "-" * 62)
if not packets:
print(f"{Y}Nothing captured.{O}")
print("The software may not have written, or it may be talking to a")
print("different device number. Check lsusb while Wine is running.")
return 1
print(f"{G}Captured {len(packets)} outgoing transfer(s){O} -> {OUTFILE}")
hits = [p for p in packets if "04" in p["data"]]
if hits:
print(f"{DIM}{len(hits)} contain byte 0x04, which may be your 'a' keycode.{O}")
print(f"\nSend me {B}{OUTFILE}{O} and I'll decode the format.")
return 0
if __name__ == "__main__":
sys.exit(main())

152
macropad-handshake.py Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
macropad-handshake - find out how your pad wants to be talked to.
Sends only the benign "version check" report upstream uses on connect:
all zeros, no configuration payload. It cannot change your keybinds.
It tries each report id with two different transfer methods, and checks
after every attempt whether the device survived. If the pad resets, the
script waits for it to come back before continuing.
Usage:
python3 macropad-handshake.py /dev/hidraw10
"""
import fcntl
import glob
import os
import sys
import time
REPORT_IDS = [0, 2, 3]
PAYLOAD_LEN = 64
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
def hidioc_sfeature(size):
"""_IOWR('H', 0x06, size) - the SET_FEATURE ioctl hidraw exposes."""
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
def device_present(path):
return os.path.exists(path)
def wait_for_device(path, timeout=6.0):
"""After a reset the node usually reappears with the same name."""
start = time.time()
while time.time() - start < timeout:
if os.path.exists(path):
time.sleep(0.4) # let udev reapply the ACL
return True
time.sleep(0.2)
return False
def try_write(path, report_id, method):
"""
Returns (ok, detail). 'ok' means the device accepted the transfer
without erroring and was still present afterwards.
"""
data = bytes([report_id]) + bytes(PAYLOAD_LEN)
try:
if method == "write":
fd = os.open(path, os.O_WRONLY)
try:
os.write(fd, data)
finally:
os.close(fd)
else: # feature report via ioctl
fd = os.open(path, os.O_RDWR)
try:
buf = bytearray(data)
fcntl.ioctl(fd, hidioc_sfeature(len(buf)), buf)
finally:
os.close(fd)
except OSError as e:
return False, f"{e.__class__.__name__}: {e.strerror} (errno {e.errno})"
except Exception as e:
return False, str(e)
time.sleep(0.3)
if not device_present(path):
return False, "device vanished - firmware reset"
return True, "accepted"
def main():
if len(sys.argv) > 1:
path = sys.argv[1]
else:
cands = sorted(glob.glob("/dev/hidraw*"))
print("Pass the device path, e.g.:")
print(f" python3 {sys.argv[0]} /dev/hidraw10")
print(f"\n{DIM}Available: {' '.join(cands)}{O}")
return 1
if not os.path.exists(path):
print(f"{R}{path} doesn't exist.{O}")
return 1
if not os.access(path, os.W_OK):
print(f"{R}{path} isn't writable.{O} Check your udev rule.")
return 1
print(f"{B}Handshake probe on {path}{O}")
print(f"{DIM}Sending zero-filled version-check reports only.")
print(f"Your saved keybinds are not touched.{O}\n")
results = []
for method in ("write", "feature"):
label = "interrupt OUT (write)" if method == "write" else "SET_FEATURE (ioctl)"
print(f"{B}{label}{O}")
for rid in REPORT_IDS:
if not device_present(path):
print(f" {DIM}waiting for device to come back...{O}")
if not wait_for_device(path):
print(f" {R}device did not return. Replug it and rerun.{O}")
return 1
ok, detail = try_write(path, rid, method)
mark = f"{G}OK {O}" if ok else f"{R}FAIL {O}"
print(f" report id {rid}: {mark} {detail}")
results.append((method, rid, ok, detail))
if not ok and "vanished" in detail:
if not wait_for_device(path):
print(f" {R}device did not come back.{O} Replug and rerun.")
return 1
print()
# ------------------------------------------------------------ verdict
print(f"{B}Verdict{O}")
good = [(m, r) for m, r, ok, _ in results if ok]
if not good:
print(f" {R}Nothing was accepted.{O}")
print(" This pad may not use the MacroPad protocol at all, or it")
print(" wants a vendor-specific unlock sequence first.")
print("\n Worth trying: the other config interface, if the probe found one.")
return 1
print(f" {G}Accepted combinations:{O}")
for m, r in good:
how = "write" if m == "write" else "feature"
print(f" report id {r} via {how}")
m, r = good[0]
print(f"\n Upstream picks the first report id that accepts a write,")
print(f" so your pad looks like {B}protocol version {r}{O}.")
print(f"\n Try a real binding with:")
extra = "" if m == "write" else " (needs --feature support adding)"
print(f" python3 macropad.py set key1 ctrl+c --report-id {r}{extra}")
return 0
if __name__ == "__main__":
sys.exit(main())

257
macropad-probe.py Normal file
View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
macropad-probe - work out how your macro pad presents itself to Linux.
Read-only. It looks at what the kernel already knows about your USB devices
and prints it. It does not write to the pad, change any setting, or touch
your saved keybinds.
Pure standard library - nothing to install.
Usage:
python3 macropad-probe.py # look for known macro pad vendors
python3 macropad-probe.py 1189 # a specific vendor id
python3 macropad-probe.py 1189:8840 # a specific device
python3 macropad-probe.py --all # every HID device on the system
"""
import os
import sys
import glob
HIDRAW_GLOB = "/sys/class/hidraw/hidraw*"
# Vendors known to ship these cheap configurable pads. 1189 is the one the
# MacroPad project targets - it appears as "Acer Communications" but isn't.
KNOWN_VENDORS = {"1189": "Holtek/generic macro pad (MacroPad project family)",
"1a81": "Holtek Semiconductor"}
# Product ids the upstream MacroPad project has code for.
KNOWN_PRODUCTS = {
"8890": "Legacy protocol (tested upstream)",
"8830": "Extended protocol", "8831": "Extended protocol",
"8832": "Extended protocol", "8897": "Extended protocol",
"8874": "Extended protocol",
}
B = "\033[1m"; DIM = "\033[2m"; G = "\033[32m"; Y = "\033[33m"; R = "\033[31m"; O = "\033[0m"
def read(path, binary=False):
try:
with open(path, "rb" if binary else "r") as f:
return f.read() if binary else f.read().strip()
except OSError:
return None
def parse_descriptor(raw):
"""
Walk a HID report descriptor far enough to list report IDs and sizes.
Not a full parser - we only need to know which reports exist and how big
they are, which is what determines how we talk to the device.
"""
out = {"usage_page": None, "reports": {}}
i = 0
report_id, size, count = 0, 0, 0
current = None
while i < len(raw):
b = raw[i]
if b == 0b11111110: # long item, skip it
i += 2 + (raw[i + 1] if i + 1 < len(raw) else 0)
continue
tag, typ, length = b >> 4, (b >> 2) & 0x3, b & 0x3
length = 4 if length == 3 else length
data = int.from_bytes(raw[i + 1:i + 1 + length], "little") if length else 0
if typ == 1: # global
if tag == 0 and out["usage_page"] is None:
out["usage_page"] = data
elif tag == 7:
size = data
elif tag == 8:
report_id = data
out["reports"].setdefault(report_id, {"in": 0, "out": 0, "feature": 0})
elif tag == 9:
count = data
elif typ == 0: # main
kind = {8: "in", 9: "out", 11: "feature"}.get(tag)
if kind:
current = out["reports"].setdefault(
report_id, {"in": 0, "out": 0, "feature": 0})
# Accumulate bits. Rounding each item to a whole byte here
# would inflate the total - a 5-bit field followed by 3 bits
# of padding is one byte, not two.
current[kind] += size * count
i += 1 + length
# Now convert the accumulated bit counts to bytes, once per report.
for sizes in out["reports"].values():
for kind in ("in", "out", "feature"):
sizes[kind] = (sizes[kind] + 7) // 8
return out
def describe(hidraw_dir):
"""Gather everything interesting about one hidraw node."""
name = os.path.basename(hidraw_dir)
dev = os.path.join(hidraw_dir, "device")
uevent = read(os.path.join(dev, "uevent")) or ""
hid_id = ""
for line in uevent.splitlines():
if line.startswith("HID_ID="):
hid_id = line.split("=", 1)[1]
# HID_ID looks like 0003:00001189:00008840
vid = pid = "????"
if hid_id.count(":") == 2:
_, v, p = hid_id.split(":")
vid, pid = v[-4:].lower(), p[-4:].lower()
info = {
"node": f"/dev/{name}",
"vid": vid,
"pid": pid,
"name": read(os.path.join(dev, "uevent")) or "",
"product": "",
"interface": None,
"descriptor": None,
"writable": os.access(f"/dev/{name}", os.W_OK),
"readable": os.access(f"/dev/{name}", os.R_OK),
}
for line in uevent.splitlines():
if line.startswith("HID_NAME="):
info["product"] = line.split("=", 1)[1]
# Walk up to the USB interface to find bInterfaceNumber - this is the
# Linux equivalent of the "mi_00"/"mi_01" path fragments the Windows
# code matches on.
p = os.path.realpath(dev)
for _ in range(6):
cand = os.path.join(p, "bInterfaceNumber")
if os.path.exists(cand):
info["interface"] = read(cand)
break
p = os.path.dirname(p)
raw = read(os.path.join(dev, "report_descriptor"), binary=True)
if raw:
info["descriptor"] = parse_descriptor(raw)
info["descriptor_len"] = len(raw)
return info
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
show_all = "--all" in sys.argv
want_vid = want_pid = None
if args:
if ":" in args[0]:
want_vid, want_pid = args[0].lower().split(":")
else:
want_vid = args[0].lower()
nodes = sorted(glob.glob(HIDRAW_GLOB))
if not nodes:
print(f"{R}No HID devices found at all.{O} Is anything plugged in?")
return 1
found = []
for n in nodes:
info = describe(n)
if show_all:
found.append(info)
elif want_vid:
if info["vid"] == want_vid and (not want_pid or info["pid"] == want_pid):
found.append(info)
elif info["vid"] in KNOWN_VENDORS:
found.append(info)
if not found:
print(f"{Y}Nothing matched.{O}")
print(f"Scanned {len(nodes)} HID devices. Try: python3 {sys.argv[0]} --all")
return 1
print(f"{B}Found {len(found)} matching HID interface(s){O}\n")
for info in found:
vid, pid = info["vid"], info["pid"]
print(f"{B}{info['node']}{O} {vid}:{pid}")
if info["product"]:
print(f" product {info['product']}")
if vid in KNOWN_VENDORS:
print(f" vendor {DIM}{KNOWN_VENDORS[vid]}{O}")
if pid in KNOWN_PRODUCTS:
print(f" {G}known device {KNOWN_PRODUCTS[pid]}{O}")
else:
print(f" {Y}unknown pid not in upstream's device list{O}")
if info["interface"] is not None:
print(f" usb interface {info['interface']} "
f"{DIM}(Windows calls this mi_{info['interface']}){O}")
perm = f"{G}yes{O}" if info["writable"] else f"{R}no - needs a udev rule{O}"
print(f" writable {perm}")
d = info.get("descriptor")
if d:
up = d["usage_page"]
up_name = {1: "Generic Desktop", 7: "Keyboard", 12: "Consumer"}.get(
up, f"0x{up:04x} (vendor-defined)" if up else "?")
print(f" usage page {up_name}")
print(f" descriptor {info['descriptor_len']} bytes")
if d["reports"]:
print(f" reports {DIM}id in out feature (bytes){O}")
for rid, sz in sorted(d["reports"].items()):
star = ""
if sz["out"] >= 8 or sz["feature"] >= 8:
star = f" {G}<- can receive config{O}"
print(f" {rid:<3} {sz['in']:<3} "
f"{sz['out']:<4} {sz['feature']:<7}{star}")
print()
# ------------------------------------------------------------- verdict
print(f"{B}What this means{O}")
cfg = [i for i in found
if i.get("descriptor")
and any(r["out"] >= 8 or r["feature"] >= 8
for r in i["descriptor"]["reports"].values())]
if not cfg:
print(f" {Y}No interface here looks like it accepts configuration data.{O}")
print(" The pad may expose its config interface only under a different")
print(" vendor id, or it may not be configurable over HID at all.")
else:
print(f" {G}{len(cfg)} interface(s) can receive configuration reports.{O}")
for i in cfg:
print(f" {i['node']} (usb interface {i['interface']})")
print(" That's where a configuration tool would write.")
unwritable = [i for i in cfg if not i["writable"]]
if unwritable:
print()
print(f" {Y}You don't have write permission yet.{O} Fix with a udev rule:")
v = unwritable[0]["vid"]; p = unwritable[0]["pid"]
# Must sort before 73-seat-late.rules, which is where systemd turns
# the uaccess tag into an actual permission. A 99- rule gets tagged
# and then silently ignored.
print(f" sudo nano /etc/udev/rules.d/60-macropad.rules")
print(f' KERNEL=="hidraw*", ATTRS{{idVendor}}=="{v}", '
f'ATTRS{{idProduct}}=="{p}", TAG+="uaccess"')
print(" sudo udevadm control --reload-rules && sudo udevadm trigger")
print(" Then unplug and replug the pad.")
return 0
if __name__ == "__main__":
sys.exit(main())

517
macropad.py Normal file
View File

@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""
macropad - configure Holtek-family USB macro pads from Linux.
A Linux port of the protocol from rOzzy1987/MacroPad (GPL-3.0), which
reverse-engineered the vendor's Windows software. All credit for working
out the wire format goes there. This is a clean-room reimplementation of
that protocol in Python, with the Windows-only HID layer replaced by
direct writes to /dev/hidraw.
Licensed GPL-3.0, same as the original.
Examples:
macropad.py list
macropad.py show
macropad.py set key1 ctrl+c
macropad.py set knob1-left volumedown
macropad.py set key3 "ctrl+shift+n" --dry-run
"""
import argparse
import glob
import os
import sys
# --------------------------------------------------------------- protocol
MAGIC = 0xFE # upstream's Extended protocol
MAGIC_NATIVE = 0xFD # what 1189:8840 actually wants (confirmed by usb capture)
TERMINATOR = (0xFE, 0xFF) # follows a config report to commit it
REPORT_LEN = 65 # 1 report id + 64 payload bytes
class KeyType:
NONE = 0
BASIC = 1 # keyboard keystrokes
MULTIMEDIA = 2 # media keys
MOUSE = 3
LED = 8
# Modifier bitmask, matching the USB HID keyboard modifier byte.
MODIFIERS = {
"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "win": 0x08, "super": 0x08,
"lctrl": 0x01, "lshift": 0x02, "lalt": 0x04, "lwin": 0x08,
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rwin": 0x80,
}
# Standard USB HID Keyboard/Keypad usage IDs. Verified against upstream:
# A=4, Z=29, Enter=40, Esc=41, Space=44, F1=58, F12=69, Right=79, Left=80.
KEYCODES = {}
for _i, _c in enumerate("abcdefghijklmnopqrstuvwxyz"):
KEYCODES[_c] = 4 + _i
for _i, _c in enumerate("1234567890"):
KEYCODES[_c] = 30 + _i
KEYCODES.update({
"enter": 40, "return": 40, "esc": 41, "escape": 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, "scrolllock": 71, "pause": 72, "insert": 73,
"home": 74, "pageup": 75, "delete": 76, "end": 77, "pagedown": 78,
"right": 79, "left": 80, "down": 81, "up": 82,
})
for _i in range(1, 13):
KEYCODES[f"f{_i}"] = 57 + _i # F1 = 58 .. F12 = 69
# Media keys live on the Consumer usage page, sent as a 16-bit value.
MEDIA_KEYS = {
"play": 0x00CD, "pause": 0x00CD, "playpause": 0x00CD,
"next": 0x00B5, "prev": 0x00B6, "previous": 0x00B6,
"stop": 0x00B7, "mute": 0x00E2,
"volumeup": 0x00E9, "volup": 0x00E9,
"volumedown": 0x00EA, "voldown": 0x00EA,
"brightnessup": 0x006F, "brightnessdown": 0x0070,
"calculator": 0x0192, "browser": 0x0196, "mail": 0x018A,
"mycomputer": 0x0194, # seen in capture: the vendor app's "My computer"
}
# Physical control -> protocol action byte.
#
# Keys are 1..15 directly. Knobs start at 0x10 - confirmed by usb capture on
# 1189:8840, where the two dials occupied 0x10-0x12 and 0x13-0x15.
#
# Upstream derives knob numbers as (enum - 10), putting the first knob at 13.
# That holds on pads with fewer keys but not here: this firmware reserves
# 1..15 for keys and begins knobs on the 0x10 boundary. If your pad disagrees,
# --raw-action lets you find its numbering without editing this file.
KNOB_BASE = 0x10
CONTROLS = {}
for _i in range(1, 16):
CONTROLS[f"key{_i}"] = _i
for _knob in (1, 2, 3):
base = KNOB_BASE + (_knob - 1) * 3
# Order within a knob (left, push, right) follows upstream's enum and
# matches the capture's three-consecutive-values pattern, but which
# physical direction is which was not separately verified.
CONTROLS[f"knob{_knob}-left"] = base
CONTROLS[f"knob{_knob}-push"] = base + 1
CONTROLS[f"knob{_knob}-right"] = base + 2
CONTROLS[f"dial{_knob}-left"] = base # friendlier aliases
CONTROLS[f"dial{_knob}-push"] = base + 1
CONTROLS[f"dial{_knob}-right"] = base + 2
def build_report(report_id, action, key_type, payload, layer=0, delay=0,
magic=MAGIC):
"""
Assemble one Extended-protocol report.
Layout (payload bytes, after the report id):
0 0xFE magic
1 action - which physical control
2 layer number
3 key type
4-5 delay, little endian
6-8 zero
9 count of populated pairs
10.. payload
"""
data = bytearray(REPORT_LEN - 1)
data[0] = magic
data[1] = action
data[2] = layer
data[3] = key_type
data[4] = delay & 0xFF
data[5] = (delay >> 8) & 0xFF
count = 0
for i, b in enumerate(payload):
if 10 + i >= len(data):
break
data[10 + i] = b
if b != 0:
count = (i >> 1) + 1
data[9] = count
return bytes([report_id]) + bytes(data)
def parse_keystroke(spec):
"""'ctrl+shift+n' -> [(modifier_mask, keycode)]. Comma separates a sequence."""
sequence = []
for chunk in spec.split(","):
chunk = chunk.strip().lower()
if not chunk:
continue
parts = [p.strip() for p in chunk.split("+")]
mods, code = 0, None
for p in parts:
if p in MODIFIERS:
mods |= MODIFIERS[p]
elif p in KEYCODES:
code = KEYCODES[p]
else:
raise ValueError(f"don't recognise '{p}' in '{spec}'")
if code is None and mods == 0:
raise ValueError(f"nothing usable in '{chunk}'")
sequence.append((mods, code or 0))
if not sequence:
raise ValueError(f"couldn't parse '{spec}'")
return sequence
def build_key(report_id, action, sequence, layer=0, delay=0):
payload = bytearray()
for mods, code in sequence:
payload += bytes([mods, code])
return build_report(report_id, action, KeyType.BASIC, payload, layer, delay)
def build_media(report_id, action, usage, layer=0):
payload = bytes([0, usage & 0xFF, (usage >> 8) & 0xFF, 0])
return build_report(report_id, action, KeyType.MULTIMEDIA, payload, layer)
# ------------------------------------------------------- native protocol
#
# Confirmed by capturing the vendor software over usbmon. Keystrokes use
# upstream's Extended layout with two corrections: the magic byte is 0xFD
# rather than 0xFE, and layers are numbered from 1, not 0. Sending layer 0
# is why our earlier reports were accepted and then discarded. Media keys
# use a different packing again - see native_media().
#
# Observed for key1 -> 'b':
# 03 fd 01 01 01 00 00 00 00 00 01 00 05 config
# 03 fd fe ff 00 ... commit
def native_commit(report_id):
data = bytearray(REPORT_LEN - 1)
data[0] = MAGIC_NATIVE
data[1], data[2] = TERMINATOR
return bytes([report_id]) + bytes(data)
def native_key(report_id, action, sequence, layer=1, delay=0):
payload = bytearray()
for mods, code in sequence:
payload += bytes([mods, code])
body = build_report(report_id, action, KeyType.BASIC, payload, layer, delay,
magic=MAGIC_NATIVE)
return [body, native_commit(report_id)]
def native_media(report_id, action, usage, layer=1):
"""
Confirmed by capturing the vendor software binding keys 1-12 to media
keys. Upstream's Extended media layout is NOT what this pad wants:
upstream 03 fd 01 01 02 00 00 00 00 00 01 00 b5 00 (usage one byte late)
captured 03 fd 01 01 02 00 00 00 00 00 02 b5 00 00 key1 -> next track
The count byte is 2 even when the high byte is zero, and the 16-bit
consumer usage follows it directly, little endian.
"""
data = bytearray(REPORT_LEN - 1)
data[0] = MAGIC_NATIVE
data[1] = action
data[2] = layer
data[3] = KeyType.MULTIMEDIA
data[9] = 2
data[10] = usage & 0xFF
data[11] = (usage >> 8) & 0xFF
return [bytes([report_id]) + bytes(data), native_commit(report_id)]
# ------------------------------------------------------- legacy protocol
#
# The older format. Unlike Extended, a single binding is a *sequence* of
# reports: optionally select the layer, then one report per keystroke plus
# one, then commit to flash. Missing the commit leaves the pad mid-write,
# which is a good candidate for the firmware faults some devices show.
def _legacy(report_id, b0, b1, b2=0, b3=0, b4=0, b5=0):
data = bytearray(REPORT_LEN - 1)
data[0], data[1], data[2] = b0, b1, b2
data[3], data[4], data[5] = b3, b4, b5
return bytes([report_id]) + bytes(data)
def legacy_layer_select(report_id, layer):
return _legacy(report_id, 161, layer)
def legacy_flash(report_id, led=False):
return _legacy(report_id, 170, 161 if led else 170)
def legacy_key_sequence(report_id, action, sequence, layer=0):
"""Returns the full ordered list of reports for one key binding."""
reports = []
if report_id != 0:
reports.append(legacy_layer_select(report_id, layer))
n = len(sequence)
for index in range(n + 1):
type_byte = KeyType.BASIC
if report_id != 0:
type_byte |= (layer << 4) & 0xFF
# Index 0 carries only the first entry's modifiers, no keycode.
# That asymmetry is in the original; it is not a mistake here.
if index == 0:
mods, code = sequence[0][0], 0
else:
mods, code = sequence[index - 1]
reports.append(_legacy(report_id, action, type_byte, n, index, mods, code))
reports.append(legacy_flash(report_id))
return reports
def legacy_media_sequence(report_id, action, usage, layer=0):
reports = []
if report_id != 0:
reports.append(legacy_layer_select(report_id, layer))
type_byte = KeyType.MULTIMEDIA
if report_id != 0:
type_byte |= (layer << 4) & 0xFF
reports.append(_legacy(report_id, action, type_byte,
usage & 0xFF, (usage >> 8) & 0xFF))
reports.append(legacy_flash(report_id))
return reports
# ----------------------------------------------------------- device layer
KNOWN_VENDORS = ("1189",)
def find_devices():
"""Find hidraw nodes that expose a vendor-defined 64-byte config interface."""
out = []
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
dev = os.path.join(node, "device")
try:
uevent = open(os.path.join(dev, "uevent")).read()
except OSError:
continue
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
if l.startswith("HID_ID=")), "")
if hid_id.count(":") != 2:
continue
_, v, p = hid_id.split(":")
vid, pid = v[-4:].lower(), p[-4:].lower()
if vid not in KNOWN_VENDORS:
continue
try:
desc = open(os.path.join(dev, "report_descriptor"), "rb").read()
except OSError:
continue
# Vendor-defined usage page: 0x06 followed by a page >= 0xFF00.
if not (b"\x06\x00\xff" in desc or b"\x06\x01\xff" in desc):
continue
# Report id is declared by the 0x85 global item.
rid = 0
idx = desc.find(b"\x85")
if idx != -1 and idx + 1 < len(desc):
rid = desc[idx + 1]
path = "/dev/" + os.path.basename(node)
out.append({"path": path, "vid": vid, "pid": pid, "report_id": rid,
"writable": os.access(path, os.W_OK),
"name": next((l.split("=", 1)[1] for l in uevent.splitlines()
if l.startswith("HID_NAME=")), "")})
return out
def write_report(path, report):
with open(path, "wb") as f:
f.write(report)
# ------------------------------------------------------------------- cli
def hexdump(b):
lines = []
for off in range(0, len(b), 16):
chunk = b[off:off + 16]
lines.append(f" {off:04x} " + " ".join(f"{x:02x}" for x in chunk))
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="Configure a Holtek-family macro pad.")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("list", help="show detected pads")
sub.add_parser("controls", help="list control and key names you can use")
s = sub.add_parser("set", help="bind a control")
s.add_argument("control",
help="e.g. key1, dial1-left, or action:16 for a raw byte")
s.add_argument("binding", help="e.g. 'ctrl+c', 'f5', 'volumeup', 'a,b,c'")
s.add_argument("--layer", type=int, default=1,
help="layer number, 1-indexed on this hardware")
s.add_argument("--delay", type=int, default=0, help="ms between keys")
s.add_argument("--device", help="hidraw path, if you have more than one")
s.add_argument("--report-id", type=int, help="override the detected report id")
s.add_argument("--dry-run", action="store_true",
help="show the bytes without writing")
s.add_argument("--legacy", action="store_true",
help="use the older multi-report protocol with a flash commit")
s.add_argument("--commit", action="store_true",
help="follow an extended-protocol report with a flash commit")
s.add_argument("--extended", action="store_true",
help="use upstream's 0xFE extended format instead of native")
args = ap.parse_args()
if not args.cmd:
ap.print_help()
return 0
if args.cmd == "controls":
print("Keys:")
print(" " + ", ".join(f"key{i}" for i in range(1, 16)))
print("\nDials (knobN-* and dialN-* are the same thing):")
for k in (1, 2, 3):
b = KNOB_BASE + (k - 1) * 3
print(f" dial{k}-left 0x{b:02x} dial{k}-push 0x{b+1:02x}"
f" dial{k}-right 0x{b+2:02x}")
print("\nOr a raw action byte: action:16 action:0x10")
print("\nModifiers:")
print(" " + ", ".join(sorted(set(MODIFIERS))))
print("\nMedia:")
print(" " + ", ".join(sorted(MEDIA_KEYS)))
print("\nKeys: a-z, 0-9, f1-f12, and:")
named = [k for k in KEYCODES if len(k) > 1 and not k.startswith("f")]
print(" " + ", ".join(sorted(named)))
return 0
devices = find_devices()
if args.cmd == "list":
if not devices:
print("No macro pad config interface found.")
print("Run macropad-probe.py to see what's connected.")
return 1
for d in devices:
state = "writable" if d["writable"] else "NOT WRITABLE - needs udev rule"
print(f"{d['path']} {d['vid']}:{d['pid']} report id {d['report_id']} [{state}]")
if d["name"]:
print(f" {d['name']}")
return 0
if args.cmd == "set":
if args.device:
dev = {"path": args.device, "report_id": args.report_id or 3, "writable": True}
else:
if not devices:
print("No pad found. Try --device /dev/hidrawN")
return 1
if len(devices) > 1:
print("Several candidates; pick one with --device:")
for d in devices:
print(" " + d["path"])
return 1
dev = devices[0]
report_id = args.report_id if args.report_id is not None else dev["report_id"]
control = args.control.lower()
if control.startswith("action:"):
try:
action = int(control.split(":", 1)[1], 0)
except ValueError:
print("Use action:N, e.g. action:16 or action:0x10")
return 1
if not 0 < action < 256:
print("Action must be 1-255.")
return 1
elif control in CONTROLS:
action = CONTROLS[control]
else:
print(f"Unknown control '{control}'. See: macropad.py controls")
return 1
binding = args.binding.lower().strip()
is_media = binding in MEDIA_KEYS
if args.extended or args.legacy:
pass
if args.legacy:
if is_media:
reports = legacy_media_sequence(report_id, action,
MEDIA_KEYS[binding], args.layer)
kind = f"legacy media ({binding})"
else:
try:
seq = parse_keystroke(args.binding)
except ValueError as e:
print(f"Error: {e}")
return 1
reports = legacy_key_sequence(report_id, action, seq, args.layer)
kind = f"legacy keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
elif args.extended:
if is_media:
reports = [build_media(report_id, action,
MEDIA_KEYS[binding], args.layer)]
kind = f"extended media ({binding})"
else:
try:
seq = parse_keystroke(args.binding)
except ValueError as e:
print(f"Error: {e}")
return 1
reports = [build_key(report_id, action, seq, args.layer, args.delay)]
kind = f"extended keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
if args.commit:
reports.append(legacy_flash(report_id))
kind += " + flash commit"
else:
# Native - the format confirmed by usb capture. Layer defaults to 1.
layer = args.layer if args.layer else 1
if is_media:
reports = native_media(report_id, action, MEDIA_KEYS[binding], layer)
kind = f"media ({binding})"
else:
try:
seq = parse_keystroke(args.binding)
except ValueError as e:
print(f"Error: {e}")
return 1
reports = native_key(report_id, action, seq, layer, args.delay)
kind = f"keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
print(f"{control} -> {args.binding} [{kind}]")
print(f"device {dev['path']}, report id {report_id}, "
f"{len(reports)} report{'s' if len(reports) != 1 else ''}")
for i, r in enumerate(reports):
print(f" [{i + 1}] " + " ".join(f"{x:02x}" for x in r[:14]) + " ...")
if args.dry_run:
print("\nDry run - nothing written.")
return 0
if not dev.get("writable", False):
print(f"\n{dev['path']} isn't writable. Add the udev rule first.")
return 1
import time
for i, r in enumerate(reports):
try:
write_report(dev["path"], r)
except OSError as e:
print(f"\nReport {i + 1}/{len(reports)} failed: {e}")
if i > 0:
print("Earlier reports were sent - the pad may be part-configured.")
return 1
time.sleep(0.02) # the firmware needs a moment between reports
print("\nWritten. Press the control to test it.")
return 0
if __name__ == "__main__":
sys.exit(main())

1
macropad_gui/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Linux GUI for Holtek-family USB macro pads. GPL-3.0."""

99
macropad_gui/__main__.py Normal file
View File

@@ -0,0 +1,99 @@
"""
python3 -m macropad_gui open the app
python3 -m macropad_gui doctor check the pad is connected and writable
The rest are for poking at the core from a terminal:
python3 -m macropad_gui state
python3 -m macropad_gui state
python3 -m macropad_gui write key1 "ctrl+c"
python3 -m macropad_gui write dial1-right --media volumeup
"""
import argparse
import sys
from . import core
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
def cmd_doctor(_):
d = core.diagnose()
colour = G if d.ready else (Y if d.status == "unplugged" else R)
print(f"{colour}{B}{d.headline}{O}")
for line in d.detail:
print(f" {line}")
if d.fix:
print(f"\n{B}Fix{O}")
for c in d.fix:
print(f" {DIM if c.startswith('#') else ''}{c}{O}")
for w in d.warnings:
print(f"\n{Y}Note:{O} {w}")
return 0 if d.ready else 1
def cmd_state(_):
s = core.State()
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
for c in core.CONTROL_IDS:
e = s.get(c)
byte = f"0x{core.action_byte(c):02x}"
if e.unknown:
print(f" {c:<12} {DIM}{byte}{O} {Y}unknown{O} {DIM}{e.reason}{O}")
else:
print(f" {c:<12} {DIM}{byte}{O} {e.binding.label():<24} "
f"{DIM}{e.written}{O}")
return 0
def cmd_write(a):
if a.control not in core.CONTROL_IDS:
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
return 1
try:
b = core.Binding(keys=a.keys or "", media=a.media or "",
delay=a.delay).validate()
except ValueError as e:
print(f"{R}{e}{O}")
return 1
d = core.diagnose()
if not d.ready:
return cmd_doctor(None)
state = core.State()
try:
core.write_binding(d.device, a.control, b, state)
except core.WriteError as e:
print(f"{R}Write failed:{O} {e}")
print(" " + ("The pad was partly written; this control is now marked "
"unknown." if e.pad_touched else "Nothing reached the pad."))
return 1
print(f"{G}Written.{O} {a.control} -> {b.label()} {DIM}{state.path}{O}")
return 0
def main():
ap = argparse.ArgumentParser(prog="python3 -m macropad_gui")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("doctor", help="check the pad is connected and writable")
sub.add_parser("state", help="show what this tool believes is on the pad")
w = sub.add_parser("write", help="write one binding and record it")
w.add_argument("control")
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
w.add_argument("--media", help="e.g. volumeup")
w.add_argument("--delay", type=int, default=0)
a = ap.parse_args()
if a.cmd is None:
try:
from .app import run
except ImportError as e:
print(f"{R}Can't start the app:{O} {e}")
print("Install Qt for Python: sudo pacman -S pyside6")
return 1
return run()
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write}[a.cmd](a)
if __name__ == "__main__":
sys.exit(main())

648
macropad_gui/app.py Normal file
View File

@@ -0,0 +1,648 @@
"""
The window. Open it, change what a control does, write it, close it. The
pad keeps its bindings in its own memory, so nothing here needs to keep
running afterwards.
"""
import sys
from pathlib import Path
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QGuiApplication, QPalette
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit,
QPushButton, QVBoxLayout, QWidget)
from . import core, keymap
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
GOOD = "#8db36b"
BAD = "#e0584f"
LINE = "#3a3e42"
FIELD = "#121416"
# Media keys as pairs, so opposites sit side by side.
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
("volumedown", "volumeup"), ("mute", None),
("brightnessdown", "brightnessup"),
("calculator", "mail"), ("browser", "mycomputer")]
STYLE = f"""
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }}
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }}
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }}
QLabel[role="value"] {{ font-size: 15px; }}
QPushButton {{ background: transparent; border: 1px solid {LINE};
border-radius: 4px; padding: 6px 12px; }}
QPushButton:hover {{ border-color: {INK_DIM.name()}; }}
QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }}
QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }}
QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206;
border: none; font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: #f39a45; }}
QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }}
QPushButton[role="flat"] {{ border: none; color: {INK_DIM.name()}; padding: 6px 8px; }}
QPushButton[role="flat"]:hover {{ color: {INK.name()}; }}
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
padding: 7px 8px; }}
QLineEdit:focus {{ border-color: {INK_DIM.name()}; }}
QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }}
QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }}
QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
border-radius: 4px; }}
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
"""
def _label(text="", role=None, wrap=False):
lab = QLabel(text)
lab.setIndent(0)
if role:
lab.setProperty("role", role)
lab.setWordWrap(wrap)
return lab
def _button(text, role=None, checkable=False):
b = QPushButton(text)
if role:
b.setProperty("role", role)
b.setCheckable(checkable)
b.setCursor(Qt.PointingHandCursor)
return b
# ------------------------------------------------------------------ recorder
class RecorderEdit(QLineEdit):
"""
A text field that can also listen for a key combo. Type 'ctrl+shift+n',
or press Record and then press the combo itself.
"""
recorded = Signal(str)
def __init__(self):
super().__init__()
self.recording = False
self.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
def set_recording(self, on):
self.recording = on
self.setProperty("recording", "true" if on else "false")
self.style().unpolish(self)
self.style().polish(self)
if on:
self._before = self.text()
self.clear()
self.setPlaceholderText("Press the combo now")
self.setFocus()
else:
self.setPlaceholderText("ctrl+shift+n")
def event(self, e):
# Catch Tab and friends too, which would otherwise move focus.
if self.recording and e.type() == QEvent.KeyPress:
self._record(e)
return True
if self.recording and e.type() == QEvent.ShortcutOverride:
e.accept()
return True
return super().event(e)
def _record(self, e):
mods = keymap.modifier_names(e)
if e.key() in keymap.MODIFIER_KEYS:
self.setText("+".join(mods + ["…"]))
return
name = keymap.key_name(e)
if name is None:
self.setText("")
self.setPlaceholderText("The pad can't send that key. Try another.")
return
combo = "+".join(mods + [name])
self.set_recording(False)
self.setText(combo)
self.recorded.emit(combo)
def focusOutEvent(self, e):
if self.recording:
self.set_recording(False)
self.setText(self._before)
super().focusOutEvent(e)
# ------------------------------------------------------------------ inspector
class Inspector(QWidget):
"""Right-hand panel: what the selected control does, and what to change it to."""
changed = Signal(str, object) # control, Binding or None (revert)
write = Signal(str)
revert = Signal(str)
def __init__(self):
super().__init__()
self.setFixedWidth(360)
self.control = None
self._loading = False
v = QVBoxLayout(self)
v.setContentsMargins(24, 24, 24, 16)
v.setSpacing(4)
# problem banner (no pad / no permission)
self.banner = QFrame()
self.banner.setProperty("role", "banner")
bl = QVBoxLayout(self.banner)
bl.setContentsMargins(12, 10, 12, 10)
self.banner_head = _label("", wrap=True)
self.banner_head.setStyleSheet("font-weight: 600;")
self.banner_text = _label("", "dim", wrap=True)
self.banner_fix = QPlainTextEdit()
self.banner_fix.setReadOnly(True)
self.banner_fix.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
self.banner_fix.setFixedHeight(84)
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
self.banner_copy = _button("Copy commands")
self.banner_copy.clicked.connect(self._copy)
for w in (self.banner_head, self.banner_text, self.banner_fix, self.banner_copy):
bl.addWidget(w)
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
self.banner_copy.sizePolicy().verticalPolicy())
v.addWidget(self.banner)
v.addSpacing(8)
# empty state
self.empty = _label("", "dim", wrap=True)
v.addWidget(self.empty)
# editor
self.editor = QWidget()
ev = QVBoxLayout(self.editor)
ev.setContentsMargins(0, 0, 0, 0)
ev.setSpacing(4)
self.title = _label("", "title")
self.sub = _label("", "dim")
ev.addWidget(self.title)
ev.addWidget(self.sub)
ev.addWidget(_label("On the pad now", "caption"))
self.onpad = _label("", "value", wrap=True)
self.onpad_why = _label("", "dim", wrap=True)
ev.addWidget(self.onpad)
ev.addWidget(self.onpad_why)
ev.addWidget(_label("Change it to", "caption"))
modes = QHBoxLayout()
modes.setSpacing(6)
self.mode_keys = _button("Shortcut", checkable=True)
self.mode_media = _button("Media key", checkable=True)
self.mode_group = QButtonGroup(self)
self.mode_group.setExclusive(True)
for i, b in enumerate((self.mode_keys, self.mode_media)):
self.mode_group.addButton(b, i)
modes.addWidget(b)
modes.addStretch()
ev.addLayout(modes)
ev.addSpacing(6)
# shortcut page
kp = QWidget()
kl = QVBoxLayout(kp)
kl.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
self.keys_edit = RecorderEdit()
self.keys_edit.setPlaceholderText("ctrl+shift+n")
self.record = _button("Record", checkable=True)
row.addWidget(self.keys_edit, 1)
row.addWidget(self.record)
kl.addLayout(row)
self.keys_hint = _label("", "dim", wrap=True)
kl.addWidget(self.keys_hint)
# media page
mp = QWidget()
ml = QGridLayout(mp)
ml.setContentsMargins(0, 0, 0, 0)
ml.setSpacing(6)
self.media_group = QButtonGroup(self)
self.media_group.setExclusive(True)
self.media_buttons = {}
for r, pair in enumerate(MEDIA_GRID):
for col, mid in enumerate(pair):
if mid is None:
continue
b = _button(core.MEDIA_LABEL[mid], "media", checkable=True)
self.media_group.addButton(b, [m for m, _ in core.MEDIA].index(mid))
self.media_buttons[mid] = b
ml.addWidget(b, r, col)
self.pages = (kp, mp)
ev.addWidget(kp)
ev.addWidget(mp)
ev.addSpacing(10)
actions = QHBoxLayout()
self.write_btn = _button("Write to pad", "primary")
self.revert_btn = _button("Revert", "flat")
actions.addWidget(self.write_btn)
actions.addWidget(self.revert_btn)
actions.addStretch()
ev.addLayout(actions)
v.addWidget(self.editor)
v.addStretch()
self.mode_group.idClicked.connect(self._mode)
self.keys_edit.textEdited.connect(self._keys_typed)
self.keys_edit.recorded.connect(self._keys_typed)
self.record.toggled.connect(self._toggle_record)
self.media_group.idClicked.connect(self._media_picked)
self.write_btn.clicked.connect(lambda: self.write.emit(self.control))
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
self.show_control(None, None, None, False)
# -------------------------------------------------------------- banner
def show_problem(self, diag, note=None):
if diag is None or diag.ready:
if not note:
self.banner.hide()
return
diag = core.Diagnosis("note", "Heads up", detail=[note])
quiet = diag.status in ("unplugged", "note", "unsupported")
self.banner.setProperty("tone", "quiet" if quiet else "")
self.banner.style().unpolish(self.banner)
self.banner.style().polish(self.banner)
self.banner_head.setText(diag.headline)
self.banner_text.setText("\n\n".join(diag.detail))
cmds = [c for c in diag.fix if not c.startswith("#")]
self.banner_fix.setPlainText("\n".join(cmds))
lh = self.banner_fix.fontMetrics().lineSpacing()
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
self.banner_fix.setVisible(bool(cmds))
self.banner_copy.setVisible(bool(cmds))
if diag.fix and diag.fix[-1].startswith("#"):
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
self.banner.show()
def _copy(self):
QGuiApplication.clipboard().setText(self.banner_fix.toPlainText())
self.banner_copy.setText("Copied")
QTimer.singleShot(1500, lambda: self.banner_copy.setText("Copy commands"))
# -------------------------------------------------------------- editor
def show_control(self, control, entry, desired, can_write, all_unknown=False):
self._loading = True
self.control = control
if control is None:
self.editor.hide()
self.empty.setText(
"Click a key or dial to change what it does.\n\n"
+ ("The pad can't report what's on it, so this app only knows "
"what it has written itself. Hatched controls are unknown. "
"If you set the pad up with the vendor software, you can "
"import that capture instead." if all_unknown else ""))
self.empty.show()
self._loading = False
return
self.empty.hide()
self.editor.show()
self.title.setText(core.control_name(control))
self.sub.setText(f"Action byte 0x{core.action_byte(control):02x}")
if entry.unknown:
self.onpad.setText("Unknown")
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
self.onpad_why.show()
else:
self.onpad.setText(entry.binding.label())
self.onpad.setStyleSheet("")
self.onpad_why.hide()
shown = desired or (None if entry.unknown else entry.binding)
if shown is not None and shown.media:
self.mode_media.setChecked(True)
self._page(1)
self.media_buttons[shown.media].setChecked(True)
self.keys_edit.clear()
else:
self.mode_keys.setChecked(True)
self._page(0)
self.keys_edit.setText(shown.keys if shown else "")
self._clear_media()
self.keys_hint.setText("")
self.update_buttons(desired is not None, can_write)
self._loading = False
def update_buttons(self, pending, can_write):
self.write_btn.setEnabled(can_write and pending)
self.revert_btn.setVisible(pending)
def _clear_media(self):
self.media_group.setExclusive(False)
for b in self.media_buttons.values():
b.setChecked(False)
self.media_group.setExclusive(True)
def _page(self, i):
# Plain show/hide rather than QStackedWidget, which always reserves
# the height of its tallest page.
for j, page in enumerate(self.pages):
page.setVisible(j == i)
def _mode(self, i):
self._page(i)
if i == 0 and self.record.isChecked():
self.keys_edit.setFocus()
def _toggle_record(self, on):
self.keys_edit.set_recording(on)
self.keys_hint.setText(
"Plasma keeps some combos for itself, like Meta shortcuts, so the "
"app never sees them. Type those instead." if on else "")
def _keys_typed(self, text):
if self.record.isChecked() and not self.keys_edit.recording:
self.record.setChecked(False)
if self._loading or self.control is None:
return
text = core.normalize_keys(text)
if not text:
self.keys_hint.setText("")
self.changed.emit(self.control, None)
return
try:
b = core.Binding(keys=text).validate()
except ValueError as e:
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
self.changed.emit(self.control, None)
return
if b.is_sequence and not core.SEQUENCES_VERIFIED:
self.keys_hint.setText(
f"<span style='color:{BAD}'>Sequences aren't verified on this "
"pad yet, so they're switched off for now. One shortcut per "
"control.</span>")
self.changed.emit(self.control, None)
return
self.keys_hint.setText(f"Sends {b.label()}")
self.changed.emit(self.control, b)
def _media_picked(self, i):
if self._loading or self.control is None:
return
self.changed.emit(self.control, core.Binding(media=core.MEDIA[i][0]))
# ------------------------------------------------------------------ window
class Window(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("MacroPad")
self.settings = QSettings("macropad", "macropad")
self.state = core.State()
self.desired = {} # control -> Binding, unwritten edits
self.diag = None
root = QWidget()
outer = QVBoxLayout(root)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
body = QHBoxLayout()
body.setSpacing(0)
self.pad = PadView()
self.pad.set_orientation(self.settings.value("orientation", "upright"))
self.inspector = Inspector()
body.addWidget(self.pad, 1)
body.addWidget(self.inspector)
outer.addLayout(body, 1)
footer = QFrame()
footer.setProperty("role", "footer")
fl = QHBoxLayout(footer)
fl.setContentsMargins(16, 8, 16, 8)
self.dot = QLabel()
self.dot.setFixedSize(9, 9)
self.status = _label("", "dim")
self.message = _label("")
self.rotate = _button("Rotate view", "flat")
self.import_btn = _button("Import capture…", "flat")
self.write_all = _button("", "primary")
fl.addWidget(self.dot)
fl.addWidget(self.status)
fl.addSpacing(16)
fl.addWidget(self.message, 1)
fl.addWidget(self.rotate)
fl.addWidget(self.import_btn)
fl.addWidget(self.write_all)
outer.addWidget(footer)
self.setCentralWidget(root)
self.pad.selected.connect(self._select)
self.inspector.changed.connect(self._changed)
self.inspector.revert.connect(self._revert)
self.inspector.write.connect(lambda c: self._write([c]))
self.write_all.clicked.connect(lambda: self._write(self._pending()))
self.rotate.clicked.connect(self._rotate)
self.import_btn.clicked.connect(self._import)
self.note = core._keyd_warning()
self.timer = QTimer(self)
self.timer.timeout.connect(self._poll)
self.timer.start(2000)
self._poll()
self._refresh()
# ------------------------------------------------------------- model
def _pending(self):
out = []
for c in core.CONTROL_IDS:
b = self.desired.get(c)
if b is None:
continue
e = self.state.get(c)
if e.unknown or e.binding != b:
out.append(c)
return out
def _refresh(self, editor=True):
pending = set(self._pending())
looks = {}
for c in core.CONTROL_IDS:
e = self.state.get(c)
if c in pending:
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
elif e.unknown:
looks[c] = Look("", known=False)
else:
looks[c] = Look(e.binding.label(), known=True)
self.pad.set_looks(looks)
n = len(pending)
self.write_all.setText(f"Write {n} change{'s' if n != 1 else ''}")
self.write_all.setVisible(n > 1)
self.write_all.setEnabled(bool(self.diag and self.diag.ready))
c = self.pad.current
can_write = bool(self.diag and self.diag.ready)
if not editor:
self.inspector.update_buttons(c in pending, can_write)
return
all_unknown = all(self.state.get(x).unknown for x in core.CONTROL_IDS)
self.inspector.show_control(
c, self.state.get(c) if c else None,
self.desired.get(c) if c in pending else None,
can_write, all_unknown)
def _select(self, control):
self._refresh()
def _changed(self, control, binding):
if binding is None:
self.desired.pop(control, None)
else:
self.desired[control] = binding
self._refresh(editor=False)
def _revert(self, control):
self.desired.pop(control, None)
self._refresh()
# ------------------------------------------------------------ device
def _poll(self):
old = self.diag.status if self.diag else None
try:
self.diag = core.diagnose(keyd=False)
except Exception as e: # never let polling kill the window
self._say(f"Couldn't check the pad: {e}", BAD)
return
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
self.status.setText({
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
"unplugged": "No pad connected",
"no-interface": "Pad found, config interface missing",
"no-permission": "No permission to write to the pad",
"unsupported": "Unsupported pad",
}[self.diag.status])
self.inspector.show_problem(self.diag, self.note)
if self.diag.status != old:
self._refresh(editor=False)
# ------------------------------------------------------------- write
def _write(self, controls):
controls = [c for c in controls if c in self.desired]
if not controls:
return
self._poll()
if not self.diag.ready:
self._say(self.diag.headline, BAD)
return
done = []
for c in controls:
b = self.desired[c]
try:
core.write_binding(self.diag.device, c, b, self.state)
except core.WriteError as e:
what = core.control_name(c)
tail = (f"{what} is now marked unknown, since part of it reached the pad."
if e.pad_touched else "Nothing reached the pad.")
why = str(e).split(": ", 1)[-1]
self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
self._refresh()
return
self.desired.pop(c, None)
done.append((c, b))
QApplication.processEvents()
if len(done) == 1:
c, b = done[0]
self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD)
else:
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
self._refresh()
def _say(self, text, colour=None):
self.message.setText(text)
self.message.setStyleSheet(f"color: {colour};" if colour else "")
# ------------------------------------------------------------- misc
def _rotate(self):
o = "flat" if self.pad.orientation == "upright" else "upright"
self.pad.set_orientation(o)
self.settings.setValue("orientation", o)
def _import(self):
path, _ = QFileDialog.getOpenFileName(
self, "Import a capture of the vendor software",
str(Path.cwd()), "Captures (*.txt);;All files (*)")
if not path:
return
try:
got = core.import_capture(path, self.state)
except (OSError, ValueError) as e:
self._say(f"Couldn't read that capture: {e}", BAD)
return
if not got:
self._say("No saved bindings found in that capture.", BAD)
return
for c, _, _ in got:
self.desired.pop(c, None)
self._say(f"Imported {len(got)} bindings from {Path(path).name}.", GOOD)
self._refresh()
def closeEvent(self, e):
n = len(self._pending())
if not n:
e.accept()
return
box = QMessageBox(self)
box.setWindowTitle("Unwritten changes")
box.setText(f"{n} change{'s are' if n != 1 else ' is'} not on the pad yet.")
write = box.addButton("Write and close", QMessageBox.AcceptRole)
discard = box.addButton("Close without writing", QMessageBox.DestructiveRole)
box.addButton("Cancel", QMessageBox.RejectRole)
box.exec()
if box.clickedButton() is write:
self._write(self._pending())
e.accept() if not self._pending() else e.ignore()
elif box.clickedButton() is discard:
e.accept()
else:
e.ignore()
def apply_theme(app):
app.setStyle("Fusion")
pal = QPalette()
for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)),
(QPalette.Text, INK), (QPalette.WindowText, INK),
(QPalette.Button, WINDOW), (QPalette.ButtonText, INK),
(QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))):
pal.setColor(role, colour)
app.setPalette(pal)
app.setStyleSheet(STYLE)
def run():
app = QApplication(sys.argv)
app.setApplicationName("macropad")
app.setDesktopFileName("macropad")
apply_theme(app)
w = Window()
w.resize(1000, 800)
w.show()
return app.exec()

626
macropad_gui/core.py Normal file
View File

@@ -0,0 +1,626 @@
"""
Core of the macro pad GUI. No Qt in here: the GUI imports this, and so do
the tests, so everything that matters can be checked without a display.
The pad is write-only, which shapes the whole design. There are two kinds
of truth and they live in separate files:
profile what you *want* on the pad. You edit it; the GUI edits it.
state what this tool last wrote *successfully*. Only the writer
touches it. It is our shadow copy of the pad's memory.
A control we never wrote, or whose write failed partway, is UNKNOWN. The
pad could hold anything there, and the UI must say so rather than guess.
Every byte on the wire still comes from macropad.py. This module decides
what to send and remembers what was sent; it never builds a report itself.
"""
import errno
import glob
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
try:
import macropad as proto
except ImportError: # running from inside the package dir
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import macropad as proto
# ------------------------------------------------------------------ device
VID, PID = "1189", "8840"
DEVICE_ID = f"{VID}:{PID}"
KEYS = tuple(f"key{i}" for i in range(1, 13))
DIALS = ("dial1", "dial2")
DIAL_ACTIONS = ("left", "push", "right")
CONTROL_IDS = KEYS + tuple(f"{d}-{a}" for d in DIALS for a in DIAL_ACTIONS)
# Action bytes come from macropad.CONTROLS, never from here.
for _cid in CONTROL_IDS:
assert _cid in proto.CONTROLS, f"macropad.py has no control {_cid}"
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
def action_byte(control):
return proto.CONTROLS[control]
CONTROL_FOR_ACTION = {action_byte(c): c for c in CONTROL_IDS}
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
def control_name(control):
"""'key4' -> 'Key 4', 'dial1-left' -> 'Dial 1, turn left'."""
if control.startswith("key"):
return f"Key {control[3:]}"
dial, act = control.split("-")
return f"Dial {dial[4:]}, {_DIAL_WORDS[act]}"
# Media keys the GUI offers, in the order it offers them. Every id must be a
# name macropad.MEDIA_KEYS knows; the usage codes themselves live there.
MEDIA = [
("playpause", "Play / pause"), ("next", "Next track"),
("prev", "Previous track"), ("stop", "Stop"),
("volumeup", "Volume up"), ("volumedown", "Volume down"), ("mute", "Mute"),
("brightnessup", "Brightness up"), ("brightnessdown", "Brightness down"),
("calculator", "Calculator"), ("mail", "Email"),
("browser", "Browser"), ("mycomputer", "My computer"),
]
MEDIA_LABEL = dict(MEDIA)
for _m, _ in MEDIA:
assert _m in proto.MEDIA_KEYS, f"macropad.py has no media key {_m}"
_MEDIA_BY_USAGE = {}
for _m, _ in MEDIA:
_MEDIA_BY_USAGE.setdefault(proto.MEDIA_KEYS[_m], _m)
# Verified on 1189:8840 so far: single keys and shortcuts (capture + use),
# media keys (capture). Sequences and the delay field are upstream's layout
# and have not been seen on this pad - the GUI keeps them locked until a
# capture confirms them.
SEQUENCES_VERIFIED = False
_MOD_ORDER = [("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04), ("super", 0x08),
("rctrl", 0x10), ("rshift", 0x20), ("ralt", 0x40), ("rwin", 0x80)]
_MOD_LABEL = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "super": "Meta",
"win": "Meta", "lctrl": "Ctrl", "lshift": "Shift", "lalt": "Alt",
"lwin": "Meta", "rctrl": "Right Ctrl", "rshift": "Right Shift",
"ralt": "AltGr", "rwin": "Right Meta"}
_KEY_LABEL = {"pageup": "Page Up", "pagedown": "Page Down", "printscreen": "Print",
"scrolllock": "Scroll Lock", "capslock": "Caps Lock",
"backspace": "Backspace", "leftbracket": "[", "rightbracket": "]",
"backslash": "\\", "semicolon": ";", "quote": "'", "grave": "`",
"comma": ",", "period": ".", "slash": "/", "minus": "-", "equal": "=",
"esc": "Esc", "escape": "Esc", "return": "Enter"}
_KEYNAME_BY_CODE = {}
for _name, _code in proto.KEYCODES.items():
_KEYNAME_BY_CODE.setdefault(_code, _name)
def normalize_keys(text):
"""' Ctrl + C ,' -> 'ctrl+c'. Doesn't validate; Binding does that."""
alias = {"meta": "super"} # Plasma says Meta; macropad.py says super
steps = []
for chunk in text.lower().split(","):
parts = [alias.get(p.strip(), p.strip()) for p in chunk.split("+") if p.strip()]
if parts:
steps.append("+".join(parts))
return ",".join(steps)
def pretty_step(step):
parts = []
for p in step.split("+"):
p = p.strip().lower()
if p in _MOD_LABEL:
parts.append(_MOD_LABEL[p])
else:
parts.append(_KEY_LABEL.get(p, p.upper() if len(p) <= 3 else p.title()))
return "+".join(parts)
# ----------------------------------------------------------------- binding
@dataclass(frozen=True)
class Binding:
"""
What one control does. Stored in the CLI's own syntax, so every entry
in a profile is exactly a `macropad.py set <control> <binding>` command.
Binding(keys="ctrl+c")
Binding(keys="a,b,c", delay=50)
Binding(media="volumeup")
The kind is explicit rather than guessed from the string, because
'pause' is both a keyboard key and a media key.
"""
keys: str = ""
media: str = ""
delay: int = 0
def validate(self):
if bool(self.keys) == bool(self.media):
raise ValueError("a binding is either keys or media, not both or neither")
if self.media and self.media not in proto.MEDIA_KEYS:
raise ValueError(f"unknown media key '{self.media}'")
if self.keys:
proto.parse_keystroke(self.keys) # raises ValueError
if not 0 <= self.delay <= 0xFFFF:
raise ValueError("delay must be 0-65535 ms")
if self.media and self.delay:
raise ValueError("delay only applies to key sequences")
return self
@property
def steps(self):
return len(proto.parse_keystroke(self.keys)) if self.keys else 1
def reports(self, report_id, action, layer=1):
"""The exact reports to send, built entirely by macropad.py."""
self.validate()
if self.media:
return proto.native_media(report_id, action,
proto.MEDIA_KEYS[self.media], layer)
return proto.native_key(report_id, action,
proto.parse_keystroke(self.keys),
layer, self.delay)
@property
def is_sequence(self):
return bool(self.keys) and self.steps > 1
def label(self):
"""What a person reads on the key: 'Ctrl+Shift+N', 'Volume up'."""
if self.media:
return MEDIA_LABEL.get(self.media, self.media)
text = ", ".join(pretty_step(s) for s in self.keys.split(",") if s.strip())
return text + (f" ({self.delay} ms)" if self.delay else "")
def to_json(self):
if self.media:
return {"media": self.media}
d = {"keys": self.keys}
if self.delay:
d["delay"] = self.delay
return d
@classmethod
def from_json(cls, d):
return cls(keys=d.get("keys", ""), media=d.get("media", ""),
delay=int(d.get("delay", 0))).validate()
# ------------------------------------------------------------------- files
def config_dir():
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return Path(base) / "macropad"
def _atomic_write_json(path, data):
"""Write-then-rename, so a crash mid-save never leaves half a file."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
FORMAT = 1
@dataclass
class Entry:
"""One control's line in the shadow state."""
binding: Binding = None # what we believe is on the pad
written: str = "" # ISO timestamp of the successful write
unknown: bool = True # True = pad contents not known
reason: str = "not written by this app yet"
class State:
"""
Shadow copy of the pad. Keyed by (layer, control). Only the writer
should call record()/mark_unknown(); the GUI just reads it.
"""
def __init__(self, path=None):
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
self.entries = {} # (layer, control) -> Entry
if self.path.exists():
self._load()
def _load(self):
data = json.loads(self.path.read_text())
if data.get("format") != FORMAT:
raise ValueError(f"{self.path}: unsupported format {data.get('format')}")
for layer, controls in data.get("layers", {}).items():
for control, e in controls.items():
if e.get("unknown"):
entry = Entry(unknown=True, reason=e.get("reason", ""))
else:
entry = Entry(binding=Binding.from_json(e["binding"]),
written=e.get("written", ""), unknown=False,
reason="")
self.entries[(int(layer), control)] = entry
def save(self):
layers = {}
for (layer, control), e in sorted(self.entries.items()):
if e.unknown:
d = {"unknown": True, "reason": e.reason}
else:
d = {"binding": e.binding.to_json(), "written": e.written}
layers.setdefault(str(layer), {})[control] = d
_atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID,
"layers": layers})
def get(self, control, layer=1):
return self.entries.get((layer, control), Entry())
def record(self, control, binding, layer=1):
self.entries[(layer, control)] = Entry(
binding=binding, unknown=False, reason="",
written=datetime.now().isoformat(timespec="seconds"))
def mark_unknown(self, control, reason, layer=1):
self.entries[(layer, control)] = Entry(unknown=True, reason=reason)
# ---------------------------------------------------------------- diagnosis
@dataclass
class Device:
path: str
report_id: int
writable: bool
name: str = ""
@dataclass
class Diagnosis:
status: str # ready | unplugged | no-interface | no-permission | unsupported
headline: str
device: Device = None
detail: list = field(default_factory=list)
fix: list = field(default_factory=list) # works in bash, zsh and fish
warnings: list = field(default_factory=list)
@property
def ready(self):
return self.status == "ready"
RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
SUGGESTED_RULE = "60-macropad.rules"
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"')
def _uaccess_cutoff():
"""
The rules file that turns a uaccess tag into an actual ACL. On systemd
this is 73-seat-late.rules. udev runs every rules file in one lexical
order by filename, so a rule that adds the tag must sort before this one
- 99-foo.rules is tagged but never granted, and so is an unnumbered
foo.rules, since letters sort after digits.
"""
for d in SYSTEM_RULES:
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
try:
if 'RUN{builtin}+="uaccess"' in open(f).read():
return os.path.basename(f)
except OSError:
continue
return "73-seat-late.rules"
def _matching_rules():
"""User rule files that mention our vendor id and uaccess."""
hits = []
for d in RULES_DIRS:
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
try:
text = open(f).read()
except OSError:
continue
for line in text.splitlines():
s = line.strip()
if s.startswith("#"):
continue
if re.search(rf'idVendor\}}=="{VID}"', s, re.I) and "uaccess" in s:
hits.append(f)
break
return hits
def _udev_tags(devpath):
try:
out = subprocess.run(["udevadm", "info", "--query=property",
f"--name={devpath}"],
capture_output=True, text=True, timeout=3).stdout
except (OSError, subprocess.TimeoutExpired):
return None
tags = set()
for line in out.splitlines():
if line.startswith(("TAGS=", "CURRENT_TAGS=")):
tags |= {t for t in line.split("=", 1)[1].split(":") if t}
return tags
def _on_usb_bus():
for d in glob.glob("/sys/bus/usb/devices/*"):
try:
v = open(os.path.join(d, "idVendor")).read().strip().lower()
p = open(os.path.join(d, "idProduct")).read().strip().lower()
except OSError:
continue
if (v, p) == (VID, PID):
return True
return False
def _keyd_warning():
"""keyd sits between the pad and the desktop and can rewrite its output."""
if not shutil.which("keyd"):
return None
try:
active = subprocess.run(["systemctl", "is-active", "keyd"],
capture_output=True, text=True,
timeout=3).stdout.strip() == "active"
except (OSError, subprocess.TimeoutExpired):
return None
if not active:
return None
for f in glob.glob("/etc/keyd/*.conf"):
try:
if DEVICE_ID in open(f).read().lower():
return (f"keyd, a key remapping service, is running and {f} "
"lists this pad. It can change keys on their way from "
"the pad to your desktop, so a key may not do exactly "
"what you set here. If you only set keyd up for this "
"pad, you can switch it off with: "
"sudo systemctl disable --now keyd")
except OSError:
continue
return None
def diagnose(keyd=True):
"""keyd=False skips the keyd check, which shells out - the GUI polls."""
warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
everything = proto.find_devices()
found = [d for d in everything if d["pid"] == PID]
if not found and everything:
other = everything[0]
return Diagnosis(
"unsupported",
f"Found a {other['vid']}:{other['pid']} pad, which this app doesn't know yet.",
detail=[f"So far only {DEVICE_ID} (12 keys, 2 dials) has been "
"worked out. Pads in this family differ in layout and "
"protocol, so writing to yours with the wrong one could "
"fail or scramble it. The app won't try.",
"You can help add it: the README's \"Other pads\" section "
"shows how to capture what the vendor software sends."],
warnings=warnings)
if not found:
if _on_usb_bus():
return Diagnosis(
"no-interface",
"Pad is plugged in, but its configuration interface is missing.",
detail=["The USB device is present, but no hidraw node with the "
"vendor-defined config interface was found.",
"Usually a replug fixes this. If not, run "
"macropad-probe.py and check the interface list."],
warnings=warnings)
return Diagnosis("unplugged", "No pad connected.",
detail=[f"Looking for {DEVICE_ID} on USB."],
warnings=warnings)
d = found[0]
dev = Device(path=d["path"], report_id=d["report_id"],
writable=d["writable"], name=d.get("name", ""))
if dev.writable:
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
detail=[f"report id {dev.report_id}"], warnings=warnings)
# Not writable: work out which of the three usual causes it is.
cutoff = _uaccess_cutoff()
rules = _matching_rules()
late = [r for r in rules if os.path.basename(r) >= cutoff]
early = [r for r in rules if os.path.basename(r) < cutoff]
tags = _udev_tags(dev.path)
tagged = bool(tags and "uaccess" in tags)
diag = Diagnosis("no-permission", f"{dev.path} isn't writable.",
device=dev, warnings=warnings)
if late and not early:
bad = late[0]
new = os.path.join(os.path.dirname(bad), SUGGESTED_RULE)
diag.detail = [
f"{bad} matches the pad, but it runs after {cutoff}, which is "
"where access is actually granted. The device gets tagged, "
"then nothing acts on the tag.",
"Rename it so it sorts earlier."]
diag.fix = [f"sudo mv {bad} {new}",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"]
elif early and not tagged:
diag.detail = [f"{early[0]} looks right, but the device hasn't picked "
"it up yet."]
diag.fix = ["sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"]
elif early and tagged:
diag.detail = [
"The rule matched and the device is tagged, but no access was "
"granted. That happens when you aren't in an active local desktop "
"session - for example over SSH, or in a shell started with sudo."]
else:
diag.detail = ["No udev rule lets you write to the pad yet. Running "
"the installer again adds it, or paste these commands "
"into a terminal:"]
diag.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"]
return diag
# ------------------------------------------------------------------ writer
class WriteError(Exception):
"""
pad_touched is the thing the UI cares about: False means the pad is
exactly as it was; True means some reports landed and the control's
contents are now unknown.
"""
def __init__(self, message, pad_touched, cause=None):
super().__init__(message)
self.pad_touched = pad_touched
self.cause = cause
def _explain(err):
return {
errno.ENOENT: "the pad was unplugged",
errno.ENODEV: "the pad was unplugged",
errno.EACCES: "no permission to write to the pad",
errno.EPERM: "no permission to write to the pad",
errno.EPIPE: "the pad rejected the report",
errno.EIO: "the pad stopped responding (it may have reset)",
errno.ETIMEDOUT: "the pad didn't accept the report in time",
}.get(err.errno, err.strerror or str(err))
def write_binding(device, control, binding, state, layer=1, _write=None):
"""
Send one binding (config report + commit) and update the shadow state.
Raises WriteError; on success the state file is already saved.
"""
write = _write or proto.write_report
reports = binding.reports(device.report_id, action_byte(control), layer)
sent = 0
try:
for r in reports:
write(device.path, r)
sent += 1
time.sleep(WRITE_GAP)
except OSError as e:
why = _explain(e)
# If even the first write failed on open, nothing reached the pad.
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted after {sent} of "
f"{len(reports)} reports: {why}", layer)
state.save()
raise WriteError(f"{control}: {why}", pad_touched=not untouched, cause=e)
state.record(control, binding, layer)
state.save()
# --------------------------------------------------------------- decoding
def decode_config(report):
"""
The inverse of Binding.reports()[0]: (control, layer, Binding) from a
native config report, or None if it isn't one we understand. Used to
import captures and to prove round trips in the tests.
"""
if len(report) < 13 or report[1] != proto.MAGIC_NATIVE:
return None
action, layer, kind = report[2], report[3], report[4]
control = 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:
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
return (control, layer, Binding(media=media)) if media else None
if kind == 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):
return len(report) >= 4 and report[1] == proto.MAGIC_NATIVE and \
(report[2], report[3]) == proto.TERMINATOR
def read_capture(path):
"""Raw report bytes from a macropad-capture.py output file, in order."""
out = []
for line in Path(path).read_text().splitlines():
if line.startswith(" "):
try:
out.append(bytes.fromhex(line.strip()))
except ValueError:
continue
return out
def import_capture(path, state):
"""
Record what a vendor-software session wrote, so the GUI knows what's on
the pad. Only bindings followed by a commit count - the vendor app can
send many configs and save once. Returns [(control, layer, Binding)].
"""
pending, done = [], []
for report in read_capture(path):
if is_commit(report):
done.extend(pending)
pending = []
continue
decoded = decode_config(report)
if decoded:
pending = [p for p in pending if p[:2] != decoded[:2]] + [decoded]
for control, layer, binding in done:
state.record(control, binding, layer)
if done:
state.save()
return done

69
macropad_gui/keymap.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Turn a key press in the GUI into a name macropad.py understands.
The pad sends HID usage codes, which identify a physical key position, not
a character - your keyboard layout decides what that position types. So the
recorder uses the physical key too: on Linux (X11 and Wayland) Qt reports
the xkb keycode, which is the evdev code + 8. That keeps 'record' correct on
UK, German, AZERTY and so on. Letters and digits fall back to Qt's key
value if the scancode is missing.
"""
from PySide6.QtCore import Qt
import macropad as proto
# evdev KEY_* code -> macropad.KEYCODES name. Only keys macropad.py can send.
EVDEV = {
1: "esc", 14: "backspace", 15: "tab", 28: "enter", 57: "space",
12: "minus", 13: "equal", 26: "leftbracket", 27: "rightbracket",
43: "backslash", 39: "semicolon", 40: "quote", 41: "grave",
51: "comma", 52: "period", 53: "slash", 58: "capslock",
99: "printscreen", 70: "scrolllock", 119: "pause",
110: "insert", 102: "home", 104: "pageup", 111: "delete", 107: "end",
109: "pagedown", 106: "right", 105: "left", 108: "down", 103: "up",
87: "f11", 88: "f12",
}
for _i, _c in enumerate("1234567890"):
EVDEV[2 + _i] = _c
for _row, _start in (("qwertyuiop", 16), ("asdfghjkl", 30), ("zxcvbnm", 44)):
for _i, _c in enumerate(_row):
EVDEV[_start + _i] = _c
for _i in range(1, 11):
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68
for _name in EVDEV.values():
assert _name in proto.KEYCODES, _name
MODIFIER_KEYS = {Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_Meta,
Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_AltGr}
_QT_FALLBACK = {}
for _c in "abcdefghijklmnopqrstuvwxyz":
_QT_FALLBACK[getattr(Qt, f"Key_{_c.upper()}")] = _c
for _c in "0123456789":
_QT_FALLBACK[getattr(Qt, f"Key_{_c}")] = _c
for _i in range(1, 13):
_QT_FALLBACK[getattr(Qt, f"Key_F{_i}")] = f"f{_i}"
def key_name(event):
"""HID key name for a QKeyEvent, or None if the pad can't send it."""
code = event.nativeScanCode() - 8
if code in EVDEV:
return EVDEV[code]
return _QT_FALLBACK.get(Qt.Key(event.key()))
def modifier_names(event):
m = event.modifiers()
out = []
if m & Qt.ControlModifier:
out.append("ctrl")
if m & Qt.ShiftModifier:
out.append("shift")
if m & Qt.AltModifier:
out.append("alt")
if m & Qt.MetaModifier:
out.append("super")
return out

398
macropad_gui/padview.py Normal file
View File

@@ -0,0 +1,398 @@
"""
The pad, drawn the way a circuit board is labelled: outlines and printed
legends on a dark board, each control marked with its action byte. It's the
one place the app has any personality; everything else is plain Qt.
Visual states:
written solid outline, legend in board ink
unknown hatched, like a no-go zone - we genuinely don't know
changed a strip of orange tape on the corner: edited, not yet written
selected orange outline
"""
import math
from dataclasses import dataclass
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
QPainter, QPainterPath, QPen, QTransform)
from PySide6.QtWidgets import QSizePolicy, QWidget
from . import core
WINDOW = QColor("#17191b")
BOARD = QColor("#202326")
INK = QColor("#d8d4cb") # silkscreen white, slightly warm
INK_DIM = QColor("#72767b")
HATCH = QColor("#34383c")
TAPE = QColor("#eb8a2f") # Kapton orange
# Geometry in board units; the view scales it to fit.
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin
R_OUT, R_IN, R_KNOB = 54.0, 40.0, 30.0
ROW_H, ROW_GAP = 22.0, 12.0
DIAL_W = 160.0
# Which dial is dial 1 (action bytes 0x10-0x12). Upright, dials on top:
# assumed left. Flat (the vendor app's view): assumed top.
DIALS_IN_ORDER = ("dial1", "dial2")
@dataclass
class Look:
text: str = "" # what's on the pad, or will be once written
known: bool = False
pending: bool = False
def _key_grid(orientation):
"""[(key_id, row, col)] with row 0 at the top."""
out = []
if orientation == "upright": # 4 rows x 3 cols, dials above
for r in range(4):
for c in range(3):
out.append((f"key{(3 - r) + 1 + c * 4}", r, c))
else: # 3 rows x 4 cols, dials right
for r in range(3):
for c in range(4):
out.append((f"key{r * 4 + c + 1}", r, c))
return out
class PadView(QWidget):
selected = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setMouseTracking(True)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMinimumSize(360, 420)
self.orientation = "upright"
self.looks = {c: Look() for c in core.CONTROL_IDS}
self.current = None
self.hover = None
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._layout()
# ------------------------------------------------------------ public
def set_orientation(self, orientation):
self.orientation = orientation
self._layout()
self.update()
def set_looks(self, looks):
self.looks = looks
self.update()
def select(self, control):
self.current = control
self.update()
# ------------------------------------------------------------ layout
def _layout(self):
self.keys = {} # key -> QRectF
self.dials = {} # dial -> centre QPointF
self.rows = {} # dial action -> legend row QRectF
if self.orientation == "upright":
dial_centres = [QPointF(M + DIAL_W / 2, M + R_OUT),
QPointF(M + DIAL_W * 1.5, M + R_OUT)]
grid_top = M + 2 * R_OUT + ROW_GAP + 3 * ROW_H + 28
grid_left = M
else:
grid_top, grid_left = M, M
x = M + 4 * K + 3 * G + 30 + DIAL_W / 2
first = M + R_OUT
second = first + R_OUT + ROW_GAP + 3 * ROW_H + 20 + R_OUT
dial_centres = [QPointF(x, first), QPointF(x, second)]
for key, r, c in _key_grid(self.orientation):
self.keys[key] = QRectF(grid_left + c * (K + G),
grid_top + r * (K + G), K, K)
for dial, centre in zip(DIALS_IN_ORDER, dial_centres):
self.dials[dial] = centre
top = centre.y() + R_OUT + ROW_GAP
for i, act in enumerate(core.DIAL_ACTIONS):
self.rows[f"{dial}-{act}"] = QRectF(
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H,
DIAL_W - 12, ROW_H)
right = max([r.right() for r in self.keys.values()] +
[r.right() for r in self.rows.values()])
bottom = max([r.bottom() for r in self.keys.values()] +
[r.bottom() for r in self.rows.values()])
self.board = QRectF(0, 0, right + M, bottom + 46)
def _transform(self):
pad = 24
avail = QRectF(self.rect()).adjusted(pad, pad, -pad, -pad)
s = min(avail.width() / self.board.width(),
avail.height() / self.board.height())
s = max(0.4, min(s, 1.6))
dx = avail.left() + (avail.width() - self.board.width() * s) / 2
dy = avail.top() + (avail.height() - self.board.height() * s) / 2
return QTransform(s, 0, 0, s, dx, dy)
# ----------------------------------------------------------- hit test
def _segment(self, dial, act):
"""Clickable area for one dial action, in board units."""
c = self.dials[dial]
path = QPainterPath()
if act == "push":
path.addEllipse(c, R_IN - 2, R_IN - 2)
return path
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)
inner = QRectF(c.x() - R_IN, c.y() - R_IN, 2 * R_IN, 2 * R_IN)
path.arcMoveTo(outer, start)
path.arcTo(outer, start, span)
path.arcTo(inner, start + span, -span)
path.closeSubpath()
return path
def _hit(self, pos):
inv, ok = self._transform().inverted()
if not ok:
return None
p = inv.map(QPointF(pos))
for key, rect in self.keys.items():
if rect.contains(p):
return key
for control, rect in self.rows.items():
if rect.contains(p):
return control
for dial in self.dials:
for act in core.DIAL_ACTIONS:
if self._segment(dial, act).contains(p):
return f"{dial}-{act}"
return None
def mouseMoveEvent(self, e):
h = self._hit(e.position())
if h != self.hover:
self.hover = h
self.setCursor(Qt.PointingHandCursor if h else Qt.ArrowCursor)
self.update()
def leaveEvent(self, e):
self.hover = None
self.update()
def mousePressEvent(self, e):
h = self._hit(e.position())
if h and e.button() == Qt.LeftButton:
self.current = h
self.selected.emit(h)
self.update()
# ------------------------------------------------------------ paint
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.fillRect(self.rect(), WINDOW)
p.setTransform(self._transform())
board = QPainterPath()
board.addRoundedRect(self.board, 18, 18)
p.fillPath(board, BOARD)
p.setPen(QPen(INK_DIM, 1.2))
p.drawPath(board)
for key, rect in self.keys.items():
self._paint_key(p, key, rect)
for dial, centre in self.dials.items():
self._paint_dial(p, dial, centre)
f = QFont(self.mono)
f.setPixelSize(10)
p.setFont(f)
p.setPen(INK_DIM)
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
Qt.AlignLeft | Qt.AlignVCenter, core.DEVICE_ID)
def _outline_pen(self, control, known):
if control == self.current:
return QPen(TAPE, 2.2)
if control == self.hover:
return QPen(INK, 1.6)
return QPen(INK if known else INK_DIM, 1.1)
def _hatch(self, p, path, rect, step=9.0):
p.save()
p.setClipPath(path)
p.setPen(QPen(HATCH, 1.4))
x = rect.left() - rect.height()
while x < rect.right():
p.drawLine(QPointF(x, rect.bottom()),
QPointF(x + rect.height(), rect.top()))
x += step
p.restore()
def _tape(self, p, at, length=30.0, width=9.0):
"""A strip of Kapton stuck across a corner."""
p.save()
p.translate(at)
p.rotate(45)
tape = QColor(TAPE)
tape.setAlpha(225)
p.setPen(Qt.NoPen)
p.setBrush(tape)
p.drawRect(QRectF(-length / 2, -width / 2, length, width))
p.restore()
def _paint_key(self, p, key, rect):
look = self.looks.get(key, Look())
path = QPainterPath()
path.addRoundedRect(rect, 5, 5)
if not look.known and not look.pending:
self._hatch(p, path, rect)
p.setBrush(Qt.NoBrush)
p.setPen(self._outline_pen(key, look.known or look.pending))
p.drawPath(path)
f = QFont(self.mono)
f.setPixelSize(10)
p.setFont(f)
p.setPen(INK_DIM)
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
f"0x{core.action_byte(key):02x}")
f = QFont(self.font())
f.setPixelSize(13)
p.setFont(f)
if look.pending:
p.setPen(TAPE)
else:
p.setPen(INK if look.known else INK_DIM)
text = look.text if (look.known or look.pending) else "Unknown"
body = rect.adjusted(8, 22, -8, -10)
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
text = text.replace("+", "+\u200b")
p.drawText(body, Qt.AlignCenter | Qt.TextWordWrap,
self._fit(text, f, body))
if look.pending:
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7))
def _paint_turn_icon(self, p, centre, clockwise, colour):
r = 5.5
box = QRectF(centre.x() - r, centre.y() - r, 2 * r, 2 * r)
pen = QPen(colour, 1.4)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
start, span = (120, 270) if not clockwise else (60, -270)
p.drawArc(box, int(start * 16), int(span * 16))
end = math.radians(start + span)
tip = QPointF(centre.x() + r * math.cos(end), centre.y() - r * math.sin(end))
# arrowhead along the tangent at the end of the arc
d = 1 if not clockwise else -1
tangent = end + d * math.pi / 2
for spread in (0.5, -0.5):
a = tangent + math.pi + spread
p.drawLine(tip, QPointF(tip.x() + 3.5 * math.cos(a),
tip.y() - 3.5 * math.sin(a)))
def _paint_dial(self, p, dial, c):
# Ring segments for turn left / turn right, centre for press.
for act in core.DIAL_ACTIONS:
control = f"{dial}-{act}"
look = self.looks.get(control, Look())
seg = self._segment(dial, act)
if act != "push":
if not look.known and not look.pending:
self._hatch(p, seg, seg.boundingRect(), 7.0)
if control == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
p.fillPath(seg, fill)
p.setPen(self._outline_pen(control, look.known or look.pending))
p.setBrush(Qt.NoBrush)
p.drawPath(seg)
push = f"{dial}-push"
look = self.looks.get(push, Look())
knob = QPainterPath()
knob.addEllipse(c, R_KNOB, R_KNOB)
if not look.known and not look.pending:
self._hatch(p, knob, knob.boundingRect(), 7.0)
if push == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
p.fillPath(knob, fill)
p.setPen(self._outline_pen(push, look.known or look.pending))
p.setBrush(Qt.NoBrush)
p.drawPath(knob)
# knurling, the way a footprint marks a rotary encoder
p.setPen(QPen(INK_DIM, 1.0))
for i in range(24):
a = math.radians(i * 15)
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)),
QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
f = QFont(self.font())
f.setPixelSize(11)
p.setFont(f)
label = QRectF(c.x() - 21, c.y() - 8, 42, 16)
p.setPen(Qt.NoPen)
p.setBrush(BOARD)
p.drawRoundedRect(label, 3, 3)
p.setPen(TAPE if push == self.current else INK_DIM)
p.drawText(label, Qt.AlignCenter, f"Dial {dial[4:]}")
# Legend rows: the silkscreen text next to the part.
for act in core.DIAL_ACTIONS:
control = f"{dial}-{act}"
self._paint_row(p, control, act, self.rows[control])
def _paint_row(self, p, control, act, rect):
look = self.looks.get(control, Look())
is_cur = control == self.current
if is_cur or control == self.hover:
bg = QColor(TAPE if is_cur else INK)
bg.setAlpha(40 if is_cur else 18)
p.setPen(Qt.NoPen)
p.setBrush(bg)
p.drawRoundedRect(rect, 3, 3)
colour = TAPE if (look.pending or is_cur) else (INK if look.known else INK_DIM)
icon_c = QPointF(rect.left() + 10, rect.center().y())
if act == "push":
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawEllipse(icon_c, 2.6, 2.6)
else:
self._paint_turn_icon(p, icon_c, act == "right", colour)
mono = QFont(self.mono)
mono.setPixelSize(10)
p.setFont(mono)
p.setPen(INK_DIM)
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
f"{core.action_byte(control):02x}")
f = QFont(self.font())
f.setPixelSize(12)
p.setFont(f)
p.setPen(TAPE if look.pending else (INK if look.known else INK_DIM))
text = look.text if (look.known or look.pending) else "Unknown"
body = rect.adjusted(22, 0, -26, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
if look.pending:
self._tape(p, QPointF(rect.left() - 4, rect.top() + 3), 14, 6)
@staticmethod
def _fit(text, font, rect):
"""Elide to at most two wrapped lines."""
fm = QFontMetricsF(font)
if fm.boundingRect(rect, Qt.TextWordWrap, text).height() <= rect.height():
return text
while text and fm.boundingRect(rect, Qt.TextWordWrap, text + "…").height() > rect.height():
text = text[:-1]
return text + "…"

View File

@@ -0,0 +1,3 @@
# MacroPad: let the logged-in user configure 1189:8840 macro pads.
# Must sort before 73-seat-late.rules, which is where uaccess takes effect.
KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess"

15
packaging/macropad.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- The pad standing upright: two dials, keys, and one key taped orange. -->
<rect x="11" y="3" width="42" height="58" rx="8" fill="#202326" stroke="#d8d4cb" stroke-width="2.5"/>
<circle cx="23" cy="16" r="6" fill="none" stroke="#d8d4cb" stroke-width="2.5"/>
<circle cx="41" cy="16" r="6" fill="none" stroke="#d8d4cb" stroke-width="2.5"/>
<g fill="none" stroke="#d8d4cb" stroke-width="2">
<rect x="16.5" y="28" width="8" height="8" rx="1.5"/>
<rect x="28" y="28" width="8" height="8" rx="1.5"/>
<rect x="16.5" y="39.5" width="8" height="8" rx="1.5"/>
<rect x="39.5" y="39.5" width="8" height="8" rx="1.5"/>
<rect x="28" y="39.5" width="8" height="8" rx="1.5"/>
</g>
<rect x="39.5" y="28" width="8" height="8" rx="1.5" fill="#eb8a2f"/>
<rect x="16.5" y="51" width="31" height="3" rx="1.5" fill="#72767b"/>
</svg>

After

Width:  |  Height:  |  Size: 933 B

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,27 @@
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
# bus 1 device 11
[1] interrupt out ep=4
03fd100101000000000001000500000000000000000000000000000000000000
[2] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[3] interrupt out ep=4
03fd110101000000000001000500000000000000000000000000000000000000
[4] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[5] interrupt out ep=4
03fd120101000000000001000600000000000000000000000000000000000000
[6] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[7] interrupt out ep=4
03fd130101000000000001002300000000000000000000000000000000000000
[8] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[9] interrupt out ep=4
03fd140101000000000001002400000000000000000000000000000000000000
[10] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[11] interrupt out ep=4
03fd150101000000000001002500000000000000000000000000000000000000
[12] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000

View File

@@ -0,0 +1,39 @@
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
# keys 1-12 set to media keys in the vendor app, saved once (one commit),
# then dial 1 set to 1 / 2 / 3 on the slots labelled anticlockwise / press / clockwise
# bus 1 device 11
[1] interrupt out ep=4
03fd010102000000000002b50000000000000000000000000000000000000000
[2] interrupt out ep=4
03fd020102000000000002b70000000000000000000000000000000000000000
[3] interrupt out ep=4
03fd030102000000000002b60000000000000000000000000000000000000000
[4] interrupt out ep=4
03fd040102000000000002cd0000000000000000000000000000000000000000
[5] interrupt out ep=4
03fd050102000000000002ea0000000000000000000000000000000000000000
[6] interrupt out ep=4
03fd060102000000000002e90000000000000000000000000000000000000000
[7] interrupt out ep=4
03fd070102000000000002e20000000000000000000000000000000000000000
[8] interrupt out ep=4
03fd080102000000000002920100000000000000000000000000000000000000
[9] interrupt out ep=4
03fd0901020000000000028a0100000000000000000000000000000000000000
[10] interrupt out ep=4
03fd0a0102000000000002940100000000000000000000000000000000000000
[11] interrupt out ep=4
03fd0b0102000000000002700000000000000000000000000000000000000000
[12] interrupt out ep=4
03fd0c01020000000000026f0000000000000000000000000000000000000000
[13] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[14] interrupt out ep=4
03fd100101000000000001001e00000000000000000000000000000000000000
[15] interrupt out ep=4
03fd110101000000000001001f00000000000000000000000000000000000000
[16] interrupt out ep=4
03fd120101000000000001002000000000000000000000000000000000000000
[17] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000

297
tests/test_core.py Normal file
View File

@@ -0,0 +1,297 @@
"""
Run from the repo root: python3 -m unittest -v
The first test class is the important one. It replays the packets the
vendor's own software sent (captured over usbmon) and checks that the
path the GUI uses - Binding -> macropad.py - produces the same bytes.
If anyone edits the protocol layer and breaks it, this fails.
"""
import errno
import json
import os
import re
import tempfile
import unittest
from pathlib import Path
from macropad_gui import core
import macropad as proto
CAPTURES = Path(__file__).parent / "captures"
def load_capture(path):
"""[(label, bytes)] from a macropad-capture.py output file."""
out, label = [], None
for line in path.read_text().splitlines():
m = re.match(r"\[(\d+)\] (.*)", line)
if m:
label = m.group(0)
elif line.startswith(" ") and label:
out.append((label, bytes.fromhex(line.strip())))
return out
KEYNAME = {}
for _name, _code in proto.KEYCODES.items():
KEYNAME.setdefault(_code, _name)
class CaptureReplay(unittest.TestCase):
def test_dial_capture_reproduced_exactly(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
self.assertEqual(len(packets), 12)
for i in range(0, len(packets), 2):
(_, config), (_, commit) = packets[i], packets[i + 1]
# usbmon's text interface stops at 32 bytes; the rest is unseen.
self.assertEqual(len(config), 32)
action, layer, mods, code = config[2], config[3], config[11], config[12]
self.assertEqual(mods, 0)
binding = core.Binding(keys=KEYNAME[code])
ours = binding.reports(3, action, layer)
self.assertEqual(len(ours), 2, "config + commit")
self.assertEqual(ours[0][:32], config, f"config for action 0x{action:02x}")
self.assertEqual(ours[1][:32], commit, "commit")
self.assertEqual(len(ours[0]), 65)
self.assertFalse(any(ours[0][32:]), "bytes past the capture are zero")
def test_dial_action_order_matches_capture(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
captured = [p[2] for _, p in packets[0::2]]
ours = [core.action_byte(c) for c in core.CONTROL_IDS if c.startswith("dial")]
self.assertEqual(ours, captured)
def test_keys_are_1_to_12(self):
self.assertEqual([core.action_byte(k) for k in core.KEYS], list(range(1, 13)))
class EveryCapture(unittest.TestCase):
"""Every config packet in every capture must decode and re-encode exactly."""
def test_round_trip_all_captures(self):
seen = 0
for path in sorted(CAPTURES.glob("*.txt")):
for report in core.read_capture(path):
if core.is_commit(report):
self.assertEqual(proto.native_commit(3)[:len(report)], report)
continue
decoded = core.decode_config(report)
self.assertIsNotNone(decoded, f"{path.name}: {report.hex()}")
control, layer, binding = decoded
ours = binding.reports(3, core.action_byte(control), layer)[0]
self.assertEqual(ours[:len(report)], report,
f"{path.name} {control}: ours {ours[:16].hex()}")
seen += 1
self.assertGreaterEqual(seen, 21)
def test_media_keys_capture(self):
"""Vendor app: keys 1-12 set to media keys, saved with one commit."""
want = ["next", "stop", "prev", "playpause", "volumedown", "volumeup",
"mute", "calculator", "mail", "mycomputer",
"brightnessdown", "brightnessup"]
with tempfile.TemporaryDirectory() as d:
state = core.State(Path(d) / "s.json")
got = core.import_capture(CAPTURES / "1189-8840-media-keys.txt", state)
self.assertEqual([c for c, _, _ in got][:12], list(core.KEYS))
self.assertEqual([b.media for _, _, b in got][:12], want)
# dial 1, slots labelled anticlockwise / press / clockwise -> 1 / 2 / 3
self.assertEqual(state.get("dial1-left").binding, core.Binding(keys="1"))
self.assertEqual(state.get("dial1-push").binding, core.Binding(keys="2"))
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
self.assertTrue(state.get("dial2-left").unknown)
def test_uncommitted_configs_are_not_imported(self):
with tempfile.TemporaryDirectory() as d:
cap = Path(d) / "c.txt"
cap.write_text("[1] interrupt out ep=4\n"
" 03fd010101000000000001000400\n")
state = core.State(Path(d) / "s.json")
self.assertEqual(core.import_capture(cap, state), [])
self.assertTrue(state.get("key1").unknown)
class BindingModel(unittest.TestCase):
def test_round_trip(self):
for b in (core.Binding(keys="ctrl+shift+n"),
core.Binding(keys="a,b,c", delay=250),
core.Binding(media="volumeup")):
self.assertEqual(core.Binding.from_json(json.loads(json.dumps(b.to_json()))), b)
def test_rejects_nonsense(self):
bad = [core.Binding(), core.Binding(keys="a", media="mute"),
core.Binding(media="nope"), core.Binding(keys="ctrl+wat"),
core.Binding(media="mute", delay=10), core.Binding(keys="a", delay=-1)]
for b in bad:
with self.assertRaises(ValueError, msg=repr(b)):
b.validate()
def test_normalize(self):
self.assertEqual(core.normalize_keys(" Ctrl + C ,"), "ctrl+c")
self.assertEqual(core.normalize_keys("a, b"), "a,b")
self.assertEqual(core.normalize_keys("Meta+Ctrl+Left"), "super+ctrl+left")
def test_labels(self):
self.assertEqual(core.Binding(keys="ctrl+shift+n").label(), "Ctrl+Shift+N")
self.assertEqual(core.Binding(keys="super+pageup").label(), "Meta+Page Up")
self.assertEqual(core.Binding(media="mycomputer").label(), "My computer")
def test_pause_is_unambiguous(self):
key = core.Binding(keys="pause").reports(3, 1)[0]
media = core.Binding(media="pause").reports(3, 1)[0]
self.assertEqual(key[4], proto.KeyType.BASIC)
self.assertEqual(media[4], proto.KeyType.MULTIMEDIA)
class ShadowState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "state.json"
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
def tearDown(self):
self.tmp.cleanup()
def test_everything_starts_unknown(self):
s = core.State(self.path)
self.assertTrue(all(s.get(c).unknown for c in core.CONTROL_IDS))
def test_successful_write_is_recorded_and_persisted(self):
sent = []
s = core.State(self.path)
b = core.Binding(keys="ctrl+c")
core.write_binding(self.dev, "key4", b, s, _write=lambda p, r: sent.append(r))
self.assertEqual([r[:4] for r in sent], [b"\x03\xfd\x04\x01", b"\x03\xfd\xfe\xff"])
again = core.State(self.path)
self.assertFalse(again.get("key4").unknown)
self.assertEqual(again.get("key4").binding, b)
self.assertEqual([f for f in os.listdir(self.tmp.name) if f.endswith(".tmp")], [])
def test_failed_commit_leaves_control_unknown(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
calls = []
def flaky(path, report):
calls.append(report)
if len(calls) == 2:
raise OSError(errno.EIO, "I/O error")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=flaky)
self.assertTrue(ctx.exception.pad_touched)
e = core.State(self.path).get("key1")
self.assertTrue(e.unknown)
self.assertIn("1 of 2", e.reason)
def test_unplugged_before_first_write_changes_nothing(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
s.save()
def gone(path, report):
raise FileNotFoundError(errno.ENOENT, "No such file")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=gone)
self.assertFalse(ctx.exception.pad_touched)
self.assertEqual(core.State(self.path).get("key1").binding, core.Binding(keys="a"))
class UdevOrdering(unittest.TestCase):
"""udev sorts rule files by name; the uaccess tag must land before the cutoff."""
def test_ordering(self):
cutoff = "73-seat-late.rules"
self.assertLess("60-macropad.rules", cutoff)
self.assertLess("70-macropad.rules", cutoff)
self.assertGreaterEqual("99-macropad.rules", cutoff)
self.assertGreaterEqual("macropad.rules", cutoff) # letters sort after digits
def test_diagnose_without_hardware_does_not_crash(self):
d = core.diagnose()
self.assertIn(d.status, {"ready", "unplugged", "no-interface", "no-permission"})
if __name__ == "__main__":
unittest.main()
class PermissionDiagnosis(unittest.TestCase):
"""The three ways a pad ends up unwritable, with the system faked out."""
def diag(self, rules, tags):
from unittest import mock
dev = [{"path": "/dev/hidraw10", "pid": "8840", "report_id": 3,
"writable": False, "name": "USB Composite Device"}]
with mock.patch.object(proto, "find_devices", return_value=dev), \
mock.patch.object(core, "_matching_rules", return_value=rules), \
mock.patch.object(core, "_udev_tags", return_value=tags), \
mock.patch.object(core, "_uaccess_cutoff", return_value="73-seat-late.rules"), \
mock.patch.object(core, "_keyd_warning", return_value=None):
return core.diagnose()
def test_rule_numbered_too_late(self):
d = self.diag(["/etc/udev/rules.d/99-macropad.rules"], {"uaccess", "seat"})
self.assertEqual(d.status, "no-permission")
self.assertIn("sudo mv /etc/udev/rules.d/99-macropad.rules "
"/etc/udev/rules.d/60-macropad.rules", d.fix)
def test_rule_not_loaded_yet(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], set())
self.assertTrue(any("reload-rules" in c for c in d.fix))
self.assertFalse(any(c.startswith("sudo mv") for c in d.fix))
def test_no_rule(self):
d = self.diag([], set())
self.assertTrue(d.fix[0].startswith("echo 'KERNEL==\"hidraw*\""))
self.assertIn("60-macropad.rules", d.fix[0])
def test_other_family_pad_is_refused_not_ignored(self):
from unittest import mock
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
"report_id": 0, "writable": True, "name": ""}]
with mock.patch.object(proto, "find_devices", return_value=other), \
mock.patch.object(core, "_keyd_warning", return_value=None):
d = core.diagnose()
self.assertEqual(d.status, "unsupported")
self.assertIn("1189:8890", d.headline)
self.assertIsNone(d.device)
def test_rule_fine_but_not_local_session(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
self.assertEqual(d.fix, [])
self.assertIn("SSH", d.detail[0])
class GuiSmoke(unittest.TestCase):
"""Builds the window offscreen. Skipped if Qt for Python isn't installed."""
def test_window_builds_and_selects(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import app
except ImportError:
self.skipTest("PySide6 not installed")
from unittest import mock
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
qa = QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
for c in core.CONTROL_IDS:
w.pad.select(c)
w._refresh()
self.assertEqual(w.inspector.title.text(), core.control_name(c))
w._changed("key1", core.Binding(media="mute"))
self.assertEqual(w._pending(), ["key1"])
w.desired.clear()
w.close()

33
uninstall.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Removes MacroPad. Your pad keeps its bindings; they live on the pad itself.
set -euo pipefail
DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
CONF="${XDG_CONFIG_HOME:-$HOME/.config}/macropad"
RULE="/etc/udev/rules.d/60-macropad.rules"
ask() {
local answer=""
if { exec 3</dev/tty; } 2>/dev/null; then
read -r -p " $1 [y/N] " answer <&3 || true
exec 3<&-
fi
case "$answer" in [yY]*) return 0 ;; *) return 1 ;; esac
}
rm -rf "$DATA/macropad"
rm -f "$HOME/.local/bin/macropad" "$DATA/applications/macropad.desktop" \
"$DATA/icons/hicolor/scalable/apps/macropad.svg"
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 >/dev/null 2>&1 || true
echo "Removed the app and its menu entry."
if [ -d "$CONF" ] && ask "Also delete the app's memory of what's on your pad ($CONF)?"; then
rm -rf "$CONF"
echo "Deleted $CONF."
fi
if [ -f "$RULE" ] && ask "Also remove the USB permission rule? (asks for your password)"; then
sudo rm -f "$RULE"
sudo udevadm control --reload-rules
echo "Removed $RULE."
fi
echo "Done. The pad keeps working with whatever you last wrote to it."