add fake-wheel
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(chmod:*)",
|
||||||
|
"Bash(python3:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Fake Gaming Wheel Emulator
|
||||||
|
|
||||||
|
Virtual USB gaming wheel device emulator for Linux. Creates a fake Logitech G29 Racing Wheel for testing purposes.
|
||||||
|
|
||||||
|
## Quick Start (Recommended)
|
||||||
|
|
||||||
|
**Use the uinput version** - it's more reliable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 fake_wheel_uinput.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **fake_wheel_uinput.py** - Uses `/dev/uinput` (RECOMMENDED)
|
||||||
|
- **fake_wheel.py** - Uses `/dev/uhid` (alternative, more complex)
|
||||||
|
- **debug_uhid.py** - Debug script for uhid
|
||||||
|
- **check_wheel.sh** - Check if device appears
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
The emulated wheel provides:
|
||||||
|
- **Steering Wheel**: Full 900° rotation (±450°)
|
||||||
|
- **Throttle Pedal**: 0-100% range
|
||||||
|
- **Brake Pedal**: 0-100% range
|
||||||
|
- **16 Buttons**: Standard gaming buttons
|
||||||
|
- **USB Device ID**: Logitech (0x046d) G29 (0xc24f)
|
||||||
|
|
||||||
|
## Simulated Behavior
|
||||||
|
|
||||||
|
When running, the script automatically simulates:
|
||||||
|
- Steering wheel rotating left/right in smooth sine wave
|
||||||
|
- Throttle pulsing between 0-78%
|
||||||
|
- Brake activating every 5 seconds
|
||||||
|
- Buttons cycling through 1-16 in sequence
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Check if the device appears:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Method 1: Check by-id
|
||||||
|
ls -l /dev/input/by-id/ | grep -i logitech
|
||||||
|
|
||||||
|
# Method 2: Check input devices
|
||||||
|
cat /proc/bus/input/devices | grep -A 10 "Logitech G29"
|
||||||
|
|
||||||
|
# Method 3: Use evtest
|
||||||
|
sudo evtest
|
||||||
|
# Then select the G29 device from the list
|
||||||
|
|
||||||
|
# Method 4: Use jstest (for joystick interface)
|
||||||
|
jstest /dev/input/js0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux kernel with uinput support
|
||||||
|
- Python 3.6+
|
||||||
|
- Root access OR user in 'input' group
|
||||||
|
- uinput kernel module (usually loaded by default)
|
||||||
|
|
||||||
|
### Enable uinput module (if needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo modprobe uinput
|
||||||
|
# Make it permanent:
|
||||||
|
echo "uinput" | sudo tee /etc/modules-load.d/uinput.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run without sudo (optional)
|
||||||
|
|
||||||
|
Add udev rule to allow non-root access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'KERNEL=="uinput", MODE="0666"' | sudo tee /etc/udev/rules.d/99-uinput.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add your user to the input group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -a -G input $USER
|
||||||
|
# Log out and back in for changes to take effect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Device doesn't appear
|
||||||
|
|
||||||
|
1. Check if uinput module is loaded:
|
||||||
|
```bash
|
||||||
|
lsmod | grep uinput
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check permissions:
|
||||||
|
```bash
|
||||||
|
ls -l /dev/uinput
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check kernel messages:
|
||||||
|
```bash
|
||||||
|
sudo dmesg | tail -20 | grep -i input
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with minimal script
|
||||||
|
|
||||||
|
Run the debug_uhid.py to test basic functionality:
|
||||||
|
```bash
|
||||||
|
sudo python3 debug_uhid.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Your Testing App
|
||||||
|
|
||||||
|
Once the fake wheel is running, it appears as a real USB device. Your gaming wheel tester should detect it automatically as:
|
||||||
|
|
||||||
|
- **Device Name**: "Logitech G29 Racing Wheel"
|
||||||
|
- **Vendor ID**: 0x046d (Logitech)
|
||||||
|
- **Product ID**: 0xc24f (G29 Racing Wheel)
|
||||||
|
|
||||||
|
The device will show up in standard input device APIs:
|
||||||
|
- `/dev/input/eventX` (evdev)
|
||||||
|
- `/dev/input/jsX` (joystick)
|
||||||
|
- SDL2, pygame, etc.
|
||||||
|
|
||||||
|
## Stopping the Emulator
|
||||||
|
|
||||||
|
Press `Ctrl+C` to cleanly stop and remove the virtual device.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### uinput vs uhid
|
||||||
|
|
||||||
|
- **uinput**: Kernel interface for creating virtual input devices. Simpler API, better for basic input devices.
|
||||||
|
- **uhid**: Kernel interface for userspace HID drivers. More complex, allows full HID descriptor control.
|
||||||
|
|
||||||
|
This project provides both implementations. The uinput version is recommended for most use cases.
|
||||||
|
|
||||||
|
### Device Specifications
|
||||||
|
|
||||||
|
- **Bus Type**: USB (0x03)
|
||||||
|
- **Axes**:
|
||||||
|
- X (Steering): -32768 to 32767
|
||||||
|
- Y (Throttle): 0 to 255
|
||||||
|
- Z (Brake): 0 to 255
|
||||||
|
- **Buttons**: 16 buttons (BTN_TRIGGER + 0-15)
|
||||||
|
- **Update Rate**: 100 Hz (10ms intervals)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Free to use for testing and educational purposes.
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick script to check if the fake wheel device is visible
|
||||||
|
|
||||||
|
echo "=== Checking for Gaming Wheel Devices ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "1. Devices in /dev/input/by-id/:"
|
||||||
|
ls -l /dev/input/by-id/ 2>/dev/null | grep -i "logitech\|wheel\|g29" || echo " No wheel devices found"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "2. All input devices:"
|
||||||
|
ls /dev/input/event* 2>/dev/null
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "3. Input devices info (from /proc/bus/input/devices):"
|
||||||
|
if [ -f /proc/bus/input/devices ]; then
|
||||||
|
grep -A 10 -i "logitech\|wheel\|g29\|046d.*c24f" /proc/bus/input/devices 2>/dev/null || echo " No wheel found in input devices"
|
||||||
|
else
|
||||||
|
echo " /proc/bus/input/devices not found"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "4. Try running: sudo evtest"
|
||||||
|
echo " Then select the wheel device to see live events"
|
||||||
Executable
+116
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug script to test uhid communication
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
UHID_CREATE2 = 11
|
||||||
|
|
||||||
|
# Simple HID descriptor for testing - just a joystick with X/Y
|
||||||
|
hid_descriptor = bytes([
|
||||||
|
0x05, 0x01, # Usage Page (Generic Desktop)
|
||||||
|
0x09, 0x04, # Usage (Joystick)
|
||||||
|
0xA1, 0x01, # Collection (Application)
|
||||||
|
0x09, 0x30, # Usage (X)
|
||||||
|
0x09, 0x31, # Usage (Y)
|
||||||
|
0x15, 0x00, # Logical Minimum (0)
|
||||||
|
0x26, 0xFF, 0x00, # Logical Maximum (255)
|
||||||
|
0x75, 0x08, # Report Size (8 bits)
|
||||||
|
0x95, 0x02, # Report Count (2)
|
||||||
|
0x81, 0x02, # Input (Data, Variable, Absolute)
|
||||||
|
0xC0 # End Collection
|
||||||
|
])
|
||||||
|
|
||||||
|
print("Testing UHID device creation...")
|
||||||
|
print(f"HID descriptor size: {len(hid_descriptor)} bytes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd = os.open('/dev/uhid', os.O_RDWR | os.O_NONBLOCK)
|
||||||
|
print("✓ Opened /dev/uhid")
|
||||||
|
except PermissionError:
|
||||||
|
print("✗ Permission denied. Run with sudo.")
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("✗ /dev/uhid not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Build event according to kernel structure
|
||||||
|
name = b"Test Fake Wheel"
|
||||||
|
phys = b"fake-test"
|
||||||
|
uniq = b"test-001"
|
||||||
|
|
||||||
|
# Pack the create2 structure
|
||||||
|
# struct uhid_create2_req {
|
||||||
|
# __u8 name[128];
|
||||||
|
# __u8 phys[64];
|
||||||
|
# __u8 uniq[64];
|
||||||
|
# __u16 rd_size;
|
||||||
|
# __u16 bus;
|
||||||
|
# __u32 vendor;
|
||||||
|
# __u32 product;
|
||||||
|
# __u32 version;
|
||||||
|
# __u32 country;
|
||||||
|
# __u8 rd_data[HID_MAX_DESCRIPTOR_SIZE];
|
||||||
|
# }
|
||||||
|
|
||||||
|
event_data = struct.pack('<I', UHID_CREATE2) # Event type
|
||||||
|
|
||||||
|
create2_struct = (
|
||||||
|
name.ljust(128, b'\x00') +
|
||||||
|
phys.ljust(64, b'\x00') +
|
||||||
|
uniq.ljust(64, b'\x00') +
|
||||||
|
struct.pack('<HHIIII',
|
||||||
|
len(hid_descriptor), # rd_size (u16)
|
||||||
|
0x03, # bus (u16) - USB
|
||||||
|
0x046d, # vendor (u32) - Logitech
|
||||||
|
0xc24f, # product (u32) - G29
|
||||||
|
0x0111, # version (u32)
|
||||||
|
0x00 # country (u32)
|
||||||
|
) +
|
||||||
|
hid_descriptor.ljust(4096, b'\x00') # rd_data padded
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Create2 struct size: {len(create2_struct)} bytes")
|
||||||
|
print(f"Event type: {UHID_CREATE2}")
|
||||||
|
|
||||||
|
# The full event needs to be 4096 bytes
|
||||||
|
full_event = event_data + create2_struct
|
||||||
|
if len(full_event) < 4096:
|
||||||
|
full_event = full_event.ljust(4096, b'\x00')
|
||||||
|
|
||||||
|
print(f"Full event size: {len(full_event)} bytes")
|
||||||
|
|
||||||
|
# Write to uhid
|
||||||
|
try:
|
||||||
|
bytes_written = os.write(fd, full_event)
|
||||||
|
print(f"✓ Wrote {bytes_written} bytes to /dev/uhid")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Write failed: {e}")
|
||||||
|
os.close(fd)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Wait for kernel to process
|
||||||
|
print("Waiting for kernel to create device...")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Check if device appeared
|
||||||
|
print("\nChecking /proc/bus/input/devices:")
|
||||||
|
os.system("cat /proc/bus/input/devices | grep -A 5 'Test Fake' || echo 'Device not found'")
|
||||||
|
|
||||||
|
print("\nChecking dmesg for errors:")
|
||||||
|
os.system("sudo dmesg | tail -10 | grep -i 'uhid\|hid' || echo 'No relevant kernel messages'")
|
||||||
|
|
||||||
|
print("\nPress Ctrl+C to destroy device...")
|
||||||
|
try:
|
||||||
|
time.sleep(10)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
destroy_event = struct.pack('<I', 1).ljust(4096, b'\x00') # UHID_DESTROY = 1
|
||||||
|
os.write(fd, destroy_event)
|
||||||
|
os.close(fd)
|
||||||
|
print("✓ Device destroyed")
|
||||||
Executable
+303
@@ -0,0 +1,303 @@
|
|||||||
|
#!/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()
|
||||||
Executable
+588
@@ -0,0 +1,588 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fake Gaming Wheel - Simple and Compatible Version
|
||||||
|
Uses python-evdev library for reliable device creation.
|
||||||
|
|
||||||
|
Install evdev if needed:
|
||||||
|
sudo pip3 install evdev
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sudo python3 fake_wheel_simple.py [options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Static position (no movement)
|
||||||
|
sudo python3 fake_wheel_simple.py --steering 0 --throttle 128 --brake 0
|
||||||
|
|
||||||
|
# Hold button 5
|
||||||
|
sudo python3 fake_wheel_simple.py --button 5
|
||||||
|
|
||||||
|
# Auto-rotate with custom speed
|
||||||
|
sudo python3 fake_wheel_simple.py --auto-rotate --rotate-speed 1.0
|
||||||
|
|
||||||
|
# Faster update rate
|
||||||
|
sudo python3 fake_wheel_simple.py --rate 200
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
|
||||||
|
try:
|
||||||
|
import evdev
|
||||||
|
from evdev import UInput, AbsInfo, ecodes as e
|
||||||
|
except ImportError:
|
||||||
|
print("Error: python-evdev library not found.")
|
||||||
|
print("\nInstall it with:")
|
||||||
|
print(" sudo pip3 install evdev")
|
||||||
|
print(" or: sudo apt install python3-evdev")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
class FakeGamingWheel:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.running = True
|
||||||
|
self.device = None
|
||||||
|
self.args = args
|
||||||
|
|
||||||
|
# Current state
|
||||||
|
self.steering = args.steering if args.steering is not None else 0
|
||||||
|
self.throttle = args.throttle if args.throttle is not None else 0
|
||||||
|
self.brake = args.brake if args.brake is not None else 0
|
||||||
|
self.clutch = args.clutch if args.clutch is not None else 0
|
||||||
|
self.buttons = 0 # Button bitmask
|
||||||
|
|
||||||
|
if args.button is not None and 1 <= args.button <= 16:
|
||||||
|
self.buttons = 1 << (args.button - 1)
|
||||||
|
|
||||||
|
def create_device(self):
|
||||||
|
"""Create the virtual gaming wheel device."""
|
||||||
|
|
||||||
|
# Define capabilities
|
||||||
|
cap = {
|
||||||
|
e.EV_KEY: [
|
||||||
|
e.BTN_TRIGGER,
|
||||||
|
e.BTN_THUMB,
|
||||||
|
e.BTN_THUMB2,
|
||||||
|
e.BTN_TOP,
|
||||||
|
e.BTN_TOP2,
|
||||||
|
e.BTN_PINKIE,
|
||||||
|
e.BTN_BASE,
|
||||||
|
e.BTN_BASE2,
|
||||||
|
e.BTN_BASE3,
|
||||||
|
e.BTN_BASE4,
|
||||||
|
e.BTN_BASE5,
|
||||||
|
e.BTN_BASE6,
|
||||||
|
e.BTN_DEAD,
|
||||||
|
e.BTN_TRIGGER_HAPPY1,
|
||||||
|
e.BTN_TRIGGER_HAPPY2,
|
||||||
|
e.BTN_TRIGGER_HAPPY3,
|
||||||
|
],
|
||||||
|
e.EV_ABS: [
|
||||||
|
(e.ABS_X, AbsInfo(value=0, min=-32768, max=32767, fuzz=16, flat=128, resolution=0)), # Steering wheel - Axis 0
|
||||||
|
(e.ABS_GAS, AbsInfo(value=0, min=0, max=255, fuzz=0, flat=15, resolution=0)), # Clutch pedal - Axis 1 (!)
|
||||||
|
(e.ABS_BRAKE, AbsInfo(value=0, min=0, max=255, fuzz=0, flat=15, resolution=0)), # Brake pedal - Axis 2 (!)
|
||||||
|
(e.ABS_RZ, AbsInfo(value=0, min=0, max=255, fuzz=0, flat=15, resolution=0)), # Throttle pedal - Axis 3
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create device with Logitech G29 identity
|
||||||
|
self.device = UInput(
|
||||||
|
events=cap,
|
||||||
|
name='Logitech G29 Racing Wheel',
|
||||||
|
vendor=0x046d, # Logitech
|
||||||
|
product=0xc24f, # G29
|
||||||
|
version=0x0111,
|
||||||
|
bustype=e.BUS_USB
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("✓ Created virtual gaming wheel device")
|
||||||
|
print(" Name: Logitech G29 Racing Wheel")
|
||||||
|
print(" Vendor: 0x046d (Logitech)")
|
||||||
|
print(" Product: 0xc24f (G29)")
|
||||||
|
print(" Device: {}".format(self.device.device.path))
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print("\nDevice is now available in:")
|
||||||
|
print(" • /dev/input/by-id/ (look for Logitech)")
|
||||||
|
print(" • {}".format(self.device.device.path))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Send initial state to ensure axes are set correctly
|
||||||
|
print("Initializing device state...")
|
||||||
|
self.send_state()
|
||||||
|
print(" ✓ Initial state sent")
|
||||||
|
|
||||||
|
def destroy_device(self):
|
||||||
|
"""Destroy the virtual device."""
|
||||||
|
if self.device:
|
||||||
|
self.device.close()
|
||||||
|
print("\n✓ Destroyed virtual gaming wheel device")
|
||||||
|
|
||||||
|
def send_state(self, debug=False):
|
||||||
|
"""Send current device state."""
|
||||||
|
# Send axis values directly (no conversion needed with 0-255 range)
|
||||||
|
steering_raw = self.steering
|
||||||
|
clutch_raw = self.clutch
|
||||||
|
brake_raw = self.brake
|
||||||
|
throttle_raw = self.throttle
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(f"\n[DEBUG] Sending state:")
|
||||||
|
print(f" Steering: {self.steering} -> raw {steering_raw}")
|
||||||
|
print(f" Clutch: {self.clutch} -> raw {clutch_raw}")
|
||||||
|
print(f" Brake: {self.brake} -> raw {brake_raw}")
|
||||||
|
print(f" Throttle: {self.throttle} -> raw {throttle_raw}")
|
||||||
|
|
||||||
|
self.device.write(e.EV_ABS, e.ABS_X, steering_raw) # Steering wheel - Axis 0
|
||||||
|
self.device.write(e.EV_ABS, e.ABS_GAS, clutch_raw) # Clutch pedal - Axis 1
|
||||||
|
self.device.write(e.EV_ABS, e.ABS_BRAKE, brake_raw) # Brake pedal - Axis 2
|
||||||
|
self.device.write(e.EV_ABS, e.ABS_RZ, throttle_raw) # Throttle pedal - Axis 3
|
||||||
|
|
||||||
|
# Send button states
|
||||||
|
button_map = [
|
||||||
|
e.BTN_TRIGGER,
|
||||||
|
e.BTN_THUMB,
|
||||||
|
e.BTN_THUMB2,
|
||||||
|
e.BTN_TOP,
|
||||||
|
e.BTN_TOP2,
|
||||||
|
e.BTN_PINKIE,
|
||||||
|
e.BTN_BASE,
|
||||||
|
e.BTN_BASE2,
|
||||||
|
e.BTN_BASE3,
|
||||||
|
e.BTN_BASE4,
|
||||||
|
e.BTN_BASE5,
|
||||||
|
e.BTN_BASE6,
|
||||||
|
e.BTN_DEAD,
|
||||||
|
e.BTN_TRIGGER_HAPPY1,
|
||||||
|
e.BTN_TRIGGER_HAPPY2,
|
||||||
|
e.BTN_TRIGGER_HAPPY3,
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, btn in enumerate(button_map):
|
||||||
|
if i < 16: # Only 16 buttons
|
||||||
|
button_pressed = (self.buttons >> i) & 1
|
||||||
|
self.device.write(e.EV_KEY, btn, button_pressed)
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
self.device.syn()
|
||||||
|
|
||||||
|
def update_state(self, elapsed_time):
|
||||||
|
"""Update the wheel state with simulated movement."""
|
||||||
|
# Steering: auto-rotate if enabled, otherwise use static value
|
||||||
|
if self.args.auto_rotate:
|
||||||
|
angle = elapsed_time * self.args.rotate_speed
|
||||||
|
self.steering = int(32767 * math.sin(angle))
|
||||||
|
elif self.args.steering is None:
|
||||||
|
# Default auto-rotate if nothing specified
|
||||||
|
angle = elapsed_time * 0.5
|
||||||
|
self.steering = int(32767 * math.sin(angle))
|
||||||
|
# else: keep the static steering value from __init__
|
||||||
|
|
||||||
|
# Throttle: auto-pulse if enabled, otherwise use static value
|
||||||
|
if self.args.auto_throttle:
|
||||||
|
throttle_wave = (math.sin(elapsed_time * 2) + 1) / 2
|
||||||
|
self.throttle = int(255 * throttle_wave)
|
||||||
|
elif self.args.throttle is None:
|
||||||
|
# Default auto-pulse if nothing specified
|
||||||
|
throttle_wave = (math.sin(elapsed_time * 2) + 1) / 2
|
||||||
|
self.throttle = int(255 * throttle_wave)
|
||||||
|
# else: keep the static throttle value from __init__
|
||||||
|
|
||||||
|
# Brake: auto-brake if enabled, otherwise use static value
|
||||||
|
if self.args.auto_brake:
|
||||||
|
if int(elapsed_time) % 5 < 1:
|
||||||
|
self.brake = 255
|
||||||
|
else:
|
||||||
|
self.brake = 0
|
||||||
|
elif self.args.brake is None:
|
||||||
|
# Default auto-brake if nothing specified
|
||||||
|
if int(elapsed_time) % 5 < 1:
|
||||||
|
self.brake = 255
|
||||||
|
else:
|
||||||
|
self.brake = 0
|
||||||
|
# else: keep the static brake value from __init__
|
||||||
|
|
||||||
|
# Clutch: auto-clutch if enabled, otherwise use static value
|
||||||
|
if self.args.auto_clutch:
|
||||||
|
# Clutch engages/disengages in a pattern (simulating gear changes)
|
||||||
|
clutch_cycle = (int(elapsed_time * 0.5) % 4) # 4 second cycle
|
||||||
|
if clutch_cycle == 0: # Pressed for 1 second
|
||||||
|
self.clutch = 255
|
||||||
|
else:
|
||||||
|
self.clutch = 0
|
||||||
|
elif self.args.clutch is None:
|
||||||
|
# Default: no clutch activity if nothing specified
|
||||||
|
self.clutch = 0
|
||||||
|
# else: keep the static clutch value from __init__
|
||||||
|
|
||||||
|
# Buttons: cycle if enabled, otherwise keep static
|
||||||
|
if self.args.cycle_buttons:
|
||||||
|
button_index = int(elapsed_time) % 16
|
||||||
|
self.buttons = 1 << button_index
|
||||||
|
elif self.args.button is None:
|
||||||
|
# Default cycle if nothing specified
|
||||||
|
button_index = int(elapsed_time) % 16
|
||||||
|
self.buttons = 1 << button_index
|
||||||
|
# else: keep the static button value from __init__
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main loop."""
|
||||||
|
print("Simulating gaming wheel...")
|
||||||
|
|
||||||
|
# Show current configuration
|
||||||
|
if self.args.auto_rotate or self.args.steering is None:
|
||||||
|
if self.args.auto_rotate:
|
||||||
|
print(f" • Steering: Auto-rotating ±450° (speed: {self.args.rotate_speed}x)")
|
||||||
|
else:
|
||||||
|
print(f" • Steering: Auto-rotating ±450° (default)")
|
||||||
|
else:
|
||||||
|
steering_deg = self.steering / 32768 * 450
|
||||||
|
print(f" • Steering: Static at {steering_deg:+.1f}°")
|
||||||
|
|
||||||
|
if self.args.auto_throttle or self.args.throttle is None:
|
||||||
|
print(f" • Throttle: {'Auto-pulsing' if self.args.auto_throttle else 'Pulsing'} 0-100%")
|
||||||
|
else:
|
||||||
|
print(f" • Throttle: Static at {self.throttle / 255 * 100:.1f}%")
|
||||||
|
|
||||||
|
if self.args.auto_brake or self.args.brake is None:
|
||||||
|
print(f" • Brake: {'Auto-activating' if self.args.auto_brake else 'Periodic'} at 100% (every 5s)")
|
||||||
|
else:
|
||||||
|
print(f" • Brake: Static at {self.brake / 255 * 100:.1f}%")
|
||||||
|
|
||||||
|
if self.args.auto_clutch:
|
||||||
|
print(f" • Clutch: Auto-engaging (gear change pattern)")
|
||||||
|
elif self.args.clutch is not None:
|
||||||
|
print(f" • Clutch: Static at {self.clutch / 255 * 100:.1f}%")
|
||||||
|
else:
|
||||||
|
print(f" • Clutch: Released (0%)")
|
||||||
|
|
||||||
|
if self.args.cycle_buttons or self.args.button is None:
|
||||||
|
print(f" • Buttons: Cycling 1-16")
|
||||||
|
elif self.args.button is not None:
|
||||||
|
print(f" • Buttons: Holding button {self.args.button}")
|
||||||
|
else:
|
||||||
|
print(f" • Buttons: None pressed")
|
||||||
|
|
||||||
|
print(f" • Update rate: {self.args.rate} Hz")
|
||||||
|
print("\nPress Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
update_interval = 1.0 / self.args.rate
|
||||||
|
debug_counter = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self.update_state(elapsed)
|
||||||
|
|
||||||
|
# Send to device (with debug for first 3 iterations)
|
||||||
|
if debug_counter < 3:
|
||||||
|
print(f"\n=== Iteration {debug_counter + 1} ===")
|
||||||
|
self.send_state(debug=True)
|
||||||
|
debug_counter += 1
|
||||||
|
else:
|
||||||
|
self.send_state()
|
||||||
|
|
||||||
|
# Display status (shorter format to prevent line wrapping)
|
||||||
|
if int(elapsed * 10) % 10 == 0:
|
||||||
|
steering_deg = self.steering / 32768 * 450
|
||||||
|
throttle_pct = self.throttle / 255 * 100
|
||||||
|
brake_pct = self.brake / 255 * 100
|
||||||
|
clutch_pct = self.clutch / 255 * 100
|
||||||
|
|
||||||
|
pressed_buttons = []
|
||||||
|
for i in range(16):
|
||||||
|
if self.buttons & (1 << i):
|
||||||
|
pressed_buttons.append(str(i + 1))
|
||||||
|
|
||||||
|
button_str = ','.join(pressed_buttons) if pressed_buttons else '-'
|
||||||
|
|
||||||
|
# Compact format
|
||||||
|
print(f"\rS:{steering_deg:+6.1f}° T:{throttle_pct:5.1f}% "
|
||||||
|
f"B:{brake_pct:5.1f}% C:{clutch_pct:5.1f}% Btn:{button_str:<4}",
|
||||||
|
end='', flush=True)
|
||||||
|
|
||||||
|
time.sleep(update_interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopping...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def run_interactive(self):
|
||||||
|
"""Interactive mode - accept commands to change settings on the fly."""
|
||||||
|
print("Simulating gaming wheel in INTERACTIVE mode...")
|
||||||
|
print(f" • Update rate: {self.args.rate} Hz")
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("INTERACTIVE MODE")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nAvailable commands:")
|
||||||
|
print(" steering <value> Set steering (-32768 to 32767, 0=center)")
|
||||||
|
print(" throttle <value> Set throttle (0 to 255)")
|
||||||
|
print(" brake <value> Set brake (0 to 255)")
|
||||||
|
print(" clutch <value> Set clutch (0 to 255)")
|
||||||
|
print(" button <num> Press button (1-16, 0=release all)")
|
||||||
|
print(" status Show current values")
|
||||||
|
print(" help Show this help")
|
||||||
|
print(" quit / exit Stop the script")
|
||||||
|
print("\nShorthand commands:")
|
||||||
|
print(" s <value> steering")
|
||||||
|
print(" t <value> throttle")
|
||||||
|
print(" b <value> brake")
|
||||||
|
print(" c <value> clutch")
|
||||||
|
print(" btn <num> button")
|
||||||
|
print("\nExamples:")
|
||||||
|
print(" steering 0 Center the wheel")
|
||||||
|
print(" throttle 255 Full throttle")
|
||||||
|
print(" button 5 Press button 5")
|
||||||
|
print(" s -10000 t 200 Turn left and half throttle")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Start the device update loop in a separate thread
|
||||||
|
update_thread = threading.Thread(target=self._update_loop, daemon=True)
|
||||||
|
update_thread.start()
|
||||||
|
|
||||||
|
# Main interactive input loop
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
user_input = input("> ").strip().lower()
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Split input into commands (allows multiple commands in one line)
|
||||||
|
tokens = user_input.split()
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
cmd = tokens[i]
|
||||||
|
|
||||||
|
# Quit commands
|
||||||
|
if cmd in ['quit', 'exit', 'q']:
|
||||||
|
print("Exiting...")
|
||||||
|
self.running = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Help
|
||||||
|
elif cmd in ['help', 'h', '?']:
|
||||||
|
print("\nCommands: steering/s, throttle/t, brake/b, clutch/c, button/btn, status, quit")
|
||||||
|
print("Example: s 0 t 128 b 0 btn 5\n")
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Status
|
||||||
|
elif cmd == 'status':
|
||||||
|
self._print_status()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Commands that take a value
|
||||||
|
elif cmd in ['steering', 's', 'throttle', 't', 'brake', 'b', 'clutch', 'c', 'button', 'btn']:
|
||||||
|
if i + 1 >= len(tokens):
|
||||||
|
print(f"Error: '{cmd}' requires a value")
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = int(tokens[i + 1])
|
||||||
|
|
||||||
|
if cmd in ['steering', 's']:
|
||||||
|
if -32768 <= value <= 32767:
|
||||||
|
self.steering = value
|
||||||
|
print(f"✓ Steering set to {value} ({value / 32768 * 450:+.1f}°)")
|
||||||
|
else:
|
||||||
|
print("Error: Steering must be -32768 to 32767")
|
||||||
|
|
||||||
|
elif cmd in ['throttle', 't']:
|
||||||
|
if 0 <= value <= 255:
|
||||||
|
self.throttle = value
|
||||||
|
print(f"✓ Throttle set to {value} ({value / 255 * 100:.1f}%)")
|
||||||
|
self.send_state(debug=True)
|
||||||
|
else:
|
||||||
|
print("Error: Throttle must be 0 to 255")
|
||||||
|
|
||||||
|
elif cmd in ['brake', 'b']:
|
||||||
|
if 0 <= value <= 255:
|
||||||
|
self.brake = value
|
||||||
|
print(f"✓ Brake set to {value} ({value / 255 * 100:.1f}%)")
|
||||||
|
self.send_state(debug=True)
|
||||||
|
else:
|
||||||
|
print("Error: Brake must be 0 to 255")
|
||||||
|
|
||||||
|
elif cmd in ['clutch', 'c']:
|
||||||
|
if 0 <= value <= 255:
|
||||||
|
self.clutch = value
|
||||||
|
print(f"✓ Clutch set to {value} ({value / 255 * 100:.1f}%)")
|
||||||
|
self.send_state(debug=True)
|
||||||
|
else:
|
||||||
|
print("Error: Clutch must be 0 to 255")
|
||||||
|
|
||||||
|
elif cmd in ['button', 'btn']:
|
||||||
|
if 0 <= value <= 16:
|
||||||
|
if value == 0:
|
||||||
|
self.buttons = 0
|
||||||
|
print("✓ All buttons released")
|
||||||
|
else:
|
||||||
|
self.buttons = 1 << (value - 1)
|
||||||
|
print(f"✓ Button {value} pressed")
|
||||||
|
else:
|
||||||
|
print("Error: Button must be 0 to 16")
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
print(f"Error: Invalid value '{tokens[i + 1]}'")
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: '{cmd}'. Type 'help' for commands.")
|
||||||
|
break
|
||||||
|
|
||||||
|
except EOFError:
|
||||||
|
print("\nExiting...")
|
||||||
|
self.running = False
|
||||||
|
break
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopping...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def _update_loop(self):
|
||||||
|
"""Background thread that continuously sends device state."""
|
||||||
|
print("[DEBUG] Background update thread started")
|
||||||
|
update_interval = 1.0 / self.args.rate
|
||||||
|
iteration = 0
|
||||||
|
while self.running:
|
||||||
|
self.send_state()
|
||||||
|
if iteration < 3:
|
||||||
|
print(f"[DEBUG] Background thread iteration {iteration}: T={self.throttle} B={self.brake} C={self.clutch}")
|
||||||
|
iteration += 1
|
||||||
|
time.sleep(update_interval)
|
||||||
|
|
||||||
|
def _print_status(self):
|
||||||
|
"""Print current device state."""
|
||||||
|
steering_deg = self.steering / 32768 * 450
|
||||||
|
throttle_pct = self.throttle / 255 * 100
|
||||||
|
brake_pct = self.brake / 255 * 100
|
||||||
|
clutch_pct = self.clutch / 255 * 100
|
||||||
|
|
||||||
|
pressed_buttons = []
|
||||||
|
for i in range(16):
|
||||||
|
if self.buttons & (1 << i):
|
||||||
|
pressed_buttons.append(str(i + 1))
|
||||||
|
button_str = ','.join(pressed_buttons) if pressed_buttons else 'None'
|
||||||
|
|
||||||
|
print(f"\n--- Current Status ---")
|
||||||
|
print(f"Steering: {self.steering:6d} ({steering_deg:+6.1f}°)")
|
||||||
|
print(f"Throttle: {self.throttle:3d} ({throttle_pct:5.1f}%)")
|
||||||
|
print(f"Brake: {self.brake:3d} ({brake_pct:5.1f}%)")
|
||||||
|
print(f"Clutch: {self.clutch:3d} ({clutch_pct:5.1f}%)")
|
||||||
|
print(f"Buttons: {button_str}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import signal
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Fake Gaming Wheel - Virtual Logitech G29 Racing Wheel',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Default behavior (auto-rotating, pulsing throttle, periodic brake)
|
||||||
|
sudo python3 fake_wheel_simple.py
|
||||||
|
|
||||||
|
# Static centered position with no inputs
|
||||||
|
sudo python3 fake_wheel_simple.py --steering 0 --throttle 0 --brake 0 --button 0
|
||||||
|
|
||||||
|
# Full throttle, half brake, steering left 45°
|
||||||
|
sudo python3 fake_wheel_simple.py --steering -3640 --throttle 255 --brake 128
|
||||||
|
|
||||||
|
# Hold button 5 while auto-rotating
|
||||||
|
sudo python3 fake_wheel_simple.py --button 5 --auto-rotate
|
||||||
|
|
||||||
|
# Fast rotation with high update rate
|
||||||
|
sudo python3 fake_wheel_simple.py --rotate-speed 2.0 --rate 200
|
||||||
|
|
||||||
|
# Static steering at center, auto throttle only
|
||||||
|
sudo python3 fake_wheel_simple.py --steering 0 --auto-throttle --brake 0
|
||||||
|
|
||||||
|
Axis Ranges:
|
||||||
|
Steering: -32768 to 32767 (represents ±450°, so ±72.8 per degree)
|
||||||
|
Throttle: 0 to 255 (0% to 100%)
|
||||||
|
Brake: 0 to 255 (0% to 100%)
|
||||||
|
Clutch: 0 to 255 (0% to 100%, 0=released, 255=fully pressed)
|
||||||
|
Buttons: 1 to 16
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static values
|
||||||
|
parser.add_argument('--steering', type=int, metavar='N',
|
||||||
|
help='Static steering position (-32768 to 32767, 0=center)')
|
||||||
|
parser.add_argument('--throttle', type=int, metavar='N',
|
||||||
|
help='Static throttle position (0 to 255)')
|
||||||
|
parser.add_argument('--brake', type=int, metavar='N',
|
||||||
|
help='Static brake position (0 to 255)')
|
||||||
|
parser.add_argument('--clutch', type=int, metavar='N',
|
||||||
|
help='Static clutch position (0 to 255, 0=released, 255=fully pressed)')
|
||||||
|
parser.add_argument('--button', type=int, metavar='N',
|
||||||
|
help='Hold specific button (1-16, 0=none)')
|
||||||
|
|
||||||
|
# Auto behaviors (override static values)
|
||||||
|
parser.add_argument('--auto-rotate', action='store_true',
|
||||||
|
help='Enable auto-rotating steering (overrides --steering)')
|
||||||
|
parser.add_argument('--rotate-speed', type=float, default=0.5, metavar='X',
|
||||||
|
help='Rotation speed multiplier (default: 0.5)')
|
||||||
|
parser.add_argument('--auto-throttle', action='store_true',
|
||||||
|
help='Enable auto-pulsing throttle (overrides --throttle)')
|
||||||
|
parser.add_argument('--auto-brake', action='store_true',
|
||||||
|
help='Enable auto-braking pattern (overrides --brake)')
|
||||||
|
parser.add_argument('--auto-clutch', action='store_true',
|
||||||
|
help='Enable auto-clutch pattern (simulates gear changes)')
|
||||||
|
parser.add_argument('--cycle-buttons', action='store_true',
|
||||||
|
help='Cycle through all buttons (overrides --button)')
|
||||||
|
|
||||||
|
# System options
|
||||||
|
parser.add_argument('--rate', type=int, default=100, metavar='HZ',
|
||||||
|
help='Update rate in Hz (default: 100)')
|
||||||
|
parser.add_argument('--interactive', '-i', action='store_true',
|
||||||
|
help='Interactive mode - change settings via CLI commands')
|
||||||
|
parser.add_argument('--quiet', action='store_true',
|
||||||
|
help='Suppress status output (only show device info)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate ranges
|
||||||
|
if args.steering is not None and not (-32768 <= args.steering <= 32767):
|
||||||
|
parser.error('--steering must be between -32768 and 32767')
|
||||||
|
if args.throttle is not None and not (0 <= args.throttle <= 255):
|
||||||
|
parser.error('--throttle must be between 0 and 255')
|
||||||
|
if args.brake is not None and not (0 <= args.brake <= 255):
|
||||||
|
parser.error('--brake must be between 0 and 255')
|
||||||
|
if args.clutch is not None and not (0 <= args.clutch <= 255):
|
||||||
|
parser.error('--clutch must be between 0 and 255')
|
||||||
|
if args.button is not None and not (0 <= args.button <= 16):
|
||||||
|
parser.error('--button must be between 0 and 16')
|
||||||
|
if args.rate < 1 or args.rate > 1000:
|
||||||
|
parser.error('--rate must be between 1 and 1000 Hz')
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
wheel = FakeGamingWheel(args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wheel.create_device()
|
||||||
|
if args.interactive:
|
||||||
|
wheel.run_interactive()
|
||||||
|
else:
|
||||||
|
wheel.run()
|
||||||
|
finally:
|
||||||
|
wheel.destroy_device()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+264
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fake Gaming Wheel using uinput
|
||||||
|
Creates a virtual gaming wheel device for testing.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Linux with uinput kernel module
|
||||||
|
- Root/sudo access or user in 'input' group
|
||||||
|
- Python 3.6+
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sudo python3 fake_wheel_uinput.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import fcntl
|
||||||
|
import signal
|
||||||
|
import math
|
||||||
|
|
||||||
|
# uinput constants
|
||||||
|
UINPUT_MAX_NAME_SIZE = 80
|
||||||
|
|
||||||
|
# ioctl commands
|
||||||
|
UI_DEV_CREATE = 0x5501
|
||||||
|
UI_DEV_DESTROY = 0x5502
|
||||||
|
UI_DEV_SETUP = 0x405c5503
|
||||||
|
UI_ABS_SETUP = 0x401c5504
|
||||||
|
UI_SET_EVBIT = 0x40045564
|
||||||
|
UI_SET_KEYBIT = 0x40045565
|
||||||
|
UI_SET_ABSBIT = 0x40045567
|
||||||
|
UI_SET_FFBIT = 0x4004556b
|
||||||
|
|
||||||
|
# Event types
|
||||||
|
EV_SYN = 0x00
|
||||||
|
EV_KEY = 0x01
|
||||||
|
EV_ABS = 0x03
|
||||||
|
EV_FF = 0x15
|
||||||
|
|
||||||
|
# Absolute axes
|
||||||
|
ABS_X = 0x00 # Steering wheel
|
||||||
|
ABS_Y = 0x01 # Throttle
|
||||||
|
ABS_Z = 0x02 # Brake
|
||||||
|
ABS_RZ = 0x05 # Clutch (optional)
|
||||||
|
|
||||||
|
# Button codes (BTN_JOYSTICK range)
|
||||||
|
BTN_TRIGGER = 0x120
|
||||||
|
BTN_BASE = 0x126
|
||||||
|
|
||||||
|
class FakeGamingWheel:
|
||||||
|
def __init__(self):
|
||||||
|
self.running = True
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
# Current state
|
||||||
|
self.steering = 0 # -32768 to 32767
|
||||||
|
self.throttle = 0 # 0 to 255
|
||||||
|
self.brake = 0 # 0 to 255
|
||||||
|
self.buttons = 0 # Bitmask
|
||||||
|
|
||||||
|
def create_device(self):
|
||||||
|
"""Create the virtual gaming wheel device."""
|
||||||
|
try:
|
||||||
|
self.fd = os.open('/dev/uinput', os.O_WRONLY | os.O_NONBLOCK)
|
||||||
|
except PermissionError:
|
||||||
|
try:
|
||||||
|
self.fd = os.open('/dev/input/uinput', os.O_WRONLY | os.O_NONBLOCK)
|
||||||
|
except:
|
||||||
|
print("Error: Permission denied accessing uinput.")
|
||||||
|
print("Try: sudo chmod +0666 /dev/uinput")
|
||||||
|
print("Or run with: sudo python3 fake_wheel_uinput.py")
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Error: uinput device not found.")
|
||||||
|
print("Try: sudo modprobe uinput")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Enable event types
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY) # Button events
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_ABS) # Absolute axis events
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_FF) # Force feedback
|
||||||
|
|
||||||
|
# Enable buttons (16 buttons)
|
||||||
|
for i in range(16):
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_KEYBIT, BTN_TRIGGER + i)
|
||||||
|
|
||||||
|
# Enable and setup axes
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_ABSBIT, ABS_X) # Steering
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_ABSBIT, ABS_Y) # Throttle
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_ABSBIT, ABS_Z) # Brake
|
||||||
|
|
||||||
|
# Setup axis parameters using UI_ABS_SETUP (new API)
|
||||||
|
# struct uinput_abs_setup: code(u16), padding(u16), absinfo(struct input_absinfo)
|
||||||
|
# struct input_absinfo: value(s32), minimum(s32), maximum(s32), fuzz(s32), flat(s32), resolution(s32)
|
||||||
|
|
||||||
|
# ABS_X: Steering wheel (-32768 to 32767)
|
||||||
|
abs_setup_x = struct.pack('HH6i', ABS_X, 0, 0, -32768, 32767, 16, 128, 0)
|
||||||
|
fcntl.ioctl(self.fd, UI_ABS_SETUP, abs_setup_x)
|
||||||
|
|
||||||
|
# ABS_Y: Throttle (0 to 255)
|
||||||
|
abs_setup_y = struct.pack('HH6i', ABS_Y, 0, 0, 0, 255, 0, 15, 0)
|
||||||
|
fcntl.ioctl(self.fd, UI_ABS_SETUP, abs_setup_y)
|
||||||
|
|
||||||
|
# ABS_Z: Brake (0 to 255)
|
||||||
|
abs_setup_z = struct.pack('HH6i', ABS_Z, 0, 0, 0, 255, 0, 15, 0)
|
||||||
|
fcntl.ioctl(self.fd, UI_ABS_SETUP, abs_setup_z)
|
||||||
|
|
||||||
|
# Setup device info using UI_DEV_SETUP (new API)
|
||||||
|
# struct uinput_setup: id(struct input_id), name(80 bytes), ff_effects_max(u32)
|
||||||
|
# struct input_id: bustype(u16), vendor(u16), product(u16), version(u16)
|
||||||
|
device_setup = struct.pack(
|
||||||
|
'HHHH80sI',
|
||||||
|
0x03, # bustype (BUS_USB)
|
||||||
|
0x046d, # vendor (Logitech)
|
||||||
|
0xc24f, # product (G29)
|
||||||
|
0x0111, # version
|
||||||
|
b'Logitech G29 Racing Wheel\x00', # name (80 bytes, null-terminated)
|
||||||
|
0 # ff_effects_max
|
||||||
|
)
|
||||||
|
fcntl.ioctl(self.fd, UI_DEV_SETUP, device_setup)
|
||||||
|
|
||||||
|
# Create the device
|
||||||
|
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("✓ Created virtual gaming wheel device")
|
||||||
|
print(" Name: Logitech G29 Racing Wheel")
|
||||||
|
print(" Vendor: 0x046d (Logitech)")
|
||||||
|
print(" Product: 0xc24f (G29)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
time.sleep(0.5) # Give system time to recognize device
|
||||||
|
|
||||||
|
print("\nDevice should now appear in:")
|
||||||
|
print(" • /dev/input/by-id/ (look for Logitech)")
|
||||||
|
print(" • /proc/bus/input/devices")
|
||||||
|
print("\nTest with: evtest (select the G29 device)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def destroy_device(self):
|
||||||
|
"""Destroy the virtual device."""
|
||||||
|
if self.fd:
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
|
||||||
|
os.close(self.fd)
|
||||||
|
print("\n✓ Destroyed virtual gaming wheel device")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nWarning: Error destroying device: {e}")
|
||||||
|
|
||||||
|
def send_event(self, ev_type, code, value):
|
||||||
|
"""Send a single input event."""
|
||||||
|
# struct input_event: timeval (8 bytes) + type (2) + code (2) + value (4) = 16 bytes on 32-bit, 24 on 64-bit
|
||||||
|
# We'll use the simpler 5-value format: sec, usec, type, code, value
|
||||||
|
if sys.maxsize > 2**32: # 64-bit
|
||||||
|
event = struct.pack('llHHi', 0, 0, ev_type, code, value)
|
||||||
|
else: # 32-bit
|
||||||
|
event = struct.pack('IIHHi', 0, 0, ev_type, code, value)
|
||||||
|
|
||||||
|
os.write(self.fd, event)
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
"""Send sync event to mark end of event batch."""
|
||||||
|
self.send_event(EV_SYN, 0, 0)
|
||||||
|
|
||||||
|
def send_state(self):
|
||||||
|
"""Send current device state."""
|
||||||
|
# Send axis values
|
||||||
|
self.send_event(EV_ABS, ABS_X, self.steering)
|
||||||
|
self.send_event(EV_ABS, ABS_Y, self.throttle)
|
||||||
|
self.send_event(EV_ABS, ABS_Z, self.brake)
|
||||||
|
|
||||||
|
# Send button states
|
||||||
|
for i in range(16):
|
||||||
|
button_pressed = (self.buttons >> i) & 1
|
||||||
|
self.send_event(EV_KEY, BTN_TRIGGER + i, button_pressed)
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def update_state(self, elapsed_time):
|
||||||
|
"""Update the wheel state with simulated movement."""
|
||||||
|
# Steering: smooth sine wave (full range)
|
||||||
|
angle = elapsed_time * 0.5
|
||||||
|
self.steering = int(16384 * math.sin(angle))
|
||||||
|
|
||||||
|
# Throttle: pulse pattern (0-255)
|
||||||
|
throttle_wave = (math.sin(elapsed_time * 2) + 1) / 2
|
||||||
|
self.throttle = int(200 * throttle_wave)
|
||||||
|
|
||||||
|
# Brake: periodic braking
|
||||||
|
if int(elapsed_time) % 5 < 1:
|
||||||
|
self.brake = 200
|
||||||
|
else:
|
||||||
|
self.brake = 0
|
||||||
|
|
||||||
|
# Buttons: cycle through
|
||||||
|
button_index = int(elapsed_time) % 16
|
||||||
|
self.buttons = 1 << button_index
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main loop."""
|
||||||
|
print("Simulating gaming wheel...")
|
||||||
|
print(" • Steering: Auto-rotating ±450°")
|
||||||
|
print(" • Throttle: Pulsing 0-78%")
|
||||||
|
print(" • Brake: Periodic (every 5s)")
|
||||||
|
print(" • Buttons: Cycling 1-16")
|
||||||
|
print("\nPress Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self.update_state(elapsed)
|
||||||
|
|
||||||
|
# Send to device
|
||||||
|
self.send_state()
|
||||||
|
|
||||||
|
# Display status
|
||||||
|
if int(elapsed * 10) % 10 == 0:
|
||||||
|
steering_deg = self.steering / 32768 * 450
|
||||||
|
throttle_pct = self.throttle / 255 * 100
|
||||||
|
brake_pct = self.brake / 255 * 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 if pressed_button else 'None':>4}",
|
||||||
|
end='', flush=True)
|
||||||
|
|
||||||
|
time.sleep(0.01) # 100 Hz
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopping...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
"""Handle shutdown signals."""
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user