Read the pad, detect unknown ones, layout editor

added a feature that now can read the inputs off the board, meaning if you already have saved settings and you uninstall / reinstall the app or use it on a new device it can auto detect what each key has saved.

using the above feature i now also do believe it is possible to auto detect the macros pads rough layout / how many keys and dials it has!. hopfully this means that more people can use the app easily without needing to hard code a bunch of new layouts. its not truly universal but it should open the door to much more pads working out of the box right now!

and lastly in case the extra layouts do work i made a little layout editor so you can the app to look like your actual macropad!
This commit is contained in:
Mohamed Mahjoub
2026-09-16 20:39:44 +01:00
committed by GitHub
parent 5b7bf9dadd
commit ed707b1ae7
26 changed files with 1740 additions and 174 deletions

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
macropad-capture - watch what the vendor's software actually sends.
macropad-capture - watch what the vendor's software sends, and what the pad
says back.
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.
Uses the kernel's usbmon facility to record USB traffic to and from your
macro pad, then decodes the reports so we can see the real wire format.
This is passive. It only observes - it never sends anything itself.
@@ -16,15 +17,16 @@ How to use it:
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.
3. Do ONE thing in that software, then come back and press Ctrl+C.
4. Click whatever it uses to apply or save.
To learn how a binding is written: change ONE key to the letter 'a'
and apply it. ('a' is HID code 0x04, easy to spot in the bytes.)
5. Come back here and press Ctrl+C.
To learn whether the pad can be read back: press whatever the
software calls "view settings" or "read". Anything the pad sends is
recorded and marked with a <- arrow.
It writes macropad-capture.txt, which has everything we need.
4. It writes macropad-capture.txt, which has everything we need.
"""
import os
@@ -57,63 +59,135 @@ def find_pad():
return candidates
def kernel_has_usbmon():
"""
'y' built into the kernel, 'm' a loadable module, None not built at all.
Read from the running kernel's own config where it's available.
"""
try:
import gzip
with gzip.open("/proc/config.gz", "rt") as f:
text = f.read()
except OSError:
try:
text = open(f"/boot/config-{os.uname().release}").read()
except OSError:
return "?"
for line in text.splitlines():
if line.startswith("CONFIG_USB_MON="):
return line.split("=", 1)[1].strip()
return None
def debugfs_mounted():
try:
return " /sys/kernel/debug " in open("/proc/mounts").read()
except OSError:
return False
def ensure_usbmon():
"""
Get /sys/kernel/debug/usb/usbmon to exist, and explain it if we can't.
Two separate things have to be true: the usbmon facility has to be in
the kernel, and debugfs has to be mounted for it to appear. A missing
module and an unmounted debugfs look identical until you check both.
"""
if os.path.isdir(DEBUGFS):
return True
print(f"{DIM}Loading usbmon module...{O}")
subprocess.run(["modprobe", "usbmon"], capture_output=True)
built = kernel_has_usbmon()
if built == "m":
print(f"{DIM}Loading usbmon module...{O}")
subprocess.run(["modprobe", "usbmon"], capture_output=True)
if not debugfs_mounted():
print(f"{DIM}Mounting debugfs...{O}")
subprocess.run(["mount", "-t", "debugfs", "none", "/sys/kernel/debug"],
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)
# Still nothing. Say which of the two is actually missing.
print(f"\n{R}Can't get at usbmon.{O} {DIM}kernel {os.uname().release}{O}")
if built is None:
print("Your kernel was built without usbmon (CONFIG_USB_MON is not set),")
print("so USB capture isn't available on it at all.")
print(f"\n{B}What to do{O}")
print(" Boot a kernel that has it. On Arch and CachyOS the LTS kernel does:")
print(" sudo pacman -S linux-lts linux-lts-headers")
print(" Then pick it in the boot menu and run this again.")
elif built == "m":
mod = f"/lib/modules/{os.uname().release}/kernel/drivers/usb/mon"
print("Your kernel lists usbmon as a module, but it wouldn't load.")
if not os.path.isdir(mod):
print(f"{mod} is missing, which usually means the kernel was")
print("updated and not rebooted into yet.")
print(f"\n{B}What to do{O}\n Reboot, then run this again.")
else:
print(f"\n{B}What to do{O}")
print(" sudo depmod -a && sudo modprobe usbmon")
else:
print("usbmon should be built in, but the folder still isn't there.")
print(f"\n{B}What to do{O}")
print(" sudo mount -t debugfs none /sys/kernel/debug")
print(" sudo ls /sys/kernel/debug/usb")
return False
def decode(line, want_dev):
"""
usbmon text format, roughly:
<tag> <ts> <S|C> <type>:<bus>:<dev>:<ep> <flags> <len> = <hex data>
<tag> <ts> <S|C|E> <type><dir>:<bus>:<dev>:<ep> <flags> <len> = <hex>
We want submissions (S) carrying data toward our device: control
transfers (Co) and interrupt out (Io).
Direction is the second letter: 'o' toward the device, 'i' from it.
Data rides on the submission (S) of an OUT transfer and on the
completion (C) of an IN transfer, so we want both.
"""
parts = line.split()
if len(parts) < 5:
return None
event = parts[2]
addr = parts[3]
if event != "S":
return None
event, addr = parts[2], parts[3]
bits = addr.split(":")
if len(bits) < 4:
return None
xfer, bus, dev, ep = bits[0], bits[1], bits[2], bits[3]
xfer, ep = bits[0], bits[3]
if int(dev) != want_dev:
return None
if not xfer.endswith("o"): # 'o' = out, toward the device
try:
if int(bits[2]) != want_dev:
return None
except ValueError:
return None
if "=" not in line:
outgoing = xfer.endswith("o")
if outgoing and event != "S": # OUT data rides on the submission
return None
if not outgoing and event != "C": # IN data rides on the completion
return None
if "=" not in line: # no payload on this 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)
kind = {"Co": "control out", "Ci": "control in",
"Io": "interrupt out", "Ii": "interrupt in",
"Bo": "bulk out", "Bi": "bulk in"}.get(xfer, xfer)
setup = ""
if xfer == "Co":
if xfer.startswith("C"):
# 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}
return {"kind": kind, "ep": ep, "setup": setup.strip(), "data": data,
"outgoing": outgoing}
def main():
@@ -154,8 +228,9 @@ def main():
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("\nDo ONE thing in it, then come back here.")
print(f"{DIM} -> the software talking to the pad")
print(f" <- the pad talking back{O}")
print(f"\nPress {B}Ctrl+C{O} when you're done.\n")
print("-" * 62)
@@ -171,7 +246,8 @@ def main():
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}"
arrow = f"{DIM}->{O}" if rec["outgoing"] else f"{G}<-{O}"
print(f"{n:3} {arrow} {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 "")
@@ -186,15 +262,15 @@ def main():
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("The software may not have talked to the pad, or it may be on 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}")
sent = sum(1 for p in packets if p["outgoing"])
got = len(packets) - sent
print(f"{G}Captured {sent} sent, {got} received{O} -> {OUTFILE}")
if got:
print(f"{G}The pad answered back.{O} It can be read, not only written.")
print(f"\nSend me {B}{OUTFILE}{O} and I'll decode the format.")
return 0