diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe15d59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +macropad-capture.txt +.venv/ diff --git a/README.md b/README.md index cb9369a..0d1168f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- Macropad app icon: a macro pad with two dials and one orange key + MacroPad app icon: a macro pad with two dials and one orange key

-

Macropad

+

MacroPad

Set up your cheap 12 key, 2 knob USB macro pad on Linux.
@@ -19,7 +19,7 @@

- Macropad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key + MacroPad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key

@@ -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. 🎉

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

**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."