From aaeaff55cc48acb76f86bf933331a734c0db93e1 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 25 Sep 2026 10:55:30 +1200 Subject: [PATCH] Add a hidapi 3D mouse backend that needs no 3Dconnexion driver The 3D mouse only worked on Linux, through spacenavd. This adds a second backend that reads the device directly over USB through hidapi, with no 3Dconnexion driver or SDK: the route to Windows and macOS (discussion #599), and usable on Linux without spacenavd. SpaceMouseHid decodes the raw reports from the device's own report descriptor -- where each axis and button sits, its range, absolute or relative -- so no per-model table is needed, with the classic report 1/2/3 layout as a fallback when the descriptor cannot be read and the 0x1c button list newer devices send. Absolute axes are rescaled to +-500 exactly as spacenavd does, so both backends give QET the same values. HidBackend polls from the main thread (fast while moving, slow when still), emits one sample per poll, and looks for a device every 3 s so plugging one in or back in needs no restart. QET_SPACEMOUSE_BACKEND (auto, spnav, hid) picks the backend; auto keeps libspnav on Linux when it is found and uses hidapi otherwise. hidapi is found through pkg-config as hidapi-hidraw (Linux) or hidapi (MSYS2, Homebrew). A sample arriving in the same millisecond as the previous one now counts for no time instead of a full period, so a burst of queued samples no longer moves the view further than the time it covers. Tested without a device: tst_spacemousehid (descriptor parsing, broken and hostile descriptors, every report form, recordings from real devices once they are added to fixtures/spacemouse), and end to end on Linux through a virtual USB device created with /dev/uhid: the same moves give byte-identical screenshots through the hidapi and libspnav backends, an absolute axis is rescaled as spacenavd does, buttons trigger their bound action, and unplugging and replugging while QET runs (including with a dialog open that a device button opened) reconnects cleanly. Not tested on Windows, macOS or real hardware. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 +- cmake/developer_options.cmake | 8 +- cmake/find_spacemouse.cmake | 74 +++-- cmake/qet_compilation_vars.cmake | 9 + sources/spacemouse/hidbackend.cpp | 197 ++++++++++++ sources/spacemouse/hidbackend.h | 69 +++++ sources/spacemouse/spacemousebackend.h | 23 +- sources/spacemouse/spacemousehid.cpp | 357 ++++++++++++++++++++++ sources/spacemouse/spacemousehid.h | 107 +++++++ sources/spacemouse/spacemouselistener.cpp | 10 +- sources/spacemouse/spacemousemotion.cpp | 2 +- sources/spacemouse/spacemousemotion.h | 4 +- tests/qttest/CMakeLists.txt | 11 + tests/qttest/tst_spacemousehid.cpp | 353 +++++++++++++++++++++ tests/qttest/tst_spacemousemotion.cpp | 2 +- 15 files changed, 1176 insertions(+), 55 deletions(-) create mode 100644 sources/spacemouse/hidbackend.cpp create mode 100644 sources/spacemouse/hidbackend.h create mode 100644 sources/spacemouse/spacemousehid.cpp create mode 100644 sources/spacemouse/spacemousehid.h create mode 100644 tests/qttest/tst_spacemousehid.cpp 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/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..9e4876e85 --- /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 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;