304 lines
10 KiB
Python
Executable File
304 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Fake Gaming Wheel USB Device Emulator
|
|
Creates a virtual USB HID gaming wheel device for testing purposes.
|
|
Simulates a Logitech G29 Racing Wheel.
|
|
|
|
Requirements:
|
|
- Linux with uhid kernel module
|
|
- Root/sudo access to access /dev/uhid
|
|
- Python 3.6+
|
|
|
|
Usage:
|
|
sudo python3 fake_wheel.py
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import struct
|
|
import time
|
|
import select
|
|
import signal
|
|
|
|
# UHID event types
|
|
UHID_CREATE2 = 11
|
|
UHID_DESTROY = 1
|
|
UHID_INPUT2 = 12
|
|
UHID_OUTPUT = 6
|
|
UHID_GET_REPORT = 9
|
|
UHID_SET_REPORT = 13
|
|
|
|
class FakeGamingWheel:
|
|
def __init__(self):
|
|
self.running = True
|
|
self.fd = None
|
|
|
|
# HID Report Descriptor for a gaming wheel with:
|
|
# - Steering wheel axis (X axis)
|
|
# - Throttle (Y axis)
|
|
# - Brake (Z axis)
|
|
# - Multiple buttons
|
|
self.hid_descriptor = bytes([
|
|
0x05, 0x01, # Usage Page (Generic Desktop)
|
|
0x09, 0x04, # Usage (Joystick)
|
|
0xA1, 0x01, # Collection (Application)
|
|
0x09, 0x01, # Usage (Pointer)
|
|
0xA1, 0x00, # Collection (Physical)
|
|
|
|
# Steering wheel (X axis) - 16 bit
|
|
0x09, 0x30, # Usage (X)
|
|
0x15, 0x00, # Logical Minimum (0)
|
|
0x27, 0xFF, 0xFF, 0x00, 0x00, # Logical Maximum (65535)
|
|
0x35, 0x00, # Physical Minimum (0)
|
|
0x47, 0xFF, 0xFF, 0x00, 0x00, # Physical Maximum (65535)
|
|
0x75, 0x10, # Report Size (16 bits)
|
|
0x95, 0x01, # Report Count (1)
|
|
0x81, 0x02, # Input (Data, Variable, Absolute)
|
|
|
|
# Throttle (Y axis) - 16 bit
|
|
0x09, 0x31, # Usage (Y)
|
|
0x15, 0x00, # Logical Minimum (0)
|
|
0x27, 0xFF, 0xFF, 0x00, 0x00, # Logical Maximum (65535)
|
|
0x75, 0x10, # Report Size (16 bits)
|
|
0x95, 0x01, # Report Count (1)
|
|
0x81, 0x02, # Input (Data, Variable, Absolute)
|
|
|
|
# Brake (Z axis) - 16 bit
|
|
0x09, 0x32, # Usage (Z)
|
|
0x15, 0x00, # Logical Minimum (0)
|
|
0x27, 0xFF, 0xFF, 0x00, 0x00, # Logical Maximum (65535)
|
|
0x75, 0x10, # Report Size (16 bits)
|
|
0x95, 0x01, # Report Count (1)
|
|
0x81, 0x02, # Input (Data, Variable, Absolute)
|
|
|
|
# Buttons (16 buttons)
|
|
0x05, 0x09, # Usage Page (Button)
|
|
0x19, 0x01, # Usage Minimum (Button 1)
|
|
0x29, 0x10, # Usage Maximum (Button 16)
|
|
0x15, 0x00, # Logical Minimum (0)
|
|
0x25, 0x01, # Logical Maximum (1)
|
|
0x75, 0x01, # Report Size (1 bit)
|
|
0x95, 0x10, # Report Count (16)
|
|
0x81, 0x02, # Input (Data, Variable, Absolute)
|
|
|
|
0xC0, # End Collection
|
|
0xC0 # End Collection
|
|
])
|
|
|
|
# Current state
|
|
self.steering = 32768 # Center position (0-65535)
|
|
self.throttle = 0 # No throttle
|
|
self.brake = 0 # No brake
|
|
self.buttons = 0 # No buttons pressed
|
|
|
|
def handle_uhid_event(self):
|
|
"""Read and handle events from uhid."""
|
|
try:
|
|
# Use select to check if data is available (non-blocking)
|
|
readable, _, _ = select.select([self.fd], [], [], 0)
|
|
if not readable:
|
|
return
|
|
|
|
data = os.read(self.fd, 4096)
|
|
if len(data) < 4:
|
|
return
|
|
|
|
event_type = struct.unpack('<I', data[:4])[0]
|
|
|
|
# We can ignore most events, but logging them helps with debugging
|
|
if event_type == UHID_OUTPUT:
|
|
pass # Output report from host
|
|
elif event_type == UHID_GET_REPORT:
|
|
pass # Host requesting report
|
|
elif event_type == UHID_SET_REPORT:
|
|
pass # Host setting report
|
|
|
|
except BlockingIOError:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Warning: Error handling uhid event: {e}")
|
|
|
|
def create_device(self):
|
|
"""Create the virtual HID device."""
|
|
try:
|
|
self.fd = os.open('/dev/uhid', os.O_RDWR | os.O_NONBLOCK)
|
|
except PermissionError:
|
|
print("Error: Permission denied. Please run with sudo.")
|
|
sys.exit(1)
|
|
except FileNotFoundError:
|
|
print("Error: /dev/uhid not found. Make sure uhid module is loaded.")
|
|
print("Try: sudo modprobe uhid")
|
|
sys.exit(1)
|
|
|
|
# Create UHID_CREATE2 event
|
|
name = b"Fake Logitech G29 Wheel"
|
|
phys = b"fake-wheel-phys"
|
|
uniq = b"fake-wheel-001"
|
|
|
|
# Logitech G29 vendor/product IDs
|
|
bus = 0x03 # USB
|
|
vid = 0x046d # Logitech
|
|
pid = 0xc24f # G29 Racing Wheel
|
|
|
|
# Prepare the create2 structure matching kernel's uhid_event
|
|
# struct uhid_create2_req is what we need to match
|
|
rd_size = len(self.hid_descriptor)
|
|
|
|
# Pack: name(128), phys(64), uniq(64), rd_size(u16), bus(u16), vid(u32), pid(u32), version(u32), country(u32), rd_data
|
|
create2_data = struct.pack(
|
|
'<I', # event type
|
|
UHID_CREATE2
|
|
) + struct.pack(
|
|
'<128s64s64sHHIIII',
|
|
name.ljust(128, b'\x00'),
|
|
phys.ljust(64, b'\x00'),
|
|
uniq.ljust(64, b'\x00'),
|
|
rd_size,
|
|
bus,
|
|
vid,
|
|
pid,
|
|
0, # version
|
|
0 # country
|
|
) + self.hid_descriptor
|
|
|
|
# Pad to 4096 bytes (kernel's UHID_DATA_MAX)
|
|
if len(create2_data) < 4096:
|
|
create2_data += b'\x00' * (4096 - len(create2_data))
|
|
|
|
bytes_written = os.write(self.fd, create2_data)
|
|
print(f"✓ Sent UHID_CREATE2 event ({bytes_written} bytes)")
|
|
print(f" Name: {name.decode().strip()}")
|
|
print(f" Vendor ID: 0x{vid:04x} (Logitech)")
|
|
print(f" Product ID: 0x{pid:04x} (G29 Racing Wheel)")
|
|
print(f" HID Descriptor: {rd_size} bytes")
|
|
|
|
# Give kernel time to process and create device
|
|
time.sleep(0.5)
|
|
|
|
# Check for any response from kernel
|
|
self.handle_uhid_event()
|
|
|
|
print(f" Device should appear in /dev/input/")
|
|
print("\nCheck with: ls -l /dev/input/by-id/ | grep -i logitech")
|
|
|
|
def destroy_device(self):
|
|
"""Destroy the virtual HID device."""
|
|
if self.fd:
|
|
event = struct.pack('<I', UHID_DESTROY)
|
|
payload = b'\x00' * 4096
|
|
os.write(self.fd, event + payload)
|
|
os.close(self.fd)
|
|
print("\n✓ Destroyed virtual gaming wheel device")
|
|
|
|
def send_report(self):
|
|
"""Send a HID input report with current state."""
|
|
# Handle any pending uhid events from kernel
|
|
self.handle_uhid_event()
|
|
|
|
# Pack the report: steering (16-bit), throttle (16-bit), brake (16-bit), buttons (16-bit)
|
|
report = struct.pack('<HHHH',
|
|
self.steering,
|
|
self.throttle,
|
|
self.brake,
|
|
self.buttons)
|
|
|
|
# Create UHID_INPUT2 event: type(u32) + size(u16) + data
|
|
input_data = struct.pack('<IH', UHID_INPUT2, len(report)) + report
|
|
|
|
# Pad to 4096 bytes
|
|
if len(input_data) < 4096:
|
|
input_data += b'\x00' * (4096 - len(input_data))
|
|
|
|
os.write(self.fd, input_data)
|
|
|
|
def update_state(self, elapsed_time):
|
|
"""Update the wheel state (simulate movement)."""
|
|
import math
|
|
|
|
# Simulate steering wheel movement (slow sine wave)
|
|
# Range: 0-65535, center at 32768
|
|
angle = elapsed_time * 0.5 # Slow rotation
|
|
self.steering = int(32768 + 16384 * math.sin(angle))
|
|
|
|
# Simulate throttle (pulse pattern)
|
|
throttle_wave = (math.sin(elapsed_time * 2) + 1) / 2
|
|
self.throttle = int(65535 * throttle_wave * 0.5)
|
|
|
|
# Simulate brake (occasional braking)
|
|
if int(elapsed_time) % 5 < 1: # Brake for 1s every 5s
|
|
self.brake = int(65535 * 0.8)
|
|
else:
|
|
self.brake = 0
|
|
|
|
# Simulate button presses (cycle through buttons)
|
|
button_index = int(elapsed_time) % 16
|
|
self.buttons = 1 << button_index
|
|
|
|
def run(self):
|
|
"""Main loop - send periodic updates."""
|
|
print("\nSimulating gaming wheel...")
|
|
print("Features:")
|
|
print(" • Steering wheel: Auto-rotating left/right")
|
|
print(" • Throttle: Pulsing 0-50%")
|
|
print(" • Brake: Activates every 5 seconds")
|
|
print(" • Buttons: Cycling through buttons 1-16")
|
|
print("\nPress Ctrl+C to stop\n")
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
while self.running:
|
|
elapsed = time.time() - start_time
|
|
|
|
# Update state with simulated movement
|
|
self.update_state(elapsed)
|
|
|
|
# Send the report
|
|
self.send_report()
|
|
|
|
# Print status every second
|
|
if int(elapsed * 10) % 10 == 0:
|
|
steering_deg = (self.steering - 32768) / 32768 * 900 # G29 has 900° rotation
|
|
throttle_pct = self.throttle / 65535 * 100
|
|
brake_pct = self.brake / 65535 * 100
|
|
pressed_button = None
|
|
for i in range(16):
|
|
if self.buttons & (1 << i):
|
|
pressed_button = i + 1
|
|
break
|
|
|
|
print(f"\rSteering: {steering_deg:+6.1f}° | "
|
|
f"Throttle: {throttle_pct:5.1f}% | "
|
|
f"Brake: {brake_pct:5.1f}% | "
|
|
f"Button: {pressed_button:2d}", end='', flush=True)
|
|
|
|
time.sleep(0.01) # 100 Hz update rate
|
|
|
|
except KeyboardInterrupt:
|
|
print("\n\nStopping...")
|
|
self.running = False
|
|
|
|
def signal_handler(sig, frame):
|
|
"""Handle Ctrl+C gracefully."""
|
|
print("\n\nReceived signal, stopping...")
|
|
sys.exit(0)
|
|
|
|
def main():
|
|
print("=" * 60)
|
|
print("Fake Gaming Wheel USB Device Emulator")
|
|
print("=" * 60)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
wheel = FakeGamingWheel()
|
|
|
|
try:
|
|
wheel.create_device()
|
|
wheel.run()
|
|
finally:
|
|
wheel.destroy_device()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|