Set up your cheap 12 key, 2 knob USB macro pad on Linux.
@@ -19,7 +19,7 @@
-
+
@@ -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
```
What does that line actually do? (optional reading)
-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.
@@ -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. 🎉
-
+
**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.
diff --git a/docs/images/first-launch.png b/docs/images/first-launch.png
new file mode 100644
index 0000000..8a2e578
Binary files /dev/null and b/docs/images/first-launch.png differ
diff --git a/docs/images/flat-view.png b/docs/images/flat-view.png
new file mode 100644
index 0000000..a0ee2a1
Binary files /dev/null and b/docs/images/flat-view.png differ
diff --git a/docs/images/hero.png b/docs/images/hero.png
new file mode 100644
index 0000000..45a45b6
Binary files /dev/null and b/docs/images/hero.png differ
diff --git a/docs/images/icon.png b/docs/images/icon.png
new file mode 100644
index 0000000..a04cdbd
Binary files /dev/null and b/docs/images/icon.png differ
diff --git a/docs/images/keyd-note.png b/docs/images/keyd-note.png
new file mode 100644
index 0000000..8e7bb53
Binary files /dev/null and b/docs/images/keyd-note.png differ
diff --git a/docs/images/legend.png b/docs/images/legend.png
new file mode 100644
index 0000000..c412552
Binary files /dev/null and b/docs/images/legend.png differ
diff --git a/docs/images/permission.png b/docs/images/permission.png
new file mode 100644
index 0000000..172654b
Binary files /dev/null and b/docs/images/permission.png differ
diff --git a/docs/images/social-preview.png b/docs/images/social-preview.png
new file mode 100644
index 0000000..505d93f
Binary files /dev/null and b/docs/images/social-preview.png differ
diff --git a/docs/images/step-media.png b/docs/images/step-media.png
new file mode 100644
index 0000000..87cfa24
Binary files /dev/null and b/docs/images/step-media.png differ
diff --git a/docs/images/step-pending.png b/docs/images/step-pending.png
new file mode 100644
index 0000000..1fa1805
Binary files /dev/null and b/docs/images/step-pending.png differ
diff --git a/docs/images/step-record.png b/docs/images/step-record.png
new file mode 100644
index 0000000..235c7c3
Binary files /dev/null and b/docs/images/step-record.png differ
diff --git a/docs/images/step-type.png b/docs/images/step-type.png
new file mode 100644
index 0000000..dcf86ae
Binary files /dev/null and b/docs/images/step-type.png differ
diff --git a/docs/images/step-written.png b/docs/images/step-written.png
new file mode 100644
index 0000000..1026bf2
Binary files /dev/null and b/docs/images/step-written.png differ
diff --git a/docs/images/unsupported.png b/docs/images/unsupported.png
new file mode 100644
index 0000000..d09881c
Binary files /dev/null and b/docs/images/unsupported.png differ
diff --git a/docs/make_screenshots.py b/docs/make_screenshots.py
new file mode 100644
index 0000000..f6b389e
--- /dev/null
+++ b/docs/make_screenshots.py
@@ -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()
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..67404ab
--- /dev/null
+++ b/install.sh
@@ -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/null; then
+ read -r -p " $1 [Y/n] " answer <&3 || true
+ exec 3<&-
+ fi
+ case "$answer" in [nN]*) return 1 ;; *) return 0 ;; esac
+}
+
+cat </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" < "$DESKTOP" </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
diff --git a/macropad-capture.py b/macropad-capture.py
new file mode 100644
index 0000000..6406c38
--- /dev/null
+++ b/macropad-capture.py
@@ -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:
+ ::: =
+
+ 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())
diff --git a/macropad-handshake.py b/macropad-handshake.py
new file mode 100644
index 0000000..de6cc2f
--- /dev/null
+++ b/macropad-handshake.py
@@ -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())
diff --git a/macropad-probe.py b/macropad-probe.py
new file mode 100644
index 0000000..9a3e93b
--- /dev/null
+++ b/macropad-probe.py
@@ -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())
diff --git a/macropad.py b/macropad.py
new file mode 100644
index 0000000..ca6f05c
--- /dev/null
+++ b/macropad.py
@@ -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())
diff --git a/macropad_gui/__init__.py b/macropad_gui/__init__.py
new file mode 100644
index 0000000..41e08f3
--- /dev/null
+++ b/macropad_gui/__init__.py
@@ -0,0 +1 @@
+"""Linux GUI for Holtek-family USB macro pads. GPL-3.0."""
diff --git a/macropad_gui/__main__.py b/macropad_gui/__main__.py
new file mode 100644
index 0000000..d3f51cb
--- /dev/null
+++ b/macropad_gui/__main__.py
@@ -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())
diff --git a/macropad_gui/app.py b/macropad_gui/app.py
new file mode 100644
index 0000000..376ea2c
--- /dev/null
+++ b/macropad_gui/app.py
@@ -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"{e}")
+ self.changed.emit(self.control, None)
+ return
+ if b.is_sequence and not core.SEQUENCES_VERIFIED:
+ self.keys_hint.setText(
+ f"Sequences aren't verified on this "
+ "pad yet, so they're switched off for now. One shortcut per "
+ "control.")
+ 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()
diff --git a/macropad_gui/core.py b/macropad_gui/core.py
new file mode 100644
index 0000000..8cb01cb
--- /dev/null
+++ b/macropad_gui/core.py
@@ -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 ` 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
diff --git a/macropad_gui/keymap.py b/macropad_gui/keymap.py
new file mode 100644
index 0000000..0ab1990
--- /dev/null
+++ b/macropad_gui/keymap.py
@@ -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
diff --git a/macropad_gui/padview.py b/macropad_gui/padview.py
new file mode 100644
index 0000000..8bbd1ac
--- /dev/null
+++ b/macropad_gui/padview.py
@@ -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 + "…"
diff --git a/packaging/60-macropad.rules b/packaging/60-macropad.rules
new file mode 100644
index 0000000..4adaace
--- /dev/null
+++ b/packaging/60-macropad.rules
@@ -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"
diff --git a/packaging/macropad.svg b/packaging/macropad.svg
new file mode 100644
index 0000000..bd68a2c
--- /dev/null
+++ b/packaging/macropad.svg
@@ -0,0 +1,15 @@
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/captures/1189-8840-dials.txt b/tests/captures/1189-8840-dials.txt
new file mode 100644
index 0000000..5754e4d
--- /dev/null
+++ b/tests/captures/1189-8840-dials.txt
@@ -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
diff --git a/tests/captures/1189-8840-media-keys.txt b/tests/captures/1189-8840-media-keys.txt
new file mode 100644
index 0000000..bf1270a
--- /dev/null
+++ b/tests/captures/1189-8840-media-keys.txt
@@ -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
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..1211379
--- /dev/null
+++ b/tests/test_core.py
@@ -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()
diff --git a/uninstall.sh b/uninstall.sh
new file mode 100644
index 0000000..2597699
--- /dev/null
+++ b/uninstall.sh
@@ -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/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."