SpaceBridge/SpaceBridge.py

846 lines
40 KiB
Python

#!/usr/bin/env python3
import ctypes
import time
import threading
import sys
import os
from evdev import UInput, ecodes, AbsInfo
import logging
# Logging levels used in this script: logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG
LOGLEVEL = logging.WARNING # Set to DEBUG for detailed output during troubleshooting
# --- Setup Logging ---
def setup_logging(level=LOGLEVEL):
"""Configures the standard Python logging module."""
logging.basicConfig(
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
level=level
)
logger = logging.getLogger("SpaceControl")
# --- Configuration ---
SC_LIB_PATH = "/usr/lib/spacecontrol/lib/libspc_ctrl.so" # Verify this path
# --- Device Enablement Flags ---
# Set to True to enable the corresponding virtual device, False to disable.
ENABLE_3DMOUSE_VIRTUAL_DEVICE = True
ENABLE_GAMEPAD_VIRTUAL_DEVICE = False
# Configuration for the Virtual 3D Mouse (for spacenavd)
UINPUT_3DMOUSE_NAME = "SpaceController spacenavd"
UINPUT_3DMOUSE_VENDOR_ID = 0x046d # 3Dconnexion
UINPUT_3DMOUSE_PRODUCT_ID = 0xc627 # SpaceMouse Enterprise (Well-known PID supported by spacenavd)
# Configuration for the Virtual Gamepad
UINPUT_GAMEPAD_NAME = "SpaceController Virtual Gamepad"
UINPUT_GAMEPAD_VENDOR_ID = 0x1209 # Linux Foundation / Custom Development
UINPUT_GAMEPAD_PRODUCT_ID = 0x0001 # Custom Virtual Gamepad
# Axis scaling factors - apply to both devices
AXIS_SCALE = 20
# Define a constant for the threshold of high-level events, used for numerical comparison.
HIGH_LEVEL_EVENT_THRESHOLD = 0x20000
# APPL_FUNC_START is explicitly defined as an event ID here, matching the original map entry.
APPL_FUNC_START = 0x20020
# Comprehensive map of all SpaceControl event IDs and DLL status codes to their descriptive string names.
# This serves as the single source of truth for translating numeric event values.
EVENT_ID_TO_NAME = {
# Special status/control events
-1: "NOTHING_CHANGED", # Returned when no new motion data
# DLL Status Codes (from ScDllWrapper.java's intToStatus method)
0: "SC_OK",
1: "SC_COMMUNICATION_ERROR",
2: "SC_WRONG_DEVICE_INDEX",
3: "SC_PARAMETER_OUT_OF_0RANGE",
4: "SC_FILE_IO_ERROR",
5: "SC_KEYSTROKE_ERROR",
6: "SC_APPL_NOT_FOUND",
7: "SC_REGISTRY_ERROR",
8: "SC_NOT_SUPPORTED",
9: "SC_EXEC_CMD_ERROR",
10: "SC_THREAD_ERROR",
11: "SC_WRONG_USER",
# High-Level SpaceControl Device/Application Events
0x20020: "APPL_FUNC_START",
0x20000: "DEV_BASIC_SETTINGS_REQ",
0x20001: "DEV_ADVANCED_SETTINGS_REQ",
0x20002: "DEV_DEV_PARS_CHANGED",
0x20003: "DEV_UNKNOWN_COMMAND_BYTE",
0x20004: "DEV_PARAM_OUT_OF_RANGE",
0x20005: "DEV_PARSE_ERROR",
0x20006: "DEV_INTERNAL_DEVICE_ERROR",
0x20007: "DEV_WRONG_TRANSCEIVER_ID",
0x20008: "DEV_BUFFER_OVERFLOW",
0x20009: "DEV_FRONT",
0x2000A: "DEV_RIGHT",
0x2000B: "DEV_TOP",
0x2000C: "DEV_FIT",
0x2000D: "DEV_WHEEL_LEFT",
0x2000E: "DEV_WHEEL_RIGHT",
0x2000F: "EVT_HNDL_SENS_DLG",
0x20010: "EVT_HNDL_THRESH_DLG",
0x20011: "EVT_HNDL_LCD_DLG",
0x20012: "EVT_HNDL_LEDS_DLG",
0x20013: "EVT_APPL_IN_FRGRND",
0x20014: "EVT_HNDL_KBD_DLG",
0x20015: "EVT_HNDL_WFL_DLG",
0x20016: "DEV_BACK",
0x20017: "DEV_LEFT",
0x20018: "DEV_BOTTOM",
0x20019: "DEV_CTRL",
}
# --- SpaceControl Data Structures (from C headers - spc_ctrlr.h) ---
class ScStdData(ctypes.Structure):
_fields_ = [
("mX", ctypes.c_short), ("mY", ctypes.c_short), ("mZ", ctypes.c_short),
("mA", ctypes.c_short), ("mB", ctypes.c_short), ("mC", ctypes.c_short),
("mTraLmh", ctypes.c_int), ("mRotLmh", ctypes.c_int), ("event", ctypes.c_int),
("mTvSec", ctypes.c_long), ("mTvUsec", ctypes.c_long)
]
# --- Button and Event Mappings for evdev ---
# 3D Mouse specific button mappings (for spacenavd)
EVDEV_3DMOUSE_LOW_LEVEL_BUTTON_MAP = {
"SC_KEY_1": ecodes.BTN_MISC + 0, # Button 1
"SC_KEY_2": ecodes.BTN_MISC + 1, # Button 2
"SC_KEY_3": ecodes.BTN_MISC + 2,
"SC_KEY_4": ecodes.BTN_MISC + 3,
"SC_KEY_5": ecodes.BTN_MISC + 4,
"SC_KEY_6": ecodes.BTN_MISC + 5,
"SC_KEY_CTRL": ecodes.BTN_MISC + 6,
"SC_KEY_ALT": ecodes.BTN_MISC + 7,
"SC_KEY_SHIFT": ecodes.BTN_MISC + 8,
"SC_KEY_ESC": ecodes.BTN_MISC + 9,
"SC_KEY_FRONT": ecodes.BTN_MISC + 10,
"SC_KEY_RIGHT": ecodes.BTN_MISC + 11,
"SC_KEY_TOP": ecodes.BTN_MISC + 12,
"SC_KEY_FIT": ecodes.BTN_MISC + 13,
"SC_KEY_2D3D": ecodes.BTN_MISC + 14, # From GUI combobox string, not a direct high-level event
# 'B' variants (Button-Press versions, typically for double-press or long-press, from vks.ini)
"SC_KEY_1_B": ecodes.BTN_MISC + 17, "SC_KEY_2_B": ecodes.BTN_MISC + 18,
"SC_KEY_3_B": ecodes.BTN_MISC + 19, "SC_KEY_4_B": ecodes.BTN_MISC + 20,
"SC_KEY_5_B": ecodes.BTN_MISC + 21, "SC_KEY_6_B": ecodes.BTN_MISC + 22,
"SC_KEY_CTRL_B": ecodes.BTN_MISC + 23, "SC_KEY_ALT_B": ecodes.BTN_MISC + 24,
"SC_KEY_SHIFT_B": ecodes.BTN_MISC + 25, "SC_KEY_ESC_B": ecodes.BTN_MISC + 26,
"SC_KEY_FRONT_B": ecodes.BTN_MISC + 27, "SC_KEY_RIGHT_B": ecodes.BTN_MISC + 28,
"SC_KEY_TOP_B": ecodes.BTN_MISC + 29, "SC_KEY_FIT_B": ecodes.BTN_MISC + 30,
"SC_KEY_2D3D_B": ecodes.BTN_MISC + 31,
}
# Mapping of SpaceControl high-level event values to evdev button codes for 3D mouse.
HIGH_LEVEL_EVENT_MAP_FOR_UINPUT = {
0x20009: ecodes.BTN_SIDE, # DEV_FRONT
0x2000A: ecodes.BTN_EXTRA, # DEV_RIGHT
0x2000B: ecodes.BTN_FORWARD, # DEV_TOP
0x2000C: ecodes.BTN_GEAR_UP, # DEV_FIT
0x20016: ecodes.BTN_SIDE + 1, # DEV_BACK
0x20017: ecodes.BTN_EXTRA + 1, # DEV_LEFT
0x20018: ecodes.BTN_FORWARD + 1, # DEV_BOTTOM
0x2000D: ecodes.BTN_LEFT, # DEV_WHEEL_LEFT
0x2000E: ecodes.BTN_RIGHT, # DEV_WHEEL_RIGHT
0x2000F: ecodes.BTN_MISC + 34, # EVT_HNDL_SENS_DLG
0x20010: ecodes.BTN_MISC + 35, # EVT_HNDL_THRESH_DLG
0x20011: ecodes.BTN_MISC + 36, # EVT_HNDL_LCD_DLG
0x20012: ecodes.BTN_MISC + 37, # EVT_HNDL_LEDS_DLG
0x20013: ecodes.BTN_MISC + 38, # EVT_APPL_IN_FRGRND
0x20014: ecodes.BTN_MISC + 39, # EVT_HNDL_KBD_DLG
0x20015: ecodes.BTN_MISC + 40, # EVT_HNDL_WFL_DLG
0x20019: ecodes.BTN_MISC + 41, # DEV_CTRL
# Error and basic settings events are not typically mapped to virtual device buttons.
}
# Combine all unique button codes for 3D Mouse UInput capabilities
ALL_3DMOUSE_UINPUT_BUTTONS = list(set(list(EVDEV_3DMOUSE_LOW_LEVEL_BUTTON_MAP.values()) + list(HIGH_LEVEL_EVENT_MAP_FOR_UINPUT.values())))
# Gamepad specific button mappings
EVDEV_GAMEPAD_BUTTON_MAP = {
"SC_KEY_1": ecodes.BTN_A,
"SC_KEY_2": ecodes.BTN_B,
"SC_KEY_3": ecodes.BTN_X,
"SC_KEY_4": ecodes.BTN_Y,
"SC_KEY_5": ecodes.BTN_TL, # Left Shoulder
"SC_KEY_6": ecodes.BTN_TR, # Right Shoulder
"SC_KEY_CTRL": ecodes.BTN_SELECT,
"SC_KEY_ALT": ecodes.BTN_START,
"SC_KEY_SHIFT": ecodes.BTN_THUMBL, # Left Stick Click
"SC_KEY_ESC": ecodes.BTN_THUMBR, # Right Stick Click
"SC_KEY_FRONT": ecodes.BTN_DPAD_UP, # D-pad up
"SC_KEY_RIGHT": ecodes.BTN_DPAD_RIGHT, # D-pad right
"SC_KEY_TOP": ecodes.BTN_DPAD_DOWN, # D-pad down (for consistency in mapping, top -> down is arbitrary)
"SC_KEY_FIT": ecodes.BTN_DPAD_LEFT, # D-pad left
"SC_KEY_2D3D": ecodes.BTN_TRIGGER_HAPPY1, # Generic additional button
"SC_KEY_1_B": ecodes.BTN_TRIGGER_HAPPY4, # More generic buttons
"SC_KEY_2_B": ecodes.BTN_TRIGGER_HAPPY5,
"SC_KEY_3_B": ecodes.BTN_TRIGGER_HAPPY6,
"SC_KEY_4_B": ecodes.BTN_TRIGGER_HAPPY7,
"SC_KEY_5_B": ecodes.BTN_TRIGGER_HAPPY8,
"SC_KEY_6_B": ecodes.BTN_TRIGGER_HAPPY9,
"SC_KEY_CTRL_B": ecodes.BTN_TRIGGER_HAPPY10,
"SC_KEY_ALT_B": ecodes.BTN_TRIGGER_HAPPY11,
"SC_KEY_SHIFT_B": ecodes.BTN_TRIGGER_HAPPY12,
"SC_KEY_ESC_B": ecodes.BTN_TRIGGER_HAPPY13,
"SC_KEY_FRONT_B": ecodes.BTN_TRIGGER_HAPPY14,
"SC_KEY_RIGHT_B": ecodes.BTN_TRIGGER_HAPPY15,
"SC_KEY_TOP_B": ecodes.BTN_TRIGGER_HAPPY16,
"SC_KEY_FIT_B": ecodes.BTN_TRIGGER_HAPPY17,
"SC_KEY_2D3D_B": ecodes.BTN_TRIGGER_HAPPY18,
# Mapping high-level DEV_WHEEL_LEFT/RIGHT to gamepad buttons
"SC_DEV_WHEEL_LEFT": ecodes.BTN_TL2, # Left Trigger 2 (often for secondary actions)
"SC_DEV_WHEEL_RIGHT": ecodes.BTN_TR2, # Right Trigger 2 (often for secondary actions)
"SC_EVT_HNDL_SENS_DLG": ecodes.BTN_TRIGGER_HAPPY19,
"SC_EVT_HNDL_THRESH_DLG": ecodes.BTN_TRIGGER_HAPPY20,
"SC_EVT_HNDL_LCD_DLG": ecodes.BTN_TRIGGER_HAPPY21,
"SC_EVT_HNDL_LEDS_DLG": ecodes.BTN_TRIGGER_HAPPY22,
"SC_EVT_APPL_IN_FRGRND": ecodes.BTN_TRIGGER_HAPPY23,
"SC_EVT_HNDL_KBD_DLG": ecodes.BTN_TRIGGER_HAPPY24,
"SC_EVT_HNDL_WFL_DLG": ecodes.BTN_TRIGGER_HAPPY25,
"SC_DEV_CTRL": ecodes.BTN_TRIGGER_HAPPY26,
}
ALL_GAMEPAD_UINPUT_BUTTONS = list(EVDEV_GAMEPAD_BUTTON_MAP.values())
# SpaceController button bit positions (for decoding 'event' field as bitmask)
# This mapping assumes a 1-to-1 correspondence with the bit position in the 'event' field.
# IMPORTANT: Panel and Menu buttons have fixed internal daemon functions and should not be
# attempted to be remapped by our script to avoid conflicts.
BUTTON_BIT_MAP = {
0: "SC_KEY_1", 1: "SC_KEY_2", 2: "SC_KEY_3", 3: "SC_KEY_4", 4: "SC_KEY_5",
5: "SC_KEY_6", 6: "SC_KEY_CTRL", 7: "SC_KEY_ALT", 8: "SC_KEY_SHIFT",
9: "SC_KEY_ESC", 10: "SC_KEY_FRONT", 11: "SC_KEY_RIGHT", 12: "SC_KEY_TOP",
13: "SC_KEY_FIT", 14: "SC_KEY_2D3D",
# 15: "SC_KEY_PANEL", # Removed as it is a high-level event used by the device
# 16: "SC_KEY_MENU", # Removed as it is a high-level event used by the device
17: "SC_KEY_1_B", 18: "SC_KEY_2_B", 19: "SC_KEY_3_B", 20: "SC_KEY_4_B",
21: "SC_KEY_5_B", 22: "SC_KEY_6_B", 23: "SC_KEY_CTRL_B", 24: "SC_KEY_ALT_B",
25: "SC_KEY_SHIFT_B", 26: "SC_KEY_ESC_B", 27: "SC_KEY_FRONT_B",
28: "SC_KEY_RIGHT_B", 29: "SC_KEY_TOP_B", 30: "SC_KEY_FIT_B",
31: "SC_KEY_2D3D_B",
}
# Helper function to decode event
def decode_event(event_value):
# Check if the value is a known event ID in the consolidated map
if event_value in EVENT_ID_TO_NAME:
return EVENT_ID_TO_NAME[event_value]
# Handle low-level button event bitmasks
# These are typically positive integers less than the first high-level event (0x20000).
# We use HIGH_LEVEL_EVENT_THRESHOLD as the upper bound for these bitmask events.
if event_value > 0 and event_value < HIGH_LEVEL_EVENT_THRESHOLD:
pressed_buttons = []
for bit_position, sc_button_name in BUTTON_BIT_MAP.items():
if (event_value >> bit_position) & 1:
pressed_buttons.append(sc_button_name)
return f"Key event: {' + '.join(pressed_buttons)}" if pressed_buttons else f"Key event {event_value}"
# Default case for unknown values
return f"Unknown event {event_value}"
# --- Daemon Communication Class ---
class ScDaemonComm:
def __init__(self, sc_dll):
self.sc_dll = sc_dll
self._is_connected = False
self._dev_count = 0
self._used_dev_count = 0
self._max_dev_idx = 0
logger.info("Initializing daemon communication interface.")
# Define function prototypes for ctypes
# scConnect2 signature from ScDllWrapper.java: connect2(ScOneBoolean, ScOneString)
self.sc_dll.scConnect2.argtypes = [
ctypes.c_bool, # isAlwaysReceivingData flag
ctypes.c_char_p # application name string (NULL for anonymous)
]
self.sc_dll.scConnect2.restype = ctypes.c_int # Returns an int status
self.sc_dll.scDisconnect.argtypes = []
self.sc_dll.scDisconnect.restype = ctypes.c_int
self.sc_dll.scGetDevNum.argtypes = [
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int)
]
self.sc_dll.scGetDevNum.restype = ctypes.c_int
self.sc_dll.scFetchStdData.argtypes = [
ctypes.c_int, # devIdx
ctypes.POINTER(ctypes.c_short), # mX
ctypes.POINTER(ctypes.c_short), # mY
ctypes.POINTER(ctypes.c_short), # mZ
ctypes.POINTER(ctypes.c_short), # mA
ctypes.POINTER(ctypes.c_short), # mB
ctypes.POINTER(ctypes.c_short), # mC
ctypes.POINTER(ctypes.c_int), # mTraLmh
ctypes.POINTER(ctypes.c_int), # mRotLmh
ctypes.POINTER(ctypes.c_int), # event
ctypes.POINTER(ctypes.c_long), # mTvSec
ctypes.POINTER(ctypes.c_long) # mTvUsec
]
self.sc_dll.scFetchStdData.restype = ctypes.c_int
logger.info("Function prototypes defined based on spc_ctrlr.h and ScDllWrapper.")
def connect(self):
"""
Establishes the connection to the SpaceControl daemon using scConnect2.
'applName' is passed as NULL for anonymous connection.
'isAlwaysReceivingData' is hardcoded to True as it's typically required.
"""
# Pass ctypes.c_char_p(None) to explicitly send a C NULL for the application name.
# Hardcode is_always_receiving_data to True.
logger.info(f"Connecting using scConnect2 with anonymous application name (NULL) and isAlwaysReceivingData=True.")
sc_status = self.sc_dll.scConnect2(True, ctypes.c_char_p(None))
if sc_status == 0:
self._is_connected = True
logger.info("Successfully connected to daemon via scConnect2.")
dev_num_p = ctypes.c_int(0)
used_dev_num_p = ctypes.c_int(0)
max_dev_idx_p = ctypes.c_int(0)
status_get_dev_num = self.sc_dll.scGetDevNum(
ctypes.byref(dev_num_p),
ctypes.byref(used_dev_num_p),
ctypes.byref(max_dev_idx_p)
)
if status_get_dev_num == 0:
self._dev_count = dev_num_p.value
self._used_dev_count = used_dev_num_p.value
self._max_dev_idx = max_dev_idx_p.value
logger.info(f"Detected {self._dev_count} SpaceControl device(s). Used: {self._used_dev_count}, Max Index: {self._max_dev_idx}")
else:
logger.error(f"scGetDevNum() failed with status: {status_get_dev_num} ({decode_event(status_get_dev_num)})")
self._is_connected = False # Disconnect if device count retrieval fails
return self._is_connected
else:
self._is_connected = False
logger.error(f"Failed to connect to daemon via scConnect2. Connection status: {sc_status} ({decode_event(sc_status)})")
return False
def disconnect(self):
if self._is_connected:
self.sc_dll.scDisconnect()
self._is_connected = False
logger.info("Disconnected from daemon.")
def get_device_count(self):
return self._dev_count
def fetch_data(self, device_index):
"""Fetches data from the SpaceControl device."""
if not self._is_connected:
return None, 1 # Return error if not connected
# Prepare ctypes variables for output
x, y, z, a, b, c = (ctypes.c_short(0) for _ in range(6))
traLmh, rotLmh, event = (ctypes.c_int(0) for _ in range(3))
tvSec, tvUsec = (ctypes.c_long(0) for _ in range(2))
start_time = time.perf_counter()
sc_status = self.sc_dll.scFetchStdData(
device_index, ctypes.byref(x), ctypes.byref(y), ctypes.byref(z),
ctypes.byref(a), ctypes.byref(b), ctypes.byref(c),
ctypes.byref(traLmh), ctypes.byref(rotLmh), ctypes.byref(event),
ctypes.byref(tvSec), ctypes.byref(tvUsec)
)
end_time = time.perf_counter()
# logger.debug(f"[DEBUG_TIMING] scFetchStdData took: {(end_time - start_time) * 1000:.3f} ms") # Uncomment for detailed timing logs of each fetch call
if sc_status == 0:
sc_data_result = {
"x": x.value, "y": y.value, "z": z.value,
"a": a.value, "b": b.value, "c": c.value,
"traLmh": traLmh.value, "rotLmh": rotLmh.value,
"event": event.value,
"tvSec": tvSec.value, "tvUsec": tvUsec.value
}
return sc_data_result, 0
else:
return None, sc_status
# --- Shared Data Container for thread-safe access ---
class SharedSpaceControlData:
"""Thread-safe container to pass data from acquirer thread to main thread."""
def __init__(self):
self._data = None
self._lock = threading.Lock()
self._new_data_event = threading.Event()
def set_data(self, data):
"""Sets new data and signals consumers."""
with self._lock:
self._data = data
self._new_data_event.set() # Signal that new data is available
def get_data(self, timeout=None):
"""Waits for and retrieves new data."""
self._new_data_event.wait(timeout) # Wait for new data to be set
with self._lock:
data_copy = self._data.copy() if self._data else None
self._new_data_event.clear() # Reset the event for the next data cycle
return data_copy
# --- SpaceControl Data Acquisition Thread ---
class SpaceControlDataAcquirer(threading.Thread):
"""
Dedicated thread to continuously acquire data from the SpaceControl daemon.
This offloads the blocking `scFetchStdData` call from the main thread.
"""
def __init__(self, daemon_comm, device_index, shared_data):
super().__init__()
self.daemon_comm = daemon_comm
self.device_index = device_index
self.shared_data = shared_data
self._running = True # Flag to control thread's main loop
logger.info("Data acquirer thread initialized.")
def run(self):
logger.info(f"DataAcquirer[{self.device_index}]: Starting data acquisition loop...")
while self._running:
data, status = self.daemon_comm.fetch_data(self.device_index)
# Log the raw data and status for debugging purposes
# Always log data if it's not None, regardless of status.
if data:
logger.debug(f"DataAcquirer[{self.device_index}]: Fetched data (raw): {data}, Status: {status} ({decode_event(status)})")
else:
logger.debug(f"DataAcquirer[{self.device_index}]: Fetched data: None, Status: {status} ({decode_event(status)})")
if status == 0 and data:
self.shared_data.set_data(data)
# If status is not 0 (SC_OK) AND not a "NOTHING_CHANGED" event value (1 or -1), then it's an error.
# We explicitly check for 1 and -1 here because decode_event maps them to "NOTHING_CHANGED".
# Any other non-zero status is a genuine error.
elif status != 0 and status != 1 and status != -1:
logger.error(f"DataAcquirer[{self.device_index}]: scFetchStdData() returned error status: {status} ({decode_event(status)})")
# If status is 1 or -1, it's NOTHING_CHANGED, which is expected when no movement.
elif status == 1 or status == -1:
logger.debug(f"DataAcquirer[{self.device_index}]: No new data (NOTHING_CHANGED), as expected. Status: {status} ({decode_event(status)})")
logger.info(f"DataAcquirer[{self.device_index}]: Data acquisition loop ended.")
def stop(self):
"""Signals the thread to stop its execution."""
self._running = False
# --- Base Virtual Controller Class ---
class BaseVirtualController:
"""
Base class for creating virtual evdev input devices (3D mouse or gamepad).
Handles common UInput setup and axis event generation.
"""
def __init__(self, device_index, uinput_name, uinput_vendor_id, uinput_product_id, all_uinput_buttons):
self.device_index = device_index
self.uinput_device = None
# Store last axis values to send events only on change
self.last_axis_values = {
"x": 0, "y": 0, "z": 0,
"a": 0, "b": 0, "c": 0
}
self.log_source = self.__class__.__name__
self._setup_uinput(uinput_name, uinput_vendor_id, uinput_product_id, all_uinput_buttons)
def _setup_uinput(self, uinput_name, uinput_vendor_id, uinput_product_id, all_uinput_buttons):
"""Sets up the virtual UInput device capabilities."""
capabilities = {
ecodes.EV_ABS: [
# Define 3 translational axes (X, Y, Z) and 3 rotational axes (RX, RY, RZ)
# Max/min values cover the typical range of SpaceControl data
(ecodes.ABS_X, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
(ecodes.ABS_Y, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
(ecodes.ABS_Z, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
(ecodes.ABS_RX, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
(ecodes.ABS_RY, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
(ecodes.ABS_RZ, AbsInfo(value=0, min=-32767, max=32767, fuzz=0, flat=0, resolution=0)),
],
ecodes.EV_KEY: all_uinput_buttons, # All possible buttons for this device
}
try:
self.uinput_device = UInput(
capabilities,
name=uinput_name,
vendor=uinput_vendor_id,
product=uinput_product_id,
bustype=ecodes.BUS_USB
)
logger.info(f"{self.log_source}: Virtual device '{uinput_name}' created successfully with Vendor: {hex(uinput_vendor_id)}, Product: {hex(uinput_product_id)}.")
return True
except Exception as e:
logger.error(f"{self.log_source}: Failed to create uinput device: {e}")
logger.error(f"{self.log_source}: Error type: {type(e).__name__}, Message: {e}")
logger.error(f"{self.log_source}: Ensure 'uinput' kernel module is loaded and you have permissions (e.g., in 'input' group).")
return False
def update(self, data):
"""
Processes raw SpaceControl data and sends axis events via UInput.
Returns whether motion occurred and the scaled axis values.
"""
if not self.uinput_device:
return False, {} # Indicate failure to update if device not ready
# Direct mapping: We assume the SpaceControl driver has already applied any remapping
# based on its loaded configuration file (e.g., FreeCAD.cfg.txt).
# Our script simply forwards the values it receives from the driver.
current_axis_values = {
"x": int(data["x"] * AXIS_SCALE),
"y": int(data["y"] * AXIS_SCALE),
"z": int(data["z"] * AXIS_SCALE),
"a": int(data["a"] * AXIS_SCALE),
"b": int(data["b"] * AXIS_SCALE),
"c": int(data["c"] * AXIS_SCALE)
}
motion_changed = False
# Send axis events only if the value has changed from the last known value
evdev_abs_map = {
"x": ecodes.ABS_X, "y": ecodes.ABS_Y, "z": ecodes.ABS_Z,
"a": ecodes.ABS_RX, "b": ecodes.ABS_RY, "c": ecodes.ABS_RZ
}
for axis, value in current_axis_values.items():
if value != self.last_axis_values[axis]:
self.uinput_device.write(ecodes.EV_ABS, evdev_abs_map[axis], value)
motion_changed = True
self.last_axis_values.update(current_axis_values)
return motion_changed, current_axis_values
def close(self):
"""Closes the virtual UInput device."""
if self.uinput_device:
self.uinput_device.close()
logger.info(f"{self.log_source}: Virtual device closed.")
# --- Virtual 3D Mouse Controller ---
class Virtual3DMouseController(BaseVirtualController):
"""
Manages the virtual 3D mouse device, handling both axis and button events.
Prioritizes high-level events over low-level bitmask buttons if both are present.
"""
def __init__(self, device_index):
super().__init__(device_index, UINPUT_3DMOUSE_NAME, UINPUT_3DMOUSE_VENDOR_ID, UINPUT_3DMOUSE_PRODUCT_ID, ALL_3DMOUSE_UINPUT_BUTTONS)
self.last_low_level_button_state = {} # To track state of bitmask buttons
self.last_high_level_event_state = None # To track state of high-level events (only one can be active at a time)
logger.info(f"{self.log_source}: 3D Mouse controller specific initialization complete.")
def update(self, data):
"""
Updates the 3D mouse virtual device with new data.
Handles both axis motion and button presses (low-level bitmask and high-level events).
"""
motion_changed, current_axis_values = super().update(data)
if not self.uinput_device:
return
# Log motion if it changes and is not zero
if motion_changed and any(val != 0 for val in current_axis_values.values()):
logger.info(f"{self.log_source}: Sending Motion: X={current_axis_values['x']} Y={current_axis_values['y']} Z={current_axis_values['z']} | "
f"RX={current_axis_values['a']} RY={current_axis_values['b']} RZ={current_axis_values['c']}")
current_event_value = data["event"]
is_any_axis_moving = any(abs(val) > 0 for val in current_axis_values.values())
# --- Handle High-Level (DEV_*) Events ---
# These are special events sent by the daemon, typically triggered by specific button combinations
# or special cap gestures (e.g., "Fit" view). They are mutually exclusive with low-level buttons
# when a high-level event occurs.
current_high_level_event = None
# Only consider high-level events if no significant axis motion
if current_event_value >= HIGH_LEVEL_EVENT_THRESHOLD and not is_any_axis_moving:
current_high_level_event = current_event_value
# Release the previous high-level event if it's no longer active
if self.last_high_level_event_state is not None and \
self.last_high_level_event_state != current_high_level_event and \
self.last_high_level_event_state in HIGH_LEVEL_EVENT_MAP_FOR_UINPUT:
evdev_button_code_to_release = HIGH_LEVEL_EVENT_MAP_FOR_UINPUT.get(self.last_high_level_event_state)
if evdev_button_code_to_release is not None:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code_to_release, 0)
logger.info(f"{self.log_source}: High-level event {decode_event(self.last_high_level_event_state)} (evdev {evdev_button_code_to_release}) RELEASED")
# Press the current high-level event if it's new and active
if current_high_level_event is not None and \
current_high_level_event != self.last_high_level_event_state and \
current_high_level_event in HIGH_LEVEL_EVENT_MAP_FOR_UINPUT:
evdev_button_code = HIGH_LEVEL_EVENT_MAP_FOR_UINPUT.get(current_high_level_event)
if evdev_button_code is not None:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code, 1)
logger.info(f"{self.log_source}: High-level event {decode_event(current_high_level_event)} (evdev {evdev_button_code}) PRESSED")
self.last_high_level_event_state = current_high_level_event
# --- Handle Low-Level (Bitmask) Buttons for 3D Mouse ---
# These are the physical buttons on the device, represented by a bitmask in the event field.
# They are only processed if no high-level event is active.
current_low_level_button_names = set()
# Process bitmask only if event value is within expected low-level range and no high-level event or motion
if current_event_value > 0 and current_event_value < HIGH_LEVEL_EVENT_THRESHOLD and \
current_high_level_event is None and not is_any_axis_moving:
for bit_position, sc_button_name in BUTTON_BIT_MAP.items():
if (current_event_value >> bit_position) & 1:
current_low_level_button_names.add(sc_button_name)
# Iterate through all possible low-level buttons and update their state
for sc_button_name in BUTTON_BIT_MAP.values():
if sc_button_name in EVDEV_3DMOUSE_LOW_LEVEL_BUTTON_MAP:
evdev_button_code = EVDEV_3DMOUSE_LOW_LEVEL_BUTTON_MAP[sc_button_name]
is_currently_pressed = sc_button_name in current_low_level_button_names
was_previously_pressed = self.last_low_level_button_state.get(sc_button_name, False)
if is_currently_pressed and not was_previously_pressed:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code, 1)
logger.info(f"{self.log_source}: Low-level Button {sc_button_name} (evdev {evdev_button_code}) PRESSED")
elif not is_currently_pressed and was_previously_pressed:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code, 0)
logger.info(f"{self.log_source}: Low-level Button {sc_button_name} (evdev {evdev_button_code}) RELEASED")
self.last_low_level_button_state[sc_button_name] = is_currently_pressed
self.uinput_device.syn() # Synchronize events
# --- Virtual Gamepad Controller ---
class VirtualGamepadController(BaseVirtualController):
"""
Manages the virtual gamepad device, handling both axis and button events.
Maps all SpaceControl buttons to standard gamepad buttons.
"""
def __init__(self, device_index):
super().__init__(device_index, UINPUT_GAMEPAD_NAME, UINPUT_GAMEPAD_VENDOR_ID, UINPUT_GAMEPAD_PRODUCT_ID, ALL_GAMEPAD_UINPUT_BUTTONS)
self.last_button_state = {} # To track state of gamepad buttons
self.last_high_level_event_state = None # Track high-level event for gamepad
logger.info(f"{self.log_source}: Gamepad controller specific initialization complete.")
def update(self, data):
"""
Updates the gamepad virtual device with new data.
Handles axis motion and all button presses (low-level bitmask and high-level events).
"""
motion_changed, current_axis_values = super().update(data)
if not self.uinput_device:
return
# Gamepad logging with custom axis names for clarity
if motion_changed and any(val != 0 for val in current_axis_values.values()):
logger.info(f"{self.log_source}: Sending Gamepad Motion: X-Trans={current_axis_values['x']} Y-Trans={current_axis_values['y']} Z-Trans={current_axis_values['z']} | "
f"Roll={current_axis_values['a']} Pitch={current_axis_values['b']} Yaw={current_axis_values['c']}")
current_event_value = data["event"]
is_any_axis_moving = any(abs(val) > 0 for val in current_axis_values.values())
# --- Handle High-Level (DEV_*) Events for Gamepad ---
# Explicitly map DEV_WHEEL_LEFT/RIGHT to gamepad buttons
current_high_level_event = None
if current_event_value >= HIGH_LEVEL_EVENT_THRESHOLD and not is_any_axis_moving:
current_high_level_event = current_event_value
# Release the previous high-level event if no longer active
if self.last_high_level_event_state is not None and \
self.last_high_level_event_state != current_high_level_event:
# Get the descriptive string from EVENT_ID_TO_NAME, then derive the gamepad map key.
event_name_from_map = EVENT_ID_TO_NAME.get(self.last_high_level_event_state)
gamepad_map_key = None
if event_name_from_map == "DEV_WHEEL_LEFT":
gamepad_map_key = "SC_DEV_WHEEL_LEFT"
elif event_name_from_map == "DEV_WHEEL_RIGHT":
gamepad_map_key = "SC_DEV_WHEEL_RIGHT"
elif event_name_from_map == "DEV_CTRL":
gamepad_map_key = "SC_DEV_CTRL"
elif event_name_from_map in ["EVT_HNDL_SENS_DLG", "EVT_HNDL_THRESH_DLG", "EVT_HNDL_LCD_DLG", "EVT_HNDL_LEDS_DLG", "EVT_APPL_IN_FRGRND", "EVT_HNDL_KBD_DLG", "EVT_HNDL_WFL_DLG"]:
gamepad_map_key = event_name_from_map
if gamepad_map_key and gamepad_map_key in EVDEV_GAMEPAD_BUTTON_MAP:
evdev_button_code_to_release = EVDEV_GAMEPAD_BUTTON_MAP[gamepad_map_key]
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code_to_release, 0)
logger.info(f"{self.log_source}: High-level Event {decode_event(self.last_high_level_event_state)} (evdev {evdev_button_code_to_release}) RELEASED for Gamepad")
# Press the current high-level event if it's new and active
if current_high_level_event is not None and \
current_high_level_event != self.last_high_level_event_state:
event_name_from_map = EVENT_ID_TO_NAME.get(current_high_level_event)
gamepad_map_key = None
if event_name_from_map == "DEV_WHEEL_LEFT":
gamepad_map_key = "SC_DEV_WHEEL_LEFT"
elif event_name_from_map == "DEV_WHEEL_RIGHT":
gamepad_map_key = "SC_DEV_WHEEL_RIGHT"
elif event_name_from_map == "DEV_CTRL":
gamepad_map_key = "SC_DEV_CTRL"
elif event_name_from_map in ["EVT_HNDL_SENS_DLG", "EVT_HNDL_THRESH_DLG", "EVT_HNDL_LCD_DLG", "EVT_HNDL_LEDS_DLG", "EVT_APPL_IN_FRGRND", "EVT_HNDL_KBD_DLG", "EVT_HNDL_WFL_DLG"]:
gamepad_map_key = event_name_from_map
if gamepad_map_key and gamepad_map_key in EVDEV_GAMEPAD_BUTTON_MAP:
evdev_button_code_to_press = EVDEV_GAMEPAD_BUTTON_MAP[gamepad_map_key]
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code_to_press, 1)
logger.info(f"{self.log_source}: High-level Event {decode_event(current_high_level_event)} (evdev {evdev_button_code_to_press}) PRESSED for Gamepad")
self.last_high_level_event_state = current_high_level_event
current_pressed_bit_positions = set()
# Process low-level bitmask buttons for Gamepad
# Filter out high-level events (>= HIGH_LEVEL_EVENT_THRESHOLD) from this bitmask processing.
if current_event_value > 0 and current_event_value < HIGH_LEVEL_EVENT_THRESHOLD and \
current_high_level_event is None and not is_any_axis_moving:
for bit_position, _ in BUTTON_BIT_MAP.items(): # Iterate over bit positions
if (current_event_value >> bit_position) & 1:
current_pressed_bit_positions.add(bit_position)
# Update state for all gamepad-relevant low-level buttons using the combined map
for bit_position, evdev_button_code in GAMEPAD_BIT_TO_EVDEV_CODE.items():
sc_button_name = BUTTON_BIT_MAP[bit_position] # Get the descriptive name for logging
is_currently_pressed = bit_position in current_pressed_bit_positions
was_previously_pressed = self.last_button_state.get(sc_button_name, False) # Track state by SC name
if is_currently_pressed and not was_previously_pressed:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code, 1)
logger.info(f"{self.log_source}: Button {sc_button_name} (evdev {evdev_button_code}) PRESSED")
elif not is_currently_pressed and was_previously_pressed:
self.uinput_device.write(ecodes.EV_KEY, evdev_button_code, 0)
logger.info(f"{self.log_source}: Button {sc_button_name} (evdev {evdev_button_code}) RELEASED")
self.last_button_state[sc_button_name] = is_currently_pressed # Update state by SC name
self.uinput_device.syn() # Synchronize events
# --- Main Execution Block ---
if __name__ == "__main__":
setup_logging(LOGLEVEL)
logger.info("--- SpaceControl Dual Virtual Device Bridge ---")
logger.info("This script creates two virtual devices: a 3D mouse (for spacenavd) and a generic gamepad, using a single data acquisition thread.")
logger.info("Ensure 'uinput' kernel module is loaded and you have permissions to /dev/uinput (e.g., in 'input' group).")
logger.info("Also, ensure the SpaceControl daemon/driver is running in the background.")
# Determine config file path from command line arguments
config_to_load = None
if len(sys.argv) > 1:
config_to_load = sys.argv[1]
if not os.path.exists(config_to_load):
logger.warning(f"Warning: Specified config file '{config_to_load}' not found. Daemon will likely load its default.")
config_to_load = None # Revert to None if file doesn't exist
# Check if the SpaceControl library exists at the specified path
if not os.path.exists(SC_LIB_PATH):
logger.error(f"Error: SpaceControl shared library not found at {SC_LIB_PATH}")
sys.exit(1)
try:
# Load the shared library
sc_dll = ctypes.CDLL(SC_LIB_PATH)
logger.info(f"Successfully loaded library: {SC_LIB_PATH}")
daemon_comm = ScDaemonComm(sc_dll)
if not daemon_comm.connect():
logger.error("Could not establish connection with SpaceControl daemon.")
logger.error("Please ensure the SpaceControl daemon/driver is running in the background.")
sys.exit(1)
# Check for connected devices
if daemon_comm.get_device_count() == 0:
logger.info("No SpaceControl devices detected by the daemon.")
logger.info("Ensure your device is plugged in and recognized by the SpaceControl GUI/driver.")
daemon_comm.disconnect()
sys.exit(0) # Exit gracefully if no device is found
shared_data_container = SharedSpaceControlData()
# Start the single data acquisition thread for the first device (index 0)
data_acquirer_thread = SpaceControlDataAcquirer(daemon_comm, 0, shared_data_container)
data_acquirer_thread.start()
logger.info("SpaceControl data acquisition thread started.")
# Initialize virtual device controllers based on enablement flags
mouse_controller = None
gamepad_controller = None
if ENABLE_3DMOUSE_VIRTUAL_DEVICE:
mouse_controller = Virtual3DMouseController(0)
logger.info("Virtual 3D Mouse controller initialized.")
else:
logger.info("Virtual 3D Mouse functionality is DISABLED by configuration.")
if ENABLE_GAMEPAD_VIRTUAL_DEVICE:
gamepad_controller = VirtualGamepadController(0)
logger.info("Virtual Gamepad controller initialized.")
else:
logger.info("Virtual Gamepad functionality is DISABLED by configuration.")
if not (ENABLE_3DMOUSE_VIRTUAL_DEVICE or ENABLE_GAMEPAD_VIRTUAL_DEVICE):
logger.warning("WARNING: Both virtual devices are disabled. No input will be sent.")
logger.info("Virtual device bridge running. Move SpaceController or press buttons.")
logger.info("Test the 3D mouse in applications like Blender or FreeCAD (ensure spacenavd is running in debug mode).")
logger.info("Test the gamepad with `jstest /dev/input/jsX` (replace X with the appropriate number) or in games.")
logger.info("Press Ctrl+C to stop.")
# Main loop to fetch new data from the acquirer thread and dispatch it
try:
while True:
# Wait for new data from the acquirer thread with a timeout
new_data = shared_data_container.get_data(timeout=0.5)
if new_data:
if mouse_controller:
mouse_controller.update(new_data)
if gamepad_controller:
gamepad_controller.update(new_data)
else:
# If no new data in timeout, it means the acquirer might be stuck or device inactive.
# Continue the loop, but avoid excessive CPU usage by the `get_data` timeout.
pass
except KeyboardInterrupt:
logger.info("Ctrl+C detected. Stopping all components.")
# Clean up threads and devices
data_acquirer_thread.stop()
data_acquirer_thread.join() # Wait for the data acquisition thread to finish
if mouse_controller:
mouse_controller.close()
if gamepad_controller:
gamepad_controller.close()
logger.info("Disconnecting from daemon.")
daemon_comm.disconnect()
logger.info("Script terminated.")
except OSError as e:
logger.error(f"OS Error loading library: {e}. Check library path and permissions (e.g., chmod 755).")
sys.exit(1)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}")
import traceback
traceback.print_exc() # Print full traceback for debugging unexpected errors
sys.exit(1)