Files
qelectrotech-source-mirror/misc/spacemouse-capture.py
T
ispyisail 313aafc95b 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 <noreply@anthropic.com>
2026-09-25 11:00:59 +12:00

196 lines
7.6 KiB
Python
Executable File

#!/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 <http://www.gnu.org/licenses/>.
"""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-<product>.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())