mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 14:14:15 +02:00
aaeaff55cc
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 <noreply@anthropic.com>
198 lines
4.9 KiB
C++
198 lines
4.9 KiB
C++
/*
|
|
Copyright 2006-2026 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "hidbackend.h"
|
|
|
|
#include <QTimer>
|
|
|
|
#include <hidapi.h>
|
|
|
|
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<const char *>(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<int> 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<const char *>(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;
|
|
}
|
|
}
|