Files
macropad-itprotech/macropad-probe.py
2026-09-11 20:30:34 +01:00

258 lines
9.1 KiB
Python

#!/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())