diff --git a/fake-wheel/.claude/settings.local.json b/fake-wheel/.claude/settings.local.json new file mode 100644 index 0000000..5c6821b --- /dev/null +++ b/fake-wheel/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(python3:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/fake-wheel/README.md b/fake-wheel/README.md new file mode 100644 index 0000000..3721730 --- /dev/null +++ b/fake-wheel/README.md @@ -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. diff --git a/fake-wheel/check_wheel.sh b/fake-wheel/check_wheel.sh new file mode 100755 index 0000000..b51a2f6 --- /dev/null +++ b/fake-wheel/check_wheel.sh @@ -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" diff --git a/fake-wheel/debug_uhid.py b/fake-wheel/debug_uhid.py new file mode 100755 index 0000000..8b938a2 --- /dev/null +++ b/fake-wheel/debug_uhid.py @@ -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(' 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 Set steering (-32768 to 32767, 0=center)") + print(" throttle Set throttle (0 to 255)") + print(" brake Set brake (0 to 255)") + print(" clutch Set clutch (0 to 255)") + print(" button 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 steering") + print(" t throttle") + print(" b brake") + print(" c clutch") + print(" btn 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() diff --git a/fake-wheel/fake_wheel_uinput.py b/fake-wheel/fake_wheel_uinput.py new file mode 100755 index 0000000..699c965 --- /dev/null +++ b/fake-wheel/fake_wheel_uinput.py @@ -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()