diff --git a/CMakeLists.txt b/CMakeLists.txt index f5f027d14..6af925c12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,8 +173,9 @@ endif() if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED) list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::SPNAV) endif() -# A future Windows/macOS (3DxWare) backend would list.APPEND its own SDK -# libraries here, behind its own *_ENABLED guard, alongside this one. +if(QET_SPACEMOUSE_BACKEND_HID_ENABLED) + list(APPEND QET_SPACEMOUSE_LIBRARIES PkgConfig::HIDAPI) +endif() if(APPLE) # CFBundleIdentifier must not be empty. CMake's default Info.plist diff --git a/cmake/developer_options.cmake b/cmake/developer_options.cmake index aab36207b..bdc63930e 100644 --- a/cmake/developer_options.cmake +++ b/cmake/developer_options.cmake @@ -48,7 +48,7 @@ option(BUILD_WITH_KF "Build with KDE Frameworks" ON) # strict behaviour, and only developers who opt in trade that for the speed. option(QET_ENABLE_PCH "Use precompiled headers (developer build speed; may mask missing #includes)" OFF) -# Phase 1 (Linux, libspnav) of discussion #599: 3Dconnexion SpaceMouse/ -# SpacePilot pan/zoom support. Off by default -- see cmake/find_spacemouse.cmake -# for what happens when it is on but libspnav isn't found. -option(QET_ENABLE_SPACEMOUSE "Build with 3Dconnexion/libspnav 3D mouse support for pan/zoom (Linux, requires libspnav-dev and spacenavd)" OFF) +# Discussion #599: 3Dconnexion SpaceMouse/SpacePilot pan/zoom support. Off by +# default -- see cmake/find_spacemouse.cmake for the backends and what happens +# when it is on but no library is found. +option(QET_ENABLE_SPACEMOUSE "Build with 3D mouse (3Dconnexion SpaceMouse) support for pan/zoom; needs libspnav or hidapi, see QET_SPACEMOUSE_BACKEND" OFF) diff --git a/cmake/find_spacemouse.cmake b/cmake/find_spacemouse.cmake index 6f47ae803..3da5731d4 100644 --- a/cmake/find_spacemouse.cmake +++ b/cmake/find_spacemouse.cmake @@ -20,44 +20,64 @@ message(" - find_spacemouse") # so none of this runs, and the default build is entirely unaffected: no new # dependency, no new source files, no new symbols. # -# When it is on, the platform decides which backend (if any) is available -- -# see sources/spacemouse/spacemousebackend.h for what a backend is. Only one -# exists today: SpnavBackend, Linux, via libspnav. A Windows/macOS backend -# would need 3Dconnexion's proprietary 3DxWare SDK and is not implemented -# (see discussion #599's own phase split); until it is, turning this option -# on there downgrades cleanly with a warning rather than failing configure. +# When it is on, QET_SPACEMOUSE_BACKEND picks how the device is read -- see +# sources/spacemouse/spacemousebackend.h for what a backend is: +# spnav Linux, through the spacenavd daemon (libspnav) +# hid any platform, directly over USB (hidapi), no 3Dconnexion driver +# auto spnav on Linux when libspnav is found, hid otherwise +# A backend whose library is not found downgrades the option to off with a +# warning, rather than failing configure for an opt-in feature. +set(QET_SPACEMOUSE_BACKEND "auto" CACHE STRING "3D mouse backend: auto, spnav or hid") +set_property(CACHE QET_SPACEMOUSE_BACKEND PROPERTY STRINGS auto spnav hid) + set(QET_SPACEMOUSE_ENABLED FALSE) set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED FALSE) +set(QET_SPACEMOUSE_BACKEND_HID_ENABLED FALSE) if(QET_ENABLE_SPACEMOUSE) - if(UNIX AND NOT APPLE) - # libspnav is looked for via its pkg-config file (present on every - # distro package of it this was checked against: Debian/Ubuntu's - # libspnav-dev ships /usr/share/pkgconfig/spnav.pc). Not found does - # not hard-fail the whole configure -- it downgrades the option back - # to off with a clear message, so a developer who doesn't have the - # library installed still gets a normal build instead of a configure - # error for an opt-in feature they didn't ask to block on. - find_package(PkgConfig) - if(PkgConfig_FOUND) - pkg_check_modules(SPNAV IMPORTED_TARGET spnav) - endif() + find_package(PkgConfig) + set(_qet_spacemouse_try_spnav FALSE) + set(_qet_spacemouse_try_hid FALSE) + if(QET_SPACEMOUSE_BACKEND STREQUAL "spnav") + set(_qet_spacemouse_try_spnav TRUE) + elseif(QET_SPACEMOUSE_BACKEND STREQUAL "hid") + set(_qet_spacemouse_try_hid TRUE) + else() + if(UNIX AND NOT APPLE) + set(_qet_spacemouse_try_spnav TRUE) + endif() + set(_qet_spacemouse_try_hid TRUE) + endif() + + # libspnav: Debian/Ubuntu's libspnav-dev ships spnav.pc. + if(_qet_spacemouse_try_spnav AND PkgConfig_FOUND) + pkg_check_modules(SPNAV IMPORTED_TARGET spnav) if(SPNAV_FOUND) set(QET_SPACEMOUSE_ENABLED TRUE) set(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED TRUE) - add_definitions(-DQET_SPACEMOUSE_SUPPORT) add_definitions(-DQET_SPACEMOUSE_BACKEND_SPNAV) message("QET_ENABLE_SPACEMOUSE ON (backend: libspnav ${SPNAV_VERSION})") - else() - message(WARNING "QET_ENABLE_SPACEMOUSE is ON but libspnav was not found via pkg-config " - "(install libspnav-dev, or the equivalent for your distribution) -- " - "building WITHOUT 3D mouse support.") endif() + endif() + + # hidapi: hidapi-hidraw.pc on Linux (libhidapi-dev), hidapi.pc from + # MSYS2 (mingw-w64-ucrt-x86_64-hidapi) and Homebrew (hidapi). + if(NOT QET_SPACEMOUSE_ENABLED AND _qet_spacemouse_try_hid AND PkgConfig_FOUND) + pkg_search_module(HIDAPI IMPORTED_TARGET hidapi-hidraw hidapi) + if(HIDAPI_FOUND) + set(QET_SPACEMOUSE_ENABLED TRUE) + set(QET_SPACEMOUSE_BACKEND_HID_ENABLED TRUE) + add_definitions(-DQET_SPACEMOUSE_BACKEND_HID) + message("QET_ENABLE_SPACEMOUSE ON (backend: hidapi ${HIDAPI_VERSION})") + endif() + endif() + + if(QET_SPACEMOUSE_ENABLED) + add_definitions(-DQET_SPACEMOUSE_SUPPORT) else() - message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no 3D mouse backend is implemented yet " - "for this platform (only Linux/libspnav exists today -- see " - "https://github.com/qelectrotech/qelectrotech-source-mirror/discussions/599) " - "-- building WITHOUT 3D mouse support.") + message(WARNING "QET_ENABLE_SPACEMOUSE is ON but no library was found for the " + "'${QET_SPACEMOUSE_BACKEND}' backend (libspnav-dev for spnav, " + "hidapi for hid, via pkg-config) -- building WITHOUT 3D mouse support.") endif() endif() diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 708ac35e4..98ad12f6b 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -896,6 +896,15 @@ if(QET_SPACEMOUSE_BACKEND_SPNAV_ENABLED) ) endif() +if(QET_SPACEMOUSE_BACKEND_HID_ENABLED) + list(APPEND QET_SRC_FILES + ${QET_DIR}/sources/spacemouse/hidbackend.cpp + ${QET_DIR}/sources/spacemouse/hidbackend.h + ${QET_DIR}/sources/spacemouse/spacemousehid.cpp + ${QET_DIR}/sources/spacemouse/spacemousehid.h + ) +endif() + set(TS_FILES ${QET_DIR}/lang/qet_ar.ts ${QET_DIR}/lang/qet_ca.ts 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/sources/spacemouse/hidbackend.cpp b/sources/spacemouse/hidbackend.cpp new file mode 100644 index 000000000..9ab2d5d51 --- /dev/null +++ b/sources/spacemouse/hidbackend.cpp @@ -0,0 +1,197 @@ +/* + 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 . +*/ +#include "hidbackend.h" + +#include + +#include + +namespace { + constexpr int SCAN_INTERVAL_MS = 3000; + constexpr int FAST_POLL_MS = 8; // devices report at up to ~125 Hz + constexpr int SLOW_POLL_MS = 50; + //Fast polls in a row with nothing to read before slowing down. + constexpr int IDLE_POLLS_BEFORE_SLOW = 125; + constexpr int MAX_REPORT_SIZE = 64; + constexpr int MAX_DESCRIPTOR_SIZE = 4096; +} + +/** + @brief HidBackend::HidBackend + @param parent +*/ +HidBackend::HidBackend(QObject *parent) : + SpaceMouseBackend(parent) +{ + if (hid_init() != 0) { + return; + } + m_hid_initialised = true; + + m_read_timer = new QTimer(this); + connect(m_read_timer, &QTimer::timeout, this, &HidBackend::readReports); + + m_scan_timer = new QTimer(this); + m_scan_timer->setInterval(SCAN_INTERVAL_MS); + connect(m_scan_timer, &QTimer::timeout, this, &HidBackend::scan); + + scan(); +} + +/** + @brief HidBackend::~HidBackend +*/ +HidBackend::~HidBackend() +{ + close(); + if (m_hid_initialised) { + hid_exit(); + } +} + +/** + @brief HidBackend::scan + Open the first 3D mouse found, or keep looking every few seconds. +*/ +void HidBackend::scan() +{ + if (m_device) { + return; + } + + hid_device_info *list = hid_enumerate(0, 0); + for (hid_device_info *info = list; info && !m_device; info = info->next) + { + if (!SpaceMouseHid::isSpaceMouse(info->vendor_id, info->product_id, + info->usage_page, info->usage)) { + continue; + } + m_device = hid_open_path(info->path); + } + hid_free_enumeration(list); + + if (!m_device) { + m_scan_timer->start(); + return; + } + m_scan_timer->stop(); + hid_set_nonblocking(m_device, 1); + + SpaceMouseHid::Layout layout; +#if HID_API_VERSION >= HID_API_MAKE_VERSION(0, 14, 0) + unsigned char descriptor[MAX_DESCRIPTOR_SIZE]; + const int size = hid_get_report_descriptor(m_device, descriptor, sizeof descriptor); + if (size > 0) { + layout = SpaceMouseHid::parseDescriptor( + QByteArray(reinterpret_cast(descriptor), size)); + } +#endif + if (!layout.hasAxes()) { + layout = SpaceMouseHid::fallbackLayout(); + } + m_decoder = SpaceMouseHid::Decoder(layout); + + m_idle_polls = 0; + m_read_timer->start(FAST_POLL_MS); +} + +/** + @brief HidBackend::readReports + Drain every report waiting, emit what they mean, and adjust the polling + rate. A read error means the device went away (unplugged, or the + wireless receiver lost it): go back to looking for one. +*/ +void HidBackend::readReports() +{ + //A button can trigger an action that opens a dialog, whose event + //loop fires this timer again before the signal returns. That is + //fine -- the device keeps working in the dialog -- because the + //signals are emitted last, after every use of m_device below; this + //guard only keeps two reads from ever overlapping. + if (m_reading || !m_device) { + return; + } + m_reading = true; + + unsigned char buffer[MAX_REPORT_SIZE]; + bool got_any = false; + //Everything read in one poll is one moment: a device that sends + //translation and rotation as two reports, or two samples that + //queued up, gives one sample here -- the latest state -- so the + //listener's time scaling sees one sample per real interval. + bool moved = false; + SpaceMouseSample latest; + QList pressed; + + for (;;) + { + const int size = hid_read(m_device, buffer, sizeof buffer); + if (size == 0) { + break; + } + if (size < 0) { + close(); + m_scan_timer->start(); + break; + } + got_any = true; + + const SpaceMouseHid::Decoder::Result result = m_decoder.feed( + QByteArray(reinterpret_cast(buffer), size)); + if (result.motion) { + moved = true; + latest = result.sample; + } + pressed += result.pressed; + } + + if (m_device) { + if (got_any) { + m_idle_polls = 0; + if (m_read_timer->interval() != FAST_POLL_MS) { + m_read_timer->setInterval(FAST_POLL_MS); + } + } else if (++m_idle_polls == IDLE_POLLS_BEFORE_SLOW) { + m_read_timer->setInterval(SLOW_POLL_MS); + } + } + + //Signals last: whatever they trigger (even the device going away + //during a dialog), nothing after them touches the device. + m_reading = false; + if (moved) { + emit motion(latest); + } + for (int button : pressed) { + emit buttonPressed(button); + } +} + +/** + @brief HidBackend::close +*/ +void HidBackend::close() +{ + if (m_read_timer) { + m_read_timer->stop(); + } + if (m_device) { + hid_close(m_device); + m_device = nullptr; + } +} diff --git a/sources/spacemouse/hidbackend.h b/sources/spacemouse/hidbackend.h new file mode 100644 index 000000000..ca192471d --- /dev/null +++ b/sources/spacemouse/hidbackend.h @@ -0,0 +1,69 @@ +/* + 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 . +*/ +#ifndef HIDBACKEND_H +#define HIDBACKEND_H + +#include "spacemousebackend.h" +#include "spacemousehid.h" + +class QTimer; +struct hid_device_; + +/** + @brief The HidBackend class + SpaceMouseBackend that reads the device directly over USB through + hidapi, with no 3Dconnexion driver or SDK: the backend for Windows and + macOS, and for Linux without spacenavd. Only compiled in when + QET_SPACEMOUSE_BACKEND_HID is defined (cmake/find_spacemouse.cmake). + + The raw reports are decoded by SpaceMouseHid, from the device's own + report descriptor, into the same values spacenavd would give. + + hidapi has no event to wait on, so the device is polled from the main + thread: fast while it is moving, slowly once it has been still for a + moment. With no device, it looks for one every few seconds, so plugging + one in works without restarting QElectroTech. As with every backend, + no device is the normal case and is never reported as an error. +*/ +class HidBackend : public SpaceMouseBackend +{ + Q_OBJECT + + public: + explicit HidBackend(QObject *parent = nullptr); + ~HidBackend() override; + + bool isAvailable() const override { return m_device != nullptr; } + + private slots: + void scan(); + void readReports(); + + private: + void close(); + + bool m_hid_initialised = false; + hid_device_ *m_device = nullptr; + SpaceMouseHid::Decoder m_decoder; + QTimer *m_scan_timer = nullptr; + QTimer *m_read_timer = nullptr; + int m_idle_polls = 0; + bool m_reading = false; +}; + +#endif // HIDBACKEND_H diff --git a/sources/spacemouse/spacemousebackend.h b/sources/spacemouse/spacemousebackend.h index e2d464aa4..9c52ede6f 100644 --- a/sources/spacemouse/spacemousebackend.h +++ b/sources/spacemouse/spacemousebackend.h @@ -27,23 +27,16 @@ Platform seam for discussion #599's 3D mouse support. A backend owns one platform's connection to the actual 6-DOF device driver -- opening it, pumping whatever event source that platform uses, closing it -- and - reports motion through the one signal below. Everything platform- - independent (which DiagramView to apply motion to, the pan/zoom - primitives to call, the Z-to-zoom-factor mapping) lives in - SpaceMouseListener instead, once, so it does not have to be duplicated + reports motion through the signals below. Everything platform- + independent (which view to apply motion to, the pan/zoom primitives to + call, the sample-to-motion mapping) lives in SpaceMouseListener and + SpaceMouseMotion instead, once, so it does not have to be duplicated or re-verified per backend. - SpnavBackend (Linux, spacenavd/libspnav) is the only implementation so - far -- built, linked, and its "no daemon/device present" path actually - run in the environment this was written in. A Windows/macOS backend - (3Dconnexion's proprietary 3DxWare SDK) would implement this same - interface and be selected in SpaceMouseListener's constructor, without - changing SpnavBackend or anything downstream of the motion signal. - Deliberately not attempted here: this was written on Linux with no - 3DxWare SDK and no Windows/macOS toolchain available to compile, - link, or run a single line of it against, and shipping platform code - that has never even built would be a materially different, weaker - thing than everything else in this feature. + Two implementations, chosen at build time (cmake/find_spacemouse.cmake): + SpnavBackend (Linux, through spacenavd/libspnav) and HidBackend (any + platform, directly over USB through hidapi, with no 3Dconnexion driver + or SDK). Both report the same values for the same movement. */ class SpaceMouseBackend : public QObject { diff --git a/sources/spacemouse/spacemousehid.cpp b/sources/spacemouse/spacemousehid.cpp new file mode 100644 index 000000000..a235e338f --- /dev/null +++ b/sources/spacemouse/spacemousehid.cpp @@ -0,0 +1,357 @@ +/* + 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 . +*/ +#include "spacemousehid.h" + +#include + +#include + +namespace { + constexpr unsigned short PAGE_GENERIC_DESKTOP = 0x01; + constexpr unsigned short PAGE_BUTTON = 0x09; + constexpr unsigned short USAGE_MULTI_AXIS = 0x08; + constexpr unsigned short USAGE_X = 0x30; // X, Y, Z, Rx, Ry, Rz follow + + //Newer devices (SpaceMouse Enterprise, the wireless ones) report the + //buttons held as a list of 16-bit button numbers in this report, + //outside the Button usage page. + constexpr int REPORT_BUTTON_LIST = 0x1c; + + //spacenavd rescales absolute axes to this range, so doing the same + //makes both backends hand QET the same numbers. + constexpr int SCALED_MIN = -500; + constexpr int SCALED_MAX = 500; + + //Limits on what a descriptor can make the parser walk. + constexpr quint32 MAX_FIELDS = 1024; + constexpr qint64 MAX_BITS = 1 << 20; + + //3D mice sold under Logitech's vendor id, for the ones whose + //interface does not report a usage. + const QSet LOGITECH_3D_MICE = { + 0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, + 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b + }; + + /// Read \a size bits at \a offset, little-endian as HID packs them. + bool extract(const QByteArray &payload, int offset, int size, bool is_signed, int *value) + { + if (size <= 0 || size > 32 || offset < 0 + || (offset + size + 7) / 8 > payload.size()) { + return false; + } + quint32 raw = 0; + for (int b = 0; b < size; ++b) { + const int bit = offset + b; + if (static_cast(payload.at(bit / 8)) & (1u << (bit % 8))) { + raw |= 1u << b; + } + } + if (is_signed && size < 32 && (raw & (1u << (size - 1)))) { + raw |= ~((1u << size) - 1); + } + *value = static_cast(raw); + return true; + } + + int scaled(const SpaceMouseHid::Field &field, int value) + { + if (field.relative || field.logical_max <= field.logical_min) { + return value; + } + const qint64 range = qint64(field.logical_max) - field.logical_min; + return static_cast((qint64(value) - field.logical_min) + * (SCALED_MAX - SCALED_MIN) / range + SCALED_MIN); + } +} + +bool SpaceMouseHid::isSpaceMouse(unsigned short vendor, unsigned short product, + unsigned short usage_page, unsigned short usage) +{ + const bool multi_axis = usage_page == PAGE_GENERIC_DESKTOP && usage == USAGE_MULTI_AXIS; + const bool usage_unknown = usage_page == 0 && usage == 0; + + if (vendor == VENDOR_3DCONNEXION) { + return multi_axis || usage_unknown; + } + if (vendor == VENDOR_LOGITECH) { + return multi_axis || (usage_unknown && LOGITECH_3D_MICE.contains(product)); + } + return false; +} + +bool SpaceMouseHid::Layout::hasAxes() const +{ + for (const Field &f : axes) { + if (f.isValid()) { + return true; + } + } + return false; +} + +/** + @brief SpaceMouseHid::parseDescriptor + Walks the short items of a HID report descriptor (HID 1.11, 6.2.2), + keeping only what the decoder needs: the Input fields carrying the six + Generic Desktop axes and the Button page. + @param descriptor + @return see the declaration +*/ +SpaceMouseHid::Layout SpaceMouseHid::parseDescriptor(const QByteArray &descriptor) +{ + struct Globals { + quint32 usage_page = 0; + qint32 logical_min = 0; + qint32 logical_max = 0; + quint32 report_size = 0; + quint32 report_count = 0; + quint32 report_id = 0; + }; + + Layout layout; + Globals globals; + QList stack; + QList usages; // full 32-bit usages (page << 16 | id) + quint32 usage_min = 0, usage_max = 0; + bool have_range = false; + QHash next_bit; // per report id + + auto clearLocals = [&]() { + usages.clear(); + have_range = false; + usage_min = usage_max = 0; + }; + auto fullUsage = [&](quint32 value, int size) { + return size == 4 ? value : (globals.usage_page << 16) | value; + }; + + int i = 0; + const int n = descriptor.size(); + while (i < n) + { + const quint8 prefix = static_cast(descriptor.at(i)); + if (prefix == 0xfe) { // long item: never used by these devices + if (i + 1 >= n) break; + i += 3 + static_cast(descriptor.at(i + 1)); + continue; + } + const int size = (prefix & 0x03) == 3 ? 4 : (prefix & 0x03); + const int type = (prefix >> 2) & 0x03; + const int tag = prefix >> 4; + if (i + 1 + size > n) break; + + quint32 uvalue = 0; + for (int b = 0; b < size; ++b) { + uvalue |= quint32(static_cast(descriptor.at(i + 1 + b))) << (8 * b); + } + qint32 svalue = static_cast(uvalue); + if (size > 0 && size < 4 && (uvalue & (1u << (8 * size - 1)))) { + svalue = static_cast(uvalue | ~((1u << (8 * size)) - 1)); + } + i += 1 + size; + + if (type == 1) // global + { + switch (tag) { + case 0: globals.usage_page = uvalue; break; + case 1: globals.logical_min = svalue; break; + case 2: globals.logical_max = svalue; break; + case 7: globals.report_size = uvalue; break; + case 8: globals.report_id = uvalue; layout.numbered_reports = true; break; + case 9: globals.report_count = uvalue; break; + case 10: stack.append(globals); break; + case 11: if (!stack.isEmpty()) globals = stack.takeLast(); break; + } + } + else if (type == 2) // local + { + switch (tag) { + case 0: usages.append(fullUsage(uvalue, size)); break; + case 1: usage_min = fullUsage(uvalue, size); have_range = true; break; + case 2: usage_max = fullUsage(uvalue, size); have_range = true; break; + } + } + else if (type == 0) // main + { + if (tag == 8) // Input + { + const bool constant = uvalue & 0x01; + const bool variable = uvalue & 0x02; + const bool relative = uvalue & 0x04; + int &bit = next_bit[globals.report_id]; + //A real report is at most a few hundred bytes; a count + //beyond that is a broken (or hostile) descriptor, and + //walking it field by field would stall the application. + const quint32 count = qMin(globals.report_count, MAX_FIELDS); + + for (quint32 k = 0; k < count; ++k) + { + quint32 usage = 0; + if (have_range && usage_max >= usage_min) { + usage = qMin(usage_min + k, usage_max); + } else if (!usages.isEmpty()) { + usage = usages.at(qMin(k, usages.size() - 1)); + } + + Field field; + field.report_id = static_cast(globals.report_id); + field.bit_offset = bit + static_cast(k * globals.report_size); + field.bit_size = static_cast(globals.report_size); + field.logical_min = globals.logical_min; + field.logical_max = globals.logical_max; + field.relative = relative; + + if (!constant && variable) { + const quint32 page = usage >> 16, id = usage & 0xffff; + if (page == PAGE_GENERIC_DESKTOP && id >= USAGE_X && id < USAGE_X + 6) { + layout.axes[id - USAGE_X] = field; + } else if (page == PAGE_BUTTON && id >= 1 && id <= 64) { + if (layout.buttons.size() < int(id)) { + layout.buttons.resize(id); + } + layout.buttons[id - 1] = field; + } + } + } + bit = static_cast(qMin( + bit + qint64(count) * globals.report_size, MAX_BITS)); + } + clearLocals(); // every main item (Input, Collection, ...) ends the locals + } + } + return layout; +} + +SpaceMouseHid::Layout SpaceMouseHid::fallbackLayout() +{ + Layout layout; + layout.numbered_reports = true; + for (int a = 0; a < 6; ++a) { + Field &f = layout.axes[a]; + f.report_id = a < 3 ? 1 : 2; + f.bit_offset = (a % 3) * 16; + f.bit_size = 16; + f.logical_min = -32768; // signed, and relative: passed through as sent + f.logical_max = 32767; + f.relative = true; + } + layout.buttons.resize(32); + for (int b = 0; b < 32; ++b) { + Field &f = layout.buttons[b]; + f.report_id = 3; + f.bit_offset = b; + f.bit_size = 1; + f.logical_max = 1; + } + return layout; +} + +SpaceMouseHid::Decoder::Decoder(const Layout &layout) : + m_layout(layout) +{} + +/** + @brief SpaceMouseHid::Decoder::feed + @param report : one report as read from the device, report id first + when the device numbers its reports + @return see Result +*/ +SpaceMouseHid::Decoder::Result SpaceMouseHid::Decoder::feed(const QByteArray &report) +{ + Result result; + if (report.isEmpty()) { + return result; + } + + const int id = m_layout.numbered_reports ? static_cast(report.at(0)) : 0; + const QByteArray payload = m_layout.numbered_reports ? report.mid(1) : report; + + for (int a = 0; a < 6; ++a) + { + const Field &f = m_layout.axes[a]; + int value = 0; + if (f.isValid() && f.report_id == id + && extract(payload, f.bit_offset, f.bit_size, f.logical_min < 0, &value)) { + m_axes[a] = scaled(f, value); + result.motion = true; + } + } + + //Some devices send rotation after translation in one longer report + //1, while their layout (or the fallback, which has no descriptor) + //puts rotation in a report of its own: read it from report 1 too. + if (id == 1 && payload.size() >= 12 + && m_layout.axes[3].isValid() && m_layout.axes[3].report_id != 1) { + for (int a = 3; a < 6; ++a) { + const Field &f = m_layout.axes[a]; + int value = 0; + if (extract(payload, 48 + f.bit_offset, f.bit_size, f.logical_min < 0, &value)) { + m_axes[a] = scaled(f, value); + } + } + } + + QSet down; + bool buttons_in_report = false; + for (int b = 0; b < m_layout.buttons.size(); ++b) + { + const Field &f = m_layout.buttons.at(b); + int value = 0; + if (f.isValid() && f.report_id == id + && extract(payload, f.bit_offset, f.bit_size, false, &value)) { + buttons_in_report = true; + if (value) { + down.insert(b); + } + } + } + if (!buttons_in_report && m_layout.numbered_reports && id == REPORT_BUTTON_LIST) + { + buttons_in_report = true; + for (int offset = 0; offset + 1 < payload.size(); offset += 2) { + const int number = static_cast(payload.at(offset)) + | static_cast(payload.at(offset + 1)) << 8; + //0 means no button, so the numbers start at 1: count from + //0 like the Button page and spacenavd do. + if (number) { + down.insert(number - 1); + } + } + } + if (buttons_in_report) + { + for (int b : down) { + if (!m_down.contains(b)) { + result.pressed.append(b); + } + } + std::sort(result.pressed.begin(), result.pressed.end()); + m_down = down; + } + + if (result.motion) { + result.sample.x = m_axes[0]; + result.sample.y = m_axes[1]; + result.sample.z = m_axes[2]; + result.sample.rx = m_axes[3]; + result.sample.ry = m_axes[4]; + result.sample.rz = m_axes[5]; + } + return result; +} diff --git a/sources/spacemouse/spacemousehid.h b/sources/spacemouse/spacemousehid.h new file mode 100644 index 000000000..3e23dcd8a --- /dev/null +++ b/sources/spacemouse/spacemousehid.h @@ -0,0 +1,107 @@ +/* + 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 . +*/ +#ifndef SPACEMOUSEHID_H +#define SPACEMOUSEHID_H + +#include "spacemousemotion.h" + +#include +#include +#include +#include + +/** + Decoding of a 3D mouse's raw USB HID reports, for the backends that read + the device directly (HidBackend) rather than through a driver that has + already decoded them (spacenavd). No I/O here, so it is unit-tested from + recorded bytes (tests/qttest/tst_spacemousehid). +*/ +namespace SpaceMouseHid +{ + /// USB vendor ids 3Dconnexion devices use: Logitech's for the older + /// ones, 3Dconnexion's own for everything since about 2011. + constexpr unsigned short VENDOR_LOGITECH = 0x046d; + constexpr unsigned short VENDOR_3DCONNEXION = 0x256f; + + /// @return whether a HID interface is a 3D mouse: a 3Dconnexion + /// vendor id and the Generic Desktop "multi-axis controller" usage. + /// Logitech devices that do not report a usage are matched by + /// product id instead, from the list of their 3D mice. + bool isSpaceMouse(unsigned short vendor, unsigned short product, + unsigned short usage_page, unsigned short usage); + + /// Where one value sits in the reports, per the report descriptor. + struct Field + { + int report_id = 0; ///< 0 when the device does not number its reports + int bit_offset = 0; ///< after the report id byte, if any + int bit_size = 0; + int logical_min = 0; + int logical_max = 0; + bool relative = false; + + bool isValid() const { return bit_size > 0; } + }; + + /// What the report descriptor says about the six axes and the buttons. + struct Layout + { + Field axes[6]; ///< x, y, z, rx, ry, rz + QVector buttons; ///< button n (0-based) is buttons[n] + bool numbered_reports = false; + + bool hasAxes() const; + }; + + /// @return the layout described by \a descriptor, or one where + /// hasAxes() is false if it describes no axes (or is empty) + Layout parseDescriptor(const QByteArray &descriptor); + + /// @return the fixed layout of the classic 3Dconnexion reports + /// (1 = translation, 2 = rotation, 3 = button bitmask), used when + /// the descriptor cannot be read + Layout fallbackLayout(); + + /** + @brief The Decoder class + Turns a stream of raw reports into samples and button presses. + A device can send translation and rotation in separate reports, so + the decoder keeps the last value of every axis and reports all six + each time any of them changes. + */ + class Decoder + { + public: + struct Result + { + bool motion = false; ///< sample is new + SpaceMouseSample sample; + QList pressed; ///< buttons pressed since the previous report + }; + + explicit Decoder(const Layout &layout = fallbackLayout()); + Result feed(const QByteArray &report); + + private: + Layout m_layout; + int m_axes[6] = {0, 0, 0, 0, 0, 0}; + QSet m_down; ///< buttons currently held + }; +} + +#endif // SPACEMOUSEHID_H diff --git a/sources/spacemouse/spacemouselistener.cpp b/sources/spacemouse/spacemouselistener.cpp index 325a5f334..2c76d277e 100644 --- a/sources/spacemouse/spacemouselistener.cpp +++ b/sources/spacemouse/spacemouselistener.cpp @@ -22,6 +22,9 @@ #ifdef QET_SPACEMOUSE_BACKEND_SPNAV # include "spnavbackend.h" #endif +#ifdef QET_SPACEMOUSE_BACKEND_HID +# include "hidbackend.h" +#endif #include "../diagramview.h" #include "../editor/elementview.h" @@ -45,12 +48,11 @@ SpaceMouseListener::SpaceMouseListener(QObject *parent) : QObject(parent), m_settings(SpaceMouseSettings::load()) { -#ifdef QET_SPACEMOUSE_BACKEND_SPNAV +#if defined(QET_SPACEMOUSE_BACKEND_SPNAV) m_backend = new SpnavBackend(this); +#elif defined(QET_SPACEMOUSE_BACKEND_HID) + m_backend = new HidBackend(this); #endif - //A future Windows/macOS backend (3Dconnexion's proprietary 3DxWare - //SDK) slots in here behind its own QET_SPACEMOUSE_BACKEND_3DXWARE - //guard, without changing anything below this constructor. if (m_backend) { connect(m_backend, &SpaceMouseBackend::motion, diff --git a/sources/spacemouse/spacemousemotion.cpp b/sources/spacemouse/spacemousemotion.cpp index 96de9624e..1f86d8e67 100644 --- a/sources/spacemouse/spacemousemotion.cpp +++ b/sources/spacemouse/spacemousemotion.cpp @@ -91,7 +91,7 @@ bool SpaceMouseSettings::operator==(const SpaceMouseSettings &other) const */ qreal SpaceMouseMotion::stepFor(qint64 elapsed_ms) { - if (elapsed_ms <= 0 || elapsed_ms > MAX_PERIOD_MS) { + if (elapsed_ms < 0 || elapsed_ms > MAX_PERIOD_MS) { return 1.0; } return elapsed_ms / NOMINAL_PERIOD_MS; diff --git a/sources/spacemouse/spacemousemotion.h b/sources/spacemouse/spacemousemotion.h index dbd191db4..c25ca0370 100644 --- a/sources/spacemouse/spacemousemotion.h +++ b/sources/spacemouse/spacemousemotion.h @@ -85,7 +85,9 @@ namespace SpaceMouseMotion constexpr qint64 MAX_PERIOD_MS = 100; /// @return how many nominal periods a sample covers, given the - /// milliseconds since the previous one (negative = no previous one) + /// milliseconds since the previous one: none for a sample in the + /// same millisecond (it covers no time), one for the first sample + /// or the first after a rest (elapsed negative or over the maximum) qreal stepFor(qint64 elapsed_ms); /// @return \a value with \a dead_zone removed: 0 inside it, and diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 0f6c3b2d9..16ec73fb1 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -192,6 +192,17 @@ add_test(NAME tst_spacemousemotion COMMAND tst_spacemousemotion) target_include_directories(tst_spacemousemotion PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_spacemousemotion PRIVATE Qt::Test Qt::Core) +# SpaceMouseHid -- decoding a 3D mouse's raw USB reports. Pure code, built +# whether or not QET_ENABLE_SPACEMOUSE is on; recordings from real devices +# go in fixtures/spacemouse/. +add_executable( + tst_spacemousehid + tst_spacemousehid.cpp + ${QET_DIR}/sources/spacemouse/spacemousehid.cpp) +add_test(NAME tst_spacemousehid COMMAND tst_spacemousehid) +target_include_directories(tst_spacemousehid PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_spacemousehid PRIVATE Qt::Test Qt::Core) + # CrashHandler::formatInt() -- the async-signal-safe decimal formatter the # signal handler uses for the "Signal: N" line of a crash dump. Compiles # crashhandler.cpp and logring.cpp alongside; the handler deliberately diff --git a/tests/qttest/tst_spacemousehid.cpp b/tests/qttest/tst_spacemousehid.cpp new file mode 100644 index 000000000..5aa06242c --- /dev/null +++ b/tests/qttest/tst_spacemousehid.cpp @@ -0,0 +1,353 @@ +/* + 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 . +*/ + +/* + SpaceMouseHid -- decoding a 3D mouse's raw USB reports, for the backend + that reads the device directly (Windows, macOS, and Linux without + spacenavd). + + The descriptors below are written by hand from the HID specification, + in the shape 3Dconnexion devices use. Recordings from real devices, + 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. +*/ + +#include "spacemouse/spacemousehid.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace SpaceMouseHid; + +namespace { + QByteArray hex(const char *text) { return QByteArray::fromHex(text); } + + // Report 1: X, Y, Z; report 2: Rx, Ry, Rz; each 16-bit, -350..350, + // Input(Data,Var,). Report 3: two buttons and padding. + QByteArray classicDescriptor(bool relative) + { + const char *input = relative ? "8106" : "8102"; + return hex(QByteArray("05010908a101" + "a100" "8501" "16a2fe" "265e01" "093009310932" "7510" "9503") + input + "c0" + "a100" "8502" "093309340935" "7510" "9503" + input + "c0" + "a102" "8503" "0509" "1901" "2902" "1500" "2501" "7501" "9502" "8102" + "950e" "8103" "c0" + "c0"); + } + + QByteArray report(int id, std::initializer_list values) + { + QByteArray r(1, char(id)); + for (qint16 v : values) { + r.append(char(v & 0xff)); + r.append(char((v >> 8) & 0xff)); + } + return r; + } +} + +class TstSpaceMouseHid : public QObject +{ + Q_OBJECT + + private slots: + void recognisesOnly3DMice_data(); + void recognisesOnly3DMice(); + void parsesTheClassicDescriptor(); + void parsesADescriptorWithoutReportIds(); + void survivesBrokenDescriptors_data(); + void survivesBrokenDescriptors(); + void separateTranslationAndRotationReports(); + void combinedReportOnTheFallbackLayout(); + void absoluteAxesAreScaledLikeSpacenavd(); + void relativeAxesArePassedThrough(); + void buttonBitmaskReportsEachPressOnce(); + void buttonListReport(); + void ignoresShortAndUnknownReports(); + void recordedDevices_data(); + void recordedDevices(); +}; + +void TstSpaceMouseHid::recognisesOnly3DMice_data() +{ + QTest::addColumn("vendor"); + QTest::addColumn("product"); + QTest::addColumn("page"); + QTest::addColumn("usage"); + QTest::addColumn("match"); + QTest::newRow("3Dconnexion, multi-axis") << 0x256f << 0xc635 << 1 << 8 << true; + QTest::newRow("3Dconnexion, usage unknown") << 0x256f << 0xc652 << 0 << 0 << true; + QTest::newRow("3Dconnexion receiver's mouse interface") << 0x256f << 0xc652 << 1 << 2 << false; + QTest::newRow("Logitech SpaceNavigator, usage unknown") << 0x046d << 0xc626 << 0 << 0 << true; + QTest::newRow("Logitech, multi-axis") << 0x046d << 0xc629 << 1 << 8 << true; + QTest::newRow("Logitech receiver, usage unknown") << 0x046d << 0xc52b << 0 << 0 << false; + QTest::newRow("other vendor, multi-axis") << 0x045e << 0x0001 << 1 << 8 << false; +} + +void TstSpaceMouseHid::recognisesOnly3DMice() +{ + QFETCH(int, vendor); + QFETCH(int, product); + QFETCH(int, page); + QFETCH(int, usage); + QFETCH(bool, match); + QCOMPARE(isSpaceMouse(vendor, product, page, usage), match); +} + +void TstSpaceMouseHid::parsesTheClassicDescriptor() +{ + const Layout layout = parseDescriptor(classicDescriptor(true)); + QVERIFY(layout.numbered_reports); + QVERIFY(layout.hasAxes()); + for (int a = 0; a < 6; ++a) { + const Field &f = layout.axes[a]; + QCOMPARE(f.report_id, a < 3 ? 1 : 2); + QCOMPARE(f.bit_offset, (a % 3) * 16); + QCOMPARE(f.bit_size, 16); + QCOMPARE(f.logical_min, -350); + QCOMPARE(f.logical_max, 350); + QVERIFY(f.relative); + } + QCOMPARE(layout.buttons.size(), 2); + QCOMPARE(layout.buttons[0].report_id, 3); + QCOMPARE(layout.buttons[0].bit_offset, 0); + QCOMPARE(layout.buttons[1].bit_offset, 1); + QCOMPARE(layout.buttons[1].bit_size, 1); + + QVERIFY(!parseDescriptor(classicDescriptor(false)).axes[0].relative); +} + +void TstSpaceMouseHid::parsesADescriptorWithoutReportIds() +{ + // one report: X, Y, Z as 8-bit signed, then 8 buttons + const QByteArray d = hex("05010908a101" "1581" "257f" "093009310932" "7508" "9503" "8106" + "0509" "1901" "2908" "1500" "2501" "7501" "9508" "8102" "c0"); + const Layout layout = parseDescriptor(d); + QVERIFY(!layout.numbered_reports); + QCOMPARE(layout.axes[2].bit_offset, 16); + QCOMPARE(layout.buttons.size(), 8); + QCOMPARE(layout.buttons[7].bit_offset, 24 + 7); + + Decoder decoder(layout); + const Decoder::Result r = decoder.feed(hex("05fb7f80")); // 5, -5, 127, button 8 + QVERIFY(r.motion); + QCOMPARE(r.sample.x, 5); + QCOMPARE(r.sample.y, -5); + QCOMPARE(r.sample.z, 127); + QCOMPARE(r.pressed, QList{7}); +} + +void TstSpaceMouseHid::survivesBrokenDescriptors_data() +{ + QTest::addColumn("descriptor"); + QTest::newRow("empty") << QByteArray(); + QTest::newRow("truncated item") << hex("0501090816"); + QTest::newRow("long item, truncated") << hex("fe"); + QTest::newRow("pop with empty stack") << hex("b4b4b4"); + QTest::newRow("huge report count") << hex("05010930" "7510" "97ffffff7f" "8102"); + QTest::newRow("classic, cut in half") << classicDescriptor(true).left(30); +} + +void TstSpaceMouseHid::survivesBrokenDescriptors() +{ + QFETCH(QByteArray, descriptor); + QElapsedTimer timer; + timer.start(); + const Layout layout = parseDescriptor(descriptor); + QVERIFY2(timer.elapsed() < 500, "a broken descriptor must not stall the parser"); + Decoder decoder(layout); + decoder.feed(report(1, {1, 2, 3})); // must not crash or read out of bounds +} + +void TstSpaceMouseHid::separateTranslationAndRotationReports() +{ + Decoder decoder(parseDescriptor(classicDescriptor(true))); + + Decoder::Result r = decoder.feed(report(1, {100, -5, 20})); + QVERIFY(r.motion); + QCOMPARE(r.sample.x, 100); + QCOMPARE(r.sample.y, -5); + QCOMPARE(r.sample.z, 20); + QCOMPARE(r.sample.rz, 0); + + r = decoder.feed(report(2, {7, 8, -9})); + QVERIFY(r.motion); + QCOMPARE(r.sample.x, 100); // translation remembered + QCOMPARE(r.sample.rx, 7); + QCOMPARE(r.sample.ry, 8); + QCOMPARE(r.sample.rz, -9); +} + +void TstSpaceMouseHid::combinedReportOnTheFallbackLayout() +{ + Decoder decoder; + const Decoder::Result r = decoder.feed(report(1, {1, 2, 3, 4, 5, 6})); + QVERIFY(r.motion); + QCOMPARE(r.sample.x, 1); + QCOMPARE(r.sample.z, 3); + QCOMPARE(r.sample.rx, 4); + QCOMPARE(r.sample.rz, 6); +} + +void TstSpaceMouseHid::absoluteAxesAreScaledLikeSpacenavd() +{ + Decoder decoder(parseDescriptor(classicDescriptor(false))); + const Decoder::Result r = decoder.feed(report(1, {350, -350, 0})); + QCOMPARE(r.sample.x, 500); + QCOMPARE(r.sample.y, -500); + QCOMPARE(r.sample.z, 0); +} + +void TstSpaceMouseHid::relativeAxesArePassedThrough() +{ + Decoder decoder(parseDescriptor(classicDescriptor(true))); + const Decoder::Result r = decoder.feed(report(1, {350, -350, 0})); + QCOMPARE(r.sample.x, 350); + QCOMPARE(r.sample.y, -350); +} + +void TstSpaceMouseHid::buttonBitmaskReportsEachPressOnce() +{ + Decoder decoder; + QCOMPARE(decoder.feed(hex("0301000000")).pressed, QList{0}); + QCOMPARE(decoder.feed(hex("0301000000")).pressed, QList{}); // still held + QCOMPARE(decoder.feed(hex("0303000000")).pressed, QList{1}); + QCOMPARE(decoder.feed(hex("0300000000")).pressed, QList{}); // released + QCOMPARE(decoder.feed(hex("0302000000")).pressed, QList{1}); + QCOMPARE(decoder.feed(hex("0300010000")).pressed, QList{8}); + QVERIFY(!decoder.feed(hex("0300000000")).motion); +} + +void TstSpaceMouseHid::buttonListReport() +{ + Decoder decoder; + // numbers start at 1 (0 = none); reported from 0, like the others + QCOMPARE(decoder.feed(hex("1c0d000000")).pressed, QList{12}); + QCOMPARE(decoder.feed(hex("1c0d000e00")).pressed, QList{13}); + QCOMPARE(decoder.feed(hex("1c00000000")).pressed, QList{}); + QCOMPARE(decoder.feed(hex("1c0d00")).pressed, QList{12}); + QCOMPARE(decoder.feed(hex("1c01000000")).pressed, QList{0}); // the lowest number, 1 + +} + +void TstSpaceMouseHid::ignoresShortAndUnknownReports() +{ + Decoder decoder(parseDescriptor(classicDescriptor(true))); + QVERIFY(!decoder.feed(QByteArray()).motion); + QVERIFY(!decoder.feed(hex("01ab")).motion); + QVERIFY(!decoder.feed(hex("55010203040506")).motion); + QVERIFY(decoder.feed(hex("55010203040506")).pressed.isEmpty()); +} + +void TstSpaceMouseHid::recordedDevices_data() +{ + QTest::addColumn("path"); + const QDir dir(QFINDTESTDATA("fixtures/spacemouse")); + const QStringList files = dir.exists() + ? dir.entryList({QStringLiteral("*.json")}, QDir::Files) + : QStringList(); + for (const QString &f : files) { + QTest::newRow(qPrintable(f)) << dir.filePath(f); + } + if (files.isEmpty()) { + QTest::newRow("none") << QString(); + } +} + +/* + For each recording, the axis the user moved must be the one that moved + most, in the right direction. The expected signs are the raw USB + convention as Blender documents it for Windows, and match the Linux + kernel's (so spacenavd's defaults): +x right, +y towards the user, + +z down. A recording that disagrees is a finding about that device. +*/ +void TstSpaceMouseHid::recordedDevices() +{ + QFETCH(QString, path); + if (path.isEmpty()) { + QSKIP("no recordings in fixtures/spacemouse yet"); + } + + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QJsonObject capture = QJsonDocument::fromJson(file.readAll()).object(); + const QByteArray descriptor = QByteArray::fromHex( + capture.value("report_descriptor").toString().toLatin1()); + Layout layout = parseDescriptor(descriptor); + if (!layout.hasAxes()) { + layout = fallbackLayout(); + } + + struct Expect { const char *step; int axis; int sign; }; + const Expect expectations[] = { + {"right", 0, +1}, {"left", 0, -1}, + {"toward", 1, +1}, {"away", 1, -1}, + {"down", 2, +1}, {"up", 2, -1}, + }; + + QHash steps; + for (const QJsonValue &s : capture.value("steps").toArray()) { + steps.insert(s.toObject().value("step").toString(), + s.toObject().value("reports").toArray()); + } + + for (const Expect &e : expectations) + { + if (!steps.contains(e.step)) { + continue; + } + Decoder decoder(layout); + qint64 sum[6] = {0, 0, 0, 0, 0, 0}; + for (const QJsonValue &r : steps.value(e.step)) { + const Decoder::Result res = decoder.feed( + QByteArray::fromHex(r.toArray().at(1).toString().toLatin1())); + if (res.motion) { + const int v[6] = {res.sample.x, res.sample.y, res.sample.z, + res.sample.rx, res.sample.ry, res.sample.rz}; + for (int a = 0; a < 3; ++a) sum[a] += v[a]; + } + } + int strongest = 0; + for (int a = 1; a < 3; ++a) { + if (qAbs(sum[a]) > qAbs(sum[strongest])) strongest = a; + } + QVERIFY2(strongest == e.axis && (sum[e.axis] > 0) == (e.sign > 0), + qPrintable(QStringLiteral("step '%1': sums x=%2 y=%3 z=%4") + .arg(e.step).arg(sum[0]).arg(sum[1]).arg(sum[2]))); + } + + // every recorded button press must decode as a press + if (steps.contains("buttons")) { + Decoder decoder(layout); + int presses = 0; + for (const QJsonValue &r : steps.value("buttons")) { + presses += decoder.feed(QByteArray::fromHex( + r.toArray().at(1).toString().toLatin1())).pressed.size(); + } + QVERIFY2(presses > 0, "the buttons step decoded no presses"); + } +} + +QTEST_GUILESS_MAIN(TstSpaceMouseHid) +#include "tst_spacemousehid.moc" diff --git a/tests/qttest/tst_spacemousemotion.cpp b/tests/qttest/tst_spacemousemotion.cpp index 2a80be01b..55a59ea29 100644 --- a/tests/qttest/tst_spacemousemotion.cpp +++ b/tests/qttest/tst_spacemousemotion.cpp @@ -86,7 +86,7 @@ void TstSpaceMouseMotion::stepFollowsElapsedTime_data() QTest::addColumn("elapsed"); QTest::addColumn("step"); QTest::newRow("first sample") << qint64(-1) << 1.0; - QTest::newRow("same millisecond") << qint64(0) << 1.0; + QTest::newRow("same millisecond: covers no time") << qint64(0) << 0.0; QTest::newRow("twice as fast") << qint64(8) << 0.5; QTest::newRow("nominal") << NOMINAL << 1.0; QTest::newRow("twice as slow") << qint64(32) << 2.0;