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;