From 313aafc95bb493b4c2cfc8e05e4601f28d0b27f8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 25 Sep 2026 11:00:59 +1200 Subject: [PATCH] Add misc/spacemouse-capture.py to record a 3D mouse's raw USB reports For device owners on Linux: guided movements, with every raw report and the device's report descriptor saved to one JSON file. Dropped into tests/qttest/fixtures/spacemouse/, a recording is checked by tst_spacemousehid against what the user was asked to do. Co-Authored-By: Claude Opus 5.5 --- misc/spacemouse-capture.py | 195 +++++++++++++++++++++++++++++ tests/qttest/tst_spacemousehid.cpp | 2 +- 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100755 misc/spacemouse-capture.py diff --git a/misc/spacemouse-capture.py b/misc/spacemouse-capture.py new file mode 100755 index 000000000..71d5b573c --- /dev/null +++ b/misc/spacemouse-capture.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# Copyright 2006-2026 The QElectroTech Team +# This file is part of QElectroTech. +# +# QElectroTech is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# QElectroTech is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with QElectroTech. If not, see . +"""Record raw USB reports from a 3Dconnexion 3D mouse, for QElectroTech. + +QElectroTech's Windows/macOS 3D mouse support reads the device directly +over USB, so it has to decode each device model's raw reports itself. +This records what your device sends while you make a few guided +movements, and saves it to one file you can attach to the discussion. +Nothing is sent anywhere. + + sudo python3 spacemouse-capture.py # finds the device itself + sudo python3 spacemouse-capture.py --list # just show what it finds + +sudo is needed because /dev/hidraw* is usually readable by root only. +spacenavd can keep running. If the recording comes out empty, stop it +(`sudo systemctl stop spacenavd`) and try again. + +Only the standard library is used, so it runs on any Linux with Python 3. +""" +import argparse +import datetime +import glob +import json +import os +import platform +import select +import sys +import time + +VENDORS = {0x046D: 'Logitech (older 3Dconnexion)', 0x256F: '3Dconnexion'} + +STEPS = [ + ('rest', 'Do not touch the device.', 3), + ('right', 'Push the cap to the RIGHT and hold it, then let go.', 4), + ('left', 'Push the cap to the LEFT and hold it, then let go.', 4), + ('away', 'Push the cap AWAY from you and hold it, then let go.', 4), + ('toward', 'Pull the cap TOWARDS you and hold it, then let go.', 4), + ('down', 'Press the cap DOWN and hold it, then let go.', 4), + ('up', 'Lift the cap UP and hold it, then let go.', 4), + ('twist_cw', 'TWIST the cap CLOCKWISE (seen from above) and hold, then let go.', 4), + ('twist_ccw', 'TWIST the cap ANTICLOCKWISE and hold, then let go.', 4), + ('tilt_away', 'TILT the cap AWAY from you and hold, then let go.', 4), + ('tilt_right', 'TILT the cap to the RIGHT and hold, then let go.', 4), + ('buttons', 'Press each button once, slowly, one at a time, in any order.', 15), +] + + +def find_devices(): + """Return [{hidraw, name, vendor, product, sysfs}] for 3Dconnexion devices.""" + found = [] + for sysdir in sorted(glob.glob('/sys/class/hidraw/hidraw*')): + try: + with open(os.path.join(sysdir, 'device', 'uevent')) as f: + uevent = dict(line.strip().split('=', 1) for line in f if '=' in line) + except OSError: + continue + # HID_ID=0003:0000256F:0000C635 (bus:vendor:product) + try: + _bus, vendor, product = (int(x, 16) for x in uevent.get('HID_ID', '').split(':')) + except ValueError: + continue + if vendor not in VENDORS: + continue + found.append({ + 'hidraw': '/dev/' + os.path.basename(sysdir), + 'name': uevent.get('HID_NAME', '?'), + 'vendor': '%04x' % vendor, + 'product': '%04x' % product, + 'sysfs': sysdir, + }) + return found + + +def read_descriptor(sysdir): + try: + with open(os.path.join(sysdir, 'device', 'report_descriptor'), 'rb') as f: + return f.read().hex() + except OSError as e: + return 'unreadable: %s' % e + + +def record(fd, seconds): + """Read every report arriving within `seconds`; return [[ms, hex], ...].""" + reports = [] + start = time.monotonic() + while True: + left = seconds - (time.monotonic() - start) + if left <= 0: + return reports + ready, _, _ = select.select([fd], [], [], left) + if not ready: + continue + try: + data = os.read(fd, 64) + except BlockingIOError: + continue + if not data: # only a test FIFO with no writer does this + time.sleep(0.01) + continue + reports.append([round((time.monotonic() - start) * 1000, 1), data.hex()]) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--list', action='store_true', help='only list matching devices') + ap.add_argument('--device', help='hidraw path, if more than one device is found') + ap.add_argument('--descriptor', help=argparse.SUPPRESS) # testing without a device + ap.add_argument('--yes', action='store_true', help=argparse.SUPPRESS) # no Enter prompts + ap.add_argument('-o', '--output', help='output file (default: spacemouse-capture-.json)') + args = ap.parse_args() + + devices = find_devices() + if args.list: + for d in devices: + print('%(hidraw)s %(vendor)s:%(product)s %(name)s' % d) + if not devices: + print('No 3Dconnexion device found under /sys/class/hidraw.') + return 0 if devices else 1 + + if args.device: + dev = next((d for d in devices if d['hidraw'] == args.device), + {'hidraw': args.device, 'name': '?', 'vendor': '?', 'product': '?', 'sysfs': None}) + elif len(devices) == 1: + dev = devices[0] + elif not devices: + sys.exit('No 3Dconnexion device found. Is it plugged in? (try --list)') + else: + sys.exit('Several devices found, pick one with --device:\n' + + '\n'.join(' %(hidraw)s %(name)s' % d for d in devices)) + + if args.descriptor: + with open(args.descriptor, 'rb') as f: + descriptor = f.read().hex() + elif dev['sysfs']: + descriptor = read_descriptor(dev['sysfs']) + else: + descriptor = 'unknown' + + try: + fd = os.open(dev['hidraw'], os.O_RDONLY | os.O_NONBLOCK) + except PermissionError: + sys.exit('Permission denied on %s -- run with sudo.' % dev['hidraw']) + + print('Recording from %s (%s, %s:%s).' % (dev['hidraw'], dev['name'], dev['vendor'], dev['product'])) + print('For each step, press Enter, do the movement, and wait for the next prompt.\n') + + result = { + 'tool': 'spacemouse-capture.py 1', + 'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'), + 'system': platform.platform(), + 'device': {k: dev[k] for k in ('name', 'vendor', 'product')}, + 'report_descriptor': descriptor, + 'steps': [], + } + try: + for i, (key, text, seconds) in enumerate(STEPS, 1): + print('[%d/%d] %s' % (i, len(STEPS), text)) + if not args.yes: + input(' Press Enter to start (%d s)... ' % seconds) + reports = record(fd, seconds) + print(' %d reports recorded.\n' % len(reports)) + result['steps'].append({'step': key, 'instruction': text, 'reports': reports}) + except KeyboardInterrupt: + print('\nStopped early -- saving what was recorded so far.') + finally: + os.close(fd) + + out = args.output or 'spacemouse-capture-%s.json' % dev['product'] + with open(out, 'w') as f: + json.dump(result, f, indent=1) + total = sum(len(s['reports']) for s in result['steps']) + print('Saved %s (%d reports in total).' % (out, total)) + if total == 0: + print('Nothing was recorded. Try stopping spacenavd first: sudo systemctl stop spacenavd') + else: + print('Please attach this file to discussion #599. Thank you!') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/qttest/tst_spacemousehid.cpp b/tests/qttest/tst_spacemousehid.cpp index 9e4876e85..5aa06242c 100644 --- a/tests/qttest/tst_spacemousehid.cpp +++ b/tests/qttest/tst_spacemousehid.cpp @@ -23,7 +23,7 @@ The descriptors below are written by hand from the HID specification, in the shape 3Dconnexion devices use. Recordings from real devices, - made with spacemouse-capture.py, go in fixtures/spacemouse/ and are + made with misc/spacemouse-capture.py, go in fixtures/spacemouse/ and are checked by recordedDevices(): each step says what the user did, so the decoded motion must point the right way. */