258 lines
9.1 KiB
Python
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())
|