Add files via upload
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
macropad-capture.txt
|
||||||
|
.venv/
|
||||||
26
README.md
@@ -1,8 +1,8 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/icon.png" width="112" alt="Macropad app icon: a macro pad with two dials and one orange key">
|
<img src="docs/images/icon.png" width="112" alt="MacroPad app icon: a macro pad with two dials and one orange key">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Macropad</h1>
|
<h1 align="center">MacroPad</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>Set up your cheap 12 key, 2 knob USB macro pad on Linux.</b><br>
|
<b>Set up your cheap 12 key, 2 knob USB macro pad on Linux.</b><br>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/hero.png" width="860" alt="Macropad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key">
|
<img src="docs/images/hero.png" width="860" alt="MacroPad on Linux: a drawing of the 12 key, 2 knob macro pad with every key labelled, and a panel for changing the selected key">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **In a hurry?** Open a terminal and paste this. It does everything, asks before changing anything, and is safe to run again.
|
> **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.
|
> 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`):
|
Click the copy button on the right side of this box (or select the text and press `Ctrl+C`):
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -fsSL https://raw.githubusercontent.com/Probler/macropad/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>What does that line actually do? (optional reading)</summary>
|
<summary>What does that line actually do? (optional reading)</summary>
|
||||||
|
|
||||||
In plain words: "download the Macropad installer from GitHub (`curl`), and run it (`| bash`)". The installer is a normal text file, and you can [read it here](install.sh) before running it if you like. Being suspicious of random commands from the internet is a healthy instinct, and I respect it.
|
In plain words: "download the MacroPad installer from GitHub (`curl`), and run it (`| bash`)". The installer is a normal text file, and you can [read it here](install.sh) before running it if you like. Being suspicious of random commands from the internet is a healthy instinct, and I respect it.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -168,12 +168,12 @@ The first install downloads about 100 MB on some systems, so give it a minute. Y
|
|||||||
|
|
||||||
This makes Linux notice the new permission. Unplug. Count to two. Plug back in.
|
This makes Linux notice the new permission. Unplug. Count to two. Plug back in.
|
||||||
|
|
||||||
### Step 6: Open Macropad
|
### Step 6: Open MacroPad
|
||||||
|
|
||||||
Open your app menu (press the **Meta key**), type **`Macropad`**, and click it. 🎉
|
Open your app menu (press the **Meta key**), type **`MacroPad`**, and click it. 🎉
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/first-launch.png" width="820" alt="Macropad on its first launch: every key is striped because the app hasn't written anything yet">
|
<img src="docs/images/first-launch.png" width="820" alt="MacroPad on its first launch: every key is striped because the app hasn't written anything yet">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Everything is striped the first time. That's correct.** The app hasn't written anything to your pad yet, and the pad has no way of telling it what's already there. You'll fix that in about thirty seconds.
|
**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).
|
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]
|
> [!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.
|
**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
|
### 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`.
|
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"
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
BIN
docs/images/first-launch.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/images/flat-view.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
docs/images/hero.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/images/icon.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
docs/images/keyd-note.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
docs/images/legend.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/images/permission.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
docs/images/social-preview.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/images/step-media.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
docs/images/step-pending.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
docs/images/step-record.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
docs/images/step-type.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
docs/images/step-written.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/images/unsupported.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
347
docs/make_screenshots.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Regenerate every image in docs/images from the real app, offscreen.
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=offscreen QT_SCALE_FACTOR=2 python3 docs/make_screenshots.py
|
||||||
|
|
||||||
|
Uses a fake pad and a throwaway config folder, so it never touches your real
|
||||||
|
pad or your saved state. Looks best with Noto Sans and Hack installed, which
|
||||||
|
are Plasma's defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUT = ROOT / "docs" / "images"
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt # noqa: E402
|
||||||
|
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QImage, # noqa: E402
|
||||||
|
QLinearGradient, QPainter, QPen)
|
||||||
|
from PySide6.QtSvg import QSvgRenderer # noqa: E402
|
||||||
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from macropad_gui import app as A, core, padview # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
except ImportError:
|
||||||
|
Image = None
|
||||||
|
|
||||||
|
B = core.Binding
|
||||||
|
DEMO = {
|
||||||
|
"key4": B(keys="super+ctrl+left"), "key8": B(keys="super+w"),
|
||||||
|
"key12": B(keys="super+ctrl+right"),
|
||||||
|
"key3": B(media="prev"), "key7": B(media="playpause"), "key11": B(media="next"),
|
||||||
|
"key2": B(keys="ctrl+z"), "key6": B(keys="ctrl+shift+z"), "key10": B(keys="ctrl+s"),
|
||||||
|
"key1": B(keys="printscreen"), "key5": B(keys="ctrl+alt+t"), "key9": B(media="mycomputer"),
|
||||||
|
"dial1-left": B(media="volumedown"), "dial1-push": B(media="mute"),
|
||||||
|
"dial1-right": B(media="volumeup"),
|
||||||
|
"dial2-left": B(media="brightnessdown"), "dial2-push": B(media="calculator"),
|
||||||
|
"dial2-right": B(media="brightnessup"),
|
||||||
|
}
|
||||||
|
|
||||||
|
READY = core.Diagnosis("ready", "Ready on /dev/hidraw10.",
|
||||||
|
device=core.Device("/dev/hidraw10", 3, True))
|
||||||
|
|
||||||
|
|
||||||
|
def fonts(app):
|
||||||
|
ui = QFont("Noto Sans", 10)
|
||||||
|
if "Noto Sans" in QFontDatabase.families():
|
||||||
|
app.setFont(ui)
|
||||||
|
if "Hack" in QFontDatabase.families():
|
||||||
|
hack = QFont("Hack", 10)
|
||||||
|
real = QFontDatabase.systemFont
|
||||||
|
QFontDatabase.systemFont = staticmethod(
|
||||||
|
lambda which: hack if which == QFontDatabase.FixedFont else real(which))
|
||||||
|
|
||||||
|
|
||||||
|
def window(diag=READY, bindings=None, note=None):
|
||||||
|
with mock.patch.object(core, "_keyd_warning", return_value=note), \
|
||||||
|
mock.patch.object(core, "diagnose", return_value=diag):
|
||||||
|
w = A.Window()
|
||||||
|
w.timer.stop()
|
||||||
|
w.diag = diag
|
||||||
|
w.note = note
|
||||||
|
w.state = core.State(Path(tempfile.mkdtemp()) / "state.json")
|
||||||
|
for c, b in (bindings or {}).items():
|
||||||
|
w.state.record(c, b)
|
||||||
|
w.resize(1000, 800)
|
||||||
|
w._poll_fake = diag
|
||||||
|
w.inspector.show_problem(diag, note)
|
||||||
|
status_colour = {"ready": A.GOOD, "unplugged": padview.INK_DIM.name()}.get(diag.status, A.BAD)
|
||||||
|
w.dot.setStyleSheet(f"background: {status_colour}; border-radius: 4px;")
|
||||||
|
w.status.setText({"ready": "Pad ready on /dev/hidraw10",
|
||||||
|
"unplugged": "No pad connected",
|
||||||
|
"no-permission": "No permission to write to the pad",
|
||||||
|
"unsupported": "Unsupported pad"}[diag.status])
|
||||||
|
w._refresh()
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def select(w, control):
|
||||||
|
w.pad.select(control)
|
||||||
|
w._refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def save(w, name, frame=True):
|
||||||
|
w.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
img = w.grab().toImage()
|
||||||
|
path = OUT / f"{name}.png"
|
||||||
|
img.save(str(path))
|
||||||
|
if frame and Image:
|
||||||
|
_frame(path)
|
||||||
|
w.desired.clear()
|
||||||
|
w.close()
|
||||||
|
print("wrote", path.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(path, radius=20, pad=56):
|
||||||
|
"""Rounded corners, a hairline edge and a soft shadow, on transparency."""
|
||||||
|
shot = Image.open(path).convert("RGBA")
|
||||||
|
w, h = shot.size
|
||||||
|
mask = Image.new("L", (w, h), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius, fill=255)
|
||||||
|
shot.putalpha(mask)
|
||||||
|
edge = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(edge).rounded_rectangle([0, 0, w - 1, h - 1], radius,
|
||||||
|
outline=(255, 255, 255, 34), width=2)
|
||||||
|
shot = Image.alpha_composite(shot, edge)
|
||||||
|
|
||||||
|
canvas = Image.new("RGBA", (w + 2 * pad, h + 2 * pad), (0, 0, 0, 0))
|
||||||
|
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(shadow).rounded_rectangle(
|
||||||
|
[pad, pad + 14, pad + w, pad + h + 14], radius, fill=(0, 0, 0, 120))
|
||||||
|
shadow = shadow.filter(ImageFilter.GaussianBlur(22))
|
||||||
|
canvas = Image.alpha_composite(canvas, shadow)
|
||||||
|
canvas.alpha_composite(shot, (pad, pad))
|
||||||
|
canvas.save(path, optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ scenes
|
||||||
|
|
||||||
|
def scene_hero():
|
||||||
|
w = window(bindings=DEMO)
|
||||||
|
select(w, "key12")
|
||||||
|
w._say("Wrote 3 changes to the pad.", A.GOOD)
|
||||||
|
save(w, "hero")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_first_launch():
|
||||||
|
w = window()
|
||||||
|
save(w, "first-launch")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_select_and_type():
|
||||||
|
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
|
||||||
|
select(w, "key2")
|
||||||
|
w.inspector.keys_edit.setText("ctrl+z")
|
||||||
|
w.inspector._keys_typed("ctrl+z")
|
||||||
|
save(w, "step-type")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_record():
|
||||||
|
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
|
||||||
|
select(w, "key6")
|
||||||
|
w.inspector.record.setChecked(True)
|
||||||
|
w.inspector.keys_edit.setText("ctrl+shift+…")
|
||||||
|
save(w, "step-record")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_media():
|
||||||
|
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("key")})
|
||||||
|
select(w, "dial1-left")
|
||||||
|
w.inspector.mode_media.click()
|
||||||
|
w.inspector.media_buttons["volumedown"].click()
|
||||||
|
save(w, "step-media")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_pending():
|
||||||
|
w = window(bindings={k: v for k, v in DEMO.items()
|
||||||
|
if k not in ("key2", "key6", "key10")})
|
||||||
|
for k in ("key2", "key6", "key10"):
|
||||||
|
w.desired[k] = DEMO[k]
|
||||||
|
select(w, "key10")
|
||||||
|
w.inspector.keys_hint.setText("Sends Ctrl+S")
|
||||||
|
w._refresh(editor=False)
|
||||||
|
save(w, "step-pending")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_written():
|
||||||
|
w = window(bindings=DEMO)
|
||||||
|
select(w, "key10")
|
||||||
|
w._say("Wrote 3 changes to the pad.", A.GOOD)
|
||||||
|
save(w, "step-written")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_flat():
|
||||||
|
w = window(bindings=DEMO)
|
||||||
|
w.pad.set_orientation("flat")
|
||||||
|
select(w, "dial2-push")
|
||||||
|
save(w, "flat-view")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_permission():
|
||||||
|
d = core.Diagnosis(
|
||||||
|
"no-permission", "/dev/hidraw10 isn't writable.",
|
||||||
|
device=core.Device("/dev/hidraw10", 3, False),
|
||||||
|
detail=["No udev rule lets you write to the pad yet. Running the installer "
|
||||||
|
"again adds it, or paste these commands into a terminal:"],
|
||||||
|
fix=[f"echo '{core.RULE_LINE}' | sudo tee /etc/udev/rules.d/60-macropad.rules",
|
||||||
|
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||||
|
"# then unplug and replug the pad"])
|
||||||
|
w = window(diag=d, bindings=DEMO)
|
||||||
|
select(w, "key4")
|
||||||
|
save(w, "permission")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_keyd():
|
||||||
|
note = ("keyd, a key remapping service, is running and /etc/keyd/default.conf "
|
||||||
|
"lists this pad. It can change keys on their way from the pad to your "
|
||||||
|
"desktop, so a key may not do exactly what you set here. If you only "
|
||||||
|
"set keyd up for this pad, you can switch it off with: "
|
||||||
|
"sudo systemctl disable --now keyd")
|
||||||
|
w = window(bindings=DEMO, note=note)
|
||||||
|
save(w, "keyd-note")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_unsupported():
|
||||||
|
d = core.Diagnosis(
|
||||||
|
"unsupported", "Found a 1189:8890 pad, which this app doesn't know yet.",
|
||||||
|
detail=["So far only 1189:8840 (12 keys, 2 dials) has been worked out. "
|
||||||
|
"Pads in this family differ in layout and protocol, so writing to "
|
||||||
|
"yours with the wrong one could fail or scramble it. The app won't try.",
|
||||||
|
"You can help add it: the README's \"Other pads\" section shows how "
|
||||||
|
"to capture what the vendor software sends."])
|
||||||
|
w = window(diag=d)
|
||||||
|
save(w, "unsupported")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_legend():
|
||||||
|
"""Four keycaps showing every state the drawing uses."""
|
||||||
|
scale = 2
|
||||||
|
names = [("Written", "on the pad", padview.Look("Ctrl+C", known=True), None),
|
||||||
|
("Unknown", "could be anything", padview.Look(), None),
|
||||||
|
("Changed", "not written yet", padview.Look("Mute", known=True, pending=True), None),
|
||||||
|
("Selected", "being edited", padview.Look("Ctrl+S", known=True), "sel")]
|
||||||
|
cell, gap, top = 132, 26, 24
|
||||||
|
width = len(names) * cell + (len(names) - 1) * gap + 2 * 30
|
||||||
|
img = QImage(width * scale, 210 * scale, QImage.Format_ARGB32)
|
||||||
|
img.fill(padview.BOARD)
|
||||||
|
p = QPainter(img)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.scale(scale, scale)
|
||||||
|
pv = padview.PadView()
|
||||||
|
for i, (title, sub, look, sel) in enumerate(names):
|
||||||
|
x = 30 + i * (cell + gap)
|
||||||
|
rect = QRectF(x + (cell - 96) / 2, top, 96, 96)
|
||||||
|
pv.looks = {f"key{i + 1}": look}
|
||||||
|
pv.current = f"key{i + 1}" if sel else None
|
||||||
|
pv._paint_key(p, f"key{i + 1}", rect)
|
||||||
|
f = QFont(QApplication.font())
|
||||||
|
f.setPixelSize(15)
|
||||||
|
f.setWeight(QFont.DemiBold)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(padview.TAPE if look.pending or sel else padview.INK)
|
||||||
|
p.drawText(QRectF(x, top + 110, cell, 22), Qt.AlignHCenter, title)
|
||||||
|
f.setWeight(QFont.Normal)
|
||||||
|
f.setPixelSize(12)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(padview.INK_DIM)
|
||||||
|
p.drawText(QRectF(x, top + 134, cell, 20), Qt.AlignHCenter, sub)
|
||||||
|
p.end()
|
||||||
|
path = OUT / "legend.png"
|
||||||
|
img.save(str(path))
|
||||||
|
if Image:
|
||||||
|
_frame(path, radius=24, pad=40)
|
||||||
|
print("wrote", path.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def scene_social():
|
||||||
|
"""1280x640: GitHub's social preview, which Reddit and Discord show for links."""
|
||||||
|
W, H = 1280, 640
|
||||||
|
img = QImage(W, H, QImage.Format_ARGB32)
|
||||||
|
img.fill(padview.WINDOW)
|
||||||
|
p = QPainter(img)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
# faint board-grid, like a PCB's copper pour
|
||||||
|
p.setPen(QPen(QColor(255, 255, 255, 10), 1))
|
||||||
|
for x in range(0, W, 32):
|
||||||
|
p.drawLine(x, 0, x, H)
|
||||||
|
for y in range(0, H, 32):
|
||||||
|
p.drawLine(0, y, W, y)
|
||||||
|
|
||||||
|
pv = padview.PadView()
|
||||||
|
pv.resize(460, 640)
|
||||||
|
pv.looks = {c: padview.Look(b.label(), known=True) for c, b in DEMO.items()}
|
||||||
|
pv.looks["key12"] = padview.Look("Meta+Ctrl+Right", known=True, pending=True)
|
||||||
|
pv.current = "key12"
|
||||||
|
p.save()
|
||||||
|
p.translate(70, 0)
|
||||||
|
p.setTransform(pv._transform(), True)
|
||||||
|
board = QRectF(pv.board)
|
||||||
|
path = __import__("PySide6.QtGui", fromlist=["QPainterPath"]).QPainterPath()
|
||||||
|
path.addRoundedRect(board, 18, 18)
|
||||||
|
p.fillPath(path, padview.BOARD)
|
||||||
|
p.setPen(QPen(padview.INK_DIM, 1.2))
|
||||||
|
p.drawPath(path)
|
||||||
|
for key, rect in pv.keys.items():
|
||||||
|
pv._paint_key(p, key, rect)
|
||||||
|
for dial, centre in pv.dials.items():
|
||||||
|
pv._paint_dial(p, dial, centre)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
x0 = 600
|
||||||
|
f = QFont(QApplication.font())
|
||||||
|
f.setPixelSize(92)
|
||||||
|
f.setWeight(QFont.Bold)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(padview.INK)
|
||||||
|
p.drawText(QRectF(x0, 150, 640, 110), Qt.AlignLeft | Qt.AlignVCenter, "MacroPad")
|
||||||
|
p.fillRect(QRectF(x0 + 4, 268, 120, 6), padview.TAPE)
|
||||||
|
f.setPixelSize(34)
|
||||||
|
f.setWeight(QFont.Normal)
|
||||||
|
p.setFont(f)
|
||||||
|
p.drawText(QRectF(x0, 300, 640, 140), Qt.AlignLeft | Qt.TextWordWrap,
|
||||||
|
"Set up your 12 key, 2 knob USB macro pad on Linux.")
|
||||||
|
f.setPixelSize(26)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(padview.INK_DIM)
|
||||||
|
p.drawText(QRectF(x0, 430, 640, 40), Qt.AlignLeft, "No Windows. No VM. No Wine.")
|
||||||
|
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
|
mono.setPixelSize(22)
|
||||||
|
p.setFont(mono)
|
||||||
|
p.drawText(QRectF(x0, 540, 640, 30), Qt.AlignLeft, "github.com/Probler-Yt/MacroPad")
|
||||||
|
p.end()
|
||||||
|
img.save(str(OUT / "social-preview.png"))
|
||||||
|
print("wrote docs/images/social-preview.png")
|
||||||
|
|
||||||
|
|
||||||
|
def scene_icon():
|
||||||
|
r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg"))
|
||||||
|
img = QImage(256, 256, QImage.Format_ARGB32)
|
||||||
|
img.fill(Qt.transparent)
|
||||||
|
p = QPainter(img)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
r.render(p, QRectF(0, 0, 256, 256))
|
||||||
|
p.end()
|
||||||
|
img.save(str(OUT / "icon.png"))
|
||||||
|
print("wrote docs/images/icon.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
fonts(app)
|
||||||
|
A.apply_theme(app)
|
||||||
|
for name, fn in list(globals().items()):
|
||||||
|
if name.startswith("scene_"):
|
||||||
|
fn()
|
||||||
226
install.sh
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# MacroPad installer.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/Probler-Yt/MacroPad/main/install.sh | bash
|
||||||
|
#
|
||||||
|
# or, from a downloaded copy: ./install.sh
|
||||||
|
#
|
||||||
|
# Safe to run again at any time: it updates in place and keeps your bindings.
|
||||||
|
# Everything goes in your home folder except one small permission file in
|
||||||
|
# /etc/udev/rules.d, which is the only step that asks for your password.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
NEED_REPLUG=""
|
||||||
|
|
||||||
|
REPO="Probler-Yt/MacroPad"
|
||||||
|
BRANCH="main"
|
||||||
|
|
||||||
|
DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||||
|
HOME_DIR="$DATA/macropad" # app + optional private Python
|
||||||
|
APP="$HOME_DIR/app"
|
||||||
|
BIN="$HOME/.local/bin"
|
||||||
|
DESKTOP="$DATA/applications/macropad.desktop"
|
||||||
|
ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg"
|
||||||
|
RULE="/etc/udev/rules.d/60-macropad.rules"
|
||||||
|
RULE_LINE='KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess"'
|
||||||
|
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m'
|
||||||
|
else
|
||||||
|
O=""; G=""; R=""; B=""; D=""; X=""
|
||||||
|
fi
|
||||||
|
say() { printf '%s\n' "$*"; }
|
||||||
|
step() { printf '\n%s==>%s %s%s%s\n' "$O" "$X" "$B" "$*" "$X"; }
|
||||||
|
ok() { printf ' %sok%s %s\n' "$G" "$X" "$*"; }
|
||||||
|
die() { printf '\n%sStopped:%s %s\n' "$R" "$X" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# When piped from curl, stdin is this script, so questions read the terminal.
|
||||||
|
ask() {
|
||||||
|
local answer=""
|
||||||
|
if { exec 3</dev/tty; } 2>/dev/null; then
|
||||||
|
read -r -p " $1 [Y/n] " answer <&3 || true
|
||||||
|
exec 3<&-
|
||||||
|
fi
|
||||||
|
case "$answer" in [nN]*) return 1 ;; *) return 0 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
${O} ┌────────────────┐${X}
|
||||||
|
${O} │ ◯ ◯ │${X} ${B}MacroPad${X}
|
||||||
|
${O} │ ▢ ▢ ▢ │${X} Set up your 12 key, 2 dial macro pad on Linux.
|
||||||
|
${O} │ ▢ ▢ ${X}▣${O} │${X}
|
||||||
|
${O} └────────────────┘${X} ${D}github.com/${REPO}${X}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
[ "$(id -u)" -ne 0 ] || die "Run this as your normal user, not with sudo. It will ask for your password itself when it needs it."
|
||||||
|
command -v python3 >/dev/null || die "Python 3 isn't installed. Install it with your distro's software centre, then run this again."
|
||||||
|
python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' \
|
||||||
|
|| die "Python 3.9 or newer is needed. You have $(python3 -V)."
|
||||||
|
|
||||||
|
. /etc/os-release 2>/dev/null || true
|
||||||
|
FAMILY=" ${ID:-} ${ID_LIKE:-} "
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 1. files
|
||||||
|
step "Getting the app"
|
||||||
|
SRC=""
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" 2>/dev/null && pwd || true)"
|
||||||
|
if [ -n "$HERE" ] && [ -d "$HERE/macropad_gui" ] && [ -f "$HERE/macropad.py" ]; then
|
||||||
|
SRC="$HERE"
|
||||||
|
ok "using the copy in $SRC"
|
||||||
|
else
|
||||||
|
command -v curl >/dev/null || die "curl is needed to download the app."
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
curl -fsSL "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" \
|
||||||
|
| tar -xz -C "$TMP" || die "Couldn't download the app. Check your internet connection."
|
||||||
|
SRC="$(find "$TMP" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
|
[ -d "$SRC/macropad_gui" ] || die "The download didn't contain the app. Please report this at github.com/${REPO}/issues"
|
||||||
|
ok "downloaded the latest version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME_DIR"
|
||||||
|
rm -rf "$APP.new"
|
||||||
|
mkdir -p "$APP.new"
|
||||||
|
for f in macropad.py macropad-probe.py macropad-capture.py macropad-handshake.py \
|
||||||
|
macropad_gui packaging install.sh uninstall.sh LICENSE README.md; do
|
||||||
|
if [ -e "$SRC/$f" ]; then cp -r "$SRC/$f" "$APP.new/"; fi
|
||||||
|
done
|
||||||
|
find "$APP.new" -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||||
|
rm -rf "$APP.old"
|
||||||
|
if [ -d "$APP" ]; then mv "$APP" "$APP.old"; fi
|
||||||
|
mv "$APP.new" "$APP"
|
||||||
|
rm -rf "$APP.old"
|
||||||
|
ok "installed to $APP"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 2. Qt
|
||||||
|
step "Checking for Qt, the toolkit that draws the window"
|
||||||
|
PY=""
|
||||||
|
if python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then
|
||||||
|
PY="$(command -v python3)"
|
||||||
|
ok "already installed"
|
||||||
|
elif command -v pacman >/dev/null && [[ "$FAMILY" == *" arch "* ]] && [ ! -e /run/ostree-booted ]; then
|
||||||
|
say " Your system can install it directly (package: pyside6)."
|
||||||
|
if ask "Install it now? This asks for your password."; then
|
||||||
|
sudo pacman -S --needed --noconfirm pyside6 \
|
||||||
|
&& python3 -c 'import PySide6.QtWidgets' && PY="$(command -v python3)" \
|
||||||
|
&& ok "installed pyside6"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
# Anything else gets a private copy, so nothing system-wide changes.
|
||||||
|
say " Setting up a private copy of Qt just for this app (about 100 MB, one time)."
|
||||||
|
VENV="$HOME_DIR/venv"
|
||||||
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
|
if ! python3 -m venv "$VENV" 2>/dev/null; then
|
||||||
|
rm -rf "$VENV"
|
||||||
|
if command -v apt-get >/dev/null; then
|
||||||
|
say " Python needs its 'venv' add-on first. This asks for your password."
|
||||||
|
sudo apt-get install -y python3-venv >/dev/null
|
||||||
|
python3 -m venv "$VENV" || die "Couldn't create the private Python."
|
||||||
|
else
|
||||||
|
die "Couldn't create a private Python. Install your distro's python3-venv package and run this again."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
"$VENV/bin/python" -m pip install --quiet --upgrade pip
|
||||||
|
"$VENV/bin/python" -m pip install --quiet --upgrade PySide6-Essentials \
|
||||||
|
|| die "Couldn't download Qt. Check your internet connection and run this again."
|
||||||
|
PY="$VENV/bin/python"
|
||||||
|
ok "Qt ready"
|
||||||
|
|
||||||
|
# Qt's X11 backend needs one small system library some distros leave out.
|
||||||
|
if [ "${XDG_SESSION_TYPE:-}" = "x11" ]; then
|
||||||
|
if command -v apt-get >/dev/null && ! ldconfig -p | grep -q libxcb-cursor.so.0; then
|
||||||
|
sudo apt-get install -y libxcb-cursor0 >/dev/null && ok "added libxcb-cursor0"
|
||||||
|
elif command -v dnf >/dev/null && [ ! -e /run/ostree-booted ] && ! ldconfig -p | grep -q libxcb-cursor.so.0; then
|
||||||
|
sudo dnf install -y xcb-util-cursor >/dev/null && ok "added xcb-util-cursor"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 3. launcher
|
||||||
|
step "Adding MacroPad to your app menu"
|
||||||
|
mkdir -p "$BIN" "$(dirname "$DESKTOP")" "$(dirname "$ICON")"
|
||||||
|
cat > "$BIN/macropad" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Opens MacroPad. Anything after 'macropad' is passed on, e.g. 'macropad doctor'.
|
||||||
|
cd "$APP" && exec "$PY" -m macropad_gui "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$BIN/macropad"
|
||||||
|
cp "$APP/packaging/macropad.svg" "$ICON"
|
||||||
|
cat > "$DESKTOP" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=MacroPad
|
||||||
|
GenericName=Macro pad configurator
|
||||||
|
Comment=Change what your macro pad's keys and dials do
|
||||||
|
Exec=$BIN/macropad
|
||||||
|
Icon=macropad
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;Settings;HardwareSettings;
|
||||||
|
Keywords=macro;keyboard;keypad;knob;dial;shortcut;hid;
|
||||||
|
StartupWMClass=macropad
|
||||||
|
EOF
|
||||||
|
command -v update-desktop-database >/dev/null && update-desktop-database -q "$(dirname "$DESKTOP")" 2>/dev/null || true
|
||||||
|
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 >/dev/null 2>&1 || true
|
||||||
|
ok "MacroPad is in your app menu"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 4. permission
|
||||||
|
step "Letting the app talk to the pad"
|
||||||
|
if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then
|
||||||
|
ok "permission rule already in place"
|
||||||
|
else
|
||||||
|
say " Linux doesn't let apps write to USB devices by default. This adds one"
|
||||||
|
say " small rule so MacroPad can, for whoever is logged in at the computer."
|
||||||
|
say " ${D}It writes: $RULE${X}"
|
||||||
|
if ask "Add it now? This asks for your password."; then
|
||||||
|
sudo mkdir -p "$(dirname "$RULE")"
|
||||||
|
printf '%s\n' "# MacroPad: let the logged-in user configure 1189:8840 macro pads." \
|
||||||
|
"# Must sort before 73-seat-late.rules, which is where uaccess takes effect." \
|
||||||
|
"$RULE_LINE" | sudo tee "$RULE" >/dev/null
|
||||||
|
if command -v udevadm >/dev/null; then
|
||||||
|
sudo udevadm control --reload-rules || true
|
||||||
|
sudo udevadm trigger --subsystem-match=hidraw || true
|
||||||
|
fi
|
||||||
|
ok "rule added"
|
||||||
|
NEED_REPLUG=1
|
||||||
|
else
|
||||||
|
say " Skipped. The app will show you how to add it later."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A rule named 99-something (or with no number) is read too late to work.
|
||||||
|
for f in /etc/udev/rules.d/*.rules; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
[ "$f" = "$RULE" ] && continue
|
||||||
|
if grep -qiE 'idVendor\}=="1189"' "$f" 2>/dev/null && grep -q uaccess "$f"; then
|
||||||
|
base="$(basename "$f")"
|
||||||
|
if [[ ! "$base" < "73-seat-late.rules" ]]; then
|
||||||
|
say " ${D}Note: $f also mentions this pad but is read too late to do"
|
||||||
|
say " anything. It's harmless. Remove it with: sudo rm $f${X}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- done
|
||||||
|
printf '\n%s%sAll done.%s\n\n' "$G" "$B" "$X"
|
||||||
|
n=1
|
||||||
|
if [ -n "$NEED_REPLUG" ]; then
|
||||||
|
say " ${B}$n.${X} Unplug your macro pad and plug it back in."
|
||||||
|
n=$((n + 1))
|
||||||
|
fi
|
||||||
|
say " ${B}$n.${X} Open ${B}MacroPad${X} from your app menu."
|
||||||
|
say " ${D}(or type${X} macropad ${D}in a terminal)${X}"
|
||||||
|
say ""
|
||||||
|
say " To update later, run the same command again."
|
||||||
|
say " To remove it: ${D}$APP/uninstall.sh${X}"
|
||||||
|
say ""
|
||||||
|
case ":$PATH:" in *":$BIN:"*) ;; *)
|
||||||
|
say " ${D}Tip: $BIN isn't on your PATH, so the 'macropad' command won't work"
|
||||||
|
say " in a terminal yet. The app menu entry works regardless.${X}"
|
||||||
|
say ""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
204
macropad-capture.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
macropad-capture - watch what the vendor's software actually sends.
|
||||||
|
|
||||||
|
Uses the kernel's usbmon facility to record USB traffic going to your macro
|
||||||
|
pad, then decodes the outgoing reports so we can see the real wire format.
|
||||||
|
|
||||||
|
This is passive. It only observes - it never sends anything itself.
|
||||||
|
|
||||||
|
How to use it:
|
||||||
|
|
||||||
|
1. Start this script:
|
||||||
|
sudo python3 macropad-capture.py
|
||||||
|
|
||||||
|
2. Leave it running. In another window, launch the vendor software
|
||||||
|
under Wine:
|
||||||
|
wine MINI_KEYBOARD.exe
|
||||||
|
|
||||||
|
3. In that software, change ONE key to something distinctive - key 1 to
|
||||||
|
the letter 'a' is ideal, because we know 'a' is HID code 0x04 and it
|
||||||
|
will be easy to spot in the bytes.
|
||||||
|
|
||||||
|
4. Click whatever it uses to apply or save.
|
||||||
|
|
||||||
|
5. Come back here and press Ctrl+C.
|
||||||
|
|
||||||
|
It writes macropad-capture.txt, which has everything we need.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DEBUGFS = "/sys/kernel/debug/usb/usbmon"
|
||||||
|
OUTFILE = "macropad-capture.txt"
|
||||||
|
|
||||||
|
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def find_pad():
|
||||||
|
"""Locate the pad on the USB bus via lsusb. Returns (bus, device, vid, pid)."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["lsusb"], capture_output=True, text=True).stdout
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
m = re.match(r"Bus (\d+) Device (\d+): ID ([0-9a-f]{4}):([0-9a-f]{4})", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
bus, dev, vid, pid = m.groups()
|
||||||
|
if vid.lower() in ("1189", "1a81"):
|
||||||
|
candidates.append((int(bus), int(dev), vid, pid, line.strip()))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_usbmon():
|
||||||
|
if os.path.isdir(DEBUGFS):
|
||||||
|
return True
|
||||||
|
print(f"{DIM}Loading usbmon module...{O}")
|
||||||
|
subprocess.run(["modprobe", "usbmon"], capture_output=True)
|
||||||
|
if os.path.isdir(DEBUGFS):
|
||||||
|
return True
|
||||||
|
# debugfs may simply not be mounted yet
|
||||||
|
subprocess.run(["mount", "-t", "debugfs", "none", "/sys/kernel/debug"],
|
||||||
|
capture_output=True)
|
||||||
|
return os.path.isdir(DEBUGFS)
|
||||||
|
|
||||||
|
|
||||||
|
def decode(line, want_dev):
|
||||||
|
"""
|
||||||
|
usbmon text format, roughly:
|
||||||
|
<tag> <ts> <S|C> <type>:<bus>:<dev>:<ep> <flags> <len> = <hex data>
|
||||||
|
|
||||||
|
We want submissions (S) carrying data toward our device: control
|
||||||
|
transfers (Co) and interrupt out (Io).
|
||||||
|
"""
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event = parts[2]
|
||||||
|
addr = parts[3]
|
||||||
|
if event != "S":
|
||||||
|
return None
|
||||||
|
|
||||||
|
bits = addr.split(":")
|
||||||
|
if len(bits) < 4:
|
||||||
|
return None
|
||||||
|
xfer, bus, dev, ep = bits[0], bits[1], bits[2], bits[3]
|
||||||
|
|
||||||
|
if int(dev) != want_dev:
|
||||||
|
return None
|
||||||
|
if not xfer.endswith("o"): # 'o' = out, toward the device
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "=" not in line:
|
||||||
|
return None
|
||||||
|
data = line.split("=", 1)[1].replace(" ", "").strip()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
kind = {"Co": "control out", "Io": "interrupt out",
|
||||||
|
"Bo": "bulk out"}.get(xfer, xfer)
|
||||||
|
|
||||||
|
setup = ""
|
||||||
|
if xfer == "Co":
|
||||||
|
# Control transfers carry an 8-byte setup packet in the flags field.
|
||||||
|
for p in parts[4:9]:
|
||||||
|
if re.fullmatch(r"[0-9a-f]{2,4}", p):
|
||||||
|
setup += p + " "
|
||||||
|
|
||||||
|
return {"kind": kind, "ep": ep, "setup": setup.strip(), "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print(f"{R}Needs root{O} - usbmon is a kernel facility.")
|
||||||
|
print(f" sudo python3 {sys.argv[0]}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
cands = find_pad()
|
||||||
|
if not cands:
|
||||||
|
print(f"{R}No Holtek-family device found in lsusb.{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if len(cands) > 1:
|
||||||
|
print(f"{B}Several candidates:{O}")
|
||||||
|
for i, c in enumerate(cands):
|
||||||
|
print(f" [{i}] {c[4]}")
|
||||||
|
try:
|
||||||
|
pick = int(input("Which one? ") or 0)
|
||||||
|
except (ValueError, EOFError):
|
||||||
|
pick = 0
|
||||||
|
cands = [cands[pick]]
|
||||||
|
|
||||||
|
bus, dev, vid, pid, desc = cands[0]
|
||||||
|
print(f"{B}Watching{O} {desc}")
|
||||||
|
print(f"{DIM}bus {bus}, device {dev}{O}\n")
|
||||||
|
|
||||||
|
if not ensure_usbmon():
|
||||||
|
print(f"{R}Couldn't set up usbmon.{O}")
|
||||||
|
print("Try: sudo modprobe usbmon")
|
||||||
|
print(" sudo mount -t debugfs none /sys/kernel/debug")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
path = os.path.join(DEBUGFS, f"{bus}u")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"{R}{path} doesn't exist.{O} Is the bus number right?")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{G}Capturing.{O} Now, in another terminal:")
|
||||||
|
print(f" {B}wine MINI_KEYBOARD.exe{O}")
|
||||||
|
print("\nChange ONE key to the letter 'a', then apply it.")
|
||||||
|
print(f"{DIM}('a' is HID code 0x04 - easy to spot in the bytes.){O}")
|
||||||
|
print(f"\nPress {B}Ctrl+C{O} when you're done.\n")
|
||||||
|
print("-" * 62)
|
||||||
|
|
||||||
|
packets = []
|
||||||
|
try:
|
||||||
|
with open(path, "r") as mon, open(OUTFILE, "w") as out:
|
||||||
|
out.write(f"# capture of {desc}\n# bus {bus} device {dev}\n\n")
|
||||||
|
for line in mon:
|
||||||
|
rec = decode(line, dev)
|
||||||
|
if not rec:
|
||||||
|
continue
|
||||||
|
packets.append(rec)
|
||||||
|
n = len(packets)
|
||||||
|
hexstr = rec["data"]
|
||||||
|
pretty = " ".join(hexstr[i:i + 2] for i in range(0, min(len(hexstr), 40), 2))
|
||||||
|
print(f"{n:3} {rec['kind']:<14} {pretty}"
|
||||||
|
+ (" ..." if len(hexstr) > 40 else ""))
|
||||||
|
out.write(f"[{n}] {rec['kind']} ep={rec['ep']}"
|
||||||
|
+ (f" setup={rec['setup']}" if rec["setup"] else "")
|
||||||
|
+ f"\n {hexstr}\n")
|
||||||
|
out.flush()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
except PermissionError:
|
||||||
|
print(f"{R}Permission denied reading {path}.{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n" + "-" * 62)
|
||||||
|
if not packets:
|
||||||
|
print(f"{Y}Nothing captured.{O}")
|
||||||
|
print("The software may not have written, or it may be talking to a")
|
||||||
|
print("different device number. Check lsusb while Wine is running.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{G}Captured {len(packets)} outgoing transfer(s){O} -> {OUTFILE}")
|
||||||
|
|
||||||
|
hits = [p for p in packets if "04" in p["data"]]
|
||||||
|
if hits:
|
||||||
|
print(f"{DIM}{len(hits)} contain byte 0x04, which may be your 'a' keycode.{O}")
|
||||||
|
|
||||||
|
print(f"\nSend me {B}{OUTFILE}{O} and I'll decode the format.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
152
macropad-handshake.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
macropad-handshake - find out how your pad wants to be talked to.
|
||||||
|
|
||||||
|
Sends only the benign "version check" report upstream uses on connect:
|
||||||
|
all zeros, no configuration payload. It cannot change your keybinds.
|
||||||
|
|
||||||
|
It tries each report id with two different transfer methods, and checks
|
||||||
|
after every attempt whether the device survived. If the pad resets, the
|
||||||
|
script waits for it to come back before continuing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 macropad-handshake.py /dev/hidraw10
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
REPORT_IDS = [0, 2, 3]
|
||||||
|
PAYLOAD_LEN = 64
|
||||||
|
|
||||||
|
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def hidioc_sfeature(size):
|
||||||
|
"""_IOWR('H', 0x06, size) - the SET_FEATURE ioctl hidraw exposes."""
|
||||||
|
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
|
||||||
|
|
||||||
|
|
||||||
|
def device_present(path):
|
||||||
|
return os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_device(path, timeout=6.0):
|
||||||
|
"""After a reset the node usually reappears with the same name."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
if os.path.exists(path):
|
||||||
|
time.sleep(0.4) # let udev reapply the ACL
|
||||||
|
return True
|
||||||
|
time.sleep(0.2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def try_write(path, report_id, method):
|
||||||
|
"""
|
||||||
|
Returns (ok, detail). 'ok' means the device accepted the transfer
|
||||||
|
without erroring and was still present afterwards.
|
||||||
|
"""
|
||||||
|
data = bytes([report_id]) + bytes(PAYLOAD_LEN)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "write":
|
||||||
|
fd = os.open(path, os.O_WRONLY)
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
else: # feature report via ioctl
|
||||||
|
fd = os.open(path, os.O_RDWR)
|
||||||
|
try:
|
||||||
|
buf = bytearray(data)
|
||||||
|
fcntl.ioctl(fd, hidioc_sfeature(len(buf)), buf)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"{e.__class__.__name__}: {e.strerror} (errno {e.errno})"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
if not device_present(path):
|
||||||
|
return False, "device vanished - firmware reset"
|
||||||
|
|
||||||
|
return True, "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
path = sys.argv[1]
|
||||||
|
else:
|
||||||
|
cands = sorted(glob.glob("/dev/hidraw*"))
|
||||||
|
print("Pass the device path, e.g.:")
|
||||||
|
print(f" python3 {sys.argv[0]} /dev/hidraw10")
|
||||||
|
print(f"\n{DIM}Available: {' '.join(cands)}{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"{R}{path} doesn't exist.{O}")
|
||||||
|
return 1
|
||||||
|
if not os.access(path, os.W_OK):
|
||||||
|
print(f"{R}{path} isn't writable.{O} Check your udev rule.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{B}Handshake probe on {path}{O}")
|
||||||
|
print(f"{DIM}Sending zero-filled version-check reports only.")
|
||||||
|
print(f"Your saved keybinds are not touched.{O}\n")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for method in ("write", "feature"):
|
||||||
|
label = "interrupt OUT (write)" if method == "write" else "SET_FEATURE (ioctl)"
|
||||||
|
print(f"{B}{label}{O}")
|
||||||
|
|
||||||
|
for rid in REPORT_IDS:
|
||||||
|
if not device_present(path):
|
||||||
|
print(f" {DIM}waiting for device to come back...{O}")
|
||||||
|
if not wait_for_device(path):
|
||||||
|
print(f" {R}device did not return. Replug it and rerun.{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ok, detail = try_write(path, rid, method)
|
||||||
|
mark = f"{G}OK {O}" if ok else f"{R}FAIL {O}"
|
||||||
|
print(f" report id {rid}: {mark} {detail}")
|
||||||
|
results.append((method, rid, ok, detail))
|
||||||
|
|
||||||
|
if not ok and "vanished" in detail:
|
||||||
|
if not wait_for_device(path):
|
||||||
|
print(f" {R}device did not come back.{O} Replug and rerun.")
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ verdict
|
||||||
|
print(f"{B}Verdict{O}")
|
||||||
|
good = [(m, r) for m, r, ok, _ in results if ok]
|
||||||
|
|
||||||
|
if not good:
|
||||||
|
print(f" {R}Nothing was accepted.{O}")
|
||||||
|
print(" This pad may not use the MacroPad protocol at all, or it")
|
||||||
|
print(" wants a vendor-specific unlock sequence first.")
|
||||||
|
print("\n Worth trying: the other config interface, if the probe found one.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f" {G}Accepted combinations:{O}")
|
||||||
|
for m, r in good:
|
||||||
|
how = "write" if m == "write" else "feature"
|
||||||
|
print(f" report id {r} via {how}")
|
||||||
|
|
||||||
|
m, r = good[0]
|
||||||
|
print(f"\n Upstream picks the first report id that accepts a write,")
|
||||||
|
print(f" so your pad looks like {B}protocol version {r}{O}.")
|
||||||
|
print(f"\n Try a real binding with:")
|
||||||
|
extra = "" if m == "write" else " (needs --feature support adding)"
|
||||||
|
print(f" python3 macropad.py set key1 ctrl+c --report-id {r}{extra}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
257
macropad-probe.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
macropad-probe - work out how your macro pad presents itself to Linux.
|
||||||
|
|
||||||
|
Read-only. It looks at what the kernel already knows about your USB devices
|
||||||
|
and prints it. It does not write to the pad, change any setting, or touch
|
||||||
|
your saved keybinds.
|
||||||
|
|
||||||
|
Pure standard library - nothing to install.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 macropad-probe.py # look for known macro pad vendors
|
||||||
|
python3 macropad-probe.py 1189 # a specific vendor id
|
||||||
|
python3 macropad-probe.py 1189:8840 # a specific device
|
||||||
|
python3 macropad-probe.py --all # every HID device on the system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import glob
|
||||||
|
|
||||||
|
HIDRAW_GLOB = "/sys/class/hidraw/hidraw*"
|
||||||
|
|
||||||
|
# Vendors known to ship these cheap configurable pads. 1189 is the one the
|
||||||
|
# MacroPad project targets - it appears as "Acer Communications" but isn't.
|
||||||
|
KNOWN_VENDORS = {"1189": "Holtek/generic macro pad (MacroPad project family)",
|
||||||
|
"1a81": "Holtek Semiconductor"}
|
||||||
|
|
||||||
|
# Product ids the upstream MacroPad project has code for.
|
||||||
|
KNOWN_PRODUCTS = {
|
||||||
|
"8890": "Legacy protocol (tested upstream)",
|
||||||
|
"8830": "Extended protocol", "8831": "Extended protocol",
|
||||||
|
"8832": "Extended protocol", "8897": "Extended protocol",
|
||||||
|
"8874": "Extended protocol",
|
||||||
|
}
|
||||||
|
|
||||||
|
B = "\033[1m"; DIM = "\033[2m"; G = "\033[32m"; Y = "\033[33m"; R = "\033[31m"; O = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def read(path, binary=False):
|
||||||
|
try:
|
||||||
|
with open(path, "rb" if binary else "r") as f:
|
||||||
|
return f.read() if binary else f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_descriptor(raw):
|
||||||
|
"""
|
||||||
|
Walk a HID report descriptor far enough to list report IDs and sizes.
|
||||||
|
Not a full parser - we only need to know which reports exist and how big
|
||||||
|
they are, which is what determines how we talk to the device.
|
||||||
|
"""
|
||||||
|
out = {"usage_page": None, "reports": {}}
|
||||||
|
i = 0
|
||||||
|
report_id, size, count = 0, 0, 0
|
||||||
|
current = None
|
||||||
|
|
||||||
|
while i < len(raw):
|
||||||
|
b = raw[i]
|
||||||
|
if b == 0b11111110: # long item, skip it
|
||||||
|
i += 2 + (raw[i + 1] if i + 1 < len(raw) else 0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag, typ, length = b >> 4, (b >> 2) & 0x3, b & 0x3
|
||||||
|
length = 4 if length == 3 else length
|
||||||
|
data = int.from_bytes(raw[i + 1:i + 1 + length], "little") if length else 0
|
||||||
|
|
||||||
|
if typ == 1: # global
|
||||||
|
if tag == 0 and out["usage_page"] is None:
|
||||||
|
out["usage_page"] = data
|
||||||
|
elif tag == 7:
|
||||||
|
size = data
|
||||||
|
elif tag == 8:
|
||||||
|
report_id = data
|
||||||
|
out["reports"].setdefault(report_id, {"in": 0, "out": 0, "feature": 0})
|
||||||
|
elif tag == 9:
|
||||||
|
count = data
|
||||||
|
elif typ == 0: # main
|
||||||
|
kind = {8: "in", 9: "out", 11: "feature"}.get(tag)
|
||||||
|
if kind:
|
||||||
|
current = out["reports"].setdefault(
|
||||||
|
report_id, {"in": 0, "out": 0, "feature": 0})
|
||||||
|
# Accumulate bits. Rounding each item to a whole byte here
|
||||||
|
# would inflate the total - a 5-bit field followed by 3 bits
|
||||||
|
# of padding is one byte, not two.
|
||||||
|
current[kind] += size * count
|
||||||
|
|
||||||
|
i += 1 + length
|
||||||
|
|
||||||
|
# Now convert the accumulated bit counts to bytes, once per report.
|
||||||
|
for sizes in out["reports"].values():
|
||||||
|
for kind in ("in", "out", "feature"):
|
||||||
|
sizes[kind] = (sizes[kind] + 7) // 8
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def describe(hidraw_dir):
|
||||||
|
"""Gather everything interesting about one hidraw node."""
|
||||||
|
name = os.path.basename(hidraw_dir)
|
||||||
|
dev = os.path.join(hidraw_dir, "device")
|
||||||
|
|
||||||
|
uevent = read(os.path.join(dev, "uevent")) or ""
|
||||||
|
hid_id = ""
|
||||||
|
for line in uevent.splitlines():
|
||||||
|
if line.startswith("HID_ID="):
|
||||||
|
hid_id = line.split("=", 1)[1]
|
||||||
|
|
||||||
|
# HID_ID looks like 0003:00001189:00008840
|
||||||
|
vid = pid = "????"
|
||||||
|
if hid_id.count(":") == 2:
|
||||||
|
_, v, p = hid_id.split(":")
|
||||||
|
vid, pid = v[-4:].lower(), p[-4:].lower()
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"node": f"/dev/{name}",
|
||||||
|
"vid": vid,
|
||||||
|
"pid": pid,
|
||||||
|
"name": read(os.path.join(dev, "uevent")) or "",
|
||||||
|
"product": "",
|
||||||
|
"interface": None,
|
||||||
|
"descriptor": None,
|
||||||
|
"writable": os.access(f"/dev/{name}", os.W_OK),
|
||||||
|
"readable": os.access(f"/dev/{name}", os.R_OK),
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in uevent.splitlines():
|
||||||
|
if line.startswith("HID_NAME="):
|
||||||
|
info["product"] = line.split("=", 1)[1]
|
||||||
|
|
||||||
|
# Walk up to the USB interface to find bInterfaceNumber - this is the
|
||||||
|
# Linux equivalent of the "mi_00"/"mi_01" path fragments the Windows
|
||||||
|
# code matches on.
|
||||||
|
p = os.path.realpath(dev)
|
||||||
|
for _ in range(6):
|
||||||
|
cand = os.path.join(p, "bInterfaceNumber")
|
||||||
|
if os.path.exists(cand):
|
||||||
|
info["interface"] = read(cand)
|
||||||
|
break
|
||||||
|
p = os.path.dirname(p)
|
||||||
|
|
||||||
|
raw = read(os.path.join(dev, "report_descriptor"), binary=True)
|
||||||
|
if raw:
|
||||||
|
info["descriptor"] = parse_descriptor(raw)
|
||||||
|
info["descriptor_len"] = len(raw)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
show_all = "--all" in sys.argv
|
||||||
|
|
||||||
|
want_vid = want_pid = None
|
||||||
|
if args:
|
||||||
|
if ":" in args[0]:
|
||||||
|
want_vid, want_pid = args[0].lower().split(":")
|
||||||
|
else:
|
||||||
|
want_vid = args[0].lower()
|
||||||
|
|
||||||
|
nodes = sorted(glob.glob(HIDRAW_GLOB))
|
||||||
|
if not nodes:
|
||||||
|
print(f"{R}No HID devices found at all.{O} Is anything plugged in?")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
found = []
|
||||||
|
for n in nodes:
|
||||||
|
info = describe(n)
|
||||||
|
if show_all:
|
||||||
|
found.append(info)
|
||||||
|
elif want_vid:
|
||||||
|
if info["vid"] == want_vid and (not want_pid or info["pid"] == want_pid):
|
||||||
|
found.append(info)
|
||||||
|
elif info["vid"] in KNOWN_VENDORS:
|
||||||
|
found.append(info)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print(f"{Y}Nothing matched.{O}")
|
||||||
|
print(f"Scanned {len(nodes)} HID devices. Try: python3 {sys.argv[0]} --all")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{B}Found {len(found)} matching HID interface(s){O}\n")
|
||||||
|
|
||||||
|
for info in found:
|
||||||
|
vid, pid = info["vid"], info["pid"]
|
||||||
|
print(f"{B}{info['node']}{O} {vid}:{pid}")
|
||||||
|
if info["product"]:
|
||||||
|
print(f" product {info['product']}")
|
||||||
|
if vid in KNOWN_VENDORS:
|
||||||
|
print(f" vendor {DIM}{KNOWN_VENDORS[vid]}{O}")
|
||||||
|
if pid in KNOWN_PRODUCTS:
|
||||||
|
print(f" {G}known device {KNOWN_PRODUCTS[pid]}{O}")
|
||||||
|
else:
|
||||||
|
print(f" {Y}unknown pid not in upstream's device list{O}")
|
||||||
|
|
||||||
|
if info["interface"] is not None:
|
||||||
|
print(f" usb interface {info['interface']} "
|
||||||
|
f"{DIM}(Windows calls this mi_{info['interface']}){O}")
|
||||||
|
|
||||||
|
perm = f"{G}yes{O}" if info["writable"] else f"{R}no - needs a udev rule{O}"
|
||||||
|
print(f" writable {perm}")
|
||||||
|
|
||||||
|
d = info.get("descriptor")
|
||||||
|
if d:
|
||||||
|
up = d["usage_page"]
|
||||||
|
up_name = {1: "Generic Desktop", 7: "Keyboard", 12: "Consumer"}.get(
|
||||||
|
up, f"0x{up:04x} (vendor-defined)" if up else "?")
|
||||||
|
print(f" usage page {up_name}")
|
||||||
|
print(f" descriptor {info['descriptor_len']} bytes")
|
||||||
|
if d["reports"]:
|
||||||
|
print(f" reports {DIM}id in out feature (bytes){O}")
|
||||||
|
for rid, sz in sorted(d["reports"].items()):
|
||||||
|
star = ""
|
||||||
|
if sz["out"] >= 8 or sz["feature"] >= 8:
|
||||||
|
star = f" {G}<- can receive config{O}"
|
||||||
|
print(f" {rid:<3} {sz['in']:<3} "
|
||||||
|
f"{sz['out']:<4} {sz['feature']:<7}{star}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- verdict
|
||||||
|
print(f"{B}What this means{O}")
|
||||||
|
|
||||||
|
cfg = [i for i in found
|
||||||
|
if i.get("descriptor")
|
||||||
|
and any(r["out"] >= 8 or r["feature"] >= 8
|
||||||
|
for r in i["descriptor"]["reports"].values())]
|
||||||
|
|
||||||
|
if not cfg:
|
||||||
|
print(f" {Y}No interface here looks like it accepts configuration data.{O}")
|
||||||
|
print(" The pad may expose its config interface only under a different")
|
||||||
|
print(" vendor id, or it may not be configurable over HID at all.")
|
||||||
|
else:
|
||||||
|
print(f" {G}{len(cfg)} interface(s) can receive configuration reports.{O}")
|
||||||
|
for i in cfg:
|
||||||
|
print(f" {i['node']} (usb interface {i['interface']})")
|
||||||
|
print(" That's where a configuration tool would write.")
|
||||||
|
|
||||||
|
unwritable = [i for i in cfg if not i["writable"]]
|
||||||
|
if unwritable:
|
||||||
|
print()
|
||||||
|
print(f" {Y}You don't have write permission yet.{O} Fix with a udev rule:")
|
||||||
|
v = unwritable[0]["vid"]; p = unwritable[0]["pid"]
|
||||||
|
# Must sort before 73-seat-late.rules, which is where systemd turns
|
||||||
|
# the uaccess tag into an actual permission. A 99- rule gets tagged
|
||||||
|
# and then silently ignored.
|
||||||
|
print(f" sudo nano /etc/udev/rules.d/60-macropad.rules")
|
||||||
|
print(f' KERNEL=="hidraw*", ATTRS{{idVendor}}=="{v}", '
|
||||||
|
f'ATTRS{{idProduct}}=="{p}", TAG+="uaccess"')
|
||||||
|
print(" sudo udevadm control --reload-rules && sudo udevadm trigger")
|
||||||
|
print(" Then unplug and replug the pad.")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
517
macropad.py
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
macropad - configure Holtek-family USB macro pads from Linux.
|
||||||
|
|
||||||
|
A Linux port of the protocol from rOzzy1987/MacroPad (GPL-3.0), which
|
||||||
|
reverse-engineered the vendor's Windows software. All credit for working
|
||||||
|
out the wire format goes there. This is a clean-room reimplementation of
|
||||||
|
that protocol in Python, with the Windows-only HID layer replaced by
|
||||||
|
direct writes to /dev/hidraw.
|
||||||
|
|
||||||
|
Licensed GPL-3.0, same as the original.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
macropad.py list
|
||||||
|
macropad.py show
|
||||||
|
macropad.py set key1 ctrl+c
|
||||||
|
macropad.py set knob1-left volumedown
|
||||||
|
macropad.py set key3 "ctrl+shift+n" --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- protocol
|
||||||
|
|
||||||
|
MAGIC = 0xFE # upstream's Extended protocol
|
||||||
|
MAGIC_NATIVE = 0xFD # what 1189:8840 actually wants (confirmed by usb capture)
|
||||||
|
TERMINATOR = (0xFE, 0xFF) # follows a config report to commit it
|
||||||
|
REPORT_LEN = 65 # 1 report id + 64 payload bytes
|
||||||
|
|
||||||
|
class KeyType:
|
||||||
|
NONE = 0
|
||||||
|
BASIC = 1 # keyboard keystrokes
|
||||||
|
MULTIMEDIA = 2 # media keys
|
||||||
|
MOUSE = 3
|
||||||
|
LED = 8
|
||||||
|
|
||||||
|
# Modifier bitmask, matching the USB HID keyboard modifier byte.
|
||||||
|
MODIFIERS = {
|
||||||
|
"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "win": 0x08, "super": 0x08,
|
||||||
|
"lctrl": 0x01, "lshift": 0x02, "lalt": 0x04, "lwin": 0x08,
|
||||||
|
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rwin": 0x80,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard USB HID Keyboard/Keypad usage IDs. Verified against upstream:
|
||||||
|
# A=4, Z=29, Enter=40, Esc=41, Space=44, F1=58, F12=69, Right=79, Left=80.
|
||||||
|
KEYCODES = {}
|
||||||
|
for _i, _c in enumerate("abcdefghijklmnopqrstuvwxyz"):
|
||||||
|
KEYCODES[_c] = 4 + _i
|
||||||
|
for _i, _c in enumerate("1234567890"):
|
||||||
|
KEYCODES[_c] = 30 + _i
|
||||||
|
KEYCODES.update({
|
||||||
|
"enter": 40, "return": 40, "esc": 41, "escape": 41, "backspace": 42,
|
||||||
|
"tab": 43, "space": 44, "minus": 45, "equal": 46, "leftbracket": 47,
|
||||||
|
"rightbracket": 48, "backslash": 49, "semicolon": 51, "quote": 52,
|
||||||
|
"grave": 53, "comma": 54, "period": 55, "slash": 56, "capslock": 57,
|
||||||
|
"printscreen": 70, "scrolllock": 71, "pause": 72, "insert": 73,
|
||||||
|
"home": 74, "pageup": 75, "delete": 76, "end": 77, "pagedown": 78,
|
||||||
|
"right": 79, "left": 80, "down": 81, "up": 82,
|
||||||
|
})
|
||||||
|
for _i in range(1, 13):
|
||||||
|
KEYCODES[f"f{_i}"] = 57 + _i # F1 = 58 .. F12 = 69
|
||||||
|
|
||||||
|
# Media keys live on the Consumer usage page, sent as a 16-bit value.
|
||||||
|
MEDIA_KEYS = {
|
||||||
|
"play": 0x00CD, "pause": 0x00CD, "playpause": 0x00CD,
|
||||||
|
"next": 0x00B5, "prev": 0x00B6, "previous": 0x00B6,
|
||||||
|
"stop": 0x00B7, "mute": 0x00E2,
|
||||||
|
"volumeup": 0x00E9, "volup": 0x00E9,
|
||||||
|
"volumedown": 0x00EA, "voldown": 0x00EA,
|
||||||
|
"brightnessup": 0x006F, "brightnessdown": 0x0070,
|
||||||
|
"calculator": 0x0192, "browser": 0x0196, "mail": 0x018A,
|
||||||
|
"mycomputer": 0x0194, # seen in capture: the vendor app's "My computer"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Physical control -> protocol action byte.
|
||||||
|
#
|
||||||
|
# Keys are 1..15 directly. Knobs start at 0x10 - confirmed by usb capture on
|
||||||
|
# 1189:8840, where the two dials occupied 0x10-0x12 and 0x13-0x15.
|
||||||
|
#
|
||||||
|
# Upstream derives knob numbers as (enum - 10), putting the first knob at 13.
|
||||||
|
# That holds on pads with fewer keys but not here: this firmware reserves
|
||||||
|
# 1..15 for keys and begins knobs on the 0x10 boundary. If your pad disagrees,
|
||||||
|
# --raw-action lets you find its numbering without editing this file.
|
||||||
|
KNOB_BASE = 0x10
|
||||||
|
|
||||||
|
CONTROLS = {}
|
||||||
|
for _i in range(1, 16):
|
||||||
|
CONTROLS[f"key{_i}"] = _i
|
||||||
|
for _knob in (1, 2, 3):
|
||||||
|
base = KNOB_BASE + (_knob - 1) * 3
|
||||||
|
# Order within a knob (left, push, right) follows upstream's enum and
|
||||||
|
# matches the capture's three-consecutive-values pattern, but which
|
||||||
|
# physical direction is which was not separately verified.
|
||||||
|
CONTROLS[f"knob{_knob}-left"] = base
|
||||||
|
CONTROLS[f"knob{_knob}-push"] = base + 1
|
||||||
|
CONTROLS[f"knob{_knob}-right"] = base + 2
|
||||||
|
CONTROLS[f"dial{_knob}-left"] = base # friendlier aliases
|
||||||
|
CONTROLS[f"dial{_knob}-push"] = base + 1
|
||||||
|
CONTROLS[f"dial{_knob}-right"] = base + 2
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(report_id, action, key_type, payload, layer=0, delay=0,
|
||||||
|
magic=MAGIC):
|
||||||
|
"""
|
||||||
|
Assemble one Extended-protocol report.
|
||||||
|
|
||||||
|
Layout (payload bytes, after the report id):
|
||||||
|
0 0xFE magic
|
||||||
|
1 action - which physical control
|
||||||
|
2 layer number
|
||||||
|
3 key type
|
||||||
|
4-5 delay, little endian
|
||||||
|
6-8 zero
|
||||||
|
9 count of populated pairs
|
||||||
|
10.. payload
|
||||||
|
"""
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0] = magic
|
||||||
|
data[1] = action
|
||||||
|
data[2] = layer
|
||||||
|
data[3] = key_type
|
||||||
|
data[4] = delay & 0xFF
|
||||||
|
data[5] = (delay >> 8) & 0xFF
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for i, b in enumerate(payload):
|
||||||
|
if 10 + i >= len(data):
|
||||||
|
break
|
||||||
|
data[10 + i] = b
|
||||||
|
if b != 0:
|
||||||
|
count = (i >> 1) + 1
|
||||||
|
data[9] = count
|
||||||
|
|
||||||
|
return bytes([report_id]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_keystroke(spec):
|
||||||
|
"""'ctrl+shift+n' -> [(modifier_mask, keycode)]. Comma separates a sequence."""
|
||||||
|
sequence = []
|
||||||
|
for chunk in spec.split(","):
|
||||||
|
chunk = chunk.strip().lower()
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in chunk.split("+")]
|
||||||
|
mods, code = 0, None
|
||||||
|
for p in parts:
|
||||||
|
if p in MODIFIERS:
|
||||||
|
mods |= MODIFIERS[p]
|
||||||
|
elif p in KEYCODES:
|
||||||
|
code = KEYCODES[p]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"don't recognise '{p}' in '{spec}'")
|
||||||
|
if code is None and mods == 0:
|
||||||
|
raise ValueError(f"nothing usable in '{chunk}'")
|
||||||
|
sequence.append((mods, code or 0))
|
||||||
|
if not sequence:
|
||||||
|
raise ValueError(f"couldn't parse '{spec}'")
|
||||||
|
return sequence
|
||||||
|
|
||||||
|
|
||||||
|
def build_key(report_id, action, sequence, layer=0, delay=0):
|
||||||
|
payload = bytearray()
|
||||||
|
for mods, code in sequence:
|
||||||
|
payload += bytes([mods, code])
|
||||||
|
return build_report(report_id, action, KeyType.BASIC, payload, layer, delay)
|
||||||
|
|
||||||
|
|
||||||
|
def build_media(report_id, action, usage, layer=0):
|
||||||
|
payload = bytes([0, usage & 0xFF, (usage >> 8) & 0xFF, 0])
|
||||||
|
return build_report(report_id, action, KeyType.MULTIMEDIA, payload, layer)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- native protocol
|
||||||
|
#
|
||||||
|
# Confirmed by capturing the vendor software over usbmon. Keystrokes use
|
||||||
|
# upstream's Extended layout with two corrections: the magic byte is 0xFD
|
||||||
|
# rather than 0xFE, and layers are numbered from 1, not 0. Sending layer 0
|
||||||
|
# is why our earlier reports were accepted and then discarded. Media keys
|
||||||
|
# use a different packing again - see native_media().
|
||||||
|
#
|
||||||
|
# Observed for key1 -> 'b':
|
||||||
|
# 03 fd 01 01 01 00 00 00 00 00 01 00 05 config
|
||||||
|
# 03 fd fe ff 00 ... commit
|
||||||
|
|
||||||
|
def native_commit(report_id):
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0] = MAGIC_NATIVE
|
||||||
|
data[1], data[2] = TERMINATOR
|
||||||
|
return bytes([report_id]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def native_key(report_id, action, sequence, layer=1, delay=0):
|
||||||
|
payload = bytearray()
|
||||||
|
for mods, code in sequence:
|
||||||
|
payload += bytes([mods, code])
|
||||||
|
body = build_report(report_id, action, KeyType.BASIC, payload, layer, delay,
|
||||||
|
magic=MAGIC_NATIVE)
|
||||||
|
return [body, native_commit(report_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def native_media(report_id, action, usage, layer=1):
|
||||||
|
"""
|
||||||
|
Confirmed by capturing the vendor software binding keys 1-12 to media
|
||||||
|
keys. Upstream's Extended media layout is NOT what this pad wants:
|
||||||
|
|
||||||
|
upstream 03 fd 01 01 02 00 00 00 00 00 01 00 b5 00 (usage one byte late)
|
||||||
|
captured 03 fd 01 01 02 00 00 00 00 00 02 b5 00 00 key1 -> next track
|
||||||
|
|
||||||
|
The count byte is 2 even when the high byte is zero, and the 16-bit
|
||||||
|
consumer usage follows it directly, little endian.
|
||||||
|
"""
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0] = MAGIC_NATIVE
|
||||||
|
data[1] = action
|
||||||
|
data[2] = layer
|
||||||
|
data[3] = KeyType.MULTIMEDIA
|
||||||
|
data[9] = 2
|
||||||
|
data[10] = usage & 0xFF
|
||||||
|
data[11] = (usage >> 8) & 0xFF
|
||||||
|
return [bytes([report_id]) + bytes(data), native_commit(report_id)]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- legacy protocol
|
||||||
|
#
|
||||||
|
# The older format. Unlike Extended, a single binding is a *sequence* of
|
||||||
|
# reports: optionally select the layer, then one report per keystroke plus
|
||||||
|
# one, then commit to flash. Missing the commit leaves the pad mid-write,
|
||||||
|
# which is a good candidate for the firmware faults some devices show.
|
||||||
|
|
||||||
|
def _legacy(report_id, b0, b1, b2=0, b3=0, b4=0, b5=0):
|
||||||
|
data = bytearray(REPORT_LEN - 1)
|
||||||
|
data[0], data[1], data[2] = b0, b1, b2
|
||||||
|
data[3], data[4], data[5] = b3, b4, b5
|
||||||
|
return bytes([report_id]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_layer_select(report_id, layer):
|
||||||
|
return _legacy(report_id, 161, layer)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_flash(report_id, led=False):
|
||||||
|
return _legacy(report_id, 170, 161 if led else 170)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_key_sequence(report_id, action, sequence, layer=0):
|
||||||
|
"""Returns the full ordered list of reports for one key binding."""
|
||||||
|
reports = []
|
||||||
|
if report_id != 0:
|
||||||
|
reports.append(legacy_layer_select(report_id, layer))
|
||||||
|
|
||||||
|
n = len(sequence)
|
||||||
|
for index in range(n + 1):
|
||||||
|
type_byte = KeyType.BASIC
|
||||||
|
if report_id != 0:
|
||||||
|
type_byte |= (layer << 4) & 0xFF
|
||||||
|
# Index 0 carries only the first entry's modifiers, no keycode.
|
||||||
|
# That asymmetry is in the original; it is not a mistake here.
|
||||||
|
if index == 0:
|
||||||
|
mods, code = sequence[0][0], 0
|
||||||
|
else:
|
||||||
|
mods, code = sequence[index - 1]
|
||||||
|
reports.append(_legacy(report_id, action, type_byte, n, index, mods, code))
|
||||||
|
|
||||||
|
reports.append(legacy_flash(report_id))
|
||||||
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_media_sequence(report_id, action, usage, layer=0):
|
||||||
|
reports = []
|
||||||
|
if report_id != 0:
|
||||||
|
reports.append(legacy_layer_select(report_id, layer))
|
||||||
|
type_byte = KeyType.MULTIMEDIA
|
||||||
|
if report_id != 0:
|
||||||
|
type_byte |= (layer << 4) & 0xFF
|
||||||
|
reports.append(_legacy(report_id, action, type_byte,
|
||||||
|
usage & 0xFF, (usage >> 8) & 0xFF))
|
||||||
|
reports.append(legacy_flash(report_id))
|
||||||
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- device layer
|
||||||
|
|
||||||
|
KNOWN_VENDORS = ("1189",)
|
||||||
|
|
||||||
|
def find_devices():
|
||||||
|
"""Find hidraw nodes that expose a vendor-defined 64-byte config interface."""
|
||||||
|
out = []
|
||||||
|
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||||
|
dev = os.path.join(node, "device")
|
||||||
|
try:
|
||||||
|
uevent = open(os.path.join(dev, "uevent")).read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
|
||||||
|
if l.startswith("HID_ID=")), "")
|
||||||
|
if hid_id.count(":") != 2:
|
||||||
|
continue
|
||||||
|
_, v, p = hid_id.split(":")
|
||||||
|
vid, pid = v[-4:].lower(), p[-4:].lower()
|
||||||
|
if vid not in KNOWN_VENDORS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
desc = open(os.path.join(dev, "report_descriptor"), "rb").read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
# Vendor-defined usage page: 0x06 followed by a page >= 0xFF00.
|
||||||
|
if not (b"\x06\x00\xff" in desc or b"\x06\x01\xff" in desc):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Report id is declared by the 0x85 global item.
|
||||||
|
rid = 0
|
||||||
|
idx = desc.find(b"\x85")
|
||||||
|
if idx != -1 and idx + 1 < len(desc):
|
||||||
|
rid = desc[idx + 1]
|
||||||
|
|
||||||
|
path = "/dev/" + os.path.basename(node)
|
||||||
|
out.append({"path": path, "vid": vid, "pid": pid, "report_id": rid,
|
||||||
|
"writable": os.access(path, os.W_OK),
|
||||||
|
"name": next((l.split("=", 1)[1] for l in uevent.splitlines()
|
||||||
|
if l.startswith("HID_NAME=")), "")})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(path, report):
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(report)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- cli
|
||||||
|
|
||||||
|
def hexdump(b):
|
||||||
|
lines = []
|
||||||
|
for off in range(0, len(b), 16):
|
||||||
|
chunk = b[off:off + 16]
|
||||||
|
lines.append(f" {off:04x} " + " ".join(f"{x:02x}" for x in chunk))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Configure a Holtek-family macro pad.")
|
||||||
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
|
|
||||||
|
sub.add_parser("list", help="show detected pads")
|
||||||
|
sub.add_parser("controls", help="list control and key names you can use")
|
||||||
|
|
||||||
|
s = sub.add_parser("set", help="bind a control")
|
||||||
|
s.add_argument("control",
|
||||||
|
help="e.g. key1, dial1-left, or action:16 for a raw byte")
|
||||||
|
s.add_argument("binding", help="e.g. 'ctrl+c', 'f5', 'volumeup', 'a,b,c'")
|
||||||
|
s.add_argument("--layer", type=int, default=1,
|
||||||
|
help="layer number, 1-indexed on this hardware")
|
||||||
|
s.add_argument("--delay", type=int, default=0, help="ms between keys")
|
||||||
|
s.add_argument("--device", help="hidraw path, if you have more than one")
|
||||||
|
s.add_argument("--report-id", type=int, help="override the detected report id")
|
||||||
|
s.add_argument("--dry-run", action="store_true",
|
||||||
|
help="show the bytes without writing")
|
||||||
|
s.add_argument("--legacy", action="store_true",
|
||||||
|
help="use the older multi-report protocol with a flash commit")
|
||||||
|
s.add_argument("--commit", action="store_true",
|
||||||
|
help="follow an extended-protocol report with a flash commit")
|
||||||
|
s.add_argument("--extended", action="store_true",
|
||||||
|
help="use upstream's 0xFE extended format instead of native")
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
if not args.cmd:
|
||||||
|
ap.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "controls":
|
||||||
|
print("Keys:")
|
||||||
|
print(" " + ", ".join(f"key{i}" for i in range(1, 16)))
|
||||||
|
print("\nDials (knobN-* and dialN-* are the same thing):")
|
||||||
|
for k in (1, 2, 3):
|
||||||
|
b = KNOB_BASE + (k - 1) * 3
|
||||||
|
print(f" dial{k}-left 0x{b:02x} dial{k}-push 0x{b+1:02x}"
|
||||||
|
f" dial{k}-right 0x{b+2:02x}")
|
||||||
|
print("\nOr a raw action byte: action:16 action:0x10")
|
||||||
|
print("\nModifiers:")
|
||||||
|
print(" " + ", ".join(sorted(set(MODIFIERS))))
|
||||||
|
print("\nMedia:")
|
||||||
|
print(" " + ", ".join(sorted(MEDIA_KEYS)))
|
||||||
|
print("\nKeys: a-z, 0-9, f1-f12, and:")
|
||||||
|
named = [k for k in KEYCODES if len(k) > 1 and not k.startswith("f")]
|
||||||
|
print(" " + ", ".join(sorted(named)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
devices = find_devices()
|
||||||
|
|
||||||
|
if args.cmd == "list":
|
||||||
|
if not devices:
|
||||||
|
print("No macro pad config interface found.")
|
||||||
|
print("Run macropad-probe.py to see what's connected.")
|
||||||
|
return 1
|
||||||
|
for d in devices:
|
||||||
|
state = "writable" if d["writable"] else "NOT WRITABLE - needs udev rule"
|
||||||
|
print(f"{d['path']} {d['vid']}:{d['pid']} report id {d['report_id']} [{state}]")
|
||||||
|
if d["name"]:
|
||||||
|
print(f" {d['name']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.cmd == "set":
|
||||||
|
if args.device:
|
||||||
|
dev = {"path": args.device, "report_id": args.report_id or 3, "writable": True}
|
||||||
|
else:
|
||||||
|
if not devices:
|
||||||
|
print("No pad found. Try --device /dev/hidrawN")
|
||||||
|
return 1
|
||||||
|
if len(devices) > 1:
|
||||||
|
print("Several candidates; pick one with --device:")
|
||||||
|
for d in devices:
|
||||||
|
print(" " + d["path"])
|
||||||
|
return 1
|
||||||
|
dev = devices[0]
|
||||||
|
|
||||||
|
report_id = args.report_id if args.report_id is not None else dev["report_id"]
|
||||||
|
|
||||||
|
control = args.control.lower()
|
||||||
|
if control.startswith("action:"):
|
||||||
|
try:
|
||||||
|
action = int(control.split(":", 1)[1], 0)
|
||||||
|
except ValueError:
|
||||||
|
print("Use action:N, e.g. action:16 or action:0x10")
|
||||||
|
return 1
|
||||||
|
if not 0 < action < 256:
|
||||||
|
print("Action must be 1-255.")
|
||||||
|
return 1
|
||||||
|
elif control in CONTROLS:
|
||||||
|
action = CONTROLS[control]
|
||||||
|
else:
|
||||||
|
print(f"Unknown control '{control}'. See: macropad.py controls")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
binding = args.binding.lower().strip()
|
||||||
|
is_media = binding in MEDIA_KEYS
|
||||||
|
|
||||||
|
if args.extended or args.legacy:
|
||||||
|
pass
|
||||||
|
if args.legacy:
|
||||||
|
if is_media:
|
||||||
|
reports = legacy_media_sequence(report_id, action,
|
||||||
|
MEDIA_KEYS[binding], args.layer)
|
||||||
|
kind = f"legacy media ({binding})"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
seq = parse_keystroke(args.binding)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
reports = legacy_key_sequence(report_id, action, seq, args.layer)
|
||||||
|
kind = f"legacy keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
|
||||||
|
elif args.extended:
|
||||||
|
if is_media:
|
||||||
|
reports = [build_media(report_id, action,
|
||||||
|
MEDIA_KEYS[binding], args.layer)]
|
||||||
|
kind = f"extended media ({binding})"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
seq = parse_keystroke(args.binding)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
reports = [build_key(report_id, action, seq, args.layer, args.delay)]
|
||||||
|
kind = f"extended keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
|
||||||
|
if args.commit:
|
||||||
|
reports.append(legacy_flash(report_id))
|
||||||
|
kind += " + flash commit"
|
||||||
|
else:
|
||||||
|
# Native - the format confirmed by usb capture. Layer defaults to 1.
|
||||||
|
layer = args.layer if args.layer else 1
|
||||||
|
if is_media:
|
||||||
|
reports = native_media(report_id, action, MEDIA_KEYS[binding], layer)
|
||||||
|
kind = f"media ({binding})"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
seq = parse_keystroke(args.binding)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
reports = native_key(report_id, action, seq, layer, args.delay)
|
||||||
|
kind = f"keystroke ({len(seq)} step{'s' if len(seq) != 1 else ''})"
|
||||||
|
|
||||||
|
print(f"{control} -> {args.binding} [{kind}]")
|
||||||
|
print(f"device {dev['path']}, report id {report_id}, "
|
||||||
|
f"{len(reports)} report{'s' if len(reports) != 1 else ''}")
|
||||||
|
for i, r in enumerate(reports):
|
||||||
|
print(f" [{i + 1}] " + " ".join(f"{x:02x}" for x in r[:14]) + " ...")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\nDry run - nothing written.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not dev.get("writable", False):
|
||||||
|
print(f"\n{dev['path']} isn't writable. Add the udev rule first.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
import time
|
||||||
|
for i, r in enumerate(reports):
|
||||||
|
try:
|
||||||
|
write_report(dev["path"], r)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"\nReport {i + 1}/{len(reports)} failed: {e}")
|
||||||
|
if i > 0:
|
||||||
|
print("Earlier reports were sent - the pad may be part-configured.")
|
||||||
|
return 1
|
||||||
|
time.sleep(0.02) # the firmware needs a moment between reports
|
||||||
|
|
||||||
|
print("\nWritten. Press the control to test it.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
macropad_gui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Linux GUI for Holtek-family USB macro pads. GPL-3.0."""
|
||||||
99
macropad_gui/__main__.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
python3 -m macropad_gui open the app
|
||||||
|
python3 -m macropad_gui doctor check the pad is connected and writable
|
||||||
|
|
||||||
|
The rest are for poking at the core from a terminal:
|
||||||
|
python3 -m macropad_gui state
|
||||||
|
python3 -m macropad_gui state
|
||||||
|
python3 -m macropad_gui write key1 "ctrl+c"
|
||||||
|
python3 -m macropad_gui write dial1-right --media volumeup
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import core
|
||||||
|
|
||||||
|
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(_):
|
||||||
|
d = core.diagnose()
|
||||||
|
colour = G if d.ready else (Y if d.status == "unplugged" else R)
|
||||||
|
print(f"{colour}{B}{d.headline}{O}")
|
||||||
|
for line in d.detail:
|
||||||
|
print(f" {line}")
|
||||||
|
if d.fix:
|
||||||
|
print(f"\n{B}Fix{O}")
|
||||||
|
for c in d.fix:
|
||||||
|
print(f" {DIM if c.startswith('#') else ''}{c}{O}")
|
||||||
|
for w in d.warnings:
|
||||||
|
print(f"\n{Y}Note:{O} {w}")
|
||||||
|
return 0 if d.ready else 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_state(_):
|
||||||
|
s = core.State()
|
||||||
|
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
|
||||||
|
for c in core.CONTROL_IDS:
|
||||||
|
e = s.get(c)
|
||||||
|
byte = f"0x{core.action_byte(c):02x}"
|
||||||
|
if e.unknown:
|
||||||
|
print(f" {c:<12} {DIM}{byte}{O} {Y}unknown{O} {DIM}{e.reason}{O}")
|
||||||
|
else:
|
||||||
|
print(f" {c:<12} {DIM}{byte}{O} {e.binding.label():<24} "
|
||||||
|
f"{DIM}{e.written}{O}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_write(a):
|
||||||
|
if a.control not in core.CONTROL_IDS:
|
||||||
|
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
b = core.Binding(keys=a.keys or "", media=a.media or "",
|
||||||
|
delay=a.delay).validate()
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"{R}{e}{O}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
d = core.diagnose()
|
||||||
|
if not d.ready:
|
||||||
|
return cmd_doctor(None)
|
||||||
|
|
||||||
|
state = core.State()
|
||||||
|
try:
|
||||||
|
core.write_binding(d.device, a.control, b, state)
|
||||||
|
except core.WriteError as e:
|
||||||
|
print(f"{R}Write failed:{O} {e}")
|
||||||
|
print(" " + ("The pad was partly written; this control is now marked "
|
||||||
|
"unknown." if e.pad_touched else "Nothing reached the pad."))
|
||||||
|
return 1
|
||||||
|
print(f"{G}Written.{O} {a.control} -> {b.label()} {DIM}{state.path}{O}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(prog="python3 -m macropad_gui")
|
||||||
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
|
sub.add_parser("doctor", help="check the pad is connected and writable")
|
||||||
|
sub.add_parser("state", help="show what this tool believes is on the pad")
|
||||||
|
w = sub.add_parser("write", help="write one binding and record it")
|
||||||
|
w.add_argument("control")
|
||||||
|
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
||||||
|
w.add_argument("--media", help="e.g. volumeup")
|
||||||
|
w.add_argument("--delay", type=int, default=0)
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.cmd is None:
|
||||||
|
try:
|
||||||
|
from .app import run
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"{R}Can't start the app:{O} {e}")
|
||||||
|
print("Install Qt for Python: sudo pacman -S pyside6")
|
||||||
|
return 1
|
||||||
|
return run()
|
||||||
|
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write}[a.cmd](a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
648
macropad_gui/app.py
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
"""
|
||||||
|
The window. Open it, change what a control does, write it, close it. The
|
||||||
|
pad keeps its bindings in its own memory, so nothing here needs to keep
|
||||||
|
running afterwards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QGuiApplication, QPalette
|
||||||
|
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||||
|
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||||
|
QPushButton, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
from . import core, keymap
|
||||||
|
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||||
|
|
||||||
|
GOOD = "#8db36b"
|
||||||
|
BAD = "#e0584f"
|
||||||
|
LINE = "#3a3e42"
|
||||||
|
FIELD = "#121416"
|
||||||
|
|
||||||
|
# Media keys as pairs, so opposites sit side by side.
|
||||||
|
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||||
|
("volumedown", "volumeup"), ("mute", None),
|
||||||
|
("brightnessdown", "brightnessup"),
|
||||||
|
("calculator", "mail"), ("browser", "mycomputer")]
|
||||||
|
|
||||||
|
STYLE = f"""
|
||||||
|
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }}
|
||||||
|
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
|
||||||
|
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }}
|
||||||
|
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||||
|
QLabel[role="value"] {{ font-size: 15px; }}
|
||||||
|
QPushButton {{ background: transparent; border: 1px solid {LINE};
|
||||||
|
border-radius: 4px; padding: 6px 12px; }}
|
||||||
|
QPushButton:hover {{ border-color: {INK_DIM.name()}; }}
|
||||||
|
QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }}
|
||||||
|
QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }}
|
||||||
|
QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206;
|
||||||
|
border: none; font-weight: 600; padding: 8px 16px; }}
|
||||||
|
QPushButton[role="primary"]:hover {{ background: #f39a45; }}
|
||||||
|
QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }}
|
||||||
|
QPushButton[role="flat"] {{ border: none; color: {INK_DIM.name()}; padding: 6px 8px; }}
|
||||||
|
QPushButton[role="flat"]:hover {{ color: {INK.name()}; }}
|
||||||
|
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
|
||||||
|
QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||||
|
padding: 7px 8px; }}
|
||||||
|
QLineEdit:focus {{ border-color: {INK_DIM.name()}; }}
|
||||||
|
QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }}
|
||||||
|
QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }}
|
||||||
|
QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
|
||||||
|
border-radius: 4px; }}
|
||||||
|
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
|
||||||
|
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
|
||||||
|
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _label(text="", role=None, wrap=False):
|
||||||
|
lab = QLabel(text)
|
||||||
|
lab.setIndent(0)
|
||||||
|
if role:
|
||||||
|
lab.setProperty("role", role)
|
||||||
|
lab.setWordWrap(wrap)
|
||||||
|
return lab
|
||||||
|
|
||||||
|
|
||||||
|
def _button(text, role=None, checkable=False):
|
||||||
|
b = QPushButton(text)
|
||||||
|
if role:
|
||||||
|
b.setProperty("role", role)
|
||||||
|
b.setCheckable(checkable)
|
||||||
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ recorder
|
||||||
|
|
||||||
|
class RecorderEdit(QLineEdit):
|
||||||
|
"""
|
||||||
|
A text field that can also listen for a key combo. Type 'ctrl+shift+n',
|
||||||
|
or press Record and then press the combo itself.
|
||||||
|
"""
|
||||||
|
recorded = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.recording = False
|
||||||
|
self.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||||
|
|
||||||
|
def set_recording(self, on):
|
||||||
|
self.recording = on
|
||||||
|
self.setProperty("recording", "true" if on else "false")
|
||||||
|
self.style().unpolish(self)
|
||||||
|
self.style().polish(self)
|
||||||
|
if on:
|
||||||
|
self._before = self.text()
|
||||||
|
self.clear()
|
||||||
|
self.setPlaceholderText("Press the combo now")
|
||||||
|
self.setFocus()
|
||||||
|
else:
|
||||||
|
self.setPlaceholderText("ctrl+shift+n")
|
||||||
|
|
||||||
|
def event(self, e):
|
||||||
|
# Catch Tab and friends too, which would otherwise move focus.
|
||||||
|
if self.recording and e.type() == QEvent.KeyPress:
|
||||||
|
self._record(e)
|
||||||
|
return True
|
||||||
|
if self.recording and e.type() == QEvent.ShortcutOverride:
|
||||||
|
e.accept()
|
||||||
|
return True
|
||||||
|
return super().event(e)
|
||||||
|
|
||||||
|
def _record(self, e):
|
||||||
|
mods = keymap.modifier_names(e)
|
||||||
|
if e.key() in keymap.MODIFIER_KEYS:
|
||||||
|
self.setText("+".join(mods + ["…"]))
|
||||||
|
return
|
||||||
|
name = keymap.key_name(e)
|
||||||
|
if name is None:
|
||||||
|
self.setText("")
|
||||||
|
self.setPlaceholderText("The pad can't send that key. Try another.")
|
||||||
|
return
|
||||||
|
combo = "+".join(mods + [name])
|
||||||
|
self.set_recording(False)
|
||||||
|
self.setText(combo)
|
||||||
|
self.recorded.emit(combo)
|
||||||
|
|
||||||
|
def focusOutEvent(self, e):
|
||||||
|
if self.recording:
|
||||||
|
self.set_recording(False)
|
||||||
|
self.setText(self._before)
|
||||||
|
super().focusOutEvent(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ inspector
|
||||||
|
|
||||||
|
class Inspector(QWidget):
|
||||||
|
"""Right-hand panel: what the selected control does, and what to change it to."""
|
||||||
|
changed = Signal(str, object) # control, Binding or None (revert)
|
||||||
|
write = Signal(str)
|
||||||
|
revert = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setFixedWidth(360)
|
||||||
|
self.control = None
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
v.setContentsMargins(24, 24, 24, 16)
|
||||||
|
v.setSpacing(4)
|
||||||
|
|
||||||
|
# problem banner (no pad / no permission)
|
||||||
|
self.banner = QFrame()
|
||||||
|
self.banner.setProperty("role", "banner")
|
||||||
|
bl = QVBoxLayout(self.banner)
|
||||||
|
bl.setContentsMargins(12, 10, 12, 10)
|
||||||
|
self.banner_head = _label("", wrap=True)
|
||||||
|
self.banner_head.setStyleSheet("font-weight: 600;")
|
||||||
|
self.banner_text = _label("", "dim", wrap=True)
|
||||||
|
self.banner_fix = QPlainTextEdit()
|
||||||
|
self.banner_fix.setReadOnly(True)
|
||||||
|
self.banner_fix.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||||
|
self.banner_fix.setFixedHeight(84)
|
||||||
|
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||||
|
self.banner_copy = _button("Copy commands")
|
||||||
|
self.banner_copy.clicked.connect(self._copy)
|
||||||
|
for w in (self.banner_head, self.banner_text, self.banner_fix, self.banner_copy):
|
||||||
|
bl.addWidget(w)
|
||||||
|
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
|
||||||
|
self.banner_copy.sizePolicy().verticalPolicy())
|
||||||
|
v.addWidget(self.banner)
|
||||||
|
v.addSpacing(8)
|
||||||
|
|
||||||
|
# empty state
|
||||||
|
self.empty = _label("", "dim", wrap=True)
|
||||||
|
v.addWidget(self.empty)
|
||||||
|
|
||||||
|
# editor
|
||||||
|
self.editor = QWidget()
|
||||||
|
ev = QVBoxLayout(self.editor)
|
||||||
|
ev.setContentsMargins(0, 0, 0, 0)
|
||||||
|
ev.setSpacing(4)
|
||||||
|
self.title = _label("", "title")
|
||||||
|
self.sub = _label("", "dim")
|
||||||
|
ev.addWidget(self.title)
|
||||||
|
ev.addWidget(self.sub)
|
||||||
|
|
||||||
|
ev.addWidget(_label("On the pad now", "caption"))
|
||||||
|
self.onpad = _label("", "value", wrap=True)
|
||||||
|
self.onpad_why = _label("", "dim", wrap=True)
|
||||||
|
ev.addWidget(self.onpad)
|
||||||
|
ev.addWidget(self.onpad_why)
|
||||||
|
|
||||||
|
ev.addWidget(_label("Change it to", "caption"))
|
||||||
|
modes = QHBoxLayout()
|
||||||
|
modes.setSpacing(6)
|
||||||
|
self.mode_keys = _button("Shortcut", checkable=True)
|
||||||
|
self.mode_media = _button("Media key", checkable=True)
|
||||||
|
self.mode_group = QButtonGroup(self)
|
||||||
|
self.mode_group.setExclusive(True)
|
||||||
|
for i, b in enumerate((self.mode_keys, self.mode_media)):
|
||||||
|
self.mode_group.addButton(b, i)
|
||||||
|
modes.addWidget(b)
|
||||||
|
modes.addStretch()
|
||||||
|
ev.addLayout(modes)
|
||||||
|
ev.addSpacing(6)
|
||||||
|
|
||||||
|
# shortcut page
|
||||||
|
kp = QWidget()
|
||||||
|
kl = QVBoxLayout(kp)
|
||||||
|
kl.setContentsMargins(0, 0, 0, 0)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self.keys_edit = RecorderEdit()
|
||||||
|
self.keys_edit.setPlaceholderText("ctrl+shift+n")
|
||||||
|
self.record = _button("Record", checkable=True)
|
||||||
|
row.addWidget(self.keys_edit, 1)
|
||||||
|
row.addWidget(self.record)
|
||||||
|
kl.addLayout(row)
|
||||||
|
self.keys_hint = _label("", "dim", wrap=True)
|
||||||
|
kl.addWidget(self.keys_hint)
|
||||||
|
# media page
|
||||||
|
mp = QWidget()
|
||||||
|
ml = QGridLayout(mp)
|
||||||
|
ml.setContentsMargins(0, 0, 0, 0)
|
||||||
|
ml.setSpacing(6)
|
||||||
|
self.media_group = QButtonGroup(self)
|
||||||
|
self.media_group.setExclusive(True)
|
||||||
|
self.media_buttons = {}
|
||||||
|
for r, pair in enumerate(MEDIA_GRID):
|
||||||
|
for col, mid in enumerate(pair):
|
||||||
|
if mid is None:
|
||||||
|
continue
|
||||||
|
b = _button(core.MEDIA_LABEL[mid], "media", checkable=True)
|
||||||
|
self.media_group.addButton(b, [m for m, _ in core.MEDIA].index(mid))
|
||||||
|
self.media_buttons[mid] = b
|
||||||
|
ml.addWidget(b, r, col)
|
||||||
|
self.pages = (kp, mp)
|
||||||
|
ev.addWidget(kp)
|
||||||
|
ev.addWidget(mp)
|
||||||
|
|
||||||
|
ev.addSpacing(10)
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
self.write_btn = _button("Write to pad", "primary")
|
||||||
|
self.revert_btn = _button("Revert", "flat")
|
||||||
|
actions.addWidget(self.write_btn)
|
||||||
|
actions.addWidget(self.revert_btn)
|
||||||
|
actions.addStretch()
|
||||||
|
ev.addLayout(actions)
|
||||||
|
v.addWidget(self.editor)
|
||||||
|
v.addStretch()
|
||||||
|
|
||||||
|
self.mode_group.idClicked.connect(self._mode)
|
||||||
|
self.keys_edit.textEdited.connect(self._keys_typed)
|
||||||
|
self.keys_edit.recorded.connect(self._keys_typed)
|
||||||
|
self.record.toggled.connect(self._toggle_record)
|
||||||
|
self.media_group.idClicked.connect(self._media_picked)
|
||||||
|
self.write_btn.clicked.connect(lambda: self.write.emit(self.control))
|
||||||
|
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
|
||||||
|
|
||||||
|
self.show_control(None, None, None, False)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- banner
|
||||||
|
|
||||||
|
def show_problem(self, diag, note=None):
|
||||||
|
if diag is None or diag.ready:
|
||||||
|
if not note:
|
||||||
|
self.banner.hide()
|
||||||
|
return
|
||||||
|
diag = core.Diagnosis("note", "Heads up", detail=[note])
|
||||||
|
quiet = diag.status in ("unplugged", "note", "unsupported")
|
||||||
|
self.banner.setProperty("tone", "quiet" if quiet else "")
|
||||||
|
self.banner.style().unpolish(self.banner)
|
||||||
|
self.banner.style().polish(self.banner)
|
||||||
|
self.banner_head.setText(diag.headline)
|
||||||
|
self.banner_text.setText("\n\n".join(diag.detail))
|
||||||
|
cmds = [c for c in diag.fix if not c.startswith("#")]
|
||||||
|
self.banner_fix.setPlainText("\n".join(cmds))
|
||||||
|
lh = self.banner_fix.fontMetrics().lineSpacing()
|
||||||
|
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
|
||||||
|
self.banner_fix.setVisible(bool(cmds))
|
||||||
|
self.banner_copy.setVisible(bool(cmds))
|
||||||
|
if diag.fix and diag.fix[-1].startswith("#"):
|
||||||
|
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
|
||||||
|
self.banner.show()
|
||||||
|
|
||||||
|
def _copy(self):
|
||||||
|
QGuiApplication.clipboard().setText(self.banner_fix.toPlainText())
|
||||||
|
self.banner_copy.setText("Copied")
|
||||||
|
QTimer.singleShot(1500, lambda: self.banner_copy.setText("Copy commands"))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- editor
|
||||||
|
|
||||||
|
def show_control(self, control, entry, desired, can_write, all_unknown=False):
|
||||||
|
self._loading = True
|
||||||
|
self.control = control
|
||||||
|
if control is None:
|
||||||
|
self.editor.hide()
|
||||||
|
self.empty.setText(
|
||||||
|
"Click a key or dial to change what it does.\n\n"
|
||||||
|
+ ("The pad can't report what's on it, so this app only knows "
|
||||||
|
"what it has written itself. Hatched controls are unknown. "
|
||||||
|
"If you set the pad up with the vendor software, you can "
|
||||||
|
"import that capture instead." if all_unknown else ""))
|
||||||
|
self.empty.show()
|
||||||
|
self._loading = False
|
||||||
|
return
|
||||||
|
self.empty.hide()
|
||||||
|
self.editor.show()
|
||||||
|
self.title.setText(core.control_name(control))
|
||||||
|
self.sub.setText(f"Action byte 0x{core.action_byte(control):02x}")
|
||||||
|
|
||||||
|
if entry.unknown:
|
||||||
|
self.onpad.setText("Unknown")
|
||||||
|
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
|
||||||
|
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
|
||||||
|
self.onpad_why.show()
|
||||||
|
else:
|
||||||
|
self.onpad.setText(entry.binding.label())
|
||||||
|
self.onpad.setStyleSheet("")
|
||||||
|
self.onpad_why.hide()
|
||||||
|
|
||||||
|
shown = desired or (None if entry.unknown else entry.binding)
|
||||||
|
if shown is not None and shown.media:
|
||||||
|
self.mode_media.setChecked(True)
|
||||||
|
self._page(1)
|
||||||
|
self.media_buttons[shown.media].setChecked(True)
|
||||||
|
self.keys_edit.clear()
|
||||||
|
else:
|
||||||
|
self.mode_keys.setChecked(True)
|
||||||
|
self._page(0)
|
||||||
|
self.keys_edit.setText(shown.keys if shown else "")
|
||||||
|
self._clear_media()
|
||||||
|
self.keys_hint.setText("")
|
||||||
|
self.update_buttons(desired is not None, can_write)
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
def update_buttons(self, pending, can_write):
|
||||||
|
self.write_btn.setEnabled(can_write and pending)
|
||||||
|
self.revert_btn.setVisible(pending)
|
||||||
|
|
||||||
|
def _clear_media(self):
|
||||||
|
self.media_group.setExclusive(False)
|
||||||
|
for b in self.media_buttons.values():
|
||||||
|
b.setChecked(False)
|
||||||
|
self.media_group.setExclusive(True)
|
||||||
|
|
||||||
|
def _page(self, i):
|
||||||
|
# Plain show/hide rather than QStackedWidget, which always reserves
|
||||||
|
# the height of its tallest page.
|
||||||
|
for j, page in enumerate(self.pages):
|
||||||
|
page.setVisible(j == i)
|
||||||
|
|
||||||
|
def _mode(self, i):
|
||||||
|
self._page(i)
|
||||||
|
if i == 0 and self.record.isChecked():
|
||||||
|
self.keys_edit.setFocus()
|
||||||
|
|
||||||
|
def _toggle_record(self, on):
|
||||||
|
self.keys_edit.set_recording(on)
|
||||||
|
self.keys_hint.setText(
|
||||||
|
"Plasma keeps some combos for itself, like Meta shortcuts, so the "
|
||||||
|
"app never sees them. Type those instead." if on else "")
|
||||||
|
|
||||||
|
def _keys_typed(self, text):
|
||||||
|
if self.record.isChecked() and not self.keys_edit.recording:
|
||||||
|
self.record.setChecked(False)
|
||||||
|
if self._loading or self.control is None:
|
||||||
|
return
|
||||||
|
text = core.normalize_keys(text)
|
||||||
|
if not text:
|
||||||
|
self.keys_hint.setText("")
|
||||||
|
self.changed.emit(self.control, None)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
b = core.Binding(keys=text).validate()
|
||||||
|
except ValueError as e:
|
||||||
|
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
||||||
|
self.changed.emit(self.control, None)
|
||||||
|
return
|
||||||
|
if b.is_sequence and not core.SEQUENCES_VERIFIED:
|
||||||
|
self.keys_hint.setText(
|
||||||
|
f"<span style='color:{BAD}'>Sequences aren't verified on this "
|
||||||
|
"pad yet, so they're switched off for now. One shortcut per "
|
||||||
|
"control.</span>")
|
||||||
|
self.changed.emit(self.control, None)
|
||||||
|
return
|
||||||
|
self.keys_hint.setText(f"Sends {b.label()}")
|
||||||
|
self.changed.emit(self.control, b)
|
||||||
|
|
||||||
|
def _media_picked(self, i):
|
||||||
|
if self._loading or self.control is None:
|
||||||
|
return
|
||||||
|
self.changed.emit(self.control, core.Binding(media=core.MEDIA[i][0]))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ window
|
||||||
|
|
||||||
|
class Window(QMainWindow):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("MacroPad")
|
||||||
|
self.settings = QSettings("macropad", "macropad")
|
||||||
|
self.state = core.State()
|
||||||
|
self.desired = {} # control -> Binding, unwritten edits
|
||||||
|
self.diag = None
|
||||||
|
|
||||||
|
root = QWidget()
|
||||||
|
outer = QVBoxLayout(root)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
outer.setSpacing(0)
|
||||||
|
|
||||||
|
body = QHBoxLayout()
|
||||||
|
body.setSpacing(0)
|
||||||
|
self.pad = PadView()
|
||||||
|
self.pad.set_orientation(self.settings.value("orientation", "upright"))
|
||||||
|
self.inspector = Inspector()
|
||||||
|
body.addWidget(self.pad, 1)
|
||||||
|
body.addWidget(self.inspector)
|
||||||
|
outer.addLayout(body, 1)
|
||||||
|
|
||||||
|
footer = QFrame()
|
||||||
|
footer.setProperty("role", "footer")
|
||||||
|
fl = QHBoxLayout(footer)
|
||||||
|
fl.setContentsMargins(16, 8, 16, 8)
|
||||||
|
self.dot = QLabel()
|
||||||
|
self.dot.setFixedSize(9, 9)
|
||||||
|
self.status = _label("", "dim")
|
||||||
|
self.message = _label("")
|
||||||
|
self.rotate = _button("Rotate view", "flat")
|
||||||
|
self.import_btn = _button("Import capture…", "flat")
|
||||||
|
self.write_all = _button("", "primary")
|
||||||
|
fl.addWidget(self.dot)
|
||||||
|
fl.addWidget(self.status)
|
||||||
|
fl.addSpacing(16)
|
||||||
|
fl.addWidget(self.message, 1)
|
||||||
|
fl.addWidget(self.rotate)
|
||||||
|
fl.addWidget(self.import_btn)
|
||||||
|
fl.addWidget(self.write_all)
|
||||||
|
outer.addWidget(footer)
|
||||||
|
self.setCentralWidget(root)
|
||||||
|
|
||||||
|
self.pad.selected.connect(self._select)
|
||||||
|
self.inspector.changed.connect(self._changed)
|
||||||
|
self.inspector.revert.connect(self._revert)
|
||||||
|
self.inspector.write.connect(lambda c: self._write([c]))
|
||||||
|
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
||||||
|
self.rotate.clicked.connect(self._rotate)
|
||||||
|
self.import_btn.clicked.connect(self._import)
|
||||||
|
|
||||||
|
self.note = core._keyd_warning()
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.timeout.connect(self._poll)
|
||||||
|
self.timer.start(2000)
|
||||||
|
self._poll()
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- model
|
||||||
|
|
||||||
|
def _pending(self):
|
||||||
|
out = []
|
||||||
|
for c in core.CONTROL_IDS:
|
||||||
|
b = self.desired.get(c)
|
||||||
|
if b is None:
|
||||||
|
continue
|
||||||
|
e = self.state.get(c)
|
||||||
|
if e.unknown or e.binding != b:
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _refresh(self, editor=True):
|
||||||
|
pending = set(self._pending())
|
||||||
|
looks = {}
|
||||||
|
for c in core.CONTROL_IDS:
|
||||||
|
e = self.state.get(c)
|
||||||
|
if c in pending:
|
||||||
|
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
||||||
|
elif e.unknown:
|
||||||
|
looks[c] = Look("", known=False)
|
||||||
|
else:
|
||||||
|
looks[c] = Look(e.binding.label(), known=True)
|
||||||
|
self.pad.set_looks(looks)
|
||||||
|
|
||||||
|
n = len(pending)
|
||||||
|
self.write_all.setText(f"Write {n} change{'s' if n != 1 else ''}")
|
||||||
|
self.write_all.setVisible(n > 1)
|
||||||
|
self.write_all.setEnabled(bool(self.diag and self.diag.ready))
|
||||||
|
|
||||||
|
c = self.pad.current
|
||||||
|
can_write = bool(self.diag and self.diag.ready)
|
||||||
|
if not editor:
|
||||||
|
self.inspector.update_buttons(c in pending, can_write)
|
||||||
|
return
|
||||||
|
all_unknown = all(self.state.get(x).unknown for x in core.CONTROL_IDS)
|
||||||
|
self.inspector.show_control(
|
||||||
|
c, self.state.get(c) if c else None,
|
||||||
|
self.desired.get(c) if c in pending else None,
|
||||||
|
can_write, all_unknown)
|
||||||
|
|
||||||
|
def _select(self, control):
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _changed(self, control, binding):
|
||||||
|
if binding is None:
|
||||||
|
self.desired.pop(control, None)
|
||||||
|
else:
|
||||||
|
self.desired[control] = binding
|
||||||
|
self._refresh(editor=False)
|
||||||
|
|
||||||
|
def _revert(self, control):
|
||||||
|
self.desired.pop(control, None)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ device
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
old = self.diag.status if self.diag else None
|
||||||
|
try:
|
||||||
|
self.diag = core.diagnose(keyd=False)
|
||||||
|
except Exception as e: # never let polling kill the window
|
||||||
|
self._say(f"Couldn't check the pad: {e}", BAD)
|
||||||
|
return
|
||||||
|
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
||||||
|
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
||||||
|
self.status.setText({
|
||||||
|
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
|
||||||
|
"unplugged": "No pad connected",
|
||||||
|
"no-interface": "Pad found, config interface missing",
|
||||||
|
"no-permission": "No permission to write to the pad",
|
||||||
|
"unsupported": "Unsupported pad",
|
||||||
|
}[self.diag.status])
|
||||||
|
self.inspector.show_problem(self.diag, self.note)
|
||||||
|
if self.diag.status != old:
|
||||||
|
self._refresh(editor=False)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- write
|
||||||
|
|
||||||
|
def _write(self, controls):
|
||||||
|
controls = [c for c in controls if c in self.desired]
|
||||||
|
if not controls:
|
||||||
|
return
|
||||||
|
self._poll()
|
||||||
|
if not self.diag.ready:
|
||||||
|
self._say(self.diag.headline, BAD)
|
||||||
|
return
|
||||||
|
|
||||||
|
done = []
|
||||||
|
for c in controls:
|
||||||
|
b = self.desired[c]
|
||||||
|
try:
|
||||||
|
core.write_binding(self.diag.device, c, b, self.state)
|
||||||
|
except core.WriteError as e:
|
||||||
|
what = core.control_name(c)
|
||||||
|
tail = (f"{what} is now marked unknown, since part of it reached the pad."
|
||||||
|
if e.pad_touched else "Nothing reached the pad.")
|
||||||
|
why = str(e).split(": ", 1)[-1]
|
||||||
|
self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
|
||||||
|
self._refresh()
|
||||||
|
return
|
||||||
|
self.desired.pop(c, None)
|
||||||
|
done.append((c, b))
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
if len(done) == 1:
|
||||||
|
c, b = done[0]
|
||||||
|
self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD)
|
||||||
|
else:
|
||||||
|
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _say(self, text, colour=None):
|
||||||
|
self.message.setText(text)
|
||||||
|
self.message.setStyleSheet(f"color: {colour};" if colour else "")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- misc
|
||||||
|
|
||||||
|
def _rotate(self):
|
||||||
|
o = "flat" if self.pad.orientation == "upright" else "upright"
|
||||||
|
self.pad.set_orientation(o)
|
||||||
|
self.settings.setValue("orientation", o)
|
||||||
|
|
||||||
|
def _import(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Import a capture of the vendor software",
|
||||||
|
str(Path.cwd()), "Captures (*.txt);;All files (*)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
got = core.import_capture(path, self.state)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self._say(f"Couldn't read that capture: {e}", BAD)
|
||||||
|
return
|
||||||
|
if not got:
|
||||||
|
self._say("No saved bindings found in that capture.", BAD)
|
||||||
|
return
|
||||||
|
for c, _, _ in got:
|
||||||
|
self.desired.pop(c, None)
|
||||||
|
self._say(f"Imported {len(got)} bindings from {Path(path).name}.", GOOD)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def closeEvent(self, e):
|
||||||
|
n = len(self._pending())
|
||||||
|
if not n:
|
||||||
|
e.accept()
|
||||||
|
return
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("Unwritten changes")
|
||||||
|
box.setText(f"{n} change{'s are' if n != 1 else ' is'} not on the pad yet.")
|
||||||
|
write = box.addButton("Write and close", QMessageBox.AcceptRole)
|
||||||
|
discard = box.addButton("Close without writing", QMessageBox.DestructiveRole)
|
||||||
|
box.addButton("Cancel", QMessageBox.RejectRole)
|
||||||
|
box.exec()
|
||||||
|
if box.clickedButton() is write:
|
||||||
|
self._write(self._pending())
|
||||||
|
e.accept() if not self._pending() else e.ignore()
|
||||||
|
elif box.clickedButton() is discard:
|
||||||
|
e.accept()
|
||||||
|
else:
|
||||||
|
e.ignore()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_theme(app):
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
pal = QPalette()
|
||||||
|
for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)),
|
||||||
|
(QPalette.Text, INK), (QPalette.WindowText, INK),
|
||||||
|
(QPalette.Button, WINDOW), (QPalette.ButtonText, INK),
|
||||||
|
(QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))):
|
||||||
|
pal.setColor(role, colour)
|
||||||
|
app.setPalette(pal)
|
||||||
|
app.setStyleSheet(STYLE)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("macropad")
|
||||||
|
app.setDesktopFileName("macropad")
|
||||||
|
apply_theme(app)
|
||||||
|
w = Window()
|
||||||
|
w.resize(1000, 800)
|
||||||
|
w.show()
|
||||||
|
return app.exec()
|
||||||
626
macropad_gui/core.py
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
"""
|
||||||
|
Core of the macro pad GUI. No Qt in here: the GUI imports this, and so do
|
||||||
|
the tests, so everything that matters can be checked without a display.
|
||||||
|
|
||||||
|
The pad is write-only, which shapes the whole design. There are two kinds
|
||||||
|
of truth and they live in separate files:
|
||||||
|
|
||||||
|
profile what you *want* on the pad. You edit it; the GUI edits it.
|
||||||
|
state what this tool last wrote *successfully*. Only the writer
|
||||||
|
touches it. It is our shadow copy of the pad's memory.
|
||||||
|
|
||||||
|
A control we never wrote, or whose write failed partway, is UNKNOWN. The
|
||||||
|
pad could hold anything there, and the UI must say so rather than guess.
|
||||||
|
|
||||||
|
Every byte on the wire still comes from macropad.py. This module decides
|
||||||
|
what to send and remembers what was sent; it never builds a report itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import macropad as proto
|
||||||
|
except ImportError: # running from inside the package dir
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import macropad as proto
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ device
|
||||||
|
|
||||||
|
VID, PID = "1189", "8840"
|
||||||
|
DEVICE_ID = f"{VID}:{PID}"
|
||||||
|
|
||||||
|
KEYS = tuple(f"key{i}" for i in range(1, 13))
|
||||||
|
DIALS = ("dial1", "dial2")
|
||||||
|
DIAL_ACTIONS = ("left", "push", "right")
|
||||||
|
CONTROL_IDS = KEYS + tuple(f"{d}-{a}" for d in DIALS for a in DIAL_ACTIONS)
|
||||||
|
|
||||||
|
# Action bytes come from macropad.CONTROLS, never from here.
|
||||||
|
for _cid in CONTROL_IDS:
|
||||||
|
assert _cid in proto.CONTROLS, f"macropad.py has no control {_cid}"
|
||||||
|
|
||||||
|
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
|
||||||
|
|
||||||
|
|
||||||
|
def action_byte(control):
|
||||||
|
return proto.CONTROLS[control]
|
||||||
|
|
||||||
|
|
||||||
|
CONTROL_FOR_ACTION = {action_byte(c): c for c in CONTROL_IDS}
|
||||||
|
|
||||||
|
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
|
||||||
|
|
||||||
|
|
||||||
|
def control_name(control):
|
||||||
|
"""'key4' -> 'Key 4', 'dial1-left' -> 'Dial 1, turn left'."""
|
||||||
|
if control.startswith("key"):
|
||||||
|
return f"Key {control[3:]}"
|
||||||
|
dial, act = control.split("-")
|
||||||
|
return f"Dial {dial[4:]}, {_DIAL_WORDS[act]}"
|
||||||
|
|
||||||
|
|
||||||
|
# Media keys the GUI offers, in the order it offers them. Every id must be a
|
||||||
|
# name macropad.MEDIA_KEYS knows; the usage codes themselves live there.
|
||||||
|
MEDIA = [
|
||||||
|
("playpause", "Play / pause"), ("next", "Next track"),
|
||||||
|
("prev", "Previous track"), ("stop", "Stop"),
|
||||||
|
("volumeup", "Volume up"), ("volumedown", "Volume down"), ("mute", "Mute"),
|
||||||
|
("brightnessup", "Brightness up"), ("brightnessdown", "Brightness down"),
|
||||||
|
("calculator", "Calculator"), ("mail", "Email"),
|
||||||
|
("browser", "Browser"), ("mycomputer", "My computer"),
|
||||||
|
]
|
||||||
|
MEDIA_LABEL = dict(MEDIA)
|
||||||
|
for _m, _ in MEDIA:
|
||||||
|
assert _m in proto.MEDIA_KEYS, f"macropad.py has no media key {_m}"
|
||||||
|
_MEDIA_BY_USAGE = {}
|
||||||
|
for _m, _ in MEDIA:
|
||||||
|
_MEDIA_BY_USAGE.setdefault(proto.MEDIA_KEYS[_m], _m)
|
||||||
|
|
||||||
|
# Verified on 1189:8840 so far: single keys and shortcuts (capture + use),
|
||||||
|
# media keys (capture). Sequences and the delay field are upstream's layout
|
||||||
|
# and have not been seen on this pad - the GUI keeps them locked until a
|
||||||
|
# capture confirms them.
|
||||||
|
SEQUENCES_VERIFIED = False
|
||||||
|
|
||||||
|
_MOD_ORDER = [("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04), ("super", 0x08),
|
||||||
|
("rctrl", 0x10), ("rshift", 0x20), ("ralt", 0x40), ("rwin", 0x80)]
|
||||||
|
_MOD_LABEL = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "super": "Meta",
|
||||||
|
"win": "Meta", "lctrl": "Ctrl", "lshift": "Shift", "lalt": "Alt",
|
||||||
|
"lwin": "Meta", "rctrl": "Right Ctrl", "rshift": "Right Shift",
|
||||||
|
"ralt": "AltGr", "rwin": "Right Meta"}
|
||||||
|
_KEY_LABEL = {"pageup": "Page Up", "pagedown": "Page Down", "printscreen": "Print",
|
||||||
|
"scrolllock": "Scroll Lock", "capslock": "Caps Lock",
|
||||||
|
"backspace": "Backspace", "leftbracket": "[", "rightbracket": "]",
|
||||||
|
"backslash": "\\", "semicolon": ";", "quote": "'", "grave": "`",
|
||||||
|
"comma": ",", "period": ".", "slash": "/", "minus": "-", "equal": "=",
|
||||||
|
"esc": "Esc", "escape": "Esc", "return": "Enter"}
|
||||||
|
_KEYNAME_BY_CODE = {}
|
||||||
|
for _name, _code in proto.KEYCODES.items():
|
||||||
|
_KEYNAME_BY_CODE.setdefault(_code, _name)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_keys(text):
|
||||||
|
"""' Ctrl + C ,' -> 'ctrl+c'. Doesn't validate; Binding does that."""
|
||||||
|
alias = {"meta": "super"} # Plasma says Meta; macropad.py says super
|
||||||
|
steps = []
|
||||||
|
for chunk in text.lower().split(","):
|
||||||
|
parts = [alias.get(p.strip(), p.strip()) for p in chunk.split("+") if p.strip()]
|
||||||
|
if parts:
|
||||||
|
steps.append("+".join(parts))
|
||||||
|
return ",".join(steps)
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_step(step):
|
||||||
|
parts = []
|
||||||
|
for p in step.split("+"):
|
||||||
|
p = p.strip().lower()
|
||||||
|
if p in _MOD_LABEL:
|
||||||
|
parts.append(_MOD_LABEL[p])
|
||||||
|
else:
|
||||||
|
parts.append(_KEY_LABEL.get(p, p.upper() if len(p) <= 3 else p.title()))
|
||||||
|
return "+".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- binding
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Binding:
|
||||||
|
"""
|
||||||
|
What one control does. Stored in the CLI's own syntax, so every entry
|
||||||
|
in a profile is exactly a `macropad.py set <control> <binding>` command.
|
||||||
|
|
||||||
|
Binding(keys="ctrl+c")
|
||||||
|
Binding(keys="a,b,c", delay=50)
|
||||||
|
Binding(media="volumeup")
|
||||||
|
|
||||||
|
The kind is explicit rather than guessed from the string, because
|
||||||
|
'pause' is both a keyboard key and a media key.
|
||||||
|
"""
|
||||||
|
keys: str = ""
|
||||||
|
media: str = ""
|
||||||
|
delay: int = 0
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
if bool(self.keys) == bool(self.media):
|
||||||
|
raise ValueError("a binding is either keys or media, not both or neither")
|
||||||
|
if self.media and self.media not in proto.MEDIA_KEYS:
|
||||||
|
raise ValueError(f"unknown media key '{self.media}'")
|
||||||
|
if self.keys:
|
||||||
|
proto.parse_keystroke(self.keys) # raises ValueError
|
||||||
|
if not 0 <= self.delay <= 0xFFFF:
|
||||||
|
raise ValueError("delay must be 0-65535 ms")
|
||||||
|
if self.media and self.delay:
|
||||||
|
raise ValueError("delay only applies to key sequences")
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def steps(self):
|
||||||
|
return len(proto.parse_keystroke(self.keys)) if self.keys else 1
|
||||||
|
|
||||||
|
def reports(self, report_id, action, layer=1):
|
||||||
|
"""The exact reports to send, built entirely by macropad.py."""
|
||||||
|
self.validate()
|
||||||
|
if self.media:
|
||||||
|
return proto.native_media(report_id, action,
|
||||||
|
proto.MEDIA_KEYS[self.media], layer)
|
||||||
|
return proto.native_key(report_id, action,
|
||||||
|
proto.parse_keystroke(self.keys),
|
||||||
|
layer, self.delay)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_sequence(self):
|
||||||
|
return bool(self.keys) and self.steps > 1
|
||||||
|
|
||||||
|
def label(self):
|
||||||
|
"""What a person reads on the key: 'Ctrl+Shift+N', 'Volume up'."""
|
||||||
|
if self.media:
|
||||||
|
return MEDIA_LABEL.get(self.media, self.media)
|
||||||
|
text = ", ".join(pretty_step(s) for s in self.keys.split(",") if s.strip())
|
||||||
|
return text + (f" ({self.delay} ms)" if self.delay else "")
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
if self.media:
|
||||||
|
return {"media": self.media}
|
||||||
|
d = {"keys": self.keys}
|
||||||
|
if self.delay:
|
||||||
|
d["delay"] = self.delay
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, d):
|
||||||
|
return cls(keys=d.get("keys", ""), media=d.get("media", ""),
|
||||||
|
delay=int(d.get("delay", 0))).validate()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- files
|
||||||
|
|
||||||
|
def config_dir():
|
||||||
|
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||||
|
return Path(base) / "macropad"
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_json(path, data):
|
||||||
|
"""Write-then-rename, so a crash mid-save never leaves half a file."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, sort_keys=True)
|
||||||
|
f.write("\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Entry:
|
||||||
|
"""One control's line in the shadow state."""
|
||||||
|
binding: Binding = None # what we believe is on the pad
|
||||||
|
written: str = "" # ISO timestamp of the successful write
|
||||||
|
unknown: bool = True # True = pad contents not known
|
||||||
|
reason: str = "not written by this app yet"
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
"""
|
||||||
|
Shadow copy of the pad. Keyed by (layer, control). Only the writer
|
||||||
|
should call record()/mark_unknown(); the GUI just reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path=None):
|
||||||
|
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
||||||
|
self.entries = {} # (layer, control) -> Entry
|
||||||
|
if self.path.exists():
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
data = json.loads(self.path.read_text())
|
||||||
|
if data.get("format") != FORMAT:
|
||||||
|
raise ValueError(f"{self.path}: unsupported format {data.get('format')}")
|
||||||
|
for layer, controls in data.get("layers", {}).items():
|
||||||
|
for control, e in controls.items():
|
||||||
|
if e.get("unknown"):
|
||||||
|
entry = Entry(unknown=True, reason=e.get("reason", ""))
|
||||||
|
else:
|
||||||
|
entry = Entry(binding=Binding.from_json(e["binding"]),
|
||||||
|
written=e.get("written", ""), unknown=False,
|
||||||
|
reason="")
|
||||||
|
self.entries[(int(layer), control)] = entry
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
layers = {}
|
||||||
|
for (layer, control), e in sorted(self.entries.items()):
|
||||||
|
if e.unknown:
|
||||||
|
d = {"unknown": True, "reason": e.reason}
|
||||||
|
else:
|
||||||
|
d = {"binding": e.binding.to_json(), "written": e.written}
|
||||||
|
layers.setdefault(str(layer), {})[control] = d
|
||||||
|
_atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID,
|
||||||
|
"layers": layers})
|
||||||
|
|
||||||
|
def get(self, control, layer=1):
|
||||||
|
return self.entries.get((layer, control), Entry())
|
||||||
|
|
||||||
|
def record(self, control, binding, layer=1):
|
||||||
|
self.entries[(layer, control)] = Entry(
|
||||||
|
binding=binding, unknown=False, reason="",
|
||||||
|
written=datetime.now().isoformat(timespec="seconds"))
|
||||||
|
|
||||||
|
def mark_unknown(self, control, reason, layer=1):
|
||||||
|
self.entries[(layer, control)] = Entry(unknown=True, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- diagnosis
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device:
|
||||||
|
path: str
|
||||||
|
report_id: int
|
||||||
|
writable: bool
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Diagnosis:
|
||||||
|
status: str # ready | unplugged | no-interface | no-permission | unsupported
|
||||||
|
headline: str
|
||||||
|
device: Device = None
|
||||||
|
detail: list = field(default_factory=list)
|
||||||
|
fix: list = field(default_factory=list) # works in bash, zsh and fish
|
||||||
|
warnings: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self):
|
||||||
|
return self.status == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
|
||||||
|
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
|
||||||
|
SUGGESTED_RULE = "60-macropad.rules"
|
||||||
|
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
|
||||||
|
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"')
|
||||||
|
|
||||||
|
|
||||||
|
def _uaccess_cutoff():
|
||||||
|
"""
|
||||||
|
The rules file that turns a uaccess tag into an actual ACL. On systemd
|
||||||
|
this is 73-seat-late.rules. udev runs every rules file in one lexical
|
||||||
|
order by filename, so a rule that adds the tag must sort before this one
|
||||||
|
- 99-foo.rules is tagged but never granted, and so is an unnumbered
|
||||||
|
foo.rules, since letters sort after digits.
|
||||||
|
"""
|
||||||
|
for d in SYSTEM_RULES:
|
||||||
|
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
|
||||||
|
try:
|
||||||
|
if 'RUN{builtin}+="uaccess"' in open(f).read():
|
||||||
|
return os.path.basename(f)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return "73-seat-late.rules"
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_rules():
|
||||||
|
"""User rule files that mention our vendor id and uaccess."""
|
||||||
|
hits = []
|
||||||
|
for d in RULES_DIRS:
|
||||||
|
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
|
||||||
|
try:
|
||||||
|
text = open(f).read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.startswith("#"):
|
||||||
|
continue
|
||||||
|
if re.search(rf'idVendor\}}=="{VID}"', s, re.I) and "uaccess" in s:
|
||||||
|
hits.append(f)
|
||||||
|
break
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def _udev_tags(devpath):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["udevadm", "info", "--query=property",
|
||||||
|
f"--name={devpath}"],
|
||||||
|
capture_output=True, text=True, timeout=3).stdout
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
tags = set()
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith(("TAGS=", "CURRENT_TAGS=")):
|
||||||
|
tags |= {t for t in line.split("=", 1)[1].split(":") if t}
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _on_usb_bus():
|
||||||
|
for d in glob.glob("/sys/bus/usb/devices/*"):
|
||||||
|
try:
|
||||||
|
v = open(os.path.join(d, "idVendor")).read().strip().lower()
|
||||||
|
p = open(os.path.join(d, "idProduct")).read().strip().lower()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if (v, p) == (VID, PID):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _keyd_warning():
|
||||||
|
"""keyd sits between the pad and the desktop and can rewrite its output."""
|
||||||
|
if not shutil.which("keyd"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
active = subprocess.run(["systemctl", "is-active", "keyd"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
timeout=3).stdout.strip() == "active"
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if not active:
|
||||||
|
return None
|
||||||
|
for f in glob.glob("/etc/keyd/*.conf"):
|
||||||
|
try:
|
||||||
|
if DEVICE_ID in open(f).read().lower():
|
||||||
|
return (f"keyd, a key remapping service, is running and {f} "
|
||||||
|
"lists this pad. It can change keys on their way from "
|
||||||
|
"the pad to your desktop, so a key may not do exactly "
|
||||||
|
"what you set here. If you only set keyd up for this "
|
||||||
|
"pad, you can switch it off with: "
|
||||||
|
"sudo systemctl disable --now keyd")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose(keyd=True):
|
||||||
|
"""keyd=False skips the keyd check, which shells out - the GUI polls."""
|
||||||
|
warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
|
||||||
|
everything = proto.find_devices()
|
||||||
|
found = [d for d in everything if d["pid"] == PID]
|
||||||
|
|
||||||
|
if not found and everything:
|
||||||
|
other = everything[0]
|
||||||
|
return Diagnosis(
|
||||||
|
"unsupported",
|
||||||
|
f"Found a {other['vid']}:{other['pid']} pad, which this app doesn't know yet.",
|
||||||
|
detail=[f"So far only {DEVICE_ID} (12 keys, 2 dials) has been "
|
||||||
|
"worked out. Pads in this family differ in layout and "
|
||||||
|
"protocol, so writing to yours with the wrong one could "
|
||||||
|
"fail or scramble it. The app won't try.",
|
||||||
|
"You can help add it: the README's \"Other pads\" section "
|
||||||
|
"shows how to capture what the vendor software sends."],
|
||||||
|
warnings=warnings)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
if _on_usb_bus():
|
||||||
|
return Diagnosis(
|
||||||
|
"no-interface",
|
||||||
|
"Pad is plugged in, but its configuration interface is missing.",
|
||||||
|
detail=["The USB device is present, but no hidraw node with the "
|
||||||
|
"vendor-defined config interface was found.",
|
||||||
|
"Usually a replug fixes this. If not, run "
|
||||||
|
"macropad-probe.py and check the interface list."],
|
||||||
|
warnings=warnings)
|
||||||
|
return Diagnosis("unplugged", "No pad connected.",
|
||||||
|
detail=[f"Looking for {DEVICE_ID} on USB."],
|
||||||
|
warnings=warnings)
|
||||||
|
|
||||||
|
d = found[0]
|
||||||
|
dev = Device(path=d["path"], report_id=d["report_id"],
|
||||||
|
writable=d["writable"], name=d.get("name", ""))
|
||||||
|
|
||||||
|
if dev.writable:
|
||||||
|
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
|
||||||
|
detail=[f"report id {dev.report_id}"], warnings=warnings)
|
||||||
|
|
||||||
|
# Not writable: work out which of the three usual causes it is.
|
||||||
|
cutoff = _uaccess_cutoff()
|
||||||
|
rules = _matching_rules()
|
||||||
|
late = [r for r in rules if os.path.basename(r) >= cutoff]
|
||||||
|
early = [r for r in rules if os.path.basename(r) < cutoff]
|
||||||
|
tags = _udev_tags(dev.path)
|
||||||
|
tagged = bool(tags and "uaccess" in tags)
|
||||||
|
|
||||||
|
diag = Diagnosis("no-permission", f"{dev.path} isn't writable.",
|
||||||
|
device=dev, warnings=warnings)
|
||||||
|
|
||||||
|
if late and not early:
|
||||||
|
bad = late[0]
|
||||||
|
new = os.path.join(os.path.dirname(bad), SUGGESTED_RULE)
|
||||||
|
diag.detail = [
|
||||||
|
f"{bad} matches the pad, but it runs after {cutoff}, which is "
|
||||||
|
"where access is actually granted. The device gets tagged, "
|
||||||
|
"then nothing acts on the tag.",
|
||||||
|
"Rename it so it sorts earlier."]
|
||||||
|
diag.fix = [f"sudo mv {bad} {new}",
|
||||||
|
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||||
|
"# then unplug and replug the pad"]
|
||||||
|
elif early and not tagged:
|
||||||
|
diag.detail = [f"{early[0]} looks right, but the device hasn't picked "
|
||||||
|
"it up yet."]
|
||||||
|
diag.fix = ["sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||||
|
"# then unplug and replug the pad"]
|
||||||
|
elif early and tagged:
|
||||||
|
diag.detail = [
|
||||||
|
"The rule matched and the device is tagged, but no access was "
|
||||||
|
"granted. That happens when you aren't in an active local desktop "
|
||||||
|
"session - for example over SSH, or in a shell started with sudo."]
|
||||||
|
else:
|
||||||
|
diag.detail = ["No udev rule lets you write to the pad yet. Running "
|
||||||
|
"the installer again adds it, or paste these commands "
|
||||||
|
"into a terminal:"]
|
||||||
|
diag.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
|
||||||
|
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||||
|
"# then unplug and replug the pad"]
|
||||||
|
return diag
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ writer
|
||||||
|
|
||||||
|
class WriteError(Exception):
|
||||||
|
"""
|
||||||
|
pad_touched is the thing the UI cares about: False means the pad is
|
||||||
|
exactly as it was; True means some reports landed and the control's
|
||||||
|
contents are now unknown.
|
||||||
|
"""
|
||||||
|
def __init__(self, message, pad_touched, cause=None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.pad_touched = pad_touched
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
|
def _explain(err):
|
||||||
|
return {
|
||||||
|
errno.ENOENT: "the pad was unplugged",
|
||||||
|
errno.ENODEV: "the pad was unplugged",
|
||||||
|
errno.EACCES: "no permission to write to the pad",
|
||||||
|
errno.EPERM: "no permission to write to the pad",
|
||||||
|
errno.EPIPE: "the pad rejected the report",
|
||||||
|
errno.EIO: "the pad stopped responding (it may have reset)",
|
||||||
|
errno.ETIMEDOUT: "the pad didn't accept the report in time",
|
||||||
|
}.get(err.errno, err.strerror or str(err))
|
||||||
|
|
||||||
|
|
||||||
|
def write_binding(device, control, binding, state, layer=1, _write=None):
|
||||||
|
"""
|
||||||
|
Send one binding (config report + commit) and update the shadow state.
|
||||||
|
Raises WriteError; on success the state file is already saved.
|
||||||
|
"""
|
||||||
|
write = _write or proto.write_report
|
||||||
|
reports = binding.reports(device.report_id, action_byte(control), layer)
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
try:
|
||||||
|
for r in reports:
|
||||||
|
write(device.path, r)
|
||||||
|
sent += 1
|
||||||
|
time.sleep(WRITE_GAP)
|
||||||
|
except OSError as e:
|
||||||
|
why = _explain(e)
|
||||||
|
# If even the first write failed on open, nothing reached the pad.
|
||||||
|
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
|
||||||
|
errno.EACCES, errno.EPERM)
|
||||||
|
if not untouched:
|
||||||
|
state.mark_unknown(control, f"write interrupted after {sent} of "
|
||||||
|
f"{len(reports)} reports: {why}", layer)
|
||||||
|
state.save()
|
||||||
|
raise WriteError(f"{control}: {why}", pad_touched=not untouched, cause=e)
|
||||||
|
|
||||||
|
state.record(control, binding, layer)
|
||||||
|
state.save()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- decoding
|
||||||
|
|
||||||
|
def decode_config(report):
|
||||||
|
"""
|
||||||
|
The inverse of Binding.reports()[0]: (control, layer, Binding) from a
|
||||||
|
native config report, or None if it isn't one we understand. Used to
|
||||||
|
import captures and to prove round trips in the tests.
|
||||||
|
"""
|
||||||
|
if len(report) < 13 or report[1] != proto.MAGIC_NATIVE:
|
||||||
|
return None
|
||||||
|
action, layer, kind = report[2], report[3], report[4]
|
||||||
|
control = CONTROL_FOR_ACTION.get(action)
|
||||||
|
if control is None:
|
||||||
|
return None
|
||||||
|
delay = report[5] | (report[6] << 8)
|
||||||
|
count = report[10]
|
||||||
|
|
||||||
|
if kind == proto.KeyType.MULTIMEDIA:
|
||||||
|
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
||||||
|
return (control, layer, Binding(media=media)) if media else None
|
||||||
|
|
||||||
|
if kind == proto.KeyType.BASIC and count:
|
||||||
|
steps = []
|
||||||
|
for i in range(count):
|
||||||
|
off = 11 + 2 * i
|
||||||
|
if off + 1 >= len(report):
|
||||||
|
return None
|
||||||
|
mods, code = report[off], report[off + 1]
|
||||||
|
names = [n for n, bit in _MOD_ORDER if mods & bit]
|
||||||
|
if code:
|
||||||
|
if code not in _KEYNAME_BY_CODE:
|
||||||
|
return None
|
||||||
|
names.append(_KEYNAME_BY_CODE[code])
|
||||||
|
steps.append("+".join(names))
|
||||||
|
return control, layer, Binding(keys=",".join(steps), delay=delay)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_commit(report):
|
||||||
|
return len(report) >= 4 and report[1] == proto.MAGIC_NATIVE and \
|
||||||
|
(report[2], report[3]) == proto.TERMINATOR
|
||||||
|
|
||||||
|
|
||||||
|
def read_capture(path):
|
||||||
|
"""Raw report bytes from a macropad-capture.py output file, in order."""
|
||||||
|
out = []
|
||||||
|
for line in Path(path).read_text().splitlines():
|
||||||
|
if line.startswith(" "):
|
||||||
|
try:
|
||||||
|
out.append(bytes.fromhex(line.strip()))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_capture(path, state):
|
||||||
|
"""
|
||||||
|
Record what a vendor-software session wrote, so the GUI knows what's on
|
||||||
|
the pad. Only bindings followed by a commit count - the vendor app can
|
||||||
|
send many configs and save once. Returns [(control, layer, Binding)].
|
||||||
|
"""
|
||||||
|
pending, done = [], []
|
||||||
|
for report in read_capture(path):
|
||||||
|
if is_commit(report):
|
||||||
|
done.extend(pending)
|
||||||
|
pending = []
|
||||||
|
continue
|
||||||
|
decoded = decode_config(report)
|
||||||
|
if decoded:
|
||||||
|
pending = [p for p in pending if p[:2] != decoded[:2]] + [decoded]
|
||||||
|
for control, layer, binding in done:
|
||||||
|
state.record(control, binding, layer)
|
||||||
|
if done:
|
||||||
|
state.save()
|
||||||
|
return done
|
||||||
69
macropad_gui/keymap.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Turn a key press in the GUI into a name macropad.py understands.
|
||||||
|
|
||||||
|
The pad sends HID usage codes, which identify a physical key position, not
|
||||||
|
a character - your keyboard layout decides what that position types. So the
|
||||||
|
recorder uses the physical key too: on Linux (X11 and Wayland) Qt reports
|
||||||
|
the xkb keycode, which is the evdev code + 8. That keeps 'record' correct on
|
||||||
|
UK, German, AZERTY and so on. Letters and digits fall back to Qt's key
|
||||||
|
value if the scancode is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
import macropad as proto
|
||||||
|
|
||||||
|
# evdev KEY_* code -> macropad.KEYCODES name. Only keys macropad.py can send.
|
||||||
|
EVDEV = {
|
||||||
|
1: "esc", 14: "backspace", 15: "tab", 28: "enter", 57: "space",
|
||||||
|
12: "minus", 13: "equal", 26: "leftbracket", 27: "rightbracket",
|
||||||
|
43: "backslash", 39: "semicolon", 40: "quote", 41: "grave",
|
||||||
|
51: "comma", 52: "period", 53: "slash", 58: "capslock",
|
||||||
|
99: "printscreen", 70: "scrolllock", 119: "pause",
|
||||||
|
110: "insert", 102: "home", 104: "pageup", 111: "delete", 107: "end",
|
||||||
|
109: "pagedown", 106: "right", 105: "left", 108: "down", 103: "up",
|
||||||
|
87: "f11", 88: "f12",
|
||||||
|
}
|
||||||
|
for _i, _c in enumerate("1234567890"):
|
||||||
|
EVDEV[2 + _i] = _c
|
||||||
|
for _row, _start in (("qwertyuiop", 16), ("asdfghjkl", 30), ("zxcvbnm", 44)):
|
||||||
|
for _i, _c in enumerate(_row):
|
||||||
|
EVDEV[_start + _i] = _c
|
||||||
|
for _i in range(1, 11):
|
||||||
|
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68
|
||||||
|
|
||||||
|
for _name in EVDEV.values():
|
||||||
|
assert _name in proto.KEYCODES, _name
|
||||||
|
|
||||||
|
MODIFIER_KEYS = {Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_Meta,
|
||||||
|
Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_AltGr}
|
||||||
|
|
||||||
|
_QT_FALLBACK = {}
|
||||||
|
for _c in "abcdefghijklmnopqrstuvwxyz":
|
||||||
|
_QT_FALLBACK[getattr(Qt, f"Key_{_c.upper()}")] = _c
|
||||||
|
for _c in "0123456789":
|
||||||
|
_QT_FALLBACK[getattr(Qt, f"Key_{_c}")] = _c
|
||||||
|
for _i in range(1, 13):
|
||||||
|
_QT_FALLBACK[getattr(Qt, f"Key_F{_i}")] = f"f{_i}"
|
||||||
|
|
||||||
|
|
||||||
|
def key_name(event):
|
||||||
|
"""HID key name for a QKeyEvent, or None if the pad can't send it."""
|
||||||
|
code = event.nativeScanCode() - 8
|
||||||
|
if code in EVDEV:
|
||||||
|
return EVDEV[code]
|
||||||
|
return _QT_FALLBACK.get(Qt.Key(event.key()))
|
||||||
|
|
||||||
|
|
||||||
|
def modifier_names(event):
|
||||||
|
m = event.modifiers()
|
||||||
|
out = []
|
||||||
|
if m & Qt.ControlModifier:
|
||||||
|
out.append("ctrl")
|
||||||
|
if m & Qt.ShiftModifier:
|
||||||
|
out.append("shift")
|
||||||
|
if m & Qt.AltModifier:
|
||||||
|
out.append("alt")
|
||||||
|
if m & Qt.MetaModifier:
|
||||||
|
out.append("super")
|
||||||
|
return out
|
||||||
398
macropad_gui/padview.py
Normal file
@@ -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 + "…"
|
||||||
3
packaging/60-macropad.rules
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# MacroPad: let the logged-in user configure 1189:8840 macro pads.
|
||||||
|
# Must sort before 73-seat-late.rules, which is where uaccess takes effect.
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess"
|
||||||
15
packaging/macropad.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<!-- The pad standing upright: two dials, keys, and one key taped orange. -->
|
||||||
|
<rect x="11" y="3" width="42" height="58" rx="8" fill="#202326" stroke="#d8d4cb" stroke-width="2.5"/>
|
||||||
|
<circle cx="23" cy="16" r="6" fill="none" stroke="#d8d4cb" stroke-width="2.5"/>
|
||||||
|
<circle cx="41" cy="16" r="6" fill="none" stroke="#d8d4cb" stroke-width="2.5"/>
|
||||||
|
<g fill="none" stroke="#d8d4cb" stroke-width="2">
|
||||||
|
<rect x="16.5" y="28" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="28" y="28" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="16.5" y="39.5" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="39.5" y="39.5" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="28" y="39.5" width="8" height="8" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
<rect x="39.5" y="28" width="8" height="8" rx="1.5" fill="#eb8a2f"/>
|
||||||
|
<rect x="16.5" y="51" width="31" height="3" rx="1.5" fill="#72767b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
0
tests/__init__.py
Normal file
27
tests/captures/1189-8840-dials.txt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
|
||||||
|
# bus 1 device 11
|
||||||
|
|
||||||
|
[1] interrupt out ep=4
|
||||||
|
03fd100101000000000001000500000000000000000000000000000000000000
|
||||||
|
[2] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[3] interrupt out ep=4
|
||||||
|
03fd110101000000000001000500000000000000000000000000000000000000
|
||||||
|
[4] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[5] interrupt out ep=4
|
||||||
|
03fd120101000000000001000600000000000000000000000000000000000000
|
||||||
|
[6] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[7] interrupt out ep=4
|
||||||
|
03fd130101000000000001002300000000000000000000000000000000000000
|
||||||
|
[8] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[9] interrupt out ep=4
|
||||||
|
03fd140101000000000001002400000000000000000000000000000000000000
|
||||||
|
[10] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[11] interrupt out ep=4
|
||||||
|
03fd150101000000000001002500000000000000000000000000000000000000
|
||||||
|
[12] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
39
tests/captures/1189-8840-media-keys.txt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
|
||||||
|
# keys 1-12 set to media keys in the vendor app, saved once (one commit),
|
||||||
|
# then dial 1 set to 1 / 2 / 3 on the slots labelled anticlockwise / press / clockwise
|
||||||
|
# bus 1 device 11
|
||||||
|
|
||||||
|
[1] interrupt out ep=4
|
||||||
|
03fd010102000000000002b50000000000000000000000000000000000000000
|
||||||
|
[2] interrupt out ep=4
|
||||||
|
03fd020102000000000002b70000000000000000000000000000000000000000
|
||||||
|
[3] interrupt out ep=4
|
||||||
|
03fd030102000000000002b60000000000000000000000000000000000000000
|
||||||
|
[4] interrupt out ep=4
|
||||||
|
03fd040102000000000002cd0000000000000000000000000000000000000000
|
||||||
|
[5] interrupt out ep=4
|
||||||
|
03fd050102000000000002ea0000000000000000000000000000000000000000
|
||||||
|
[6] interrupt out ep=4
|
||||||
|
03fd060102000000000002e90000000000000000000000000000000000000000
|
||||||
|
[7] interrupt out ep=4
|
||||||
|
03fd070102000000000002e20000000000000000000000000000000000000000
|
||||||
|
[8] interrupt out ep=4
|
||||||
|
03fd080102000000000002920100000000000000000000000000000000000000
|
||||||
|
[9] interrupt out ep=4
|
||||||
|
03fd0901020000000000028a0100000000000000000000000000000000000000
|
||||||
|
[10] interrupt out ep=4
|
||||||
|
03fd0a0102000000000002940100000000000000000000000000000000000000
|
||||||
|
[11] interrupt out ep=4
|
||||||
|
03fd0b0102000000000002700000000000000000000000000000000000000000
|
||||||
|
[12] interrupt out ep=4
|
||||||
|
03fd0c01020000000000026f0000000000000000000000000000000000000000
|
||||||
|
[13] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
|
[14] interrupt out ep=4
|
||||||
|
03fd100101000000000001001e00000000000000000000000000000000000000
|
||||||
|
[15] interrupt out ep=4
|
||||||
|
03fd110101000000000001001f00000000000000000000000000000000000000
|
||||||
|
[16] interrupt out ep=4
|
||||||
|
03fd120101000000000001002000000000000000000000000000000000000000
|
||||||
|
[17] interrupt out ep=4
|
||||||
|
03fdfeff00000000000000000000000000000000000000000000000000000000
|
||||||
297
tests/test_core.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Run from the repo root: python3 -m unittest -v
|
||||||
|
|
||||||
|
The first test class is the important one. It replays the packets the
|
||||||
|
vendor's own software sent (captured over usbmon) and checks that the
|
||||||
|
path the GUI uses - Binding -> macropad.py - produces the same bytes.
|
||||||
|
If anyone edits the protocol layer and breaks it, this fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from macropad_gui import core
|
||||||
|
import macropad as proto
|
||||||
|
|
||||||
|
CAPTURES = Path(__file__).parent / "captures"
|
||||||
|
|
||||||
|
|
||||||
|
def load_capture(path):
|
||||||
|
"""[(label, bytes)] from a macropad-capture.py output file."""
|
||||||
|
out, label = [], None
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
m = re.match(r"\[(\d+)\] (.*)", line)
|
||||||
|
if m:
|
||||||
|
label = m.group(0)
|
||||||
|
elif line.startswith(" ") and label:
|
||||||
|
out.append((label, bytes.fromhex(line.strip())))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
KEYNAME = {}
|
||||||
|
for _name, _code in proto.KEYCODES.items():
|
||||||
|
KEYNAME.setdefault(_code, _name)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureReplay(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_dial_capture_reproduced_exactly(self):
|
||||||
|
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
|
||||||
|
self.assertEqual(len(packets), 12)
|
||||||
|
|
||||||
|
for i in range(0, len(packets), 2):
|
||||||
|
(_, config), (_, commit) = packets[i], packets[i + 1]
|
||||||
|
# usbmon's text interface stops at 32 bytes; the rest is unseen.
|
||||||
|
self.assertEqual(len(config), 32)
|
||||||
|
|
||||||
|
action, layer, mods, code = config[2], config[3], config[11], config[12]
|
||||||
|
self.assertEqual(mods, 0)
|
||||||
|
binding = core.Binding(keys=KEYNAME[code])
|
||||||
|
ours = binding.reports(3, action, layer)
|
||||||
|
|
||||||
|
self.assertEqual(len(ours), 2, "config + commit")
|
||||||
|
self.assertEqual(ours[0][:32], config, f"config for action 0x{action:02x}")
|
||||||
|
self.assertEqual(ours[1][:32], commit, "commit")
|
||||||
|
self.assertEqual(len(ours[0]), 65)
|
||||||
|
self.assertFalse(any(ours[0][32:]), "bytes past the capture are zero")
|
||||||
|
|
||||||
|
def test_dial_action_order_matches_capture(self):
|
||||||
|
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
|
||||||
|
captured = [p[2] for _, p in packets[0::2]]
|
||||||
|
ours = [core.action_byte(c) for c in core.CONTROL_IDS if c.startswith("dial")]
|
||||||
|
self.assertEqual(ours, captured)
|
||||||
|
|
||||||
|
def test_keys_are_1_to_12(self):
|
||||||
|
self.assertEqual([core.action_byte(k) for k in core.KEYS], list(range(1, 13)))
|
||||||
|
|
||||||
|
|
||||||
|
class EveryCapture(unittest.TestCase):
|
||||||
|
"""Every config packet in every capture must decode and re-encode exactly."""
|
||||||
|
|
||||||
|
def test_round_trip_all_captures(self):
|
||||||
|
seen = 0
|
||||||
|
for path in sorted(CAPTURES.glob("*.txt")):
|
||||||
|
for report in core.read_capture(path):
|
||||||
|
if core.is_commit(report):
|
||||||
|
self.assertEqual(proto.native_commit(3)[:len(report)], report)
|
||||||
|
continue
|
||||||
|
decoded = core.decode_config(report)
|
||||||
|
self.assertIsNotNone(decoded, f"{path.name}: {report.hex()}")
|
||||||
|
control, layer, binding = decoded
|
||||||
|
ours = binding.reports(3, core.action_byte(control), layer)[0]
|
||||||
|
self.assertEqual(ours[:len(report)], report,
|
||||||
|
f"{path.name} {control}: ours {ours[:16].hex()}")
|
||||||
|
seen += 1
|
||||||
|
self.assertGreaterEqual(seen, 21)
|
||||||
|
|
||||||
|
def test_media_keys_capture(self):
|
||||||
|
"""Vendor app: keys 1-12 set to media keys, saved with one commit."""
|
||||||
|
want = ["next", "stop", "prev", "playpause", "volumedown", "volumeup",
|
||||||
|
"mute", "calculator", "mail", "mycomputer",
|
||||||
|
"brightnessdown", "brightnessup"]
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
state = core.State(Path(d) / "s.json")
|
||||||
|
got = core.import_capture(CAPTURES / "1189-8840-media-keys.txt", state)
|
||||||
|
self.assertEqual([c for c, _, _ in got][:12], list(core.KEYS))
|
||||||
|
self.assertEqual([b.media for _, _, b in got][:12], want)
|
||||||
|
# dial 1, slots labelled anticlockwise / press / clockwise -> 1 / 2 / 3
|
||||||
|
self.assertEqual(state.get("dial1-left").binding, core.Binding(keys="1"))
|
||||||
|
self.assertEqual(state.get("dial1-push").binding, core.Binding(keys="2"))
|
||||||
|
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
|
||||||
|
self.assertTrue(state.get("dial2-left").unknown)
|
||||||
|
|
||||||
|
def test_uncommitted_configs_are_not_imported(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
cap = Path(d) / "c.txt"
|
||||||
|
cap.write_text("[1] interrupt out ep=4\n"
|
||||||
|
" 03fd010101000000000001000400\n")
|
||||||
|
state = core.State(Path(d) / "s.json")
|
||||||
|
self.assertEqual(core.import_capture(cap, state), [])
|
||||||
|
self.assertTrue(state.get("key1").unknown)
|
||||||
|
|
||||||
|
|
||||||
|
class BindingModel(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
for b in (core.Binding(keys="ctrl+shift+n"),
|
||||||
|
core.Binding(keys="a,b,c", delay=250),
|
||||||
|
core.Binding(media="volumeup")):
|
||||||
|
self.assertEqual(core.Binding.from_json(json.loads(json.dumps(b.to_json()))), b)
|
||||||
|
|
||||||
|
def test_rejects_nonsense(self):
|
||||||
|
bad = [core.Binding(), core.Binding(keys="a", media="mute"),
|
||||||
|
core.Binding(media="nope"), core.Binding(keys="ctrl+wat"),
|
||||||
|
core.Binding(media="mute", delay=10), core.Binding(keys="a", delay=-1)]
|
||||||
|
for b in bad:
|
||||||
|
with self.assertRaises(ValueError, msg=repr(b)):
|
||||||
|
b.validate()
|
||||||
|
|
||||||
|
def test_normalize(self):
|
||||||
|
self.assertEqual(core.normalize_keys(" Ctrl + C ,"), "ctrl+c")
|
||||||
|
self.assertEqual(core.normalize_keys("a, b"), "a,b")
|
||||||
|
self.assertEqual(core.normalize_keys("Meta+Ctrl+Left"), "super+ctrl+left")
|
||||||
|
|
||||||
|
def test_labels(self):
|
||||||
|
self.assertEqual(core.Binding(keys="ctrl+shift+n").label(), "Ctrl+Shift+N")
|
||||||
|
self.assertEqual(core.Binding(keys="super+pageup").label(), "Meta+Page Up")
|
||||||
|
self.assertEqual(core.Binding(media="mycomputer").label(), "My computer")
|
||||||
|
|
||||||
|
def test_pause_is_unambiguous(self):
|
||||||
|
key = core.Binding(keys="pause").reports(3, 1)[0]
|
||||||
|
media = core.Binding(media="pause").reports(3, 1)[0]
|
||||||
|
self.assertEqual(key[4], proto.KeyType.BASIC)
|
||||||
|
self.assertEqual(media[4], proto.KeyType.MULTIMEDIA)
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowState(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.path = Path(self.tmp.name) / "state.json"
|
||||||
|
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_everything_starts_unknown(self):
|
||||||
|
s = core.State(self.path)
|
||||||
|
self.assertTrue(all(s.get(c).unknown for c in core.CONTROL_IDS))
|
||||||
|
|
||||||
|
def test_successful_write_is_recorded_and_persisted(self):
|
||||||
|
sent = []
|
||||||
|
s = core.State(self.path)
|
||||||
|
b = core.Binding(keys="ctrl+c")
|
||||||
|
core.write_binding(self.dev, "key4", b, s, _write=lambda p, r: sent.append(r))
|
||||||
|
|
||||||
|
self.assertEqual([r[:4] for r in sent], [b"\x03\xfd\x04\x01", b"\x03\xfd\xfe\xff"])
|
||||||
|
again = core.State(self.path)
|
||||||
|
self.assertFalse(again.get("key4").unknown)
|
||||||
|
self.assertEqual(again.get("key4").binding, b)
|
||||||
|
self.assertEqual([f for f in os.listdir(self.tmp.name) if f.endswith(".tmp")], [])
|
||||||
|
|
||||||
|
def test_failed_commit_leaves_control_unknown(self):
|
||||||
|
s = core.State(self.path)
|
||||||
|
s.record("key1", core.Binding(keys="a"))
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def flaky(path, report):
|
||||||
|
calls.append(report)
|
||||||
|
if len(calls) == 2:
|
||||||
|
raise OSError(errno.EIO, "I/O error")
|
||||||
|
|
||||||
|
with self.assertRaises(core.WriteError) as ctx:
|
||||||
|
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=flaky)
|
||||||
|
self.assertTrue(ctx.exception.pad_touched)
|
||||||
|
e = core.State(self.path).get("key1")
|
||||||
|
self.assertTrue(e.unknown)
|
||||||
|
self.assertIn("1 of 2", e.reason)
|
||||||
|
|
||||||
|
def test_unplugged_before_first_write_changes_nothing(self):
|
||||||
|
s = core.State(self.path)
|
||||||
|
s.record("key1", core.Binding(keys="a"))
|
||||||
|
s.save()
|
||||||
|
|
||||||
|
def gone(path, report):
|
||||||
|
raise FileNotFoundError(errno.ENOENT, "No such file")
|
||||||
|
|
||||||
|
with self.assertRaises(core.WriteError) as ctx:
|
||||||
|
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=gone)
|
||||||
|
self.assertFalse(ctx.exception.pad_touched)
|
||||||
|
self.assertEqual(core.State(self.path).get("key1").binding, core.Binding(keys="a"))
|
||||||
|
|
||||||
|
|
||||||
|
class UdevOrdering(unittest.TestCase):
|
||||||
|
"""udev sorts rule files by name; the uaccess tag must land before the cutoff."""
|
||||||
|
|
||||||
|
def test_ordering(self):
|
||||||
|
cutoff = "73-seat-late.rules"
|
||||||
|
self.assertLess("60-macropad.rules", cutoff)
|
||||||
|
self.assertLess("70-macropad.rules", cutoff)
|
||||||
|
self.assertGreaterEqual("99-macropad.rules", cutoff)
|
||||||
|
self.assertGreaterEqual("macropad.rules", cutoff) # letters sort after digits
|
||||||
|
|
||||||
|
def test_diagnose_without_hardware_does_not_crash(self):
|
||||||
|
d = core.diagnose()
|
||||||
|
self.assertIn(d.status, {"ready", "unplugged", "no-interface", "no-permission"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDiagnosis(unittest.TestCase):
|
||||||
|
"""The three ways a pad ends up unwritable, with the system faked out."""
|
||||||
|
|
||||||
|
def diag(self, rules, tags):
|
||||||
|
from unittest import mock
|
||||||
|
dev = [{"path": "/dev/hidraw10", "pid": "8840", "report_id": 3,
|
||||||
|
"writable": False, "name": "USB Composite Device"}]
|
||||||
|
with mock.patch.object(proto, "find_devices", return_value=dev), \
|
||||||
|
mock.patch.object(core, "_matching_rules", return_value=rules), \
|
||||||
|
mock.patch.object(core, "_udev_tags", return_value=tags), \
|
||||||
|
mock.patch.object(core, "_uaccess_cutoff", return_value="73-seat-late.rules"), \
|
||||||
|
mock.patch.object(core, "_keyd_warning", return_value=None):
|
||||||
|
return core.diagnose()
|
||||||
|
|
||||||
|
def test_rule_numbered_too_late(self):
|
||||||
|
d = self.diag(["/etc/udev/rules.d/99-macropad.rules"], {"uaccess", "seat"})
|
||||||
|
self.assertEqual(d.status, "no-permission")
|
||||||
|
self.assertIn("sudo mv /etc/udev/rules.d/99-macropad.rules "
|
||||||
|
"/etc/udev/rules.d/60-macropad.rules", d.fix)
|
||||||
|
|
||||||
|
def test_rule_not_loaded_yet(self):
|
||||||
|
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], set())
|
||||||
|
self.assertTrue(any("reload-rules" in c for c in d.fix))
|
||||||
|
self.assertFalse(any(c.startswith("sudo mv") for c in d.fix))
|
||||||
|
|
||||||
|
def test_no_rule(self):
|
||||||
|
d = self.diag([], set())
|
||||||
|
self.assertTrue(d.fix[0].startswith("echo 'KERNEL==\"hidraw*\""))
|
||||||
|
self.assertIn("60-macropad.rules", d.fix[0])
|
||||||
|
|
||||||
|
def test_other_family_pad_is_refused_not_ignored(self):
|
||||||
|
from unittest import mock
|
||||||
|
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
|
||||||
|
"report_id": 0, "writable": True, "name": ""}]
|
||||||
|
with mock.patch.object(proto, "find_devices", return_value=other), \
|
||||||
|
mock.patch.object(core, "_keyd_warning", return_value=None):
|
||||||
|
d = core.diagnose()
|
||||||
|
self.assertEqual(d.status, "unsupported")
|
||||||
|
self.assertIn("1189:8890", d.headline)
|
||||||
|
self.assertIsNone(d.device)
|
||||||
|
|
||||||
|
def test_rule_fine_but_not_local_session(self):
|
||||||
|
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
|
||||||
|
self.assertEqual(d.fix, [])
|
||||||
|
self.assertIn("SSH", d.detail[0])
|
||||||
|
|
||||||
|
|
||||||
|
class GuiSmoke(unittest.TestCase):
|
||||||
|
"""Builds the window offscreen. Skipped if Qt for Python isn't installed."""
|
||||||
|
|
||||||
|
def test_window_builds_and_selects(self):
|
||||||
|
try:
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from macropad_gui import app
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("PySide6 not installed")
|
||||||
|
from unittest import mock
|
||||||
|
with tempfile.TemporaryDirectory() as d, \
|
||||||
|
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
|
||||||
|
qa = QApplication.instance() or QApplication([])
|
||||||
|
w = app.Window()
|
||||||
|
w.timer.stop()
|
||||||
|
for c in core.CONTROL_IDS:
|
||||||
|
w.pad.select(c)
|
||||||
|
w._refresh()
|
||||||
|
self.assertEqual(w.inspector.title.text(), core.control_name(c))
|
||||||
|
w._changed("key1", core.Binding(media="mute"))
|
||||||
|
self.assertEqual(w._pending(), ["key1"])
|
||||||
|
w.desired.clear()
|
||||||
|
w.close()
|
||||||
33
uninstall.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Removes MacroPad. Your pad keeps its bindings; they live on the pad itself.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||||
|
CONF="${XDG_CONFIG_HOME:-$HOME/.config}/macropad"
|
||||||
|
RULE="/etc/udev/rules.d/60-macropad.rules"
|
||||||
|
|
||||||
|
ask() {
|
||||||
|
local answer=""
|
||||||
|
if { exec 3</dev/tty; } 2>/dev/null; then
|
||||||
|
read -r -p " $1 [y/N] " answer <&3 || true
|
||||||
|
exec 3<&-
|
||||||
|
fi
|
||||||
|
case "$answer" in [yY]*) return 0 ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$DATA/macropad"
|
||||||
|
rm -f "$HOME/.local/bin/macropad" "$DATA/applications/macropad.desktop" \
|
||||||
|
"$DATA/icons/hicolor/scalable/apps/macropad.svg"
|
||||||
|
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 >/dev/null 2>&1 || true
|
||||||
|
echo "Removed the app and its menu entry."
|
||||||
|
|
||||||
|
if [ -d "$CONF" ] && ask "Also delete the app's memory of what's on your pad ($CONF)?"; then
|
||||||
|
rm -rf "$CONF"
|
||||||
|
echo "Deleted $CONF."
|
||||||
|
fi
|
||||||
|
if [ -f "$RULE" ] && ask "Also remove the USB permission rule? (asks for your password)"; then
|
||||||
|
sudo rm -f "$RULE"
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
echo "Removed $RULE."
|
||||||
|
fi
|
||||||
|
echo "Done. The pad keeps working with whatever you last wrote to it."
|
||||||