Transparency
Source code
Support open source
Help fund ongoing development
TrueAxis stays free and its source remains public. Support helps with testing, hardware compatibility work and future updates.
#!/usr/bin/env python3
import sys
import math
import time
import threading
import pygame
import json
import os
import hashlib
import re
import shutil
import uuid
import urllib.error
import urllib.request
import ctypes
try:
import vgamepad as vg
VIGEM_AVAILABLE = True
except Exception:
vg = None
VIGEM_AVAILABLE = False
import tempfile
import subprocess
from pathlib import Path
from urllib.parse import urljoin, urlparse
from PySide6.QtCore import QObject, Signal, Slot, Property, QTimer, Qt, QEvent, QThread, QSettings, QRectF, QPointF
from PySide6.QtGui import QGuiApplication, QIcon, QPixmap, QPainter, QColor, QFont, QAction, QPen, QBrush, QLinearGradient
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle
# HID support for hardware wheel control
try:
import hid as hidapi
HID_AVAILABLE = True
except ImportError:
hidapi = None
HID_AVAILABLE = False
# Logitech wheel USB product IDs used for hardware wheel control. These are
# intentionally split by protocol below; G29-style and G920/G923-Xbox wheels do
# not use the same steering range command.
LOGITECH_VID = 0x046d
LOGITECH_WHEEL_PIDS = {
0xc202: "WingMan Formula (Yellow)",
0xc20e: "WingMan Formula GP",
0xc293: "WingMan Formula Force GP",
0xc294: "Driving Force / Formula EX",
0xc295: "Momo Force",
0xc298: "Driving Force Pro",
0xc299: "G25",
0xc29a: "Driving Force GT",
0xc29b: "G27",
0xc29c: "Speed Force Wireless",
0xca03: "MOMO Racing USB",
0xca04: "Racing Wheel USB",
0xc24f: "G29",
0xc260: "G29 (PS4 native)",
0xc262: "G920",
0xc266: "G923 (PS4/PC)",
0xc267: "G923 (PS4/PC pre-switch)",
0xc26d: "G923 (Xbox/PC)",
0xc26e: "G923 (Xbox/PC alt)",
0xc268: "Logitech Pro Racing Wheel (PlayStation/PC)",
0xc272: "Logitech Pro Racing Wheel (Xbox/PC)",
}
LOGITECH_LG4FF_RANGE_PIDS = {0xc299, 0xc29a, 0xc29b, 0xc24f, 0xc260, 0xc266}
LOGITECH_DFP_RANGE_PIDS = {0xc298}
LOGITECH_G923_PS_MODE_PIDS = {0xc267}
LOGITECH_HIDPP_RANGE_PIDS = {0xc262, 0xc26d, 0xc26e}
LOGITECH_CLASSIC_AUTOCENTER_PIDS = LOGITECH_LG4FF_RANGE_PIDS | LOGITECH_DFP_RANGE_PIDS
LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE = 0x8123
LOGITECH_HIDPP_ROOT_FEATURE_INDEX = 0x00
LOGITECH_HIDPP_ROOT_GET_FEATURE = 0x00
LOGITECH_HIDPP_GET_APERTURE = 0x51
LOGITECH_HIDPP_SET_APERTURE = 0x61
LOGITECH_HIDPP_SW_ID = 0x01
LOGITECH_LAST_RANGE_STATUS = ""
LOGITECH_LAST_RANGE_APPLIED = None
MOZA_VID = 0x346e
MOZA_LAST_RANGE_STATUS = ""
MOZA_LAST_RANGE_APPLIED = None
MOZA_SDK_ERROR_NAMES = {
0: "NORMAL",
1: "NOINSTALLSDK",
2: "NODEVICES",
3: "OUTOFRANGE",
4: "PARAMETERERR",
5: "COLLECTIONCYCLEDATALOSS",
6: "CREATEFFECTERR",
7: "ENCODINGFAILED",
8: "FFBERR",
9: "FIRMWARETOOOLD",
10: "PITHOUSENOTREADY",
}
_MOZA_SDK_CACHE = {
"loaded": False,
"dll": None,
"dir": "",
"error": "",
"checked_at": 0.0,
}
KNOWN_WHEEL_VENDOR_IDS = {
0x046d: "logitech",
0x044f: "thrustmaster",
0x0eb7: "fanatec",
MOZA_VID: "moza",
}
KNOWN_WHEEL_PRODUCTS = {
(0x046d, 0xc202): "Logitech WingMan Formula Yellow",
(0x046d, 0xc20e): "Logitech WingMan Formula GP",
(0x046d, 0xc293): "Logitech WingMan Formula Force GP",
(0x046d, 0xc294): "Logitech Driving Force / Formula EX",
(0x046d, 0xc295): "Logitech Momo Force",
(0x046d, 0xc298): "Logitech Driving Force Pro",
(0x046d, 0xc299): "Logitech G25",
(0x046d, 0xc29a): "Logitech Driving Force GT",
(0x046d, 0xc29b): "Logitech G27",
(0x046d, 0xc29c): "Logitech Speed Force Wireless",
(0x046d, 0xca03): "Logitech MOMO Racing USB",
(0x046d, 0xca04): "Logitech Racing Wheel USB",
(0x046d, 0xc24f): "Logitech G29",
(0x046d, 0xc260): "Logitech G29 PS4/initial",
(0x046d, 0xc261): "Logitech G920 initial/Xbox",
(0x046d, 0xc262): "Logitech G920",
(0x046d, 0xc266): "Logitech G923 PS4/PC",
(0x046d, 0xc267): "Logitech G923 PS pre-switch",
(0x046d, 0xc26d): "Logitech G923 Xbox initial",
(0x046d, 0xc26e): "Logitech G923 Xbox/PC",
(0x046d, 0xc268): "Logitech Pro Racing Wheel PlayStation/PC",
(0x046d, 0xc269): "Logitech Pro Racing Wheel PS mode",
(0x046d, 0xc272): "Logitech Pro Racing Wheel Xbox/PC",
(0x044f, 0xb65e): "Thrustmaster T500RS",
(0x044f, 0xb666): "Thrustmaster TX bootloader / initial",
(0x044f, 0xb66d): "Thrustmaster T300RS GT",
(0x044f, 0xb66e): "Thrustmaster T300RS",
(0x044f, 0xb66f): "Thrustmaster Ferrari F1 Advanced T300",
(0x044f, 0xb669): "Thrustmaster TX 458 Italia",
(0x044f, 0xb677): "Thrustmaster T150",
(0x044f, 0xb67f): "Thrustmaster TMX",
(0x044f, 0xb684): "Thrustmaster T-GT",
(0x044f, 0xb689): "Thrustmaster TS-PC",
(0x044f, 0xb692): "Thrustmaster TS-XW",
(0x044f, 0xb696): "Thrustmaster T248",
(0x0eb7, 0x0001): "Fanatec ClubSport Wheel Base V2",
(0x0eb7, 0x0004): "Fanatec ClubSport Wheel Base V2.5",
(0x0eb7, 0x0005): "Fanatec CSL Elite Wheel Base PS4",
(0x0eb7, 0x0006): "Fanatec Podium Wheel Base DD1",
(0x0eb7, 0x0007): "Fanatec Podium Wheel Base DD2",
(0x0eb7, 0x0011): "Fanatec CSR Elite / Forza Motorsport Wheel Base",
(0x0eb7, 0x0020): "Fanatec CSL DD / DD Pro / ClubSport DD",
(0x0eb7, 0x0197): "Fanatec Porsche 911 Turbo S / GT3 RS V2",
(0x0eb7, 0x0e03): "Fanatec CSL Elite Wheel Base",
(MOZA_VID, 0x0000): "MOZA R16 / R21",
(MOZA_VID, 0x0002): "MOZA R9",
(MOZA_VID, 0x0004): "MOZA R5",
(MOZA_VID, 0x0005): "MOZA R3",
(MOZA_VID, 0x0006): "MOZA R12",
(0x11ff, 0x3245): "PXN V10",
(0x11ff, 0x1212): "PXN V12",
(0x11ff, 0x1112): "PXN V12 Lite",
(0x11ff, 0x1211): "PXN V12 Lite SE",
(0x36e6, 0x400f): "PXN VD6",
(0x0f0d, 0x00a4): "HORI Racing Wheel Apex",
(0x0f0d, 0x0156): "HORI Racing Wheel Apex",
(0x0f0d, 0x017a): "HORI Truck Control System",
(0x0483, 0x0522): "Simagic M10 / Alpha",
(0x3670, 0x0500): "Simagic EVO Sport",
(0x3670, 0x0501): "Simagic EVO",
(0x3670, 0x0502): "Simagic EVO Pro",
(0x16d0, 0x0d5a): "Simucube 1",
(0x16d0, 0x0d61): "Simucube 2 Sport",
(0x16d0, 0x0d60): "Simucube 2 Pro",
(0x16d0, 0x0d5f): "Simucube 2 Ultimate",
(0x2433, 0xf300): "Asetek Invicta",
(0x2433, 0xf301): "Asetek Forte",
(0x2433, 0xf303): "Asetek La Prima",
(0x2433, 0xf306): "Asetek Tony Kanaan",
(0x0483, 0xa355): "VRS DirectForce Pro",
(0x1209, 0xffb0): "OpenFFBoard",
(0x1fc9, 0x804c): "SimXperience AccuForce Pro",
(0x3416, 0x0301): "Cammus C5",
(0x3416, 0x0302): "Cammus C12",
}
KNOWN_WHEEL_PRODUCT_VENDORS = {
0x11ff: "pxn",
0x36e6: "pxn",
0x0483: "directdrive",
0x3670: "directdrive",
0x16d0: "directdrive",
0x2433: "directdrive",
0x1209: "directdrive",
0x1fc9: "directdrive",
0x3416: "directdrive",
}
WHEEL_HID_NAME_TOKENS = (
"wheel", "racing", "steering", "g29", "g920", "g923", "g27", "g25",
"driving force", "thrustmaster", "t300", "t150", "t248", "t500",
"t818", "t-gt", "tmx", "moza", "fanatec", "csl", "clubsport",
"podium", "simucube", "simagic", "asetek", "vrs", "cammus", "pxn",
"hori racing", "hori wheel", "racing wheel apex", "momo"
)
GAMEPAD_HID_NAME_TOKENS = (
"xbox", "controller", "gamepad", "dualsense", "dualshock",
"8bitdo", "stadia", "wireless controller", "game sir", "gamesir",
"horipad"
)
HID_HINT_STOP_TOKENS = {
"usb", "hid", "device", "controller", "gamepad", "wireless", "advanced",
"optical", "mouse", "keyboard", "audio", "interface"
}
def _hid_value(value):
if value is None:
return None
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="replace")
except Exception:
return repr(value)
return value
def _safe_hex(value):
try:
return f"0x{int(value):04x}"
except Exception:
return ""
def _hid_path_hash(path_text):
if not path_text:
return ""
try:
return hashlib.sha256(str(path_text).encode("utf-8", errors="ignore")).hexdigest()[:16]
except Exception:
return ""
def _hid_report_summary(device):
"""Return useful HID metadata without exposing raw device paths or serials."""
usage_page = device.get("usage_page")
usage = device.get("usage")
vendor_id = device.get("vendor_id")
product_id = device.get("product_id")
vendor = _hid_vendor_label(device)
support_kind = device.get("support_kind") or _range_support_kind_for_vendor(vendor, product_id)
return {
"name": str(device.get("name") or device.get("product_string") or ""),
"manufacturer": str(device.get("manufacturer_string") or ""),
"productString": str(device.get("product_string") or ""),
"vendor": _safe_hex(vendor_id),
"product": _safe_hex(product_id),
"brand": vendor,
"knownModel": KNOWN_WHEEL_PRODUCTS.get((vendor_id, product_id), ""),
"interface": device.get("interface_number"),
"usagePage": _safe_hex(usage_page),
"usageId": _safe_hex(usage),
"usage": f"{_safe_hex(usage_page)}:{_safe_hex(usage)}" if usage_page is not None or usage is not None else "",
"release": _safe_hex(device.get("release_number")),
"protocol": str(device.get("protocol") or "unknown"),
"supportKind": support_kind,
"pathHash": _hid_path_hash(device.get("path_text")),
"matchReason": str(device.get("match_reason") or ""),
}
def _hid_text(device):
return " ".join(str(_hid_value(device.get(key)) or "") for key in (
"name", "manufacturer_string", "product_string", "serial_number"
)).lower()
def _hid_vendor_label(device):
vendor_id = device.get("vendor_id")
label = KNOWN_WHEEL_VENDOR_IDS.get(vendor_id)
if label:
return label
label = KNOWN_WHEEL_PRODUCT_VENDORS.get(vendor_id)
if label:
return label
text_value = _hid_text(device)
if "thrustmaster" in text_value or "guillemot" in text_value:
return "thrustmaster"
if "fanatec" in text_value or "endor" in text_value:
return "fanatec"
if "moza" in text_value:
return "moza"
if "pxn" in text_value:
return "pxn"
if "hori" in text_value:
return "hori"
if any(token in text_value for token in ("simucube", "simagic", "asetek", "vrs", "cammus")):
return "directdrive"
return ""
def _range_support_kind_for_vendor(vendor, product_id=None):
if vendor == "logitech":
if product_id in LOGITECH_LG4FF_RANGE_PIDS | LOGITECH_DFP_RANGE_PIDS | LOGITECH_G923_PS_MODE_PIDS | LOGITECH_HIDPP_RANGE_PIDS:
return "real_command_public"
return "diagnostic_only"
if vendor == "moza":
return "sdk_available"
if vendor in ("thrustmaster", "fanatec", "pxn", "hori", "directdrive"):
return "vendor_app_only"
return "diagnostic_only"
def _diagnostic_protocol_for_hid(device):
vendor = _hid_vendor_label(device)
if vendor == "logitech":
product_id = device.get("product_id")
if product_id in LOGITECH_LG4FF_RANGE_PIDS:
return "lg4ff"
if product_id in LOGITECH_DFP_RANGE_PIDS:
return "dfp"
if product_id in LOGITECH_G923_PS_MODE_PIDS:
return "g923_ps_mode"
if product_id in LOGITECH_HIDPP_RANGE_PIDS:
return "hidpp_g920"
return "unsupported"
if vendor in ("thrustmaster", "fanatec", "moza", "pxn", "hori", "directdrive"):
return f"{vendor}_diagnostic"
return "wheel_diagnostic"
def _is_wheel_hid_candidate(device, target_hint=""):
vendor_id = device.get("vendor_id")
product_id = device.get("product_id")
text_value = _hid_text(device)
hint = (target_hint or "").lower()
if (vendor_id, product_id) in KNOWN_WHEEL_PRODUCTS:
return True, "known_vid_pid"
if vendor_id in KNOWN_WHEEL_VENDOR_IDS:
return True, KNOWN_WHEEL_VENDOR_IDS[vendor_id]
if vendor_id in KNOWN_WHEEL_PRODUCT_VENDORS:
return True, KNOWN_WHEEL_PRODUCT_VENDORS[vendor_id]
if any(token in text_value for token in WHEEL_HID_NAME_TOKENS):
return True, "name_match"
if any(token in text_value for token in GAMEPAD_HID_NAME_TOKENS):
return False, "gamepad"
if hint and any(token in hint for token in WHEEL_HID_NAME_TOKENS):
hint_tokens = [
token for token in re.split(r"[^a-z0-9]+", hint)
if len(token) >= 3 and token not in HID_HINT_STOP_TOKENS
]
if any(token and token in text_value for token in hint_tokens):
return True, "selected_device_match"
return False, ""
def _wheel_hid_candidates(target_hint=""):
"""Enumerate sanitized wheel-like HID endpoints for diagnostics across vendors."""
if not HID_AVAILABLE:
return []
try:
devices = hidapi.enumerate()
except Exception as e:
print(f"[WHEEL HID] Error enumerating all HID devices: {e}")
return []
candidates = []
for d in devices:
matched, reason = _is_wheel_hid_candidate(d, target_hint)
if not matched:
continue
item = dict(d)
product_id = d.get("product_id")
item["product_id"] = product_id
item["vendor_id"] = d.get("vendor_id")
vendor_id = d.get("vendor_id")
item["name"] = (
KNOWN_WHEEL_PRODUCTS.get((vendor_id, product_id))
or LOGITECH_WHEEL_PIDS.get(product_id)
or _hid_value(d.get("product_string"))
or _hid_vendor_label(d)
or "Unknown HID device"
)
item["protocol"] = _diagnostic_protocol_for_hid(item)
item["support_kind"] = _range_support_kind_for_vendor(_hid_vendor_label(item), product_id)
item["path_text"] = _hid_value(d.get("path"))
item["match_reason"] = reason
candidates.append(item)
candidates.sort(key=lambda d: (
_logitech_endpoint_priority(d),
0 if d.get("vendor_id") in KNOWN_WHEEL_VENDOR_IDS else 1,
str(d.get("manufacturer_string") or ""),
str(d.get("product_string") or ""),
d.get("interface_number", 99) if d.get("interface_number") is not None else 99,
d.get("usage_page") or 0,
d.get("usage") or 0,
))
return candidates[:32]
def _moza_error_name(code):
try:
return MOZA_SDK_ERROR_NAMES.get(int(code), f"ERROR_{int(code)}")
except Exception:
return "UNKNOWN"
def _path_candidates_for_moza_sdk():
candidates = []
def add(path_value):
if not path_value:
return
try:
path = Path(path_value).expanduser()
if path not in candidates:
candidates.append(path)
except Exception:
pass
add(os.environ.get("TRUEAXIS_MOZA_SDK_DIR"))
try:
add(getattr(sys, "_MEIPASS", ""))
except Exception:
pass
try:
if getattr(sys, "frozen", False):
add(Path(sys.executable).resolve().parent)
else:
here = Path(__file__).resolve().parent
add(here)
add(here / "vendor_sdk" / "moza" / "x64")
except Exception:
pass
try:
add(Path.cwd() / "vendor_sdk" / "moza" / "x64")
except Exception:
pass
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", "PROGRAMDATA"):
root = os.environ.get(env_name)
if not root:
continue
for relative in (
"MOZA Pit House",
"MOZA Racing",
"MOZA Racing\\MOZA Pit House",
"MOZA",
"MOZA\\MOZA Pit House",
):
add(Path(root) / relative)
return candidates
def _find_moza_sdk_dir():
for directory in _path_candidates_for_moza_sdk():
try:
if (directory / "MOZA_API_C.dll").exists() and (directory / "MOZA_SDK.dll").exists():
return directory
except Exception:
continue
return None
def _load_moza_sdk(force_retry=False):
now = time.time()
if _MOZA_SDK_CACHE.get("loaded") and _MOZA_SDK_CACHE.get("dll") is not None:
return _MOZA_SDK_CACHE["dll"]
if (
not force_retry
and _MOZA_SDK_CACHE.get("error")
and now - float(_MOZA_SDK_CACHE.get("checked_at") or 0.0) < 15.0
):
return None
_MOZA_SDK_CACHE["checked_at"] = now
sdk_dir = _find_moza_sdk_dir()
if not sdk_dir:
_MOZA_SDK_CACHE["error"] = "MOZA SDK DLLs not found"
return None
try:
if hasattr(os, "add_dll_directory"):
os.add_dll_directory(str(sdk_dir))
os.environ["PATH"] = str(sdk_dir) + os.pathsep + os.environ.get("PATH", "")
dll = ctypes.WinDLL(str(sdk_dir / "MOZA_API_C.dll"))
dll.installMozaSDK_C.argtypes = []
dll.installMozaSDK_C.restype = None
dll.removeMozaSDK_C.argtypes = []
dll.removeMozaSDK_C.restype = None
dll.setMotorLimitAngle_C.argtypes = [ctypes.c_int, ctypes.c_int]
dll.setMotorLimitAngle_C.restype = ctypes.c_int
dll.getMotorLimitAngle_C.argtypes = [
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
]
dll.getMotorLimitAngle_C.restype = None
try:
dll.installMozaSDK_C()
except Exception as e:
print(f"[MOZA SDK] installMozaSDK_C warning: {e}")
_MOZA_SDK_CACHE.update({
"loaded": True,
"dll": dll,
"dir": str(sdk_dir),
"error": "",
"checked_at": now,
})
print(f"[MOZA SDK] Loaded from {sdk_dir}")
return dll
except Exception as e:
_MOZA_SDK_CACHE.update({
"loaded": False,
"dll": None,
"dir": str(sdk_dir),
"error": str(e),
"checked_at": now,
})
print(f"[MOZA SDK] Load failed: {e}")
return None
def _moza_sdk_ready():
return _load_moza_sdk() is not None
def _moza_sdk_status():
if _MOZA_SDK_CACHE.get("loaded"):
return f"MOZA SDK ready ({_MOZA_SDK_CACHE.get('dir')})"
if _MOZA_SDK_CACHE.get("error"):
return _MOZA_SDK_CACHE["error"]
return "MOZA SDK not checked"
def _get_moza_wheel_range():
dll = _load_moza_sdk()
if not dll:
return None, None, _moza_sdk_status()
first = ctypes.c_int(0)
second = ctypes.c_int(0)
err = ctypes.c_int(0)
try:
dll.getMotorLimitAngle_C(ctypes.byref(first), ctypes.byref(second), ctypes.byref(err))
if err.value != 0:
return None, None, _moza_error_name(err.value)
return int(first.value), int(second.value), "NORMAL"
except Exception as e:
return None, None, str(e)
def set_moza_wheel_range(range_degrees):
"""Set physical MOZA wheelbase range via the official MOZA C SDK."""
global MOZA_LAST_RANGE_STATUS, MOZA_LAST_RANGE_APPLIED
MOZA_LAST_RANGE_APPLIED = None
dll = _load_moza_sdk(force_retry=True)
if not dll:
MOZA_LAST_RANGE_STATUS = f"MOZA SDK unavailable: {_moza_sdk_status()}"
return False
target = max(90, min(2000, int(range_degrees)))
try:
err = int(dll.setMotorLimitAngle_C(target, target))
if err != 0:
MOZA_LAST_RANGE_STATUS = f"MOZA SDK set range failed: {_moza_error_name(err)}"
return False
time.sleep(0.05)
limit_angle, game_angle, read_status = _get_moza_wheel_range()
MOZA_LAST_RANGE_APPLIED = target
if limit_angle:
MOZA_LAST_RANGE_APPLIED = int(limit_angle)
if abs(int(limit_angle) - target) > 5:
MOZA_LAST_RANGE_STATUS = (
f"MOZA SDK range mismatch: requested {target} degrees, "
f"readback {limit_angle}/{game_angle}"
)
print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
return False
MOZA_LAST_RANGE_STATUS = (
f"MOZA hardware range command accepted: {target} degrees "
f"(readback {limit_angle}/{game_angle})"
)
else:
MOZA_LAST_RANGE_STATUS = (
f"MOZA hardware range command accepted: {target} degrees "
f"(readback unavailable: {read_status})"
)
print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
return True
except Exception as e:
MOZA_LAST_RANGE_STATUS = f"MOZA SDK range command failed: {e}"
print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
return False
def _logitech_protocol_for_pid(product_id):
if product_id in LOGITECH_LG4FF_RANGE_PIDS:
return "lg4ff"
if product_id in LOGITECH_DFP_RANGE_PIDS:
return "dfp"
if product_id in LOGITECH_G923_PS_MODE_PIDS:
return "g923_ps_mode"
if product_id in LOGITECH_HIDPP_RANGE_PIDS:
return "hidpp_g920"
return "unsupported"
def _logitech_endpoint_priority(device):
"""Prefer vendor-defined HID control endpoints over gameplay endpoints."""
protocol = device.get('protocol')
usage_page = device.get('usage_page') or 0
interface_number = device.get('interface_number')
if protocol in ("lg4ff", "dfp"):
if usage_page >= 0xff00:
return 0
if interface_number not in (None, -1, 0):
return 1
return 2
if protocol == "g923_ps_mode":
return 3
if protocol == "hidpp_g920":
return 4
return 9
def _logitech_hid_devices():
"""Return Logitech wheel HID descriptors sorted by likely control endpoint."""
if not HID_AVAILABLE:
return []
try:
devices = [
d for d in hidapi.enumerate(LOGITECH_VID)
if d.get('product_id') in LOGITECH_WHEEL_PIDS
]
except Exception as e:
print(f"[WHEEL HID] Error enumerating devices: {e}")
return []
normalized = []
for d in devices:
product_id = d.get('product_id')
item = dict(d)
item['product_id'] = product_id
item['vendor_id'] = d.get('vendor_id')
item['name'] = LOGITECH_WHEEL_PIDS.get(product_id, d.get('product_string') or 'Unknown')
item['protocol'] = _logitech_protocol_for_pid(product_id)
item['path_text'] = _hid_value(d.get('path'))
normalized.append(item)
protocol_rank = {
"lg4ff": 0,
"dfp": 1,
"g923_ps_mode": 2,
"hidpp_g920": 3,
"unsupported": 9,
}
normalized.sort(key=lambda d: (
protocol_rank.get(d.get('protocol'), 9),
_logitech_endpoint_priority(d),
d.get('interface_number', 99) if d.get('interface_number') is not None else 99,
d.get('usage_page') or 0,
d.get('usage') or 0,
))
return normalized
def _open_hid_path(path):
h = hidapi.device()
h.open_path(path)
return h
def _write_hid_report(path, report):
h = None
try:
h = _open_hid_path(path)
written = h.write(report)
ok = isinstance(written, int) and written >= len(report)
return {"ok": ok, "written": written, "error": ""}
except Exception as e:
return {"ok": False, "written": 0, "error": str(e)}
finally:
if h:
try:
h.close()
except Exception:
pass
def _logitech_lg4ff_range_reports(product_id, range_degrees):
range_degrees = max(10, min(1080, int(range_degrees)))
if product_id in LOGITECH_LG4FF_RANGE_PIDS:
return [[0x00, 0xf8, 0x81, range_degrees & 0xff, (range_degrees >> 8) & 0xff, 0x00, 0x00, 0x00]]
if product_id in LOGITECH_DFP_RANGE_PIDS:
range_degrees = max(40, min(900, range_degrees))
full_range = 900 if range_degrees > 200 else 200
reports = [[0x00, 0xf8, 0x03 if range_degrees > 200 else 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]]
fine = [0x00, 0x81, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00]
if range_degrees not in (200, 900):
start_left = (((full_range - range_degrees + 1) * 2047) // full_range)
start_right = 0xfff - start_left
fine[3] = start_left >> 4
fine[4] = start_right >> 4
fine[5] = 0xff
fine[6] = ((start_right & 0xe) << 4) | (start_left & 0xe)
fine[7] = 0xff
reports.append(fine)
return reports
return []
def _logitech_pid_matches_hint(product_id, hint_text):
hint = (hint_text or "").lower()
if not hint:
return True
if product_id in {0xc24f, 0xc260}:
return any(token in hint for token in ("g29", "logitech g29"))
if product_id == 0xc262:
return any(token in hint for token in ("g920", "logitech g920"))
if product_id in {0xc266, 0xc267, 0xc26d, 0xc26e}:
return "g923" in hint or ("g920" in hint and "g923" in hint)
if product_id == 0xc29b:
return "g27" in hint
if product_id == 0xc299:
return "g25" in hint
if product_id == 0xc29c:
return "driving force gt" in hint or "dfgt" in hint
if product_id == 0xc298:
return "driving force pro" in hint
return True
def _logitech_hint_has_specific_model(hint_text):
hint = (hint_text or "").lower()
return any(token in hint for token in (
"g29", "g920", "g923", "g27", "g25", "driving force gt",
"driving force pro", "dfgt"
))
def _filtered_logitech_hid_devices(target_hint=""):
devices = _logitech_hid_devices()
matched = [d for d in devices if _logitech_pid_matches_hint(d.get('product_id'), target_hint)]
if target_hint and _logitech_hint_has_specific_model(target_hint):
return matched
return matched or devices
def _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint=""):
attempts = []
ok_any = False
for d in _filtered_logitech_hid_devices(target_hint):
if d.get('protocol') not in ("lg4ff", "dfp"):
continue
reports = _logitech_lg4ff_range_reports(d.get('product_id'), range_degrees)
if not reports:
continue
device_ok = True
for report in reports:
result = _write_hid_report(d['path'], report)
result.update({
"protocol": d.get('protocol'),
"name": d.get('name'),
"pid": d.get('product_id'),
"interface": d.get('interface_number'),
"path": d.get('path_text'),
"report": " ".join(f"{b:02x}" for b in report),
})
attempts.append(result)
if not result["ok"]:
device_ok = False
break
if device_ok:
ok_any = True
print(f"[WHEEL HID] {d.get('name')} range report accepted via {d.get('protocol')} on interface {d.get('interface_number')}")
return True, attempts
return ok_any, attempts
def _switch_g923_ps_mode_to_native(target_hint=""):
"""Switch G923 PS-mode (PID c267) into native c266 mode when present."""
switched = False
for d in _filtered_logitech_hid_devices(target_hint):
if d.get('protocol') != "g923_ps_mode":
continue
report = [0x30, 0xf8, 0x09, 0x07, 0x01, 0x01, 0x00, 0x00]
result = _write_hid_report(d['path'], report)
print(f"[WHEEL HID] G923 PS-mode switch on interface {d.get('interface_number')}: {result}")
switched = switched or result["ok"]
if switched:
time.sleep(1.0)
return switched
def _hidpp_make_report(feature_index, command, params=None):
params = list(params or [])
report = [0x11, 0xff, feature_index & 0xff, (command | LOGITECH_HIDPP_SW_ID) & 0xff]
report.extend(params[:16])
report.extend([0x00] * (20 - len(report)))
return report
def _hidpp_parse_response(response, feature_index, command):
if not response or len(response) < 4:
return None
data = list(response)
if data[0] not in (0x10, 0x11, 0x12):
return None
sent_func = (command | LOGITECH_HIDPP_SW_ID) & 0xff
if data[2] == 0xff and len(data) >= 6:
if data[3] == feature_index and data[4] == sent_func:
return {"error": data[5], "params": data[4:], "raw": data}
if data[2] == feature_index and data[3] == sent_func:
return {"error": None, "params": data[4:], "raw": data}
return None
def _hidpp_exchange_open(handle, feature_index, command, params=None, timeout_ms=350):
report = _hidpp_make_report(feature_index, command, params)
written = handle.write(report)
if not isinstance(written, int) or written < len(report):
return {"error": f"short_write:{written}", "params": [], "raw": [], "written": written, "report": report}
deadline = time.time() + max(0.05, timeout_ms / 1000.0)
last_response = []
while time.time() < deadline:
chunk = handle.read(20, max(20, min(120, timeout_ms)))
if not chunk:
continue
last_response = list(chunk)
parsed = _hidpp_parse_response(last_response, feature_index, command)
if parsed is not None:
parsed["written"] = written
parsed["report"] = report
return parsed
return {"error": "timeout", "params": [], "raw": last_response, "written": written, "report": report}
def _set_logitech_hidpp_range(range_degrees, target_hint=""):
"""Set G920/G923-Xbox aperture through Logitech HID++ 0x8123 when available."""
range_degrees = max(180, min(900, int(range_degrees)))
attempts = []
for d in _filtered_logitech_hid_devices(target_hint):
if d.get('protocol') != "hidpp_g920":
continue
for transport in ("write", "feature"):
h = None
try:
h = _open_hid_path(d['path'])
h.set_nonblocking(0)
if transport == "feature":
# Some Windows stacks expose HID++ as feature reports only.
def send_feature(feature_index, command, params=None):
report = _hidpp_make_report(feature_index, command, params)
written = h.send_feature_report(report)
return {"error": None if written else "send_feature_report failed", "params": [], "raw": [], "written": written, "report": report}
exchange = send_feature
else:
exchange = lambda feature_index, command, params=None: _hidpp_exchange_open(h, feature_index, command, params)
root = exchange(LOGITECH_HIDPP_ROOT_FEATURE_INDEX, LOGITECH_HIDPP_ROOT_GET_FEATURE, [
LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE >> 8,
LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE & 0xff,
])
feature_index = None
if root.get("error") is None and root.get("params"):
feature_index = root["params"][0]
elif transport == "feature":
# send_feature_report cannot read responses. Leave this path as a logged probe only.
attempts.append({
"ok": False,
"transport": transport,
"stage": "root_feature",
"protocol": d.get('protocol'),
"name": d.get('name'),
"pid": d.get('product_id'),
"interface": d.get('interface_number'),
"path": d.get('path_text'),
"error": root.get("error"),
"written": root.get("written"),
})
continue
if not feature_index:
attempts.append({
"ok": False,
"transport": transport,
"stage": "root_feature",
"protocol": d.get('protocol'),
"name": d.get('name'),
"pid": d.get('product_id'),
"interface": d.get('interface_number'),
"path": d.get('path_text'),
"error": root.get("error"),
"raw": root.get("raw"),
})
continue
params = [(range_degrees >> 8) & 0xff, range_degrees & 0xff]
set_result = exchange(feature_index, LOGITECH_HIDPP_SET_APERTURE, params)
time.sleep(0.05)
get_result = exchange(feature_index, LOGITECH_HIDPP_GET_APERTURE, [])
readback = None
if get_result.get("error") is None and len(get_result.get("params", [])) >= 2:
readback = (get_result["params"][0] << 8) | get_result["params"][1]
ok = readback == range_degrees
attempts.append({
"ok": ok,
"transport": transport,
"stage": "set_get_aperture",
"protocol": d.get('protocol'),
"name": d.get('name'),
"pid": d.get('product_id'),
"interface": d.get('interface_number'),
"path": d.get('path_text'),
"feature_index": feature_index,
"target": range_degrees,
"readback": readback,
"set_error": set_result.get("error"),
"get_error": get_result.get("error"),
"set_raw": set_result.get("raw"),
"get_raw": get_result.get("raw"),
})
if ok:
print(f"[WHEEL HID] {d.get('name')} HID++ aperture confirmed at {readback} deg")
return True, attempts
except Exception as e:
attempts.append({
"ok": False,
"transport": transport,
"stage": "exception",
"protocol": d.get('protocol'),
"name": d.get('name'),
"pid": d.get('product_id'),
"interface": d.get('interface_number'),
"path": d.get('path_text'),
"error": str(e),
})
finally:
if h:
try:
h.close()
except Exception:
pass
return False, attempts
def _send_wheel_cmd(cmd_bytes):
"""Send a classic 7-byte Logitech command to lg4ff-compatible wheels."""
if not HID_AVAILABLE:
return False
ok_any = False
for d in _logitech_hid_devices():
if d.get('product_id') not in LOGITECH_CLASSIC_AUTOCENTER_PIDS:
continue
result = _write_hid_report(d['path'], [0x00] + list(cmd_bytes))
if result["ok"]:
ok_any = True
print(f"[WHEEL HID] Classic command sent to {d.get('name')} interface {d.get('interface_number')}")
else:
print(f"[WHEEL HID] Classic command failed on interface {d.get('interface_number')}: {result.get('error')}")
return ok_any
def set_logitech_wheel_range(range_degrees, target_hint="", allow_mode_switch=False):
"""Set the physical steering range for supported Logitech wheel protocols."""
global LOGITECH_LAST_RANGE_STATUS, LOGITECH_LAST_RANGE_APPLIED
LOGITECH_LAST_RANGE_APPLIED = None
if not HID_AVAILABLE:
LOGITECH_LAST_RANGE_STATUS = "HID support is not available"
return False
range_degrees = max(10, min(1080, int(range_degrees)))
ok, classic_attempts = _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint)
if ok:
LOGITECH_LAST_RANGE_APPLIED = range_degrees
LOGITECH_LAST_RANGE_STATUS = f"Classic Logitech hardware range command accepted: {range_degrees} degrees"
return True
if allow_mode_switch and any(d.get('protocol') == "g923_ps_mode" for d in _filtered_logitech_hid_devices(target_hint)):
if _switch_g923_ps_mode_to_native(target_hint):
ok, classic_attempts = _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint)
if ok:
LOGITECH_LAST_RANGE_APPLIED = range_degrees
LOGITECH_LAST_RANGE_STATUS = f"G923 PS-mode switched and range command accepted: {range_degrees} degrees"
return True
elif any(d.get('protocol') == "g923_ps_mode" for d in _filtered_logitech_hid_devices(target_hint)):
LOGITECH_LAST_RANGE_STATUS = "G923 is in PS pre-switch mode; run the hardware diagnostic mode-switch test"
ok, hidpp_attempts = _set_logitech_hidpp_range(range_degrees, target_hint)
if ok:
LOGITECH_LAST_RANGE_APPLIED = max(180, min(900, range_degrees))
LOGITECH_LAST_RANGE_STATUS = f"HID++ aperture confirmed at {LOGITECH_LAST_RANGE_APPLIED} degrees"
return True
devices = _filtered_logitech_hid_devices(target_hint)
protocols = sorted(set(d.get('protocol') for d in devices)) if devices else []
LOGITECH_LAST_RANGE_STATUS = f"No confirmed Logitech hardware range provider; protocols={protocols}, classic_attempts={len(classic_attempts)}, hidpp_attempts={len(hidpp_attempts)}"
print(f"[WHEEL HID] {LOGITECH_LAST_RANGE_STATUS}")
return False
def set_logitech_wheel_autocenter(strength):
"""Set the hardware autocenter spring strength (0.0 = off, 1.0 = max).
Uses the lg4ff autocenter protocol:
Off: [0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
On: [0xfe, 0x0d, force_a, force_a, force_b, 0x00, 0x00]
then [0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
"""
if not HID_AVAILABLE:
return False
strength = max(0.0, min(1.0, float(strength)))
if strength <= 0.0:
# Deactivate autocenter
result = _send_wheel_cmd([0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
if result:
print("[WHEEL HID] Autocenter OFF")
return result
# Convert 0.0-1.0 to 0x0000-0xFFFF magnitude
magnitude = int(strength * 0xFFFF)
# Calculate expansion values (from lg4ff kernel driver)
if magnitude <= 0xaaaa:
expand_a = 0x0c * magnitude
expand_b = 0x80 * magnitude
else:
expand_a = (0x0c * 0xaaaa) + 0x06 * (magnitude - 0xaaaa)
expand_b = (0x80 * 0xaaaa) + 0xff * (magnitude - 0xaaaa)
# Non-MOMO wheels: right-shift expand_a by 1
expand_a = expand_a >> 1
force_a = expand_a // 0xaaaa
force_b = expand_b // 0xaaaa
# Clamp to valid byte range
force_a = max(0, min(0xff, force_a))
force_b = max(0, min(0xff, force_b))
# Send autocenter parameters
cmd1 = [0xfe, 0x0d, force_a, force_a, force_b, 0x00, 0x00]
# Send activation command
cmd2 = [0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
r1 = _send_wheel_cmd(cmd1)
r2 = _send_wheel_cmd(cmd2)
if r1 and r2:
print(f"[WHEEL HID] Autocenter set to {strength:.0%} (force_a={force_a:#x}, force_b={force_b:#x})")
return r1 and r2
# Current version and update channel for the Competitive build.
CURRENT_VERSION = "4.8.23"
UPDATE_CHANNEL = "competitive"
UPDATE_PLATFORM = "windows-x64"
SITE_BASE_URLS = ("https://trueaxis.nl", "https://trueaccess.nl")
VERSION_CHECK_URL = f"{SITE_BASE_URLS[0]}/version.json"
DOWNLOAD_URL = f"{SITE_BASE_URLS[0]}/versions-competitive.html"
TRUSTED_UPDATE_HOSTS = {"trueaxis.nl", "www.trueaxis.nl", "trueaccess.nl", "www.trueaccess.nl"}
HARDWARE_REPORT_URLS = [f"{base}/api/hardware-range-report" for base in SITE_BASE_URLS]
TELEMETRY_URLS = [f"{base}/api/telemetry" for base in SITE_BASE_URLS]
DEFAULT_INSTALLER_ARGS = ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CLOSEAPPLICATIONS", "/SP-"]
ALLOWED_INSTALLER_ARGS = {"/NOSTART", "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CLOSEAPPLICATIONS", "/SP-", "/SILENT", "/QUIET"}
VIGEM_INSTALLER_URL = "https://github.com/nefarius/ViGEmBus/releases/download/v1.22.0/ViGEmBus_1.22.0_x64_x86_arm64.exe"
DEFAULT_WHEEL_MAX_RANGE = 1080
MAX_STEERING_RANGE_SETTING = 2520
SOFT_RAMP_AVAILABLE = False
UPDATE_CHECK_INTERVAL_MS = 30_000
TELEMETRY_HEARTBEAT_INTERVAL_MS = 5 * 60_000
HARDWARE_REASSERT_INTERVAL_MS = 8_000
CENTER_SPRING_PULSE_STRENGTH = 0.01
CENTER_SPRING_PULSE_DELAY_SECONDS = 0.08
INPUT_READER_POLL_SECONDS = 0.005
MAPPING_LOOP_SLEEP_SECONDS = 0.002
PREVIEW_EMIT_EPSILON = 0.002
def _version_sort_key(version):
"""Return a comparable key for versions like v4.8, 4.8.1-beta, or 4.8-comp."""
text = str(version or "").strip().lower().lstrip("v")
numbers = [int(part) for part in re.findall(r"\d+", text)[:4]]
numbers += [0] * (4 - len(numbers))
suffix_weight = 0
suffix_number = 0
if "alpha" in text:
suffix_weight = -3
elif "beta" in text:
suffix_weight = -2
elif "rc" in text:
suffix_weight = -1
suffix_match = re.search(r"(?:alpha|beta|rc)[.-]?(\d+)", text)
if suffix_match:
suffix_number = int(suffix_match.group(1))
return tuple(numbers + [suffix_weight, suffix_number])
def _default_trueaxis_install_dir():
return os.path.join(os.getenv("LOCALAPPDATA", tempfile.gettempdir()), "Programs", "TrueAxis")
def _looks_like_trueaxis_onedir_install(directory):
if not directory:
return False
directory = os.path.abspath(directory)
return (
os.path.isfile(os.path.join(directory, "TrueAxis.exe"))
and os.path.isdir(os.path.join(directory, "_internal"))
)
# Try to import pynput for keyboard support
try:
from pynput.keyboard import Controller as KeyboardController, Key
KEYBOARD_AVAILABLE = True
except ImportError:
print("Warning: pynput not installed. Keyboard bindings will not be available.")
print("Install with: pip install pynput")
KEYBOARD_AVAILABLE = False
KeyboardController = None
Key = None
# --- Platform-specific imports for auto-start ---
if sys.platform == 'win32':
import winreg
elif sys.platform == 'darwin': # macOS
import subprocess
import plistlib
from pathlib import Path
# --- CONFIG & PROFILES ---
_APP_DIR = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
def _resolve_user_data_dir():
if sys.platform == 'win32':
base_dir = os.getenv('APPDATA') or os.getenv('LOCALAPPDATA') or os.path.expanduser("~")
elif sys.platform == 'darwin':
base_dir = os.path.join(os.path.expanduser("~"), "Library", "Application Support")
else:
base_dir = os.getenv('XDG_CONFIG_HOME') or os.path.join(os.path.expanduser("~"), ".config")
return os.path.join(base_dir, "TrueAxis")
_DATA_DIR = _resolve_user_data_dir()
os.makedirs(_DATA_DIR, exist_ok=True)
CONFIG_FILE = os.path.join(_DATA_DIR, "trueaxis_config.json")
BUTTON_MAPPING_FILE = os.path.join(_DATA_DIR, "trueaxis_buttons.json")
SETTINGS_FILE = os.path.join(_DATA_DIR, "trueaxis_settings.json")
MACRO_FILE = os.path.join(_DATA_DIR, "trueaxis_macros.json")
UPDATE_STATE_FILE = os.path.join(_DATA_DIR, "update_state.json")
UPDATE_HELPER_LOG_FILE = os.path.join(_DATA_DIR, "update_helper.log")
INSTALL_ID_FILE = os.path.join(_DATA_DIR, "install_id.json")
TELEMETRY_STATE_FILE = os.path.join(_DATA_DIR, "telemetry_state.json")
LEGACY_CONFIG_FILE = os.path.join(_APP_DIR, "trueaxis_config.json")
LEGACY_BUTTON_MAPPING_FILE = os.path.join(_APP_DIR, "trueaxis_buttons.json")
LEGACY_SETTINGS_FILE = os.path.join(_APP_DIR, "trueaxis_settings.json")
LEGACY_MACRO_FILE = os.path.join(_APP_DIR, "trueaxis_macros.json")
LEGACY_MACRO_FILES = [
LEGACY_MACRO_FILE,
os.path.join(os.path.expanduser("~"), "Documents", "trueaxis_macros.json"),
os.path.join(os.path.expanduser("~"), "trueaxis_macros.json"),
]
def _migrate_legacy_file(target, legacy_paths):
"""Copy an old same-folder config file into the stable per-user data folder."""
if os.path.exists(target):
return
for legacy_path in legacy_paths:
if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(target):
continue
if os.path.exists(legacy_path):
try:
shutil.copy2(legacy_path, target)
return
except Exception as e:
print(f"Could not migrate {legacy_path} to {target}: {e}")
_migrate_legacy_file(CONFIG_FILE, [LEGACY_CONFIG_FILE])
_migrate_legacy_file(BUTTON_MAPPING_FILE, [LEGACY_BUTTON_MAPPING_FILE])
_migrate_legacy_file(SETTINGS_FILE, [LEGACY_SETTINGS_FILE])
_migrate_legacy_file(MACRO_FILE, LEGACY_MACRO_FILES)
def _atomic_json_save(filepath, data):
"""Write JSON data atomically: write to temp file, then rename over original.
Also keeps a .bak copy of the previous file for recovery."""
bak_path = filepath + ".bak"
tmp_path = filepath + ".tmp"
try:
os.makedirs(os.path.dirname(filepath), exist_ok=True)
# Write to temp file first
with open(tmp_path, 'w') as f:
json.dump(data, f, indent=2)
# Keep a backup of the current file
if os.path.exists(filepath):
try:
if os.path.exists(bak_path):
os.remove(bak_path)
os.rename(filepath, bak_path)
except Exception:
# If backup fails, still try to save
pass
# Atomic rename temp -> target
os.rename(tmp_path, filepath)
except Exception:
# Clean up temp file on failure
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
raise
def _load_json_with_backup(filepath):
"""Load JSON from filepath, falling back to .bak if main file is corrupted/missing."""
bak_path = filepath + ".bak"
# Try main file first
if os.path.exists(filepath):
try:
with open(filepath, 'r') as f:
content = f.read().strip()
if content:
return json.loads(content)
except (json.JSONDecodeError, ValueError, OSError):
print(f"Warning: {filepath} is corrupted or unreadable, trying backup...")
# Try backup
if os.path.exists(bak_path):
try:
with open(bak_path, 'r') as f:
content = f.read().strip()
if content:
print(f"Recovered settings from {bak_path}")
return json.loads(content)
except (json.JSONDecodeError, ValueError, OSError):
print(f"Warning: backup {bak_path} is also corrupted or unreadable")
return None
def _get_or_create_install_id():
data = _load_json_with_backup(INSTALL_ID_FILE)
if isinstance(data, dict):
install_id = str(data.get("install_id") or "").strip()
if install_id:
return install_id
install_id = str(uuid.uuid4())
try:
_atomic_json_save(INSTALL_ID_FILE, {
"install_id": install_id,
"created_at": int(time.time()),
})
except Exception as e:
print(f"Could not persist install id: {e}")
return install_id
PROFILES = {
"Logitech G29 (Standard)": {"axes": [0, 2, 3], "desc": "Recommended first choice for G29"},
"Logitech G29 (Alt Mode)": {"axes": [0, 1, 2], "desc": "Use if Gas/Brake inputs are swapped"},
"Logitech G920 / G923": {"axes": [0, 1, 2], "desc": "Standard Xbox-layout wheels"},
"Logitech Pro Racing Wheel": {"axes": [0, 1, 2], "desc": "Logitech direct drive wheel layout"},
"Driving Force GT": {"axes": [0, 1, 2], "desc": "Legacy Logitech wheels"},
"Logitech G27": {"axes": [0, 2, 3], "desc": "Classic G27 axis mapping"},
"PlayStation 4/5 (Standard)": {"axes": [0, 5, 4], "desc": "Steer: L-Stick, Gas: R2, Brake: L2"},
"PlayStation 4/5 (Alt Mode)": {"axes": [0, 4, 3], "desc": "Use if triggers are reversed"},
"PlayStation (DirectInput)": {"axes": [0, 2, 5], "desc": "Raw DirectInput mode"},
"MOZA (Standard)": {"axes": [0, 1, 2], "desc": "MOZA R-series direct drive wheels"},
"MOZA (Alt Pedals)": {"axes": [0, 2, 3], "desc": "MOZA layout if pedals appear on separate axes"},
"Fanatec (Standard)": {"axes": [0, 1, 2], "desc": "Yellow compatibility mode"},
"Fanatec (Alt 1)": {"axes": [0, 2, 3], "desc": "Alternative axis configuration"},
"Fanatec (Alt 2)": {"axes": [0, 4, 5], "desc": "Common on CSL DD bases"},
"Fanatec (Alt 3)": {"axes": [0, 5, 6], "desc": "Higher axis pedal mapping"},
"Direct Drive Wheel (Standard)": {"axes": [0, 1, 2], "desc": "Simucube, Simagic, Asetek, VRS, Cammus"},
"Direct Drive Wheel (Alt Pedals)": {"axes": [0, 2, 3], "desc": "Direct-drive layout if pedals are on alt axes"},
"Thrustmaster T300/T150": {"axes": [0, 1, 2], "desc": "Standard Thrustmaster mapping"},
"Thrustmaster T-GT / T248": {"axes": [0, 2, 1], "desc": "Newer generation wheels"},
"Thrustmaster (Combined)": {"axes": [0, 1, 1], "desc": "Combined pedal axis mode"},
"PXN / Hori Wheel": {"axes": [0, 1, 2], "desc": "Budget DirectInput wheels"},
"Generic Gamepad (Xbox/XInput)": {"axes": [0, 5, 4], "desc": "Standard Xbox controller layout"},
"Generic Wheel (DirectInput)": {"axes": [0, 1, 2], "desc": "Universal DirectInput wheels"},
"Combined Pedals Mode": {"axes": [0, 1, 1], "desc": "Single axis for Gas/Brake"},
}
PROFILE_MAX_STEERING_RANGE = {
"Logitech G29 (Standard)": 900,
"Logitech G29 (Alt Mode)": 900,
"Logitech G920 / G923": 900,
"Logitech Pro Racing Wheel": 1080,
"Driving Force GT": 900,
"Logitech G27": 900,
"MOZA (Standard)": 2000,
"MOZA (Alt Pedals)": 2000,
"Fanatec (Standard)": 1080,
"Fanatec (Alt 1)": 1080,
"Fanatec (Alt 2)": 1080,
"Fanatec (Alt 3)": 1080,
"Direct Drive Wheel (Standard)": 2520,
"Direct Drive Wheel (Alt Pedals)": 2520,
"Thrustmaster T300/T150": 1080,
"Thrustmaster T-GT / T248": 1080,
"Thrustmaster (Combined)": 1080,
"PXN / Hori Wheel": 900,
"Generic Wheel (DirectInput)": 1080,
"Combined Pedals Mode": 1080,
}
DEVICE_MAX_STEERING_RANGE_PATTERNS = [
(["moza", "r3"], 2000),
(["moza", "r5"], 2000),
(["moza", "r9"], 2000),
(["moza", "r12"], 2000),
(["moza", "r16"], 2000),
(["moza", "r21"], 2000),
(["moza"], 2000),
(["simucube"], 2520),
(["simagic"], 2520),
(["alpha mini"], 2520),
(["alpha"], 2520),
(["vrs"], 2520),
(["asetek"], 2520),
(["la prima"], 2520),
(["forte"], 2520),
(["invicta"], 2520),
(["cammus"], 2520),
(["fanatec", "podium"], 2520),
(["fanatec", "dd"], 2520),
(["fanatec"], 1080),
(["t818"], 1080),
(["t-gt"], 1080),
(["t300"], 1080),
(["t500"], 1080),
(["tx racing"], 900),
(["tmx"], 900),
(["t150"], 1080),
(["t248"], 900),
(["thrustmaster"], 1080),
(["logitech pro"], 1080),
(["g923"], 900),
(["g920"], 900),
(["g29"], 900),
(["g27"], 900),
(["g25"], 900),
(["driving force gt"], 900),
(["driving force pro"], 900),
(["hori"], 270),
(["pxn"], 900),
]
LOGITECH_HID_PROFILE_NAMES = {
"Logitech G29 (Standard)",
"Logitech G29 (Alt Mode)",
"Logitech G920 / G923",
"Driving Force GT",
"Logitech G27",
}
def _keywords_match(text, keywords):
lowered = (text or "").lower()
return all(keyword in lowered for keyword in keywords)
def detect_max_steering_range(device_name="", profile_name=""):
"""Best-effort physical rotation estimate for hardware range limits."""
haystack = f"{device_name or ''} {profile_name or ''}".lower()
for keywords, max_range in DEVICE_MAX_STEERING_RANGE_PATTERNS:
if _keywords_match(haystack, keywords):
return max_range
if profile_name in PROFILE_MAX_STEERING_RANGE:
return PROFILE_MAX_STEERING_RANGE[profile_name]
return DEFAULT_WHEEL_MAX_RANGE
# Device name detection patterns for auto-profile selection
# Each entry is (keywords_list, profile_name)
DEVICE_PROFILE_PATTERNS = [
# Logitech wheels
(["g29"], "Logitech G29 (Standard)"),
(["g920", "g923"], "Logitech G920 / G923"),
(["logitech pro"], "Logitech Pro Racing Wheel"),
(["g27"], "Logitech G27"),
(["g25"], "Logitech G27"),
(["driving force gt", "dfgt"], "Driving Force GT"),
# Direct-drive wheels
(["moza"], "MOZA (Standard)"),
(["simucube"], "Direct Drive Wheel (Standard)"),
(["simagic"], "Direct Drive Wheel (Standard)"),
(["asetek"], "Direct Drive Wheel (Standard)"),
(["la prima"], "Direct Drive Wheel (Standard)"),
(["forte"], "Direct Drive Wheel (Standard)"),
(["invicta"], "Direct Drive Wheel (Standard)"),
(["vrs"], "Direct Drive Wheel (Standard)"),
(["cammus"], "Direct Drive Wheel (Standard)"),
# PlayStation controllers
(["dualsense", "ps5", "playstation 5"], "PlayStation 4/5 (Standard)"),
(["dualshock", "ps4", "playstation 4"], "PlayStation 4/5 (Standard)"),
(["wireless controller"], "PlayStation 4/5 (Standard)"), # Generic PS controller name
# Fanatec
(["fanatec"], "Fanatec (Standard)"),
(["csl"], "Fanatec (Standard)"),
(["clubsport"], "Fanatec (Standard)"),
# Thrustmaster
(["t300"], "Thrustmaster T300/T150"),
(["t150"], "Thrustmaster T300/T150"),
(["tmx"], "Thrustmaster T300/T150"),
(["tx racing"], "Thrustmaster T300/T150"),
(["t500"], "Thrustmaster T300/T150"),
(["t818"], "Thrustmaster T300/T150"),
(["t-gt"], "Thrustmaster T-GT / T248"),
(["t248"], "Thrustmaster T-GT / T248"),
(["thrustmaster"], "Thrustmaster T300/T150"),
# Budget DirectInput wheels
(["pxn"], "PXN / Hori Wheel"),
(["hori racing wheel", "hori wheel", "racing wheel apex"], "PXN / Hori Wheel"),
(["drivefx", "wheel"], "Generic Wheel (DirectInput)"),
# Xbox controllers
(["xbox", "xinput"], "Generic Gamepad (Xbox/XInput)"),
]
# Default button mappings for common devices
# Maps device keywords to a dictionary of {button_index: xbox_button_name}
#
# IMPORTANT NOTE: Button indices can vary based on:
# - Operating System (Windows/Linux/Mac)
# - Connection method (USB vs Bluetooth)
# - Driver version
# - Controller firmware
#
# These are APPROXIMATE defaults based on common configurations.
# Users should verify and adjust using the Input Inspector and button configuration dialog.
# The app will remember custom configurations per device.
DEVICE_DEFAULT_BUTTONS = {
# PlayStation 5 DualSense (Corrected based on actual pygame behavior)
"dualsense": {
"0": "X (Square)", # Button 0 = Square
"1": "A (Cross)", # Button 1 = Cross/X
"2": "B (Circle)", # Button 2 = Circle
"3": "Y (Triangle)", # Button 3 = Triangle
"4": "LB (L1)", # Button 4 = L1 bumper
"5": "RB (R1)", # Button 5 = R1 bumper
"6": "LB (L1)", # Button 6 = L2 trigger press (mapped to LB for compatibility)
"7": "RB (R1)", # Button 7 = R2 trigger press (mapped to RB for compatibility)
"8": "Back (Share)", # Button 8 = Share/Create button
"9": "Start (Options)", # Button 9 = Options button
"10": "LThumb (L3)", # Button 10 = L3 stick press
"11": "RThumb (R3)", # Button 11 = R3 stick press
"12": "Guide (PS)", # Button 12 = PS button
"13": "DPad Left", # Button 13 = D-pad left (or touchpad on some)
"14": "DPad Up", # Button 14 = D-pad up
"15": "DPad Right", # Button 15 = D-pad right
"16": "DPad Down", # Button 16 = D-pad down
},
# PlayStation 4 DualShock 4 (Corrected based on actual pygame behavior)
"dualshock": {
"0": "X (Square)", # Button 0 = Square
"1": "A (Cross)", # Button 1 = Cross/X
"2": "B (Circle)", # Button 2 = Circle
"3": "Y (Triangle)", # Button 3 = Triangle
"4": "LB (L1)", # Button 4 = L1 bumper
"5": "RB (R1)", # Button 5 = R1 bumper
"6": "LB (L1)", # Button 6 = L2 trigger press (mapped to LB for compatibility)
"7": "RB (R1)", # Button 7 = R2 trigger press (mapped to RB for compatibility)
"8": "Back (Share)", # Button 8 = Share button
"9": "Start (Options)", # Button 9 = Options button
"10": "LThumb (L3)", # Button 10 = L3 stick press
"11": "RThumb (R3)", # Button 11 = R3 stick press
"12": "Guide (PS)", # Button 12 = PS button
"13": "DPad Up", # Button 13 = Touchpad press or D-pad up
"14": "DPad Left", # Button 14 = D-pad left
"15": "DPad Down", # Button 15 = D-pad down
"16": "DPad Right", # Button 16 = D-pad right
},
# Generic PlayStation controller name (Corrected)
"wireless controller": {
"0": "X (Square)", # Button 0 = Square
"1": "A (Cross)", # Button 1 = Cross/X
"2": "B (Circle)", # Button 2 = Circle
"3": "Y (Triangle)", # Button 3 = Triangle
"4": "LB (L1)", # Button 4 = L1 bumper
"5": "RB (R1)", # Button 5 = R1 bumper
"6": "LB (L1)", # Button 6 = L2 trigger press (mapped to LB for compatibility)
"7": "RB (R1)", # Button 7 = R2 trigger press (mapped to RB for compatibility)
"8": "Back (Share)", # Button 8 = Share button
"9": "Start (Options)", # Button 9 = Options button
"10": "LThumb (L3)", # Button 10 = L3 stick press
"11": "RThumb (R3)", # Button 11 = R3 stick press
"12": "Guide (PS)", # Button 12 = PS button
"13": "DPad Up", # Button 13 = Touchpad or D-pad
"14": "DPad Left", # Button 14 = D-pad left
"15": "DPad Down", # Button 15 = D-pad down
"16": "DPad Right", # Button 16 = D-pad right
},
# Xbox 360 Controller (Official pygame mapping)
"xbox 360": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)",
"5": "RB (R1)",
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
"10": "Guide (PS)", # Guide button (if present)
},
# Xbox One / Series Controller
"xbox": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)",
"5": "RB (R1)",
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
},
# Logitech G29 (Based on common racing wheel layout)
"g29": {
"0": "X (Square)", # Top left red button
"1": "B (Circle)", # Top right red button
"2": "A (Cross)", # Right red button
"3": "Y (Triangle)", # Left red button
"4": "RB (R1)", # Right shifter paddle
"5": "LB (L1)", # Left shifter paddle
"6": "RThumb (R3)", # R3
"7": "LThumb (L3)", # L3
"8": "Back (Share)", # Share
"9": "Start (Options)", # Options
"10": "Guide (PS)", # PS button (center logo)
"11": "RThumb (R3)", # Right dial press
"12": "LThumb (L3)", # Left dial press
# Buttons 13-24 are additional G29 buttons on wheel
},
# Logitech G920/G923 (Xbox layout)
"g920": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)", # Left shifter paddle
"5": "RB (R1)", # Right shifter paddle
"6": "Back (Share)", # View button
"7": "Start (Options)", # Menu button
"8": "LThumb (L3)",
"9": "RThumb (R3)",
},
"g923": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)", # Left shifter paddle
"5": "RB (R1)", # Right shifter paddle
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
},
# Logitech G27
"g27": {
"0": "X (Square)",
"1": "B (Circle)",
"2": "A (Cross)",
"3": "Y (Triangle)",
"4": "LB (L1)", # Left shifter paddle
"5": "RB (R1)", # Right shifter paddle
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
},
# Thrustmaster wheels (Common layout)
"thrustmaster": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)",
"5": "RB (R1)",
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
"10": "Guide (PS)",
},
# Fanatec wheels
"fanatec": {
"0": "A (Cross)",
"1": "B (Circle)",
"2": "X (Square)",
"3": "Y (Triangle)",
"4": "LB (L1)",
"5": "RB (R1)",
"6": "Back (Share)",
"7": "Start (Options)",
"8": "LThumb (L3)",
"9": "RThumb (R3)",
},
}
XBOX_BUTTON_NAMES = [
"DISABLED",
"--- XBOX BUTTONS ---",
"A (Cross)",
"B (Circle)",
"X (Square)",
"Y (Triangle)",
"LB (L1)",
"RB (R1)",
"Back (Share)",
"Start (Options)",
"LThumb (L3)",
"RThumb (R3)",
"Guide (PS)",
"DPad Up",
"DPad Down",
"DPad Left",
"DPad Right",
"--- KEYBOARD KEYS ---",
"Key: Space",
"Key: Enter",
"Key: Shift",
"Key: Ctrl",
"Key: Alt",
"Key: Tab",
"Key: Esc",
"Key: 0",
"Key: 1",
"Key: 2",
"Key: 3",
"Key: 4",
"Key: 5",
"Key: 6",
"Key: 7",
"Key: 8",
"Key: 9",
"Key: A",
"Key: B",
"Key: C",
"Key: D",
"Key: E",
"Key: F",
"Key: G",
"Key: H",
"Key: I",
"Key: J",
"Key: K",
"Key: L",
"Key: M",
"Key: N",
"Key: O",
"Key: P",
"Key: Q",
"Key: R",
"Key: S",
"Key: T",
"Key: U",
"Key: V",
"Key: W",
"Key: X",
"Key: Y",
"Key: Z",
"Key: F1",
"Key: F2",
"Key: F3",
"Key: F4",
"Key: F5",
"Key: F6",
"Key: F7",
"Key: F8",
"Key: F9",
"Key: F10",
"Key: F11",
"Key: F12",
"Key: Up Arrow",
"Key: Down Arrow",
"Key: Left Arrow",
"Key: Right Arrow",
]
XBOX_BUTTON_MAP = {
"DISABLED": None,
"--- XBOX BUTTONS ---": None,
"A (Cross)": 0x1000,
"B (Circle)": 0x2000,
"X (Square)": 0x4000,
"Y (Triangle)": 0x8000,
"LB (L1)": 0x0400,
"RB (R1)": 0x0800,
"Back (Share)": 0x0020,
"Start (Options)": 0x0010,
"LThumb (L3)": 0x0040,
"RThumb (R3)": 0x0080,
"Guide (PS)": 0x0400 | 0x0800, # Not a real XUSB bit; handled specially below
"DPad Up": 0x0001,
"DPad Down": 0x0002,
"DPad Left": 0x0004,
"DPad Right": 0x0008,
"--- KEYBOARD KEYS ---": None,
}
# If vgamepad loaded successfully, replace with its enums for exact correctness
if VIGEM_AVAILABLE:
XBOX_BUTTON_MAP.update({
"A (Cross)": vg.XUSB_BUTTON.XUSB_GAMEPAD_A,
"B (Circle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_B,
"X (Square)": vg.XUSB_BUTTON.XUSB_GAMEPAD_X,
"Y (Triangle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_Y,
"LB (L1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER,
"RB (R1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER,
"Back (Share)": vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK,
"Start (Options)": vg.XUSB_BUTTON.XUSB_GAMEPAD_START,
"LThumb (L3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB,
"RThumb (R3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB,
"Guide (PS)": vg.XUSB_BUTTON.XUSB_GAMEPAD_GUIDE,
"DPad Up": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP,
"DPad Down": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN,
"DPad Left": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT,
"DPad Right": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT,
})
# Keyboard key mapping (only if pynput is available)
if KEYBOARD_AVAILABLE:
KEYBOARD_MAP = {
"Key: Space": ' ',
"Key: Enter": Key.enter,
"Key: Shift": Key.shift,
"Key: Ctrl": Key.ctrl,
"Key: Alt": Key.alt,
"Key: Tab": Key.tab,
"Key: Esc": Key.esc,
"Key: 0": '0', "Key: 1": '1', "Key: 2": '2', "Key: 3": '3', "Key: 4": '4',
"Key: 5": '5', "Key: 6": '6', "Key: 7": '7', "Key: 8": '8', "Key: 9": '9',
"Key: A": 'a', "Key: B": 'b', "Key: C": 'c', "Key: D": 'd', "Key: E": 'e',
"Key: F": 'f', "Key: G": 'g', "Key: H": 'h', "Key: I": 'i', "Key: J": 'j',
"Key: K": 'k', "Key: L": 'l', "Key: M": 'm', "Key: N": 'n', "Key: O": 'o',
"Key: P": 'p', "Key: Q": 'q', "Key: R": 'r', "Key: S": 's', "Key: T": 't',
"Key: U": 'u', "Key: V": 'v', "Key: W": 'w', "Key: X": 'x', "Key: Y": 'y',
"Key: Z": 'z',
"Key: F1": Key.f1, "Key: F2": Key.f2, "Key: F3": Key.f3, "Key: F4": Key.f4,
"Key: F5": Key.f5, "Key: F6": Key.f6, "Key: F7": Key.f7, "Key: F8": Key.f8,
"Key: F9": Key.f9, "Key: F10": Key.f10, "Key: F11": Key.f11, "Key: F12": Key.f12,
"Key: Up Arrow": Key.up,
"Key: Down Arrow": Key.down,
"Key: Left Arrow": Key.left,
"Key: Right Arrow": Key.right,
}
else:
KEYBOARD_MAP = {}
QML_CODE = """
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import QtQuick.Window
import QtQuick.Shapes
ApplicationWindow {
id: mainWindow
width: 700
height: 680
minimumWidth: 660
minimumHeight: 630
visible: true
title: "TrueAxis v" + backend.currentVersion
color: colorBg
// Auto-start minimized flag
property bool autoStartMinimized: false
// Update and ViGEm status
property bool updateAvailable: backend.updateAvailable
property bool vigEmInstalled: true // Assume installed by default
property string latestVersion: backend.newVersion
property string updateStatus: ""
property string vigEmStatus: ""
property string appStatusText: "Ready"
property color appStatusColor: colorInactive
// Transient action confirmations fall back to the baseline state;
// errors and warnings stay until replaced.
Timer {
id: statusRevertTimer
interval: 3000
onTriggered: {
if (updateInProgress) {
return
}
if (backend.running) {
appStatusText = "Active: Emulating controller"
appStatusColor = colorSuccess
} else {
appStatusText = "Ready"
appStatusColor = colorInactive
}
}
}
property bool darkMode: backend.themeMode === "dark"
property bool updateInProgress: false
property int inlineUpdateProgress: 0
property string inlineUpdateStatus: ""
property bool updatePreview: false
// Connect to backend signals
Connections {
target: backend
function onUpdateAvailableChanged() {
updateAvailable = backend.updateAvailable
latestVersion = backend.newVersion
}
}
// Backend checks ViGEm after startup so the first paint is not delayed.
// Show ViGEm dialog when status changes to not installed
Connections {
target: backend
function onVigEmStatusChanged(status) {
vigEmStatus = status
if (status === "not installed") {
vigEmInstalled = false
vigEmDialog.open()
} else if (status === "installed") {
vigEmInstalled = true
vigEmDialog.close()
}
}
}
// Neutral graphite system - pure greys, no colour cast, in both modes.
readonly property color colorPrimary: darkMode ? "#e6e6e6" : "#2b2b2b"
readonly property color colorPrimaryHover: darkMode ? "#ffffff" : "#181818"
readonly property color colorAccent: darkMode ? "#f0f0f0" : "#141414"
readonly property color colorBg: darkMode ? "#181818" : "#ececec"
readonly property color colorSurface: darkMode ? "#212121" : "#f4f4f4"
readonly property color colorSurfaceHover: darkMode ? "#2b2b2b" : "#e2e2e2"
readonly property color colorCard: darkMode ? "#1f1f1f" : "#fafafa"
readonly property color colorCardElevated: darkMode ? "#2c2c2c" : "#ffffff"
readonly property color colorTextPrimary: darkMode ? "#f5f5f5" : "#171717"
readonly property color colorTextSecondary: darkMode ? "#cfcfcf" : "#343434"
readonly property color colorTextMuted: darkMode ? "#8f8f8f" : "#6c6c6c"
readonly property color colorBorder: darkMode ? "#333333" : "#d4d4d4"
readonly property color colorBorderLight: darkMode ? "#424242" : "#c4c4c4"
readonly property color colorSuccess: "#22c55e"
readonly property color colorWarning: "#f59e0b"
readonly property color colorError: "#ef4444"
readonly property color colorInactive: darkMode ? "#767676" : "#8a8a8a"
// Silver gradient colors for axis bars
readonly property color hudSilver1: darkMode ? "#1c1c1c" : "#3f3f3f"
readonly property color hudSilver2: darkMode ? "#a6a6a6" : "#9c9c9c"
readonly property color hudSilver3: darkMode ? "#5f5f5f" : "#6e6e6e"
property var silverGradient: [
{position: 0.0, color: hudSilver1},
{position: 0.5, color: hudSilver2},
{position: 1.0, color: hudSilver3}
]
// Session timer display
property string sessionTimeDisplay: "0:00"
Connections {
target: backend
function onSessionTimeChanged(time) {
sessionTimeDisplay = time
}
}
// Handle close event - close application
onClosing: function(close) {
console.log("Closing application...")
// Stop emulation if running
if (backend.running) {
backend.stop_mapping()
}
// Save settings before quitting
backend.force_save_settings()
trayManager.save_settings()
close.accepted = true
Qt.quit()
}
// Handle window state changes
onVisibilityChanged: function(visibility) {
if (visibility === Window.Minimized && trayManager.hideToTrayEnabled) {
hide()
trayManager.show_tray()
}
}
Rectangle {
anchors.fill: parent
color: colorBg
}
// TAB STATE
property int currentTab: 0
Column {
anchors.fill: parent
spacing: 0
// HEADER
Item {
width: parent.width
height: 64
Row {
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
text: "TrueAxis"
font.pixelSize: 21
font.weight: Font.DemiBold
font.letterSpacing: 0.2
color: colorTextPrimary
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Item {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
ToolTip.visible: themeMouse.containsMouse
ToolTip.delay: 350
ToolTip.text: darkMode ? "Switch to light mode" : "Switch to dark mode"
Rectangle {
anchors.fill: parent
radius: 8
color: themeMouse.containsMouse ? colorSurfaceHover : "transparent"
}
Shape {
anchors.centerIn: parent
width: 24
height: 24
scale: 0.8
preferredRendererType: Shape.CurveRenderer
// Sun rays
ShapePath {
strokeWidth: 2
strokeColor: colorTextSecondary
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathSvg { path: "M12 1 L12 3 M12 21 L12 23 M4.22 4.22 L5.64 5.64 M18.36 18.36 L19.78 19.78 M1 12 L3 12 M21 12 L23 12 M4.22 19.78 L5.64 18.36 M18.36 5.64 L19.78 4.22" }
}
// Sun core: outline in dark mode, filled in light mode
ShapePath {
strokeWidth: 2
strokeColor: colorTextSecondary
fillColor: darkMode ? "transparent" : colorTextSecondary
capStyle: ShapePath.RoundCap
PathSvg { path: "M17 12 A5 5 0 1 1 7 12 A5 5 0 1 1 17 12 Z" }
}
}
MouseArea {
id: themeMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.toggle_theme_mode()
}
}
StyledButton {
id: activateBtn
text: backend.running ? "Deactivate" : "Activate"
width: 112
height: 34
primary: !backend.running
danger: backend.running
fontSize: 12
hoverEffect: true
anchors.verticalCenter: parent.verticalCenter
onClicked: backend.toggle_mapping()
}
}
Rectangle {
width: parent.width
height: 1
anchors.bottom: parent.bottom
color: colorBorder
}
}
Item { width: 1; height: 6 }
// TAB BAR - underline style
Item {
width: parent.width
height: 44
Row {
id: tabRow
anchors.left: parent.left
anchors.leftMargin: 28
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 26
Repeater {
model: ["Drive", "Wheel", "Setup"]
Item {
width: tabLabel.implicitWidth
height: tabRow.height
Text {
id: tabLabel
anchors.centerIn: parent
text: modelData
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: "Inter"
color: currentTab === index ? colorTextPrimary : (tabMouse.containsMouse ? colorTextSecondary : colorTextMuted)
}
Rectangle {
width: parent.width
height: 2
radius: 1
anchors.bottom: parent.bottom
color: colorTextPrimary
opacity: currentTab === index ? 1 : (tabMouse.containsMouse ? 0.25 : 0)
}
MouseArea {
id: tabMouse
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: currentTab = index
}
}
}
}
// Settings backup, tucked at the right end of the tab strip.
Row {
anchors.right: parent.right
anchors.rightMargin: 22
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconActionButton {
// Arrow up out of a tray - export settings
svg: "M21 15 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V15 M7 8 L12 3 L17 8 M12 3 V15"
active: false
tip: "Export settings"
onClicked: backend.export_settings_dialog()
}
IconActionButton {
// Arrow down into a tray - import settings
svg: "M21 15 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V15 M7 10 L12 15 L17 10 M12 15 V3"
active: false
tip: "Import settings"
onClicked: backend.import_settings_dialog()
}
}
Rectangle {
width: parent.width
height: 1
anchors.bottom: parent.bottom
color: colorBorder
}
}
Item { width: 1; height: 14 }
// TAB CONTENT
Flickable {
id: tabFlick
width: parent.width
Layout.fillHeight: true
height: parent.height - 164
contentWidth: width
contentHeight: tabColumn.implicitHeight
clip: true
interactive: contentHeight > height
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
id: tabScrollBar
policy: parent.contentHeight > parent.height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
width: 6
contentItem: Rectangle {
implicitWidth: 6
radius: 3
color: tabScrollBar.pressed ? colorBorderLight : colorBorder
opacity: tabScrollBar.active ? 0.85 : 0.4
Behavior on opacity { NumberAnimation { duration: 150 } }
}
}
Connections {
target: mainWindow
function onCurrentTabChanged() {
tabFlick.contentY = 0
tabFadeAnim.restart()
}
}
NumberAnimation {
id: tabFadeAnim
target: tabColumn
property: "opacity"
from: 0.35
to: 1.0
duration: 180
easing.type: Easing.OutQuad
}
Column {
id: tabColumn
width: parent.width
spacing: 0
// ===== TAB 0: DRIVE =====
// Flat dashboard: selector band up top, bars as the hero, settings rows below.
SectionCard {
visible: currentTab === 0
title: ""
flat: true
cardHeight: drivePanel.implicitHeight + 12
Column {
id: drivePanel
spacing: 0
width: parent.width
Item {
width: parent.width
height: 60
Row {
anchors.left: parent.left
anchors.leftMargin: 28
anchors.right: driveActions.left
anchors.rightMargin: 14
anchors.top: parent.top
spacing: 12
Column {
width: (parent.width - 12) / 2
spacing: 6
Text {
text: "Device"
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.8
font.capitalization: Font.AllUppercase
color: colorTextMuted
font.family: "Inter"
}
StyledComboBox {
id: deviceCombo
width: parent.width
model: backend.devices
property bool updatingFromBackend: true
Component.onCompleted: {
updatingFromBackend = false
}
Connections {
target: backend
function onCurrentDeviceIndexChanged(index) {
deviceCombo.updatingFromBackend = true
deviceCombo.currentIndex = index
deviceCombo.updatingFromBackend = false
}
}
onCurrentIndexChanged: {
if (!updatingFromBackend && currentIndex >= 0) {
backend.select_device(currentIndex)
}
}
}
}
Column {
width: (parent.width - 12) / 2
spacing: 6
Text {
text: "Profile"
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.8
font.capitalization: Font.AllUppercase
color: colorTextMuted
font.family: "Inter"
}
StyledComboBox {
id: profileCombo
width: parent.width
model: backend.profileNames
property bool updatingFromBackend: true
Component.onCompleted: {
updatingFromBackend = false
}
Connections {
target: backend
function onCurrentProfileChanged(profileName) {
profileCombo.updatingFromBackend = true
var profileNames = backend.profileNames
for (var i = 0; i < profileNames.length; i++) {
if (profileNames[i] === profileName) {
profileCombo.currentIndex = i
break
}
}
profileCombo.updatingFromBackend = false
}
}
onCurrentTextChanged: {
if (!updatingFromBackend && currentText !== "") {
backend.select_profile(currentText)
}
}
}
}
}
Row {
id: driveActions
anchors.right: parent.right
anchors.rightMargin: 22
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 4
IconActionButton {
// Circular arrows - rescan devices
svg: "M23 4 L23 10 L17 10 M1 20 L1 14 L7 14 M3.51 9 A9 9 0 0 1 18.36 5.64 L23 10 M1 14 L5.64 18.36 A9 9 0 0 0 20.49 15"
active: false
tip: "Refresh devices"
onClicked: backend.refresh_devices()
}
IconActionButton {
// Eye - open the input inspector
svg: "M1 12 C1 12 5 4 12 4 C19 4 23 12 23 12 C23 12 19 20 12 20 C5 20 1 12 1 12 Z M15 12 A3 3 0 1 1 9 12 A3 3 0 1 1 15 12 Z"
active: false
tip: "Input inspector"
onClicked: inspectorDialog.open()
}
}
}
Text {
id: profileDesc
text: "Select a profile"
visible: false
font.pixelSize: 10
color: colorTextMuted
width: parent.width
elide: Text.ElideRight
font.family: "Inter"
}
Item { width: 1; height: 14 }
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
Item { width: 1; height: 18 }
Column {
x: 28
width: parent.width - 56
spacing: 10
AxisBar {
id: steerBar
label: "Steer"
value: 0
direction: 0
gradientColors: silverGradient
}
AxisBar {
id: gasBar
label: "Gas"
value: 0
gradientColors: silverGradient
}
AxisBar {
id: brakeBar
label: "Brake"
value: 0
gradientColors: silverGradient
}
Item { width: 1; height: 8 }
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
StyledCheckBox {
id: invertGasCheckbox
text: "Invert Gas"
checked: false
Component.onCompleted: {
checked = backend.invert_gas
}
onToggled: backend.set_invert_gas(checked)
}
StyledCheckBox {
id: invertBrakeCheckbox
text: "Invert Brake"
checked: false
Component.onCompleted: {
checked = backend.invert_brake
}
onToggled: backend.set_invert_brake(checked)
}
}
}
Item { width: 1; height: 16 }
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
Item { width: 1; height: 6 }
Row {
width: parent.width
spacing: 0
Column {
width: parent.width
spacing: 9
Item {
width: parent.width
height: 34
Text {
text: "Square input"
font.pixelSize: 12
color: colorTextSecondary
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
StyledCheckBox {
id: squareCheckbox
text: ""
checked: false
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
checked = backend.square_input
squareSteeringRow.visible = checked
}
onToggled: {
backend.set_square_input(checked)
squareSteeringRow.visible = checked
}
}
}
Row {
id: squareSteeringRow
spacing: 8
x: 28
width: parent.width - 56
visible: false
Rectangle {
id: slider
width: parent.width - 42
height: 22
radius: 11
color: "transparent"
property real from: 0.0
property real to: 0.99
property real value: 0.5
property real stepSize: 0.01
Component.onCompleted: {
value = backend.square_area
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 14, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 7))
y: (parent.height - 14) / 2
width: 14
height: 14
radius: 7
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = slider.from + (x / width) * (slider.to - slider.from)
newValue = Math.max(slider.from, Math.min(slider.to, newValue))
if (slider.stepSize > 0) {
newValue = Math.round(newValue / slider.stepSize) * slider.stepSize
}
if (newValue !== slider.value) {
slider.value = newValue
backend.set_square_area(newValue)
}
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
Text {
text: Math.round(slider.value * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 34
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
Item {
width: parent.width
height: 34
Text {
text: "Deadzone"
font.pixelSize: 12
color: colorTextSecondary
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
StyledCheckBox {
id: deadzoneCheckbox
text: ""
checked: false
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
checked = backend.deadzone_enabled
deadzoneSliderRow.visible = checked
}
onToggled: {
backend.set_deadzone_enabled(checked)
deadzoneSliderRow.visible = checked
}
}
}
Row {
id: deadzoneSliderRow
spacing: 8
x: 28
width: parent.width - 56
visible: false
Rectangle {
id: deadzoneSlider
width: parent.width - 42
height: 22
radius: 11
color: "transparent"
property real from: 0.0
property real to: 0.30
property real value: 0.0
property real stepSize: 0.01
Component.onCompleted: {
value = backend.deadzone
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 14, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 7))
y: (parent.height - 14) / 2
width: 14
height: 14
radius: 7
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = deadzoneSlider.from + (x / width) * (deadzoneSlider.to - deadzoneSlider.from)
newValue = Math.max(deadzoneSlider.from, Math.min(deadzoneSlider.to, newValue))
if (deadzoneSlider.stepSize > 0) {
newValue = Math.round(newValue / deadzoneSlider.stepSize) * deadzoneSlider.stepSize
}
if (newValue !== deadzoneSlider.value) {
deadzoneSlider.value = newValue
backend.set_deadzone(newValue)
}
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
Text {
text: Math.round(deadzoneSlider.value * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 34
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
}
}
}
}
// CONTROLS - uniform settings list; each row opens its dialog.
SectionCard {
visible: currentTab === 2
title: "Controls"
flat: true
cardHeight: controlsColumn.implicitHeight + 12
Column {
id: controlsColumn
spacing: 0
width: parent.width
SettingsRow {
label: "Button mapping"
value: buttonStatus.text
onClicked: buttonDialog.open()
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
SettingsRow {
label: "Macros"
onClicked: macroManagerDialog.open()
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
SettingsRow {
label: "Record macro"
value: backend.macro_recording ? "Recording\\u2026 click to stop" : ""
valueColor: backend.macro_recording ? colorError : colorTextMuted
rowEnabled: !backend.macro_playing
onClicked: {
if (backend.macro_recording) {
backend.stop_macro_recording()
} else {
macroNameDialog.open()
}
}
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
SettingsRow {
label: "Neutral hold"
value: backend.neutralSteeringButtonLabel
valueColor: backend.neutralSteeringButton >= 0 ? colorTextPrimary : colorTextMuted
onClicked: neutralSteeringDialog.open()
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
SettingsRow {
label: "Steering limit"
value: backend.steeringActionButton >= 0 ? backend.steeringActionButtonLabel + " - max " + backend.steeringActionMaxPercent + "% - " + (backend.steeringActionMode === "toggle" ? "Toggle" : "Hold") : "Not set"
valueColor: backend.steeringActionButton >= 0 ? colorTextPrimary : colorTextMuted
onClicked: steeringActionDialog.open()
}
Text {
id: buttonStatus
visible: false
text: ""
}
}
}
// DEVICE SLOTS (Multi-Device) - in Settings tab
SectionCard {
visible: currentTab === 2
title: "Device Slots"
flat: true
topDivider: true
cardHeight: slotsColumn.implicitHeight + 12
Column {
id: slotsColumn
spacing: 8
width: parent.width
// Slot info display
property var slotData: []
function refreshSlots() {
slotData = backend.getSlotInfo()
}
Component.onCompleted: refreshSlots()
Connections {
target: backend
function onDeviceSlotsChanged() {
slotsColumn.refreshSlots()
}
// Keep the Active/Idle badges honest when emulation starts or stops.
function onIsRunningChanged(running) {
slotsColumn.refreshSlots()
}
}
Repeater {
model: slotsColumn.slotData.length
Rectangle {
id: slotRect
width: parent.width
height: slotRow.implicitHeight + 16
color: index === backend.activeSlotIndex ? colorSurface : (slotMouse.containsMouse ? colorSurfaceHover : "transparent")
border.width: 0
// A slot counts as active while its own emulator runs, or while
// the app is emulating on the currently selected slot.
property bool slotActive: (slotsColumn.slotData[index] && slotsColumn.slotData[index].running === true)
|| (backend.running && index === backend.activeSlotIndex)
Rectangle {
width: 3
color: colorPrimary
visible: index === backend.activeSlotIndex
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
}
MouseArea {
id: slotMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.setActiveSlot(index)
}
Row {
id: slotRow
anchors.fill: parent
anchors.margins: 8
anchors.leftMargin: 28
anchors.rightMargin: 24
spacing: 8
Column {
width: parent.width - 100
spacing: 2
Text {
text: {
var d = slotsColumn.slotData[index]
var name = d ? String(d.device_name) : ""
return (name !== "" && name !== "No device") ? name : "No device"
}
font.pixelSize: 11
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
elide: Text.ElideRight
width: parent.width
}
Text {
text: "Slot " + (index + 1) + (slotsColumn.slotData[index] && slotsColumn.slotData[index].profile ? " \\u00B7 " + slotsColumn.slotData[index].profile : "")
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
elide: Text.ElideRight
width: parent.width
}
Text {
visible: slotsColumn.slotData[index] ? slotsColumn.slotData[index].combined_mode : false
text: "Combined: pedals from " + (slotsColumn.slotData[index] ? slotsColumn.slotData[index].combined_pedal_device : "")
font.pixelSize: 9
color: "#f59e0b"
font.family: "Inter"
}
}
Row {
spacing: 10
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 7
height: 7
radius: 3.5
color: slotRect.slotActive ? colorSuccess : colorInactive
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: slotRect.slotActive ? "Active" : "Idle"
font.pixelSize: 10
color: slotRect.slotActive ? colorTextSecondary : colorTextMuted
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: removeSlotButton
width: 22
height: 22
radius: 6
color: removeSlotMouse.containsMouse ? "#26ef4444" : "transparent"
visible: index > 0
opacity: (slotMouse.containsMouse || removeSlotMouse.containsMouse) ? 1.0 : 0.0
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "\\u00D7"
font.pixelSize: 13
color: removeSlotMouse.containsMouse ? colorError : colorTextMuted
font.family: "Inter"
}
MouseArea {
id: removeSlotMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.removeDeviceSlot(index)
}
}
}
}
}
}
Item {
width: parent.width
height: 36
Rectangle {
anchors.fill: parent
color: addSlotMouse.containsMouse ? colorSurfaceHover : "transparent"
}
Text {
text: "+ Add slot"
font.pixelSize: 11
color: addSlotMouse.containsMouse ? colorTextSecondary : colorTextMuted
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
MouseArea {
id: addSlotMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: backend.addDeviceSlot()
}
}
Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
// Combined Mode toggle (for active slot) - settings-row layout
Item {
width: parent.width
height: 33
visible: backend.slotCount > 0
Text {
text: "Combined Mode (separate pedal device)"
font.pixelSize: 12
color: colorTextSecondary
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
CheckBox {
id: combinedModeCheck
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 18
checked: {
var data = slotsColumn.slotData
var idx = backend.activeSlotIndex
return (data[idx] && data[idx].combined_mode) ? true : false
}
onCheckedChanged: {
backend.setSlotCombinedMode(backend.activeSlotIndex, checked)
}
indicator: Rectangle {
width: 32
height: 18
radius: 9
anchors.verticalCenter: parent.verticalCenter
color: combinedModeCheck.checked ? colorPrimary : colorSurfaceHover
border.color: combinedModeCheck.checked ? colorPrimary : colorBorder
border.width: 1
Behavior on color { ColorAnimation { duration: 140 } }
Behavior on border.color { ColorAnimation { duration: 140 } }
Rectangle {
width: 12
height: 12
radius: 6
x: combinedModeCheck.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter
color: combinedModeCheck.checked ? colorBg : colorTextMuted
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
Behavior on color { ColorAnimation { duration: 140 } }
}
}
}
}
// Pedal device selector (visible in combined mode)
Item {
width: parent.width
height: 44
visible: {
var data = slotsColumn.slotData
var idx = backend.activeSlotIndex
return (data[idx] && data[idx].combined_mode) ? true : false
}
Text {
text: "Pedal device"
font.pixelSize: 12
color: colorTextSecondary
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
StyledComboBox {
id: pedalDeviceCombo
width: Math.min(300, parent.width - 180)
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
model: backend.devices
onCurrentIndexChanged: {
if (currentIndex >= 0) {
backend.setSlotPedalDevice(backend.activeSlotIndex, currentIndex)
}
}
}
}
}
}
// STEERING RANGE & CENTER SPRING
SectionCard {
visible: currentTab === 1
title: ""
flat: true
cardHeight: steeringRangeColumn.implicitHeight + 24
Column {
id: steeringRangeColumn
spacing: 10
width: parent.width
Item { width: 1; height: 2 }
// Steering Range
Row {
spacing: 10
x: 28
width: parent.width - 56
Text {
text: "Steering Range"
font.pixelSize: 12
color: colorTextSecondary
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: steeringRangeSlider
width: parent.width - 120 - 60
height: 24
radius: 12
color: "transparent"
property real from: 10
property real to: 2520
property real value: 900
property real stepSize: 10
Component.onCompleted: {
value = backend.steeringRange
}
Connections {
target: backend
function onSteeringRangeChanged(val) {
steeringRangeSlider.value = val
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
y: (parent.height - 16) / 2
width: 16
height: 16
radius: 8
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = steeringRangeSlider.from + (x / width) * (steeringRangeSlider.to - steeringRangeSlider.from)
newValue = Math.max(steeringRangeSlider.from, Math.min(steeringRangeSlider.to, newValue))
if (steeringRangeSlider.stepSize > 0) {
newValue = Math.round(newValue / steeringRangeSlider.stepSize) * steeringRangeSlider.stepSize
}
if (newValue !== steeringRangeSlider.value) {
steeringRangeSlider.value = newValue
backend.set_steering_range(newValue)
}
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
Text {
text: steeringRangeSlider.value + " deg"
font.pixelSize: 11
color: colorTextMuted
width: 50
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
// Quick presets row
Row {
spacing: 6
x: 28
width: parent.width - 56
Text {
text: "Presets:"
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
width: 50
}
Repeater {
model: [270, 360, 540, 720, 900, 1080]
Rectangle {
width: 54
height: 22
radius: 6
color: steeringRangeSlider.value === modelData ? colorPrimary : colorSurface
border.width: 0
Text {
anchors.centerIn: parent
text: modelData + " deg"
font.pixelSize: 9
font.bold: steeringRangeSlider.value === modelData
color: steeringRangeSlider.value === modelData ? colorBg : colorTextMuted
font.family: "Inter"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
steeringRangeSlider.value = modelData
backend.set_steering_range(modelData)
}
}
}
}
}
Text {
text: {
var deg = steeringRangeSlider.value
if (deg <= 270) return "Tight range - very responsive, good for arcade racing"
if (deg <= 540) return "Medium range - balanced feel for most games"
if (deg <= 900) return "Full range - realistic sim racing feel"
if (deg <= 1080) return "Extended range - requires matching wheel hardware support"
return "Direct drive range - requires vendor hardware support"
}
font.pixelSize: 10
color: colorTextMuted
x: 28
width: parent.width - 56
wrapMode: Text.WordWrap
font.family: "Inter"
}
Row {
x: 28
width: parent.width - 56
spacing: 10
StyledButton {
text: "Hardware Test"
width: 132
height: 30
secondary: true
fontSize: 10
hoverEffect: true
onClicked: hardwareRangeDialog.openWithSnapshot()
}
Text {
visible: false
text: ""
font.pixelSize: 10
color: colorTextMuted
width: 0
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
// Separator
Rectangle {
width: parent.width
height: 1
color: colorBorder
opacity: 0.6
}
// Center Spring Strength
Row {
spacing: 10
x: 28
width: parent.width - 56
Text {
text: "Center Spring"
font.pixelSize: 12
color: colorTextSecondary
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: centerSpringSlider
width: parent.width - 120 - 50
height: 24
radius: 12
color: "transparent"
property real from: 0.0
property real to: 1.0
property real value: 0.0
property real stepSize: 0.01
Component.onCompleted: {
value = backend.centerSpring
}
Connections {
target: backend
function onCenterSpringChanged(val) {
centerSpringSlider.value = val
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
y: (parent.height - 16) / 2
width: 16
height: 16
radius: 8
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = centerSpringSlider.from + (x / width) * (centerSpringSlider.to - centerSpringSlider.from)
newValue = Math.max(centerSpringSlider.from, Math.min(centerSpringSlider.to, newValue))
if (centerSpringSlider.stepSize > 0) {
newValue = Math.round(newValue / centerSpringSlider.stepSize) * centerSpringSlider.stepSize
}
if (newValue !== centerSpringSlider.value) {
centerSpringSlider.value = newValue
backend.set_center_spring(newValue)
}
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
Text {
text: Math.round(centerSpringSlider.value * 100) + "%"
font.pixelSize: 11
color: colorTextMuted
width: 40
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
Text {
visible: centerSpringSlider.value > 0 || backend.centerSpringRamp
text: {
if (centerSpringSlider.value <= 0 && backend.centerSpringRamp) return "0% in normal range; Soft Ramp builds near lock"
if (centerSpringSlider.value <= 0) return ""
var pct = Math.round(centerSpringSlider.value * 100)
return "Hardware autocenter at " + pct + "%"
}
font.pixelSize: 10
color: colorTextMuted
x: 28
width: parent.width - 56
wrapMode: Text.WordWrap
font.family: "Inter"
}
// Separator (only meaningful while the soft-ramp rows are shown)
Rectangle {
width: parent.width
height: 1
color: colorBorder
opacity: 0.6
visible: false
}
// Center Spring Ramp Toggle
Row {
spacing: 10
x: 28
width: parent.width - 56
visible: false
Text {
text: "Soft Ramp"
font.pixelSize: 12
color: colorTextSecondary
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: centerSpringRampToggle
width: 44
height: 24
radius: 12
state: backend.centerSpringRamp ? "on" : "off"
color: state == "on" ? colorPrimary : colorSurface
border.color: state == "on" ? colorPrimary : colorBorder
border.width: 1
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
state = backend.centerSpringRamp ? "on" : "off"
}
Connections {
target: backend
function onCenterSpringRampChanged(val) {
centerSpringRampToggle.state = val ? "on" : "off"
}
}
Rectangle {
x: centerSpringRampToggle.state == "on" ? 22 : 2
y: 2
width: 20
height: 20
radius: 10
color: centerSpringRampToggle.state == "on" ? colorBg : colorTextMuted
Behavior on x { NumberAnimation { duration: 150 } }
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
backend.set_center_spring_ramp(!backend.centerSpringRamp)
}
}
}
Text {
text: backend.centerSpringRamp ? "On" : "Off"
font.pixelSize: 11
color: colorTextMuted
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
// Ramp Width Slider (visible when ramp is enabled)
Row {
spacing: 10
x: 28
width: parent.width - 56
visible: false
Text {
text: "Ramp Width"
font.pixelSize: 12
color: colorTextSecondary
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: centerSpringRampWidthSlider
width: parent.width - 120 - 50
height: 24
radius: 12
color: "transparent"
property real from: 1
property real to: 45
property real value: 5
property real stepSize: 1
Component.onCompleted: {
value = backend.centerSpringRampWidth
}
Connections {
target: backend
function onCenterSpringRampWidthChanged(val) {
centerSpringRampWidthSlider.value = val
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
y: (parent.height - 16) / 2
width: 16
height: 16
radius: 8
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = centerSpringRampWidthSlider.from + (x / width) * (centerSpringRampWidthSlider.to - centerSpringRampWidthSlider.from)
newValue = Math.max(centerSpringRampWidthSlider.from, Math.min(centerSpringRampWidthSlider.to, newValue))
if (centerSpringRampWidthSlider.stepSize > 0) {
newValue = Math.round(newValue / centerSpringRampWidthSlider.stepSize) * centerSpringRampWidthSlider.stepSize
}
if (newValue !== centerSpringRampWidthSlider.value) {
centerSpringRampWidthSlider.value = newValue
backend.set_center_spring_ramp_width(newValue)
}
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
Text {
text: centerSpringRampWidthSlider.value + " deg"
font.pixelSize: 11
color: colorTextMuted
width: 40
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
// Ramp description
Text {
visible: false
text: {
if (!backend.centerSpringRamp) return "Disabled - hardware ramp off"
var w = centerSpringRampWidthSlider.value
var pct = Math.round(centerSpringSlider.value * 100)
var sr = steeringRangeSlider.value
return "Final " + w + " deg before lock: " + pct + "% \u2192 100% spring"
}
font.pixelSize: 10
color: colorTextMuted
width: parent.width
wrapMode: Text.WordWrap
font.family: "Inter"
}
}
}
// Export status text
Text {
id: exportStatusText
visible: currentTab === 2 && text !== ""
text: ""
font.pixelSize: 10
color: colorSuccess
font.family: "Inter"
anchors.horizontalCenter: parent.horizontalCenter
Connections {
target: backend
function onExportStatusChanged(status) {
exportStatusText.text = status
exportStatusTimer.restart()
}
}
Timer {
id: exportStatusTimer
interval: 4000
onTriggered: exportStatusText.text = ""
}
}
Item { width: 1; height: 20 }
} // end tabColumn
} // end Flickable
// STATUS FOOTER
Item {
width: parent.width
height: 36
Rectangle {
width: parent.width
height: 1
anchors.top: parent.top
color: colorBorder
}
// Update download progress, drawn along the footer's top edge.
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
width: parent.width * (inlineUpdateProgress / 100)
height: 2
color: colorSuccess
visible: updateInProgress
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutQuad } }
}
Row {
anchors.left: parent.left
anchors.leftMargin: 28
anchors.right: footerIcons.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
width: 7
height: 7
radius: 4
color: appStatusColor
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 200 } }
SequentialAnimation on opacity {
running: backend.running
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { from: 1.0; to: 0.45; duration: 900; easing.type: Easing.InOutSine }
NumberAnimation { from: 0.45; to: 1.0; duration: 900; easing.type: Easing.InOutSine }
}
}
Text {
text: appStatusText
font.pixelSize: 11
font.family: "Inter"
color: colorTextSecondary
width: parent.width - 15
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
// Quick toggles + update, tucked in the corner with tooltips.
Row {
id: footerIcons
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
text: backend.running ? sessionTimeDisplay : ""
font.pixelSize: 10
font.family: "Inter"
color: colorTextMuted
anchors.verticalCenter: parent.verticalCenter
rightPadding: 8
}
IconActionButton {
id: autoStartCheckbox
property bool checked: false
// Power symbol - start with Windows
svg: "M18.36 6.64 A9 9 0 1 1 5.64 6.64 M12 2 L12 11"
active: checked
tip: "Start with Windows: " + (checked ? "on" : "off")
anchors.verticalCenter: parent.verticalCenter
onClicked: {
checked = !checked
backend.set_auto_start(checked)
}
Connections {
target: backend
function onAutoStartChanged(enabled) {
autoStartCheckbox.checked = enabled
}
}
}
IconActionButton {
id: startMinimizedCheckbox
property bool checked: false
// Window with minimize bar - start minimized
svg: "M5 3 H19 A2 2 0 0 1 21 5 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V5 A2 2 0 0 1 5 3 Z M8 16 H16"
active: checked
actionEnabled: autoStartCheckbox.checked
tip: autoStartCheckbox.checked
? "Start minimized: " + (checked ? "on" : "off")
: "Start minimized (needs Start with Windows)"
anchors.verticalCenter: parent.verticalCenter
onClicked: {
checked = !checked
backend.set_start_minimized(checked)
}
Connections {
target: backend
function onStartMinimizedChanged(enabled) {
startMinimizedCheckbox.checked = enabled
}
}
}
IconActionButton {
id: hideToTrayCheckbox
property bool checked: false
// Tray / inbox - minimize to tray
svg: "M22 12 H16 L14 15 H10 L8 12 H2 M5.45 5.11 L2 12 V18 A2 2 0 0 0 4 20 H20 A2 2 0 0 0 22 18 V12 L18.55 5.11 A2 2 0 0 0 16.76 4 H7.24 A2 2 0 0 0 5.45 5.11 Z"
active: checked
actionEnabled: trayManager.trayAvailable
tip: "Minimize to tray: " + (checked ? "on" : "off")
anchors.verticalCenter: parent.verticalCenter
onClicked: {
checked = !checked
trayManager.set_hide_to_tray(checked)
}
Connections {
target: trayManager
function onHideToTrayEnabledChanged(enabled) {
hideToTrayCheckbox.checked = enabled
}
}
}
Rectangle {
width: 1
height: 16
color: colorBorder
anchors.verticalCenter: parent.verticalCenter
visible: updateAvailable || updatePreview
}
IconActionButton {
// Arrow-down-in-circle - update available
svg: "M22 12 A10 10 0 1 1 2 12 A10 10 0 1 1 22 12 Z M8 12 L12 16 L16 12 M12 8 L12 16"
visible: (updateAvailable || updatePreview) && !updateInProgress
glyphColor: colorSuccess
tip: "Update v" + (updatePreview ? "9.9.9" : latestVersion) + " available - click to install"
anchors.verticalCenter: parent.verticalCenter
onClicked: {
if (updatePreview) {
updateDemoTimer.start()
} else {
backend.install_update()
}
}
}
}
}
} // end main Column
// Preview mode for the update flow (Ctrl+U): shows the footer update
// icon and, when clicked, plays a fake download so the styling can be
// judged without a real release.
Shortcut {
sequence: "Ctrl+U"
onActivated: {
updatePreview = !updatePreview
if (!updatePreview) {
updateDemoTimer.stop()
updateInProgress = false
inlineUpdateProgress = 0
statusRevertTimer.restart()
}
}
}
Timer {
id: updateDemoTimer
interval: 50
repeat: true
onTriggered: {
updateInProgress = true
inlineUpdateProgress = Math.min(100, inlineUpdateProgress + 1)
appStatusText = "Downloading update v9.9.9 (preview)... " + inlineUpdateProgress + "%"
appStatusColor = colorSuccess
if (inlineUpdateProgress >= 100) {
stop()
appStatusText = "Update downloaded - restarting to apply (preview)"
updateInProgress = false
inlineUpdateProgress = 0
statusRevertTimer.restart()
}
}
}
// HARDWARE RANGE TEST DIALOG
Dialog {
id: hardwareRangeDialog
width: Math.min(mainWindow.width - 40, 560)
height: Math.min(mainWindow.height - 44, 590)
anchors.centerIn: parent
modal: true
title: "Hardware Range Test"
property var snapshot: ({})
property var testResults: []
property int pendingDegrees: 0
property string copyStatus: ""
property color copyStatusColor: colorSuccess
function parseResult(raw) {
try {
return JSON.parse(raw)
} catch (err) {
return { ok: false, status: "Could not read hardware test result" }
}
}
function refreshSnapshot() {
snapshot = parseResult(backend.hardware_range_snapshot_json())
}
function openWithSnapshot() {
refreshSnapshot()
open()
}
function runRange(deg) {
copyStatus = ""
copyStatusColor = colorSuccess
var result = parseResult(backend.run_hardware_range_test_json(deg))
pendingDegrees = deg
testResults = [result].concat(testResults)
refreshSnapshot()
}
function runModeSwitch() {
copyStatus = ""
copyStatusColor = colorSuccess
var result = parseResult(backend.run_g923_mode_switch_test_json())
testResults = [result].concat(testResults)
refreshSnapshot()
}
function confirmLock(answer) {
if (pendingDegrees <= 0) return
var result = parseResult(backend.record_hardware_range_confirmation_json(pendingDegrees, answer))
testResults = [result].concat(testResults)
pendingDegrees = 0
copyStatus = "Confirmation saved"
}
function copyReport() {
var result = parseResult(backend.copy_hardware_range_report_json())
copyStatus = result.status || "Report copied"
}
function sendReport() {
var result = parseResult(backend.submit_hardware_range_report_json())
copyStatus = result.status || "Sending hardware report..."
copyStatusColor = colorTextMuted
}
Connections {
target: backend
function onHardwareReportStatusChanged(status, color) {
if (hardwareRangeDialog.visible) {
hardwareRangeDialog.copyStatus = status
hardwareRangeDialog.copyStatusColor = color
}
}
}
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 72
Column {
anchors.left: parent.left
anchors.leftMargin: 22
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Text {
text: "Hardware Range Test"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: "Checks the real wheel lock, not software scaling"
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: ScrollView {
clip: true
Column {
width: hardwareRangeDialog.width - 44
spacing: 12
Rectangle {
width: parent.width
height: 96
radius: 8
color: colorCard
border.color: colorBorder
border.width: 1
Column {
anchors.fill: parent
anchors.margins: 14
spacing: 7
Row {
width: parent.width
spacing: 8
Text {
text: "Selected"
width: 72
font.pixelSize: 10
font.bold: true
color: colorTextMuted
font.family: "Inter"
}
Text {
text: hardwareRangeDialog.snapshot.selectedDevice || "No device selected"
width: parent.width - 80
elide: Text.ElideRight
font.pixelSize: 11
color: colorTextPrimary
font.family: "Inter"
}
}
Row {
width: parent.width
spacing: 8
Text {
text: "Provider"
width: 72
font.pixelSize: 10
font.bold: true
color: colorTextMuted
font.family: "Inter"
}
Text {
text: {
var protocols = hardwareRangeDialog.snapshot.protocols || []
if (protocols.length > 0) return protocols.join(", ")
return hardwareRangeDialog.snapshot.provider || "diagnostic only"
}
width: parent.width - 80
elide: Text.ElideRight
font.pixelSize: 11
color: hardwareRangeDialog.snapshot.supported ? colorSuccess : colorWarning
font.bold: true
font.family: "Inter"
}
}
Text {
text: hardwareRangeDialog.snapshot.message || ""
width: parent.width
elide: Text.ElideRight
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
}
Text {
text: {
var diagnostics = hardwareRangeDialog.snapshot.diagnosticHidCandidates || []
var reason = hardwareRangeDialog.snapshot.supportReason || ""
return diagnostics.length > 0 ? (diagnostics.length + " HID endpoints captured - " + reason) : reason
}
width: parent.width
elide: Text.ElideRight
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
}
}
}
Rectangle {
width: parent.width
height: 102
radius: 8
color: colorCard
border.color: colorBorder
border.width: 1
Column {
anchors.fill: parent
anchors.margins: 14
spacing: 10
Text {
text: "Test Range"
font.pixelSize: 12
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
Row {
width: parent.width
spacing: 8
StyledButton {
text: "Set 270"
width: (parent.width - 16) / 3
height: 32
primary: true
fontSize: 11
hoverEffect: true
enabled: hardwareRangeDialog.snapshot.supported === true
onClicked: hardwareRangeDialog.runRange(270)
}
StyledButton {
text: "Set 900"
width: (parent.width - 16) / 3
height: 32
primary: true
fontSize: 11
hoverEffect: true
enabled: hardwareRangeDialog.snapshot.supported === true
onClicked: hardwareRangeDialog.runRange(900)
}
StyledButton {
text: "Refresh"
width: (parent.width - 16) / 3
height: 32
secondary: true
fontSize: 11
hoverEffect: true
onClicked: hardwareRangeDialog.refreshSnapshot()
}
}
StyledButton {
text: "Switch G923 Mode"
width: parent.width
height: 30
secondary: true
fontSize: 10
hoverEffect: true
visible: {
var protocols = hardwareRangeDialog.snapshot.protocols || []
return protocols.indexOf("g923_ps_mode") >= 0
}
onClicked: hardwareRangeDialog.runModeSwitch()
}
}
}
Rectangle {
width: parent.width
height: hardwareRangeDialog.pendingDegrees > 0 ? 86 : 0
radius: 8
color: colorSurface
border.color: colorBorder
border.width: hardwareRangeDialog.pendingDegrees > 0 ? 1 : 0
visible: hardwareRangeDialog.pendingDegrees > 0
clip: true
Column {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Text {
text: "Did the real wheel lock change near " + hardwareRangeDialog.pendingDegrees + " degrees?"
width: parent.width
elide: Text.ElideRight
font.pixelSize: 11
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
Row {
width: parent.width
spacing: 8
StyledButton {
text: "Yes"
width: (parent.width - 16) / 3
height: 30
primary: true
fontSize: 11
hoverEffect: true
onClicked: hardwareRangeDialog.confirmLock("yes")
}
StyledButton {
text: "No"
width: (parent.width - 16) / 3
height: 30
danger: true
fontSize: 11
hoverEffect: true
onClicked: hardwareRangeDialog.confirmLock("no")
}
StyledButton {
text: "Skip"
width: (parent.width - 16) / 3
height: 30
secondary: true
fontSize: 11
hoverEffect: true
onClicked: hardwareRangeDialog.confirmLock("skip")
}
}
}
}
Rectangle {
width: parent.width
height: Math.max(92, Math.min(210, 44 + hardwareRangeDialog.testResults.length * 48))
radius: 8
color: colorCard
border.color: colorBorder
border.width: 1
Column {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Row {
width: parent.width
spacing: 8
Text {
text: "Results"
width: parent.width - 224
font.pixelSize: 12
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
StyledButton {
text: "Send Report"
width: 104
height: 26
primary: true
fontSize: 10
hoverEffect: true
onClicked: hardwareRangeDialog.sendReport()
}
StyledButton {
text: "Copy Report"
width: 104
height: 26
secondary: true
fontSize: 10
hoverEffect: true
onClicked: hardwareRangeDialog.copyReport()
}
}
Text {
visible: hardwareRangeDialog.testResults.length === 0
text: "Run 270 and 900, then confirm what the wheel physically did."
width: parent.width
wrapMode: Text.WordWrap
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
}
Repeater {
model: hardwareRangeDialog.testResults.slice(0, 3)
Rectangle {
width: parent.width
height: 40
radius: 6
color: colorSurface
border.color: modelData.ok ? colorSuccess : colorBorder
border.width: 1
Row {
anchors.fill: parent
anchors.margins: 9
spacing: 8
Rectangle {
width: 7
height: 7
radius: 4
color: modelData.ok ? colorSuccess : colorWarning
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: modelData.status || "No status"
width: parent.width - 20
elide: Text.ElideRight
font.pixelSize: 10
color: colorTextSecondary
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
Text {
visible: hardwareRangeDialog.copyStatus !== ""
text: hardwareRangeDialog.copyStatus
width: parent.width
elide: Text.ElideRight
font.pixelSize: 10
color: hardwareRangeDialog.copyStatusColor
font.family: "Inter"
}
}
}
Item { width: 1; height: 6 }
}
}
footer: DialogButtonBox {
padding: 16
background: Rectangle {
color: colorBg
border.color: colorBorder
border.width: 1
}
StyledButton {
text: "Close"
secondary: true
width: 120
height: 32
hoverEffect: true
onClicked: hardwareRangeDialog.close()
}
}
}
// INPUT INSPECTOR DIALOG
Dialog {
id: inspectorDialog
width: 420
height: 550
anchors.centerIn: parent
modal: true
title: "Input Inspector"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "Input Inspector"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: "Identify axis IDs by moving controls"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: ScrollView {
clip: true
Column {
spacing: 6
width: parent.width
Repeater {
model: backend.numAxes
Rectangle {
width: 380
height: 50
radius: 8
color: colorCard
border.color: colorBorder
border.width: 1
property int axisIndex: index
property real axisValue: 0.5
Component.onCompleted: {
axisValue = backend.getAxisValue(index)
}
Connections {
target: backend
function onAxisValuesChanged() {
axisValue = backend.getAxisValue(axisIndex)
}
}
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 0
Text {
text: "Axis " + parent.parent.axisIndex
font.pixelSize: 11
font.bold: true
color: colorTextSecondary
width: 50
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item {
width: parent.width - 50 - 55
height: parent.height
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: axisBarBg
anchors.fill: parent
anchors.leftMargin: 5
anchors.rightMargin: 5
height: 8
radius: 4
color: colorSurface
anchors.verticalCenter: parent.verticalCenter
clip: true
Rectangle {
id: axisBarFill
width: axisBarBg.width * Math.max(0, Math.min(1, axisValue))
height: parent.height
radius: 4
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: hudSilver1 }
GradientStop { position: 0.5; color: hudSilver2 }
GradientStop { position: 1.0; color: hudSilver3 }
}
Behavior on width {
NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
}
}
}
}
Text {
id: axisValueText
text: Math.round(Math.max(0, Math.min(1, axisValue)) * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 45
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
font.family: "Inter"
}
}
}
}
}
}
footer: DialogButtonBox {
padding: 16
background: Rectangle {
color: colorBg
border.color: colorBorder
border.width: 1
}
StyledButton {
text: "Close"
secondary: true
hoverEffect: true
onClicked: inspectorDialog.close()
}
}
}
Dialog {
id: neutralSteeringDialog
width: 420
height: 244
anchors.centerIn: parent
modal: true
property var capturedButtons: []
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorderLight
border.width: 1
}
header: Item {
width: parent.width
height: 52
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Neutral Steering Button"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
}
contentItem: Column {
width: neutralSteeringDialog.width - 40
spacing: 14
anchors.margins: 20
Text {
text: "Hold this button to force steering output to center."
font.pixelSize: 12
color: colorTextSecondary
width: parent.width
wrapMode: Text.WordWrap
font.family: "Inter"
}
Rectangle {
width: parent.width
height: 64
radius: 8
color: colorCard
border.width: 1
border.color: neutralSteeringDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder
Text {
anchors.centerIn: parent
text: neutralSteeringDialog.capturedButtons.length > 0 ?
"Captured: " + backend.get_button_name(neutralSteeringDialog.capturedButtons[0]) :
"Press the physical button now"
font.pixelSize: 13
font.weight: Font.DemiBold
color: neutralSteeringDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
font.family: "Inter"
}
}
Row {
width: parent.width
spacing: 8
StyledButton {
text: "Cancel"
width: (parent.width - 16) / 3
height: 38
secondary: true
hoverEffect: true
onClicked: neutralSteeringDialog.close()
}
StyledButton {
text: "Clear"
width: (parent.width - 16) / 3
height: 38
secondary: true
hoverEffect: true
enabled: backend.neutralSteeringButton >= 0
onClicked: {
backend.clear_neutral_steering_button()
neutralSteeringDialog.close()
}
}
StyledButton {
text: "Use Button"
width: (parent.width - 16) / 3
height: 38
primary: true
hoverEffect: true
enabled: neutralSteeringDialog.capturedButtons.length > 0
onClicked: {
backend.set_neutral_steering_button(neutralSteeringDialog.capturedButtons[0])
neutralSteeringDialog.close()
}
}
}
}
Timer {
interval: 50
repeat: true
running: neutralSteeringDialog.visible
onTriggered: {
var pressed = backend.get_pressed_buttons()
if (pressed.length > 0) {
neutralSteeringDialog.capturedButtons = [pressed[0]]
}
}
}
onOpened: capturedButtons = []
}
Dialog {
id: steeringActionDialog
width: 460
height: 352
anchors.centerIn: parent
modal: true
property var capturedButtons: []
property string selectedMode: "hold"
property int selectedMax: 0
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorderLight
border.width: 1
}
header: Item {
width: parent.width
height: 52
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Steering Limit"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
}
contentItem: Column {
width: steeringActionDialog.width - 40
spacing: 14
anchors.margins: 20
Text {
text: "Set a physical button that limits virtual steering while active."
font.pixelSize: 12
color: colorTextSecondary
width: parent.width
wrapMode: Text.WordWrap
font.family: "Inter"
}
Rectangle {
width: parent.width
height: 58
radius: 8
color: colorCard
border.width: 1
border.color: steeringActionDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder
Text {
anchors.centerIn: parent
text: steeringActionDialog.capturedButtons.length > 0 ?
"Captured: " + backend.get_button_name(steeringActionDialog.capturedButtons[0]) :
"Press the physical button now"
font.pixelSize: 13
font.weight: Font.DemiBold
color: steeringActionDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
font.family: "Inter"
}
}
Rectangle {
width: parent.width
height: 34
radius: 8
color: colorSurface
border.width: 1
border.color: colorBorder
Row {
anchors.fill: parent
anchors.margins: 3
spacing: 3
Repeater {
model: [["Hold", "hold"], ["Toggle", "toggle"]]
Rectangle {
width: (parent.width - 3) / 2
height: parent.height
radius: 6
color: steeringActionDialog.selectedMode === modelData[1] ? colorCardElevated : (segMouse.containsMouse ? colorSurfaceHover : "transparent")
border.width: steeringActionDialog.selectedMode === modelData[1] ? 1 : 0
border.color: colorBorderLight
Text {
anchors.centerIn: parent
text: modelData[0]
font.pixelSize: 11
font.weight: Font.DemiBold
color: steeringActionDialog.selectedMode === modelData[1] ? colorTextPrimary : colorTextMuted
font.family: "Inter"
}
MouseArea {
id: segMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: steeringActionDialog.selectedMode = modelData[1]
}
}
}
}
}
Column {
width: parent.width
spacing: 8
Row {
width: parent.width
height: 22
Text {
text: "Max steering"
font.pixelSize: 11
font.weight: Font.DemiBold
color: colorTextSecondary
width: parent.width - 54
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Text {
text: steeringActionDialog.selectedMax + "%"
font.pixelSize: 11
color: colorTextMuted
width: 54
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
Rectangle {
id: steeringActionSlider
width: parent.width
height: 24
radius: 12
color: "transparent"
property real from: 0
property real to: 100
property real value: steeringActionDialog.selectedMax
property real stepSize: 1
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: colorSurfaceHover
border.width: 1
border.color: colorBorder
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: 6
radius: 3
color: colorInactive
}
Rectangle {
x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
y: (parent.height - 16) / 2
width: 16
height: 16
radius: 8
color: colorTextPrimary
border.width: 2
border.color: colorBg
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
propagateComposedEvents: false
function updateValue(x) {
var newValue = steeringActionSlider.from + (x / width) * (steeringActionSlider.to - steeringActionSlider.from)
newValue = Math.max(steeringActionSlider.from, Math.min(steeringActionSlider.to, newValue))
if (steeringActionSlider.stepSize > 0) {
newValue = Math.round(newValue / steeringActionSlider.stepSize) * steeringActionSlider.stepSize
}
steeringActionDialog.selectedMax = Math.round(newValue)
}
onPressed: function(mouse) { updateValue(mouse.x) }
onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
}
}
}
Row {
width: parent.width
spacing: 8
StyledButton {
text: "Cancel"
width: (parent.width - 16) / 3
height: 38
secondary: true
hoverEffect: true
onClicked: steeringActionDialog.close()
}
StyledButton {
text: "Clear"
width: (parent.width - 16) / 3
height: 38
secondary: true
hoverEffect: true
enabled: backend.steeringActionButton >= 0
onClicked: {
backend.clear_steering_action_button()
steeringActionDialog.close()
}
}
StyledButton {
text: "Save"
width: (parent.width - 16) / 3
height: 38
primary: true
hoverEffect: true
enabled: steeringActionDialog.capturedButtons.length > 0 || backend.steeringActionButton >= 0
onClicked: {
if (steeringActionDialog.capturedButtons.length > 0) {
backend.set_steering_action_button(steeringActionDialog.capturedButtons[0])
}
backend.set_steering_action_max_percent(steeringActionDialog.selectedMax)
backend.set_steering_action_mode(steeringActionDialog.selectedMode)
steeringActionDialog.close()
}
}
}
}
Timer {
interval: 50
repeat: true
running: steeringActionDialog.visible
onTriggered: {
var pressed = backend.get_pressed_buttons()
if (pressed.length > 0) {
steeringActionDialog.capturedButtons = [pressed[0]]
}
}
}
onOpened: {
capturedButtons = backend.steeringActionButton >= 0 ? [backend.steeringActionButton] : []
selectedMode = backend.steeringActionMode
selectedMax = backend.steeringActionMaxPercent
}
}
// BUTTON MAPPING DIALOG
Dialog {
id: buttonDialog
width: 480
height: 550
anchors.centerIn: parent
modal: true
title: "Button Mapping"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "Button Mapping"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: backend.numButtons + " buttons available"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: Column {
spacing: 8
ScrollView {
width: parent.width
height: 390
clip: true
Column {
spacing: 2
width: parent.width
Repeater {
model: backend.numButtons
Rectangle {
width: 440
height: 44
radius: 6
color: colorCard
border.color: colorBorder
border.width: 1
property int buttonIndex: index
property bool buttonPressed: false
Component.onCompleted: {
buttonPressed = backend.getButtonState(index)
}
Connections {
target: backend
function onButtonStatesChanged() {
buttonPressed = backend.getButtonState(buttonIndex)
}
}
Row {
anchors.centerIn: parent
spacing: 10
width: parent.width - 20
Rectangle {
width: 18
height: 18
radius: 9
color: parent.parent.buttonPressed ? colorAccent : colorInactive
border.color: parent.parent.buttonPressed ? colorPrimary : colorBorder
border.width: 2
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.centerIn: parent
width: 6
height: 6
radius: 3
color: parent.parent.parent.buttonPressed ? colorBg : "transparent"
visible: parent.parent.parent.buttonPressed
}
Behavior on color {
ColorAnimation { duration: 100 }
}
Behavior on border.color {
ColorAnimation { duration: 100 }
}
}
Text {
text: "BTN " + parent.parent.buttonIndex
font.pixelSize: 12
font.weight: Font.Medium
color: colorTextSecondary
width: 55
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item { width: 1; Layout.fillWidth: true }
StyledComboBox {
id: buttonComboBox
width: 180
height: 30
model: backend.xboxButtonNames
property bool updatingFromBackend: true // Start as true to prevent initial trigger
Component.onCompleted: {
// Set initial value without triggering change handler
var mapping = backend.get_button_mapping(buttonIndex)
currentIndex = model.indexOf(mapping)
// Now allow user changes
updatingFromBackend = false
}
onCurrentTextChanged: {
// Only call backend if not updating from backend
if (!updatingFromBackend) {
backend.set_button_mapping(buttonIndex, currentText)
}
}
Connections {
target: backend
function onButtonMappingsChanged() {
// Set flag to prevent triggering onCurrentTextChanged
buttonComboBox.updatingFromBackend = true
// Force re-evaluation of currentIndex
var mapping = backend.get_button_mapping(buttonIndex)
buttonComboBox.currentIndex = buttonComboBox.model.indexOf(mapping)
// Reset flag after a short delay
Qt.callLater(function() {
buttonComboBox.updatingFromBackend = false
})
}
}
}
}
}
}
}
}
}
footer: Item {
width: parent.width
height: 58
Row {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.topMargin: 8
anchors.bottomMargin: 10
spacing: 8
StyledButton {
text: "Auto-Configure"
width: parent.width * 0.36
height: 40
secondary: true
hoverEffect: true
onClicked: {
buttonDialog.close()
calibrationDialog.open()
}
}
StyledButton {
text: "Save & Close"
width: parent.width * 0.38
height: 40
primary: true
hoverEffect: true
onClicked: {
backend.save_button_mapping()
buttonDialog.close()
}
}
StyledButton {
text: "Cancel"
width: parent.width * 0.26 - 16
height: 40
secondary: true
hoverEffect: true
onClicked: buttonDialog.close()
}
}
}
}
// BUTTON AUTO-CALIBRATION WIZARD DIALOG
Dialog {
id: calibrationDialog
width: 520
height: 620
anchors.centerIn: parent
modal: true
closePolicy: Dialog.NoAutoClose
property int currentStep: 0
property var buttonSequence: [
{ name: "A (Cross)", instruction: "A / Cross", desc: "Primary action button" },
{ name: "B (Circle)", instruction: "B / Circle", desc: "Secondary action button" },
{ name: "X (Square)", instruction: "X / Square", desc: "Alternative action button" },
{ name: "Y (Triangle)", instruction: "Y / Triangle", desc: "Alternative action button" },
{ name: "LB (L1)", instruction: "Left Bumper", desc: "Left shoulder button" },
{ name: "RB (R1)", instruction: "Right Bumper", desc: "Right shoulder button" },
{ name: "Back (Share)", instruction: "Back / Share", desc: "Menu/share button" },
{ name: "Start (Options)", instruction: "Start / Options", desc: "Pause/options button" },
{ name: "LThumb (L3)", instruction: "Left Stick Press", desc: "Press left analog stick" },
{ name: "RThumb (R3)", instruction: "Right Stick Press", desc: "Press right analog stick" },
]
property var detectedButtons: []
property bool waitingForRelease: false
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
// Remove default header and footer - we'll build custom ones
header: Item { height: 0 }
footer: Item { height: 0 }
contentItem: Item {
anchors.fill: parent
Column {
anchors.fill: parent
spacing: 0
// CUSTOM HEADER
Rectangle {
width: parent.width
height: 90
color: colorCard
radius: 12
Column {
anchors.centerIn: parent
spacing: 10
width: parent.width - 40
Text {
text: "Button Configuration"
font.pixelSize: 20
font.weight: Font.Bold
color: colorTextPrimary
font.family: "Inter"
anchors.horizontalCenter: parent.horizontalCenter
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
Text {
text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
"Step " + (calibrationDialog.currentStep + 1) + " of " + calibrationDialog.buttonSequence.length :
"Complete"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
}
// Progress bar
Rectangle {
width: 440
height: 4
radius: 2
color: colorSurface
anchors.horizontalCenter: parent.horizontalCenter
Rectangle {
width: parent.width * (calibrationDialog.currentStep / calibrationDialog.buttonSequence.length)
height: parent.height
radius: 2
color: colorPrimary
Behavior on width {
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
}
}
}
}
// MAIN CONTENT
Item {
width: parent.width
height: 380
Column {
anchors.centerIn: parent
anchors.verticalCenterOffset: -20
spacing: 20
width: parent.width - 60
// Instruction card
Rectangle {
width: parent.width
height: 160
radius: 8
color: colorCard
border.color: calibrationDialog.waitingForRelease ? colorPrimary : colorBorder
border.width: 1
anchors.horizontalCenter: parent.horizontalCenter
Behavior on border.color {
ColorAnimation { duration: 150 }
}
Column {
anchors.centerIn: parent
spacing: 16
width: parent.width - 40
// Status icon
Text {
text: calibrationDialog.waitingForRelease ? "OK" :
(calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length ? "OK" : "o")
font.pixelSize: 48
color: calibrationDialog.waitingForRelease ? colorPrimary : colorTextMuted
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
Behavior on color {
ColorAnimation { duration: 150 }
}
}
Text {
text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
"Press: " + calibrationDialog.buttonSequence[calibrationDialog.currentStep].instruction :
"Configuration Complete!"
font.pixelSize: 18
font.weight: Font.DemiBold
color: colorTextPrimary
horizontalAlignment: Text.AlignHCenter
width: parent.width
font.family: "Inter"
}
Text {
text: calibrationDialog.waitingForRelease ?
"Now release the button..." :
(calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
calibrationDialog.buttonSequence[calibrationDialog.currentStep].desc :
"All buttons have been mapped successfully!")
font.pixelSize: 12
color: colorTextMuted
horizontalAlignment: Text.AlignHCenter
width: parent.width
wrapMode: Text.WordWrap
font.family: "Inter"
}
}
}
// Detection status
Rectangle {
width: parent.width
height: 70
radius: 8
color: calibrationDialog.detectedButtons.length > 0 ? colorPrimary : colorSurface
border.color: calibrationDialog.detectedButtons.length > 0 ? colorSuccess : colorBorder
border.width: 1
anchors.horizontalCenter: parent.horizontalCenter
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on border.color {
ColorAnimation { duration: 150 }
}
Column {
anchors.centerIn: parent
spacing: 6
Text {
text: calibrationDialog.detectedButtons.length > 0 ?
"Button " + calibrationDialog.detectedButtons[0] + " Detected" :
"Waiting for input..."
font.pixelSize: 14
font.weight: Font.Medium
color: calibrationDialog.detectedButtons.length > 0 ? colorBg : colorTextMuted
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
Text {
text: calibrationDialog.detectedButtons.length > 1 ?
"+" + (calibrationDialog.detectedButtons.length - 1) + " other button(s)" :
(calibrationDialog.detectedButtons.length > 0 ? "Button registered" : "Press any button to continue")
font.pixelSize: 11
color: calibrationDialog.detectedButtons.length > 0 ? colorBg : colorTextMuted
opacity: calibrationDialog.detectedButtons.length > 0 ? 0.9 : 0.6
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
}
}
}
}
// CUSTOM FOOTER WITH BUTTONS
Rectangle {
width: parent.width
height: 150
color: colorBg
Column {
anchors.centerIn: parent
spacing: 10
width: parent.width - 60
// Skip button - ALWAYS VISIBLE during calibration
Rectangle {
width: parent.width
height: 48
radius: 8
color: skipMouseArea.containsMouse ? colorSurface : colorCard
border.color: colorBorder
border.width: 1
visible: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length
Behavior on color {
ColorAnimation { duration: 100 }
}
Text {
text: "Skip This Button"
font.pixelSize: 14
font.weight: Font.Medium
color: colorTextPrimary
anchors.centerIn: parent
font.family: "Inter"
}
MouseArea {
id: skipMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Skip to next button
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
calibrationDialog.currentStep++
// Check if we're done after skipping
if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
backend.finish_calibration()
closeTimer.start()
}
}
}
}
// Cancel/Done button
Rectangle {
width: parent.width
height: 48
radius: 8
color: cancelMouseArea.containsMouse ?
(calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ? "#dc2626" : colorPrimaryHover) :
(calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ? "#ef4444" : colorPrimary)
border.width: 0
Text {
text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
"Cancel Configuration" : "Done"
font.pixelSize: 14
font.weight: Font.Bold
color: colorBg
anchors.centerIn: parent
font.family: "Inter"
}
MouseArea {
id: cancelMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
backend.finish_calibration()
}
calibrationDialog.currentStep = 0
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
calibrationDialog.close()
}
}
}
}
}
}
}
// Timer to auto-close after completion
Timer {
id: closeTimer
interval: 1500
repeat: false
onTriggered: {
calibrationDialog.currentStep = 0
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
calibrationDialog.close()
}
}
// Monitor button presses during calibration
Timer {
id: calibrationTimer
interval: 50
running: calibrationDialog.visible && calibrationDialog.currentStep < calibrationDialog.buttonSequence.length
repeat: true
property int noChangeCounter: 0
onTriggered: {
var pressed = backend.get_pressed_buttons()
if (calibrationDialog.waitingForRelease) {
// Waiting for user to release the button
if (pressed.length === 0) {
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
calibrationDialog.currentStep++
noChangeCounter = 0
// Check if we're done
if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
backend.finish_calibration()
// Auto-close after a short delay
closeTimer.start()
}
} else {
// Still waiting for release - increment counter
noChangeCounter++
// If stuck for more than 10 seconds (200 cycles at 50ms), auto-advance
if (noChangeCounter > 200) {
console.log("Button stuck - auto-advancing")
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
calibrationDialog.currentStep++
noChangeCounter = 0
if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
backend.finish_calibration()
closeTimer.start()
}
}
}
} else {
// Waiting for user to press a button
if (pressed.length > 0) {
calibrationDialog.detectedButtons = pressed
// Map the first detected button
var buttonIdx = pressed[0]
var buttonName = calibrationDialog.buttonSequence[calibrationDialog.currentStep].name
backend.calibrate_button(buttonIdx, buttonName)
calibrationDialog.waitingForRelease = true
noChangeCounter = 0
} else {
calibrationDialog.detectedButtons = []
}
}
}
}
onOpened: {
calibrationDialog.currentStep = 0
calibrationDialog.waitingForRelease = false
calibrationDialog.detectedButtons = []
backend.start_button_calibration()
}
}
// VIGEM DRIVER INSTALLATION DIALOG
Dialog {
id: vigEmDialog
width: 420
height: 300
anchors.centerIn: parent
modal: true
title: "ViGEm Driver Required"
closePolicy: Dialog.RejectOnEscape // Don't allow closing via X or Escape
property bool installing: false
background: Rectangle {
color: colorBg
radius: 12
border.color: colorWarning
border.width: 2
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "ViGEm Driver Not Detected"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorWarning
font.family: "Inter"
}
Text {
text: "TrueAxis requires the ViGEm driver to work"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: Column {
spacing: 16
Text {
width: parent.width - 40
text: "TrueAxis needs the ViGEm driver to create a virtual Xbox controller. Without it, the application cannot function.\n\nClick the button below to automatically download and install the driver. No action is needed from you - it installs silently in the background."
font.pixelSize: 12
color: colorTextSecondary
font.family: "Inter"
wrapMode: Text.WordWrap
lineHeight: 1.4
}
// Live status text - shows download/install progress
Text {
id: vigEmInstallStatus
width: parent.width - 40
text: {
if (vigEmStatus === "not installed" || vigEmStatus === "installed" || vigEmStatus === "") return ""
return vigEmStatus
}
font.pixelSize: 11
color: vigEmDialog.installing ? colorWarning : colorError
font.family: "Inter"
wrapMode: Text.WordWrap
visible: text !== ""
}
// Install button
StyledButton {
id: vigEmInstallBtn
text: vigEmDialog.installing ? "Installing..." : "Install ViGEm"
width: parent.width - 40
height: 40
primary: !vigEmDialog.installing
enabled: !vigEmDialog.installing
hoverEffect: !vigEmDialog.installing
onClicked: {
vigEmDialog.installing = true
backend.install_vigem_driver()
}
}
}
// No footer / no close button - dialog closes automatically on success
footer: null
}
// IMPORT SETTINGS DIALOG
Dialog {
id: importDialog
width: 420
height: 280
anchors.centerIn: parent
modal: true
title: "Import Settings"
property string selectedFile: ""
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "Import Settings"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: "Restore from a previously exported file"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: Column {
spacing: 16
Text {
width: parent.width - 40
text: "Enter the full path to your TrueAxis settings file (e.g., TrueAxis_Settings_*.json in your Documents folder):"
font.pixelSize: 12
color: colorTextSecondary
font.family: "Inter"
wrapMode: Text.WordWrap
lineHeight: 1.4
}
Rectangle {
width: parent.width - 40
height: 40
color: colorSurface
radius: 8
border.color: colorBorder
border.width: 1
TextInput {
id: importPathInput
anchors.fill: parent
anchors.margins: 10
font.pixelSize: 12
font.family: "Inter"
color: colorTextPrimary
verticalAlignment: Text.AlignVCenter
selectByMouse: true
clip: true
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Path to settings file..."
color: colorTextMuted
font.pixelSize: 12
font.family: "Inter"
visible: !parent.text && !parent.activeFocus
}
}
}
Row {
spacing: 10
StyledButton {
text: "Cancel"
width: 110
height: 40
secondary: true
hoverEffect: true
onClicked: importDialog.close()
}
StyledButton {
text: "Import"
width: 110
height: 40
primary: true
enabled: importPathInput.text.length > 0
hoverEffect: enabled
onClicked: {
var success = backend.import_settings(importPathInput.text)
if (success) {
importPathInput.text = ""
importDialog.close()
}
}
}
}
}
}
// MACRO NAME INPUT DIALOG
Dialog {
id: macroNameDialog
width: 400
height: 300
anchors.centerIn: parent
modal: true
title: "Record New Macro"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
width: parent.width
height: 60
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Record New Macro"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
}
Column {
anchors.fill: parent
anchors.margins: 20
spacing: 15
Text {
text: "Enter a name for this macro:"
font.pixelSize: 12
color: colorTextSecondary
font.family: "Inter"
}
Rectangle {
width: parent.width
height: 40
color: colorCard
border.color: macroNameInput.activeFocus ? colorPrimary : colorBorder
border.width: 1
radius: 6
TextInput {
id: macroNameInput
anchors.fill: parent
anchors.margins: 10
color: colorTextPrimary
font.pixelSize: 13
font.family: "Inter"
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
Keys.onReturnPressed: {
if (text.length > 0) {
backend.start_macro_recording(text)
text = ""
macroNameDialog.close()
}
}
}
}
Row {
width: parent.width
spacing: 10
StyledButton {
text: "Cancel"
width: (parent.width - 10) / 2
secondary: true
onClicked: {
macroNameInput.text = ""
macroNameDialog.close()
}
}
StyledButton {
text: "Start Recording"
width: (parent.width - 10) / 2
primary: true
enabled: macroNameInput.text.length > 0
onClicked: {
backend.start_macro_recording(macroNameInput.text)
macroNameInput.text = ""
macroNameDialog.close()
}
}
}
}
}
// MACRO MANAGER DIALOG
Dialog {
id: macroManagerDialog
width: 540
height: 430
anchors.centerIn: parent
modal: true
title: "Macro Manager"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
width: parent.width
height: 54
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Macro Manager"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
}
Column {
anchors.fill: parent
anchors.margins: 18
spacing: 12
Text {
text: "Your saved macros:"
font.pixelSize: 12
color: colorTextSecondary
font.family: "Inter"
}
ScrollView {
width: parent.width
height: 220
clip: true
Column {
id: macroList
width: parent.width
spacing: 10
property var macroArray: []
Repeater {
id: macroRepeater
model: macroList.macroArray.length
delegate: Rectangle {
id: macroCard
width: macroList.width
height: 128
color: colorCard
radius: 8
border.color: colorBorder
border.width: 1
property var macroData: macroList.macroArray[index] || {}
Column {
anchors.fill: parent
anchors.margins: 14
spacing: 8
Row {
width: parent.width
spacing: 10
Text {
text: macroCard.macroData.name || ""
font.pixelSize: 14
font.bold: true
color: colorTextPrimary
font.family: "Inter"
width: parent.width - 70
elide: Text.ElideRight
}
Text {
text: (macroCard.macroData.duration || 0) + "s"
font.pixelSize: 11
color: colorPrimary
font.family: "Inter"
width: 60
horizontalAlignment: Text.AlignRight
}
}
Text {
text: (macroCard.macroData.frames || 0) + " steps - Created " + (macroCard.macroData.created || "Unknown")
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
}
Text {
text: {
var triggers = macroCard.macroData.triggers || []
return triggers.length > 0 ? "Trigger: " + triggers.join(", ") : "Trigger: not set"
}
font.pixelSize: 10
color: colorTextMuted
font.family: "Inter"
elide: Text.ElideRight
width: parent.width
}
Row {
width: parent.width
spacing: 8
Rectangle {
width: parent.width
height: 34
radius: 7
color: colorSurface
border.color: colorBorder
border.width: 1
Row {
anchors.fill: parent
anchors.margins: 4
spacing: 8
StyledButton {
text: "Play"
width: 84
height: 26
fontSize: 11
primary: true
enabled: backend.running && !backend.macro_playing
onClicked: backend.play_macro(macroCard.macroData.name || "")
}
StyledButton {
text: "Set Trigger"
width: 112
height: 26
fontSize: 11
secondary: true
onClicked: {
macroTriggerDialog.currentMacro = macroCard.macroData.name || ""
macroTriggerDialog.open()
}
}
Item { width: parent.width - 84 - 112 - 76 - 32; height: 1 }
StyledButton {
text: "Delete"
width: 76
height: 26
fontSize: 11
danger: true
onClicked: {
backend.delete_macro(macroCard.macroData.name || "")
macroManagerDialog.refreshMacroList()
}
}
}
}
}
}
}
}
Text {
visible: macroRepeater.count === 0
text: "No macros recorded yet.\nClick 'Record Macro' or 'Create Custom' to create one."
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
footer: Item {
width: parent.width
height: 58
Row {
anchors.fill: parent
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 8
anchors.bottomMargin: 12
spacing: 10
StyledButton {
text: "+ Create Custom"
width: (parent.width - 10) / 2
height: 38
primary: true
onClicked: {
customMacroDialog.open()
macroManagerDialog.close()
}
}
StyledButton {
text: "Close"
width: (parent.width - 10) / 2
height: 38
secondary: true
onClicked: macroManagerDialog.close()
}
}
}
onOpened: {
refreshMacroList()
}
function refreshMacroList() {
try {
var list = JSON.parse(backend.get_macro_list())
macroList.macroArray = list
} catch(e) {
console.log("Error parsing macro list: " + e)
macroList.macroArray = []
}
}
Connections {
target: backend
function onMacrosChanged() {
if (macroManagerDialog.visible) {
macroManagerDialog.refreshMacroList()
}
}
}
}
// MACRO TRIGGER SETUP DIALOG
Dialog {
id: macroTriggerDialog
width: 450
height: 300
anchors.centerIn: parent
modal: true
title: "Set Macro Trigger"
property string currentMacro: ""
property var pressedButtons: []
property var capturedButtons: []
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
width: parent.width
height: 60
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Set Trigger for: " + macroTriggerDialog.currentMacro
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
elide: Text.ElideRight
width: parent.width - 40
}
}
Column {
anchors.fill: parent
anchors.margins: 20
spacing: 15
Text {
text: "Press the button combination once. It stays captured until you clear it."
font.pixelSize: 12
color: colorTextSecondary
font.family: "Inter"
wrapMode: Text.WordWrap
width: parent.width
}
Rectangle {
width: parent.width
height: 80
color: colorCard
radius: 8
border.color: macroTriggerDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder
border.width: 2
Text {
anchors.centerIn: parent
text: macroTriggerDialog.capturedButtons.length > 0 ?
"Captured: " + macroTriggerDialog.formatButtons(macroTriggerDialog.capturedButtons) :
"Waiting for buttons..."
font.pixelSize: 14
font.bold: true
color: macroTriggerDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
font.family: "Inter"
}
}
Row {
width: parent.width
spacing: 10
StyledButton {
text: "Cancel"
width: (parent.width - 20) / 3
secondary: true
onClicked: {
macroTriggerDialog.pressedButtons = []
macroTriggerDialog.capturedButtons = []
macroTriggerDialog.close()
}
}
StyledButton {
text: "Clear Trigger"
width: (parent.width - 20) / 3
danger: true
onClicked: {
backend.clear_macro_triggers(macroTriggerDialog.currentMacro)
macroTriggerDialog.pressedButtons = []
macroTriggerDialog.capturedButtons = []
macroTriggerDialog.close()
}
}
StyledButton {
text: "Set Trigger"
width: (parent.width - 20) / 3
primary: true
enabled: macroTriggerDialog.capturedButtons.length > 0
onClicked: {
var combo = macroTriggerDialog.capturedButtons.join("+")
backend.set_macro_trigger(combo, macroTriggerDialog.currentMacro)
macroTriggerDialog.pressedButtons = []
macroTriggerDialog.capturedButtons = []
macroTriggerDialog.close()
}
}
}
}
Timer {
id: triggerMonitor
interval: 50
running: macroTriggerDialog.visible
repeat: true
onTriggered: {
var pressed = backend.get_pressed_buttons()
macroTriggerDialog.pressedButtons = pressed
if (pressed.length > 0) {
macroTriggerDialog.capturedButtons = pressed
}
}
}
onOpened: {
pressedButtons = []
capturedButtons = []
}
function formatButtons(buttons) {
var names = []
for (var i = 0; i < buttons.length; i++) {
names.push(backend.get_button_name(buttons[i]))
}
return names.join(" + ")
}
}
// CUSTOM MACRO BUILDER DIALOG
Dialog {
id: customMacroDialog
width: 520
height: 560
anchors.centerIn: parent
modal: true
title: "Custom Macro Builder"
property var actions: []
property string macroName: ""
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
width: parent.width
height: 58
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: "Custom Macro Builder"
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
}
Column {
anchors.fill: parent
anchors.margins: 20
spacing: 14
// Macro Name Input
Column {
width: parent.width
spacing: 8
Text {
text: "Macro Name"
font.pixelSize: 13
font.bold: true
color: colorTextSecondary
font.family: "Inter"
}
Rectangle {
width: parent.width
height: 38
color: colorCard
radius: 8
border.color: customMacroNameInput.activeFocus ? colorPrimary : colorBorder
border.width: 1
TextInput {
id: customMacroNameInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
font.pixelSize: 13
color: colorTextPrimary
font.family: "Inter"
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
Text {
visible: !parent.text && !parent.activeFocus
text: "Enter a name for your macro..."
color: colorTextMuted
font.pixelSize: 13
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
// Action List
Column {
width: parent.width
spacing: 8
Row {
width: parent.width
spacing: 10
Text {
text: "Actions Sequence"
font.pixelSize: 13
font.bold: true
color: colorTextSecondary
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: 60
height: 24
radius: 12
color: colorPrimary
opacity: 0.1
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: customMacroDialog.actions.length + " steps"
font.pixelSize: 11
font.bold: true
color: colorPrimary
font.family: "Inter"
}
}
}
Item {
width: parent.width
height: 156
Rectangle {
anchors.fill: parent
visible: customMacroDialog.actions.length === 0
color: colorCard
radius: 8
border.color: colorBorder
border.width: 1
Text {
anchors.centerIn: parent
text: "No steps yet"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
ScrollView {
anchors.fill: parent
visible: customMacroDialog.actions.length > 0
clip: true
Column {
id: actionsList
width: parent.width - 10
spacing: 8
Repeater {
model: customMacroDialog.actions.length
delegate: Rectangle {
id: actionCard
width: actionsList.width
height: 58
color: colorCard
radius: 8
border.color: colorBorder
border.width: 1
property var actionData: customMacroDialog.actions[index] || {}
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 12
Rectangle {
width: 30
height: 30
radius: 15
color: colorPrimary
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: (index + 1).toString()
font.pixelSize: 12
font.bold: true
color: "white"
font.family: "Inter"
}
}
Column {
width: parent.width - 84
spacing: 3
anchors.verticalCenter: parent.verticalCenter
Text {
text: {
var type = actionCard.actionData.type || ""
if (type === "hold") return "Hold Button(s)"
if (type === "tap") return "Tap Button(s)"
if (type === "press") return "Press Button(s)"
if (type === "release") return "Release Button(s)"
if (type === "wait") return "Wait"
return type
}
font.pixelSize: 13
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: {
var action = actionCard.actionData
var details = ""
if (action.buttons && action.buttons.length > 0) {
var buttonNames = []
for (var i = 0; i < action.buttons.length; i++) {
buttonNames.push(backend.get_button_name(action.buttons[i]))
}
details = buttonNames.join(", ")
}
if (action.duration) {
details += (details ? " - " : "") + action.duration + "ms"
}
return details || "No details"
}
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
elide: Text.ElideRight
width: parent.width
}
}
Rectangle {
width: 32
height: 32
radius: 8
color: deleteMouseArea.containsMouse ? "#26ef4444" : "transparent"
border.color: deleteMouseArea.containsMouse ? "#ef4444" : colorBorder
border.width: 1
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "x"
font.pixelSize: 18
color: deleteMouseArea.containsMouse ? "#ef4444" : colorTextMuted
font.family: "Inter"
}
MouseArea {
id: deleteMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var newActions = customMacroDialog.actions.slice()
newActions.splice(index, 1)
customMacroDialog.actions = newActions
}
}
}
}
}
}
}
}
}
}
// Add Action Buttons
Column {
width: parent.width
spacing: 8
Text {
text: "Add Action"
font.pixelSize: 13
font.bold: true
color: colorTextSecondary
font.family: "Inter"
}
Grid {
columns: 3
columnSpacing: 10
rowSpacing: 10
width: parent.width
StyledButton {
text: "Hold Button"
width: (parent.width - 20) / 3
height: 34
onClicked: actionConfigDialog.open("hold")
}
StyledButton {
text: "Tap Button"
width: (parent.width - 20) / 3
height: 34
onClicked: actionConfigDialog.open("tap")
}
StyledButton {
text: "Wait"
width: (parent.width - 20) / 3
height: 34
onClicked: actionConfigDialog.open("wait")
}
StyledButton {
text: "Press Button"
width: (parent.width - 20) / 3
height: 34
onClicked: actionConfigDialog.open("press")
}
StyledButton {
text: "Release Button"
width: (parent.width - 20) / 3
height: 34
onClicked: actionConfigDialog.open("release")
}
}
}
}
footer: Item {
width: parent.width
height: 60
Row {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.topMargin: 10
anchors.bottomMargin: 12
spacing: 10
StyledButton {
text: "Clear All"
width: (parent.width - 20) / 3
height: 38
danger: customMacroDialog.actions.length > 0
secondary: customMacroDialog.actions.length === 0
enabled: customMacroDialog.actions.length > 0
onClicked: customMacroDialog.actions = []
}
StyledButton {
text: "Cancel"
width: (parent.width - 20) / 3
height: 38
secondary: true
onClicked: customMacroDialog.close()
}
StyledButton {
text: "Create Macro"
width: (parent.width - 20) / 3
height: 38
primary: true
enabled: customMacroNameInput.text.length > 0 && customMacroDialog.actions.length > 0
onClicked: {
var actionsJson = JSON.stringify(customMacroDialog.actions)
if (backend.create_custom_macro(customMacroNameInput.text, actionsJson)) {
customMacroDialog.actions = []
customMacroNameInput.text = ""
customMacroDialog.close()
}
}
}
}
}
onOpened: {
customMacroNameInput.text = ""
customMacroDialog.actions = []
}
}
// ACTION CONFIGURATION DIALOG
Dialog {
id: actionConfigDialog
width: 480
height: 600
anchors.centerIn: parent
modal: true
property string actionType: ""
property var selectedButtons: []
property int duration: 100
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
width: parent.width
height: 60
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
text: {
if (actionConfigDialog.actionType === "hold") return "Hold Button"
if (actionConfigDialog.actionType === "tap") return "Tap Button"
if (actionConfigDialog.actionType === "wait") return "Wait"
if (actionConfigDialog.actionType === "press") return "Press Button"
if (actionConfigDialog.actionType === "release") return "Release Button"
return "Configure Action"
}
font.pixelSize: 15
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: {
if (actionConfigDialog.actionType === "hold") return "Select buttons and set how long to hold them"
if (actionConfigDialog.actionType === "tap") return "Press and release selected buttons after a custom interval"
if (actionConfigDialog.actionType === "wait") return "Set how long to pause before the next action"
if (actionConfigDialog.actionType === "press") return "Select buttons to press down"
if (actionConfigDialog.actionType === "release") return "Select buttons to release"
return ""
}
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
Column {
anchors.fill: parent
anchors.margins: 20
spacing: 16
// Button Selection (for press/release/hold)
Column {
width: parent.width
spacing: 10
visible: actionConfigDialog.actionType !== "wait"
Text {
text: "Select Button(s)"
font.pixelSize: 13
font.bold: true
color: colorTextSecondary
font.family: "Inter"
}
Rectangle {
width: parent.width
height: 42
color: colorSurface
radius: 8
border.color: colorBorder
border.width: 1
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Text {
text: "i"
font.pixelSize: 14
color: colorTextMuted
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Press buttons on your controller to select them"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 30
wrapMode: Text.WordWrap
}
}
}
Rectangle {
width: parent.width
height: 64
color: colorCard
radius: 8
border.color: actionConfigDialog.selectedButtons.length > 0 ? colorSuccess : colorBorder
border.width: 2
Column {
anchors.centerIn: parent
width: parent.width - 24
spacing: 6
Text {
text: actionConfigDialog.selectedButtons.length > 0 ?
"Selected Buttons:" :
"No buttons selected"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
visible: actionConfigDialog.selectedButtons.length > 0
text: {
var names = []
for (var i = 0; i < actionConfigDialog.selectedButtons.length; i++) {
names.push(backend.get_button_name(actionConfigDialog.selectedButtons[i]))
}
return names.join(", ")
}
font.pixelSize: 16
font.bold: true
color: colorSuccess
font.family: "Inter"
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
StyledButton {
text: "Clear Selection"
width: parent.width
height: 34
secondary: true
enabled: actionConfigDialog.selectedButtons.length > 0
onClicked: actionConfigDialog.selectedButtons = []
}
}
// Duration Configuration (for wait/hold)
Column {
width: parent.width
spacing: 10
visible: actionConfigDialog.actionType === "wait" || actionConfigDialog.actionType === "hold" || actionConfigDialog.actionType === "tap"
Text {
text: "Duration"
font.pixelSize: 13
font.bold: true
color: colorTextSecondary
font.family: "Inter"
}
Row {
width: parent.width
spacing: 10
Rectangle {
width: parent.width - 70
height: 40
color: colorCard
radius: 8
border.color: durationInput.activeFocus ? colorPrimary : colorBorder
border.width: 1
TextInput {
id: durationInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
font.pixelSize: 14
color: colorTextPrimary
font.family: "Inter"
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
text: actionConfigDialog.duration.toString()
validator: IntValidator { bottom: 1; top: 300000 }
onTextChanged: {
var val = parseInt(text)
if (!isNaN(val) && val > 0) {
actionConfigDialog.duration = val
}
}
}
}
Text {
text: "ms"
font.pixelSize: 14
font.bold: true
color: colorTextSecondary
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
width: 60
horizontalAlignment: Text.AlignLeft
}
}
Text {
text: "Quick presets:"
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
}
Flow {
width: parent.width
spacing: 7
Repeater {
model: [
{label: "10ms", value: 10},
{label: "25ms", value: 25},
{label: "50ms", value: 50},
{label: "100ms", value: 100},
{label: "250ms", value: 250},
{label: "500ms", value: 500},
{label: "1 sec", value: 1000},
{label: "2 sec", value: 2000}
]
StyledButton {
text: modelData.label
width: 72
height: 32
secondary: true
onClicked: {
actionConfigDialog.duration = modelData.value
durationInput.text = modelData.value.toString()
}
}
}
}
}
}
footer: Item {
width: parent.width
height: 58
Row {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.topMargin: 8
anchors.bottomMargin: 10
spacing: 12
StyledButton {
text: "Cancel"
width: (parent.width - 12) / 2
height: 40
secondary: true
onClicked: actionConfigDialog.close()
}
StyledButton {
text: "Add Action"
width: (parent.width - 12) / 2
height: 40
primary: true
enabled: {
if (actionConfigDialog.actionType === "wait") return true
return actionConfigDialog.selectedButtons.length > 0
}
onClicked: {
var action = {
type: actionConfigDialog.actionType
}
if (actionConfigDialog.actionType !== "wait") {
action.buttons = actionConfigDialog.selectedButtons.slice()
}
if (actionConfigDialog.actionType === "wait" || actionConfigDialog.actionType === "hold" || actionConfigDialog.actionType === "tap") {
action.duration = actionConfigDialog.duration
}
var newActions = customMacroDialog.actions.slice()
newActions.push(action)
customMacroDialog.actions = newActions
actionConfigDialog.close()
}
}
}
}
function open(type) {
actionConfigDialog.actionType = type
actionConfigDialog.selectedButtons = []
actionConfigDialog.duration = 100
durationInput.text = "100"
actionConfigDialog.visible = true
}
Timer {
id: actionConfigMonitor
interval: 50
running: actionConfigDialog.visible && actionConfigDialog.actionType !== "wait"
repeat: true
onTriggered: {
var pressed = backend.get_pressed_buttons()
if (pressed.length > 0) {
actionConfigDialog.selectedButtons = pressed
}
}
}
}
// CONNECTIONS
Connections {
target: backend
function onSteeringChanged(value) {
steerBar.value = value
// Convert 0-1 value to -1 to 1 for direction
steerBar.direction = (value * 2) - 1
}
function onGasChanged(value) {
gasBar.value = value
}
function onBrakeChanged(value) {
brakeBar.value = value
}
function onStatusChanged(text, color) {
appStatusText = text
appStatusColor = color
var c = String(color).toLowerCase()
if (c === "#ef4444" || c === "#f59e0b") {
statusRevertTimer.stop()
} else {
statusRevertTimer.restart()
}
}
function onUpdateProgressChanged(progress) {
inlineUpdateProgress = progress
if (progress > 0 && progress < 100) {
updateInProgress = true
}
}
function onUpdateStatusTextChanged(status) {
inlineUpdateStatus = status
if (updateInProgress) {
appStatusText = status
appStatusColor = colorSuccess
}
}
function onUpdateWindowVisibleChanged(visible) {
updateInProgress = visible
if (!visible) {
inlineUpdateProgress = 0
}
}
function onProfileDescChanged(desc) {
profileDesc.text = desc
}
function onButtonStatusChanged(status) {
buttonStatus.text = status
}
function onAutoStartChanged(enabled) {
autoStartCheckbox.checked = enabled
}
function onStartMinimizedChanged(enabled) {
startMinimizedCheckbox.checked = enabled
}
}
Connections {
target: trayManager
function onHideToTrayEnabledChanged(enabled) {
hideToTrayCheckbox.checked = enabled
}
function onTrayAvailableChanged(available) {
hideToTrayCheckbox.enabled = available
}
}
// CUSTOM COMPONENTS
component SectionCard: Item {
property string title: ""
property string subtitle: ""
property int cardHeight: 100
property bool flat: false
property bool topDivider: false
default property alias content: contentArea.children
width: parent.width
height: (topDivider ? 17 : 0) + (headerRow.visible ? headerRow.height + 12 : 8) + card.height + 16
// Full-bleed divider announcing a new section.
Rectangle {
visible: topDivider
width: parent.width
height: 1
anchors.top: parent.top
color: colorBorder
}
Row {
id: headerRow
visible: title !== ""
anchors.left: parent.left
anchors.leftMargin: 28
anchors.top: parent.top
anchors.topMargin: topDivider ? 16 : 0
spacing: 10
Text {
text: title
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 1.2
font.capitalization: Font.AllUppercase
color: colorTextMuted
font.family: "Inter"
}
Text {
text: subtitle
font.pixelSize: 10
color: colorInactive
font.family: "Inter"
}
}
// Soft borderless group (macOS-settings style): a barely-lighter
// rounded surface holds the rows - grouping without box borders.
Rectangle {
id: card
anchors.top: headerRow.visible ? headerRow.bottom : parent.top
anchors.topMargin: headerRow.visible ? 8 : 0
anchors.left: parent.left
anchors.leftMargin: flat ? 0 : 28
anchors.right: parent.right
anchors.rightMargin: flat ? 0 : 28
height: cardHeight
radius: 12
color: flat ? "transparent" : colorCard
border.width: 0
Item {
id: contentArea
anchors.fill: parent
anchors.leftMargin: flat ? 0 : 16
anchors.rightMargin: flat ? 0 : 16
anchors.topMargin: flat ? 6 : 12
anchors.bottomMargin: flat ? 6 : 12
}
}
}
component IconActionButton: Item {
id: iab
property string svg: ""
property bool active: true
property bool actionEnabled: true
property string tip: ""
property color glyphColor: iab.active ? colorTextPrimary : (iabMouse.containsMouse && iab.actionEnabled ? colorTextSecondary : colorInactive)
property real glyphScale: 0.75
property real strokeW: 2
signal clicked()
width: 28
height: 28
opacity: actionEnabled ? 1.0 : 0.45
ToolTip.visible: iabMouse.containsMouse && tip !== ""
ToolTip.delay: 350
ToolTip.text: tip
Rectangle {
anchors.fill: parent
radius: 7
color: iabMouse.containsMouse && iab.actionEnabled ? colorSurfaceHover : "transparent"
}
Shape {
anchors.centerIn: parent
width: 24
height: 24
scale: iab.glyphScale
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeWidth: iab.strokeW
strokeColor: iab.glyphColor
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
PathSvg { path: iab.svg }
}
}
MouseArea {
id: iabMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: iab.actionEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (iab.actionEnabled) iab.clicked()
}
}
component SettingsRow: Item {
id: srow
property string label: ""
property string value: ""
property color valueColor: colorTextMuted
property bool rowEnabled: true
property bool showChevron: true
signal clicked()
width: parent.width
height: 40
opacity: rowEnabled ? 1.0 : 0.45
Rectangle {
anchors.fill: parent
color: srowMouse.containsMouse && srow.rowEnabled ? colorSurfaceHover : "transparent"
}
Text {
text: srow.label
font.pixelSize: 12
color: colorTextSecondary
anchors.left: parent.left
anchors.leftMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Text {
id: srowChevron
text: "\\u203A"
visible: srow.showChevron
font.pixelSize: 14
color: srowMouse.containsMouse && srow.rowEnabled ? colorTextSecondary : colorInactive
anchors.right: parent.right
anchors.rightMargin: 28
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Text {
text: srow.value
font.pixelSize: 11
color: srow.valueColor
anchors.right: srow.showChevron ? srowChevron.left : parent.right
anchors.rightMargin: srow.showChevron ? 10 : 28
anchors.left: parent.left
anchors.leftMargin: 150
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
MouseArea {
id: srowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: srow.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (srow.rowEnabled) srow.clicked()
}
}
component AxisBar: Item {
property string label: ""
property color barColor: colorPrimary
property real value: 0.0
property real direction: 0.0 // -1 to 1 for steering direction
property var gradientColors: mainWindow.silverGradient
width: parent.width
height: 30
Row {
anchors.fill: parent
spacing: 0
Text {
text: label
font.pixelSize: 12
font.weight: Font.DemiBold
color: colorTextSecondary
width: 70
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item {
width: parent.width - 70 - 40
height: parent.height
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: axisBarBg
anchors.fill: parent
anchors.leftMargin: 5
anchors.rightMargin: 5
height: 10
radius: 5
color: colorSurfaceHover
anchors.verticalCenter: parent.verticalCenter
clip: true
Rectangle {
width: parent.width * Math.max(0, Math.min(1, value))
height: parent.height
radius: 5
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: gradientColors[0].position; color: gradientColors[0].color }
GradientStop { position: gradientColors[1].position; color: gradientColors[1].color }
GradientStop { position: gradientColors[2].position; color: gradientColors[2].color }
}
Behavior on width {
NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
}
}
}
}
Text {
text: Math.round(Math.max(0, Math.min(1, value)) * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 30
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
font.family: "Inter"
}
}
}
component StyledButton: Rectangle {
id: btn
property string text: ""
property bool primary: false
property bool danger: false
property bool secondary: false
property bool hoverEffect: true
property bool hovered: btnMouse.containsMouse && btn.enabled
property int fontSize: 12
signal clicked()
width: 100
height: 34
radius: 7
color: {
if (!enabled) return colorSurface
if (danger) return btn.hovered && hoverEffect ? "#dc2626" : colorError
if (primary) return btn.hovered && hoverEffect ? colorPrimaryHover : colorPrimary
return btn.hovered && hoverEffect ? colorSurfaceHover : colorSurface
}
border.color: colorBorder
border.width: 0
onEnabledChanged: {
if (!enabled) {
opacity = 1.0
}
}
Text {
anchors.centerIn: parent
text: btn.text
font.pixelSize: fontSize
font.weight: (primary || danger) ? Font.DemiBold : Font.Medium
color: !btn.enabled ? colorTextMuted : (danger ? "#fafafa" : (primary ? (darkMode ? "#141414" : "#fafafa") : (btn.hovered && hoverEffect ? colorTextPrimary : colorTextSecondary)))
font.family: "Inter"
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
width: parent.width - 20
}
MouseArea {
id: btnMouse
anchors.fill: parent
enabled: btn.enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onExited: {
btn.opacity = 1.0
}
onClicked: if (btn.enabled) btn.clicked()
onPressed: btn.opacity = hoverEffect ? 0.92 : 1.0
onReleased: btn.opacity = 1.0
onCanceled: {
btn.opacity = 1.0
}
}
Behavior on opacity {
NumberAnimation { duration: 80; easing.type: Easing.OutQuad }
}
}
component StyledComboBox: Rectangle {
id: combo
property var model: []
property int currentIndex: 0
property string currentText: model[currentIndex] || ""
property bool hovered: false
width: 200
height: 38
radius: 7
bottomLeftRadius: popup.visible ? 0 : 7
bottomRightRadius: popup.visible ? 0 : 7
color: popup.visible ? colorSurface : (combo.hovered ? colorSurfaceHover : colorSurface)
border.color: popup.visible ? colorBorderLight : colorBorder
border.width: 1
onEnabledChanged: {
if (!enabled) {
hovered = false
}
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: combo.currentText
font.pixelSize: 12
color: colorTextPrimary
font.family: "Inter"
elide: Text.ElideRight
width: parent.width - 40
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "\\u25BE"
font.pixelSize: 10
color: combo.hovered || popup.visible ? colorTextSecondary : colorTextMuted
rotation: popup.visible ? 180 : 0
Behavior on rotation { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
}
MouseArea {
id: comboMouse
anchors.fill: parent
enabled: combo.enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: combo.hovered = true
onExited: combo.hovered = false
onCanceled: combo.hovered = false
onClicked: popup.visible ? popup.close() : popup.open()
}
// Dropdown extends seamlessly from the box above.
Popup {
id: popup
y: parent.height - 1
width: parent.width
height: Math.min(listView.contentHeight + 8, 300)
padding: 4
enter: Transition {
NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 100 }
}
exit: Transition {
NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: 80 }
}
background: Rectangle {
color: colorSurface
radius: 7
topLeftRadius: 0
topRightRadius: 0
border.color: colorBorderLight
border.width: 1
}
contentItem: ListView {
id: listView
clip: true
model: combo.model
currentIndex: combo.currentIndex
ScrollBar.vertical: ScrollBar {
id: comboScrollBar
width: 5
contentItem: Rectangle {
implicitWidth: 5
radius: 2.5
color: colorBorderLight
opacity: comboScrollBar.active ? 0.8 : 0.3
}
}
delegate: Rectangle {
width: ListView.view.width
height: 32
color: delegateMouse.containsMouse ? colorSurfaceHover : "transparent"
radius: 5
Text {
anchors.left: parent.left
anchors.leftMargin: 12
width: parent.width - 24
anchors.verticalCenter: parent.verticalCenter
text: modelData
font.pixelSize: 12
font.weight: index === combo.currentIndex ? Font.DemiBold : Font.Normal
color: index === combo.currentIndex ? colorTextPrimary : colorTextSecondary
font.family: "Inter"
elide: Text.ElideRight
}
MouseArea {
id: delegateMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
combo.currentIndex = index
popup.close()
}
}
}
}
}
}
component StyledCheckBox: Item {
id: check
property string text: ""
property bool checked: false
property bool enabled: true
signal toggled()
width: checkRow.width
height: 20
Row {
id: checkRow
spacing: 8
Rectangle {
width: 32
height: 18
radius: 9
color: check.checked && check.enabled ? colorPrimary : colorSurfaceHover
border.color: check.enabled ? (check.checked ? colorPrimary : colorBorder) : colorBorder
border.width: 1
anchors.verticalCenter: parent.verticalCenter
opacity: check.enabled ? 1.0 : 0.45
Behavior on color { ColorAnimation { duration: 140 } }
Behavior on border.color { ColorAnimation { duration: 140 } }
Rectangle {
width: 12
height: 12
radius: 6
x: check.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter
color: check.checked ? colorBg : colorTextMuted
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
Behavior on color { ColorAnimation { duration: 140 } }
}
}
Text {
text: check.text
visible: check.text !== ""
font.pixelSize: 11
color: check.enabled ? colorTextSecondary : colorInactive
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
MouseArea {
anchors.fill: parent
cursorShape: check.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (check.enabled) {
check.checked = !check.checked
check.toggled()
}
}
}
}
// UPDATE PROGRESS WINDOW
Window {
id: updateProgressWindow
width: 480
height: 280
visible: false
color: colorBg
modality: Qt.ApplicationModal
flags: Qt.Dialog | Qt.WindowStaysOnTopHint
title: "TrueAxis Update"
property int progressValue: 0
property string statusText: "Initializing update..."
// The update flow lives entirely in the main window's status bar now;
// this window stays hidden and is kept only for compatibility.
Connections {
target: backend
function onUpdateProgressChanged(progress) {
updateProgressWindow.progressValue = progress
}
function onUpdateStatusTextChanged(status) {
updateProgressWindow.statusText = status
}
function onUpdateWindowVisibleChanged(visible) {
updateProgressWindow.visible = false
}
}
Rectangle {
anchors.fill: parent
color: colorBg
Column {
anchors.centerIn: parent
spacing: 32
width: parent.width - 64
// Header
Column {
width: parent.width
spacing: 12
// Icon
Rectangle {
width: 64
height: 64
radius: 32
anchors.horizontalCenter: parent.horizontalCenter
color: colorCard
border.width: 2
border.color: colorPrimary
Text {
anchors.centerIn: parent
text: "R"
font.pixelSize: 36
color: colorPrimary
font.bold: true
}
}
Text {
text: "Updating TrueAxis"
font.pixelSize: 24
font.bold: true
color: colorTextPrimary
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
}
// Progress Section
Column {
width: parent.width
spacing: 16
// Status text
Text {
text: updateProgressWindow.statusText
font.pixelSize: 13
color: colorTextSecondary
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
// Progress bar
Rectangle {
width: parent.width
height: 8
radius: 4
color: colorCard
border.width: 1
border.color: colorBorder
Rectangle {
width: (parent.width - 2) * (updateProgressWindow.progressValue / 100)
height: parent.height - 2
radius: 3
x: 1
y: 1
gradient: Gradient {
GradientStop { position: 0.0; color: "#4a4a4a" }
GradientStop { position: 0.5; color: "#a8a8a8" }
GradientStop { position: 1.0; color: "#6e6e6e" }
}
Behavior on width {
NumberAnimation { duration: 200; easing.type: Easing.OutQuad }
}
}
}
// Percentage
Text {
text: updateProgressWindow.progressValue + "%"
font.pixelSize: 14
font.bold: true
color: colorPrimary
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
}
// Info text
Text {
text: "The update is downloaded from trueaxis.nl, verified, and installed into your TrueAxis folder.\nThe application will restart automatically."
font.pixelSize: 11
color: colorTextMuted
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
font.family: "Inter"
}
}
}
}
}
"""
def create_app_icon_pixmap(size):
"""Render the TrueAxis icon at a given size: a silver steering wheel
on a graphite rounded square, matching the app theme."""
pm = QPixmap(size, size)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
s = float(size)
# Graphite rounded-square background
m = s * 0.03
rect = QRectF(m, m, s - 2 * m, s - 2 * m)
grad = QLinearGradient(0, 0, 0, s)
grad.setColorAt(0.0, QColor("#2e2e2e"))
grad.setColorAt(1.0, QColor("#141414"))
p.setBrush(QBrush(grad))
p.setPen(QPen(QColor("#404040"), max(1.0, s * 0.02)))
corner = s * 0.22
p.drawRoundedRect(rect, corner, corner)
silver = QColor("#dcdcdc")
cx = cy = s / 2.0
# Wheel rim
r = s * 0.30
rim_w = max(1.5, s * 0.075)
p.setPen(QPen(silver, rim_w))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawEllipse(QRectF(cx - r, cy - r, 2 * r, 2 * r))
# Spokes: left, right, down
hub_r = s * 0.085
spoke_w = max(1.2, s * 0.06)
p.setPen(QPen(silver, spoke_w))
for ang_deg in (180.0, 0.0, 90.0):
ang = math.radians(ang_deg)
x1 = cx + hub_r * math.cos(ang)
y1 = cy + hub_r * math.sin(ang)
x2 = cx + (r - rim_w * 0.35) * math.cos(ang)
y2 = cy + (r - rim_w * 0.35) * math.sin(ang)
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
# Hub
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(silver)
p.drawEllipse(QRectF(cx - hub_r, cy - hub_r, 2 * hub_r, 2 * hub_r))
p.end()
return pm
def create_app_icon():
"""Multi-resolution QIcon so the taskbar, alt-tab and tray all stay crisp."""
icon = QIcon()
for sz in (16, 24, 32, 48, 64, 128, 256):
icon.addPixmap(create_app_icon_pixmap(sz))
return icon
class SystemTrayManager(QObject):
"""Manages system tray functionality for TrueAxis"""
hideToTrayEnabledChanged = Signal(bool)
trayAvailableChanged = Signal(bool)
def __init__(self, main_window=None, backend=None):
super().__init__()
self.main_window = main_window
self.backend = backend
self.tray_icon = None
self._hide_to_tray_enabled = False
self._tray_available = False
self.setup_tray()
@Property(bool, notify=hideToTrayEnabledChanged)
def hideToTrayEnabled(self):
return self._hide_to_tray_enabled
@Property(bool, notify=trayAvailableChanged)
def trayAvailable(self):
return self._tray_available
@Slot(bool)
def set_hide_to_tray(self, enabled):
self._hide_to_tray_enabled = enabled
self.hideToTrayEnabledChanged.emit(enabled)
self.save_settings()
def setup_tray(self):
"""Setup system tray icon and menu"""
if QSystemTrayIcon.isSystemTrayAvailable():
self._tray_available = True
self.trayAvailableChanged.emit(True)
# Create tray icon
self.tray_icon = QSystemTrayIcon()
# Create a simple icon for TrueAxis
icon = self.create_trueaxis_icon()
self.tray_icon.setIcon(icon)
self.tray_icon.setToolTip("TrueAxis - Running in background")
# Create tray menu
tray_menu = QMenu()
# Show/Hide action
self.show_action = QAction("Show TrueAxis", tray_menu)
self.show_action.triggered.connect(self.show_main_window)
tray_menu.addAction(self.show_action)
tray_menu.addSeparator()
# Exit action
exit_action = QAction("Exit", tray_menu)
exit_action.triggered.connect(self.quit_application)
tray_menu.addAction(exit_action)
self.tray_icon.setContextMenu(tray_menu)
# Connect tray icon click
self.tray_icon.activated.connect(self.on_tray_activated)
else:
self._tray_available = False
self.trayAvailableChanged.emit(False)
print("System tray is not available on this system")
def create_trueaxis_icon(self):
"""Create a TrueAxis tray icon"""
return create_app_icon()
@Slot()
def show_main_window(self):
"""Show the main window from tray"""
if self.main_window:
self.main_window.show()
self.main_window.raise_()
self.main_window.requestActivate()
self.hide_tray()
if self.show_action:
self.show_action.setText("Show TrueAxis")
@Slot()
def hide_main_window(self):
"""Hide the main window to tray"""
if self.main_window:
self.main_window.hide()
@Slot()
def show_tray(self):
"""Show the tray icon"""
if self.tray_icon and self._tray_available:
self.tray_icon.show()
@Slot()
def hide_tray(self):
"""Hide the tray icon"""
if self.tray_icon:
self.tray_icon.hide()
def on_tray_activated(self, reason):
"""Handle tray icon activation"""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.show_main_window()
elif reason == QSystemTrayIcon.ActivationReason.Trigger:
pass # Single click - do nothing
def quit_application(self):
"""Quit the application"""
app = QApplication.instance()
if app:
app.quit()
@Slot()
def save_settings(self):
"""Save tray settings"""
# Don't save during backend initialization
if self.backend and hasattr(self.backend, '_initializing') and self.backend._initializing:
return
try:
settings = _load_json_with_backup(SETTINGS_FILE) or {}
settings['tray_settings'] = {
'hide_to_tray': self._hide_to_tray_enabled
}
_atomic_json_save(SETTINGS_FILE, settings)
except Exception as e:
print(f"Error saving tray settings: {e}")
def load_settings(self):
"""Load tray settings"""
try:
settings = _load_json_with_backup(SETTINGS_FILE)
if settings and 'tray_settings' in settings:
tray_settings = settings['tray_settings']
self._hide_to_tray_enabled = tray_settings.get('hide_to_tray', False)
self.hideToTrayEnabledChanged.emit(self._hide_to_tray_enabled)
except Exception as e:
print(f"Error loading tray settings: {e}")
class AutoStartManager(QObject):
"""Manages auto-start with Windows functionality"""
def __init__(self):
super().__init__()
self.app_name = "TrueAxis"
if getattr(sys, 'frozen', False):
# PyInstaller executable
self.app_path = os.path.abspath(sys.executable)
self.script_path = None
else:
# Running as Python script - need python.exe + script path
self.app_path = os.path.abspath(sys.executable)
script_candidate = os.path.abspath(sys.argv[0])
self.script_path = script_candidate if os.path.exists(script_candidate) else os.path.abspath(__file__)
def enable_auto_start(self, start_minimized=True):
"""Enable auto-start with Windows"""
try:
if sys.platform == 'win32':
return self._enable_windows_auto_start(start_minimized)
elif sys.platform == 'darwin': # macOS
return self._enable_macos_auto_start(start_minimized)
elif sys.platform == 'linux':
return self._enable_linux_auto_start(start_minimized)
else:
print(f"Auto-start not supported on platform: {sys.platform}")
return False
except Exception as e:
print(f"Error enabling auto-start: {e}")
return False
def disable_auto_start(self):
"""Disable auto-start with Windows"""
try:
if sys.platform == 'win32':
return self._disable_windows_auto_start()
elif sys.platform == 'darwin': # macOS
return self._disable_macos_auto_start()
elif sys.platform == 'linux':
return self._disable_linux_auto_start()
else:
print(f"Auto-start not supported on platform: {sys.platform}")
return False
except Exception as e:
print(f"Error disabling auto-start: {e}")
return False
def is_auto_start_enabled(self):
"""Check if auto-start is enabled"""
try:
if sys.platform == 'win32':
return self._is_windows_auto_start_enabled()
elif sys.platform == 'darwin': # macOS
return self._is_macos_auto_start_enabled()
elif sys.platform == 'linux':
return self._is_linux_auto_start_enabled()
else:
return False
except Exception as e:
print(f"Error checking auto-start: {e}")
return False
def _windows_run_key_path(self):
return r"Software\Microsoft\Windows\CurrentVersion\Run"
def _launch_args(self, start_minimized=True):
args = ["--startup"]
if self.script_path:
args.insert(0, self.script_path)
if start_minimized:
args.append("--minimized")
return args
def _windows_launch_parts(self, start_minimized=True):
args = self._launch_args(start_minimized)
working_dir = os.path.dirname(self.script_path or self.app_path)
return self.app_path, subprocess.list2cmdline(args), working_dir
def _windows_launch_command(self, start_minimized=True):
return subprocess.list2cmdline([self.app_path] + self._launch_args(start_minimized))
def _windows_startup_shortcut_path(self):
try:
import winshell
startup_dir = winshell.startup()
except Exception:
startup_dir = os.path.join(os.getenv("APPDATA", ""), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
return os.path.join(startup_dir, f"{self.app_name}.lnk")
def _command_targets_current_app(self, command):
if not command:
return False
normalized_command = os.path.normcase(str(command).replace("\\", "/"))
target = os.path.normcase(self.app_path.replace("\\", "/"))
return target in normalized_command and os.path.exists(self.app_path)
def _shortcut_targets_current_app(self, shortcut_path):
if not os.path.exists(shortcut_path):
return False
try:
from win32com.client import Dispatch
shell = Dispatch("WScript.Shell")
shortcut = shell.CreateShortCut(shortcut_path)
target_path = os.path.abspath(shortcut.Targetpath or "")
if os.path.normcase(target_path) != os.path.normcase(self.app_path):
return False
if self.script_path and os.path.normcase(self.script_path) not in os.path.normcase(shortcut.Arguments or ""):
return False
return os.path.exists(target_path)
except Exception as e:
print(f"Windows startup shortcut check error: {e}")
return False
def _write_windows_startup_shortcut(self, start_minimized=True):
try:
shortcut_path = self._windows_startup_shortcut_path()
os.makedirs(os.path.dirname(shortcut_path), exist_ok=True)
target, args, working_dir = self._windows_launch_parts(start_minimized)
from win32com.client import Dispatch
shell = Dispatch("WScript.Shell")
shortcut = shell.CreateShortCut(shortcut_path)
shortcut.Targetpath = target
shortcut.Arguments = args
shortcut.WorkingDirectory = working_dir
shortcut.IconLocation = target
shortcut.Description = "Start TrueAxis when Windows starts"
shortcut.save()
return self._shortcut_targets_current_app(shortcut_path)
except Exception as e:
print(f"Windows startup shortcut error: {e}")
return False
def _enable_windows_auto_start(self, start_minimized=True):
"""Enable auto-start on Windows using both Run key and Startup shortcut."""
registry_ok = False
shortcut_ok = False
try:
import winreg
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_SET_VALUE) as reg_key:
reg_value = self._windows_launch_command(start_minimized)
winreg.SetValueEx(reg_key, self.app_name, 0, winreg.REG_SZ, reg_value)
registry_ok = self._is_windows_registry_current()
except Exception as e:
print(f"Windows registry error: {e}")
shortcut_ok = self._write_windows_startup_shortcut(start_minimized)
return registry_ok or shortcut_ok
def _disable_windows_auto_start(self):
"""Disable auto-start on Windows"""
registry_ok = False
shortcut_ok = False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_SET_VALUE) as reg_key:
try:
winreg.DeleteValue(reg_key, self.app_name)
except FileNotFoundError:
pass
registry_ok = not self._is_windows_registry_current()
except Exception as e:
print(f"Windows registry error: {e}")
try:
shortcut_path = self._windows_startup_shortcut_path()
if os.path.exists(shortcut_path):
os.remove(shortcut_path)
shortcut_ok = not os.path.exists(shortcut_path)
except Exception as e:
print(f"Windows startup shortcut removal error: {e}")
return registry_ok and shortcut_ok
def _is_windows_registry_current(self):
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_READ) as reg_key:
value, _ = winreg.QueryValueEx(reg_key, self.app_name)
return self._command_targets_current_app(value)
except FileNotFoundError:
return False
except OSError:
return False
except Exception as e:
print(f"Windows registry check error: {e}")
return False
def _is_windows_auto_start_enabled(self):
"""Check if auto-start is enabled on Windows"""
return self._is_windows_registry_current() or self._shortcut_targets_current_app(self._windows_startup_shortcut_path())
def _enable_macos_auto_start(self, start_minimized=True):
"""Enable auto-start on macOS using launchd"""
try:
launchd_dir = Path.home() / "Library" / "LaunchAgents"
launchd_dir.mkdir(parents=True, exist_ok=True)
program_arguments = [self.app_path] + self._launch_args(start_minimized)
plist_content = {
'Label': f'com.trueaxis.{self.app_name.lower()}',
'ProgramArguments': program_arguments,
'RunAtLoad': True,
'KeepAlive': False,
'WorkingDirectory': os.path.dirname(self.script_path or self.app_path),
}
plist_path = launchd_dir / f'com.trueaxis.{self.app_name.lower()}.plist'
with open(plist_path, 'wb') as f:
plistlib.dump(plist_content, f)
# Load the launch agent
subprocess.run(['launchctl', 'load', str(plist_path)])
return True
except Exception as e:
print(f"macOS launchd error: {e}")
return False
def _disable_macos_auto_start(self):
"""Disable auto-start on macOS"""
try:
plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
if plist_path.exists():
# Unload the launch agent
subprocess.run(['launchctl', 'unload', str(plist_path)])
plist_path.unlink()
return True
except Exception as e:
print(f"macOS launchd error: {e}")
return False
def _is_macos_auto_start_enabled(self):
"""Check if auto-start is enabled on macOS"""
try:
plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
return plist_path.exists()
except Exception:
return False
def _enable_linux_auto_start(self, start_minimized=True):
"""Enable auto-start on Linux using .desktop file"""
try:
autostart_dir = Path.home() / ".config" / "autostart"
autostart_dir.mkdir(parents=True, exist_ok=True)
exec_cmd = subprocess.list2cmdline([self.app_path] + self._launch_args(start_minimized))
desktop_content = f"""[Desktop Entry]
Type=Application
Name={self.app_name}
Exec={exec_cmd}
Comment=TrueAxis Application
Categories=Utility;
StartupNotify=false
Terminal=false
Hidden=false
"""
desktop_path = autostart_dir / f"{self.app_name.lower()}.desktop"
with open(desktop_path, 'w') as f:
f.write(desktop_content)
# Make it executable
os.chmod(desktop_path, 0o755)
return True
except Exception as e:
print(f"Linux autostart error: {e}")
return False
def _disable_linux_auto_start(self):
"""Disable auto-start on Linux"""
try:
desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
if desktop_path.exists():
desktop_path.unlink()
return True
except Exception as e:
print(f"Linux autostart error: {e}")
return False
def _is_linux_auto_start_enabled(self):
"""Check if auto-start is enabled on Linux"""
try:
desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
return desktop_path.exists()
except Exception:
return False
class DeviceSlot:
"""Represents a single device-to-virtual-gamepad mapping slot.
Each slot independently maps one physical device (or a combined pair) to one virtual Xbox controller."""
def __init__(self, slot_id=0):
self.slot_id = slot_id
self.joystick = None
self.vg_gamepad = None
self.input_reader = None
self.mapper_thread = None
self.profile = "Logitech G29 (Standard)"
self.button_mapping = {}
self.axis_values = []
self.button_states = []
self.running = False
self.device_name = ""
self.device_index = -1
# Per-slot settings
self.invert_gas = False
self.invert_brake = False
self.square_input = False
self.square_area = 0.5
self.deadzone_enabled = False
self.deadzone = 0.0
self.sensitivity_curve = "linear"
self.steering_range = 900
self.center_spring = 0.0
self.center_spring_width = 0.05
self.center_spring_ramp = False
self.center_spring_ramp_width = 5.0
self.neutral_steering_button = -1
self.steering_action_button = -1
self.steering_action_max_percent = 0
self.steering_action_mode = "hold"
self.steering_action_toggle_active = False
self.steering_action_last_pressed = False
# Combined device mode: secondary device feeds pedal axes into this slot's virtual gamepad
self.combined_mode = False
self.combined_pedal_device_name = ""
self.combined_pedal_joystick = None
self.combined_pedal_input_reader = None
def to_dict(self):
"""Serialize slot configuration for saving"""
return {
'device_name': self.device_name,
'device_index': self.device_index,
'profile': self.profile,
'button_mapping': self.button_mapping,
'invert_gas': self.invert_gas,
'invert_brake': self.invert_brake,
'square_input': self.square_input,
'square_area': self.square_area,
'deadzone_enabled': self.deadzone_enabled,
'deadzone': self.deadzone,
'sensitivity_curve': self.sensitivity_curve,
'steering_range': self.steering_range,
'center_spring': self.center_spring,
'center_spring_width': self.center_spring_width,
'center_spring_ramp': self.center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
'center_spring_ramp_width': self.center_spring_ramp_width,
'neutral_steering_button': self.neutral_steering_button,
'steering_action_button': self.steering_action_button,
'steering_action_max_percent': self.steering_action_max_percent,
'steering_action_mode': self.steering_action_mode,
'combined_mode': self.combined_mode,
'combined_pedal_device_name': self.combined_pedal_device_name,
}
def from_dict(self, data):
"""Load slot configuration from saved data"""
self.device_name = data.get('device_name', '')
self.device_index = data.get('device_index', -1)
self.profile = data.get('profile', 'Logitech G29 (Standard)')
self.button_mapping = data.get('button_mapping', {})
self.invert_gas = data.get('invert_gas', False)
self.invert_brake = data.get('invert_brake', False)
self.square_input = data.get('square_input', False)
self.square_area = data.get('square_area', 0.5)
self.deadzone_enabled = data.get('deadzone_enabled', False)
self.deadzone = data.get('deadzone', 0.0)
self.sensitivity_curve = data.get('sensitivity_curve', 'linear')
self.steering_range = data.get('steering_range', 900)
self.center_spring = data.get('center_spring', 0.0)
self.center_spring_width = data.get('center_spring_width', 0.05)
self.center_spring_ramp = data.get('center_spring_ramp', False) if SOFT_RAMP_AVAILABLE else False
self.center_spring_ramp_width = data.get('center_spring_ramp_width', 5.0)
self.neutral_steering_button = int(data.get('neutral_steering_button', -1) or -1)
self.steering_action_button = int(data.get('steering_action_button', -1) or -1)
self.steering_action_max_percent = max(0, min(100, int(data.get('steering_action_max_percent', 0) or 0)))
mode = str(data.get('steering_action_mode', 'hold') or 'hold').lower()
self.steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
self.steering_action_toggle_active = False
self.steering_action_last_pressed = False
self.combined_mode = data.get('combined_mode', False)
self.combined_pedal_device_name = data.get('combined_pedal_device_name', '')
class InputReader(QThread):
"""Separate thread for reading input to prevent UI lag"""
axisValuesChanged = Signal(list)
buttonStatesChanged = Signal(list)
deviceDisconnected = Signal()
def __init__(self):
super().__init__()
self.joystick = None
self._running = True
self._consecutive_errors = 0
self._lock = threading.Lock()
self._axis_values = []
self._button_states = []
self._last_signal_emit_at = 0.0
self._last_emitted_buttons = []
def set_joystick(self, joystick):
with self._lock:
self.joystick = joystick
self._axis_values = []
self._button_states = []
self._consecutive_errors = 0
def snapshot(self):
with self._lock:
return list(self._axis_values), list(self._button_states)
def run(self):
while self._running:
try:
with self._lock:
joystick = self.joystick
if joystick:
pygame.event.pump()
# Read axis values
axis_values = []
num_axes = joystick.get_numaxes()
for i in range(num_axes):
try:
raw = joystick.get_axis(i)
axis_values.append((raw + 1) / 2)
except Exception:
axis_values.append(0.5)
# Read button states
button_states = []
num_buttons = joystick.get_numbuttons()
for i in range(num_buttons):
try:
button_states.append(bool(joystick.get_button(i)))
except Exception:
button_states.append(False)
with self._lock:
self._axis_values = axis_values
self._button_states = button_states
now = time.time()
should_emit = (
now - self._last_signal_emit_at >= (1.0 / 60.0)
or button_states != self._last_emitted_buttons
)
if should_emit:
self.axisValuesChanged.emit(axis_values)
self.buttonStatesChanged.emit(button_states)
self._last_signal_emit_at = now
self._last_emitted_buttons = list(button_states)
self._consecutive_errors = 0
# Small delay to prevent CPU overload
time.sleep(INPUT_READER_POLL_SECONDS)
except (pygame.error, OSError) as e:
# Device likely disconnected
self._consecutive_errors += 1
if self._consecutive_errors >= 3:
print(f"Device disconnected: {e}")
self.deviceDisconnected.emit()
with self._lock:
self.joystick = None
self._axis_values = []
self._button_states = []
self._consecutive_errors = 0
time.sleep(1)
except Exception as e:
print(f"Error in input reader: {e}")
self._consecutive_errors += 1
if self._consecutive_errors >= 5:
self.deviceDisconnected.emit()
with self._lock:
self.joystick = None
self._axis_values = []
self._button_states = []
self._consecutive_errors = 0
time.sleep(1)
def stop(self):
self._running = False
if self.isRunning():
self.wait(1500)
class TrueAxisBackend(QObject):
devicesChanged = Signal(list)
profileDescChanged = Signal(str)
currentProfileChanged = Signal(str)
currentDeviceIndexChanged = Signal(int)
invertGasChanged = Signal(bool)
invertBrakeChanged = Signal(bool)
squareInputChanged = Signal(bool)
squareAreaChanged = Signal(float)
deadzoneEnabledChanged = Signal(bool)
deadzoneChanged = Signal(float)
steeringChanged = Signal(float)
gasChanged = Signal(float)
brakeChanged = Signal(float)
statusChanged = Signal(str, str)
isRunningChanged = Signal(bool)
buttonStatusChanged = Signal(str)
axisValuesChanged = Signal()
buttonStatesChanged = Signal()
numAxesChanged = Signal()
numButtonsChanged = Signal()
buttonMappingsChanged = Signal() # Signal when button mappings are loaded/changed
autoStartChanged = Signal(bool)
startMinimizedChanged = Signal(bool)
updateAvailableChanged = Signal(bool)
vigEmInstalledChanged = Signal(bool)
vigEmStatusChanged = Signal(str)
currentVersionChanged = Signal()
updateProgressChanged = Signal(int) # Update progress percentage
updateStatusTextChanged = Signal(str) # Update status message
updateWindowVisibleChanged = Signal(bool) # Show/hide update window
sensitivityCurveChanged = Signal(str) # Sensitivity curve type
steeringRangeChanged = Signal(int) # Steering range in degrees
centerSpringChanged = Signal(float) # Center spring strength 0-1
centerSpringWidthChanged = Signal(float) # Center spring dead zone width 0-1
centerSpringRampChanged = Signal(bool) # Center spring ramp enabled
centerSpringRampWidthChanged = Signal(float) # Center spring ramp width in degrees
sessionTimeChanged = Signal(str) # Session timer display
exportStatusChanged = Signal(str) # Export/import status
deviceDisconnected = Signal() # Device was disconnected
deviceSlotsChanged = Signal() # Multi-device slot list changed
activeSlotIndexChanged = Signal(int) # Active UI slot changed
macrosChanged = Signal() # Emitted when macros list changes
macroRecordingChanged = Signal(bool)
macroPlayingChanged = Signal(bool)
themeModeChanged = Signal(str)
hardwareReportStatusChanged = Signal(str, str)
neutralSteeringButtonChanged = Signal()
steeringActionChanged = Signal()
def __init__(self):
super().__init__()
self._running = False
self._devices = ["Scanning devices..."]
self._current_device_index = 0
self._current_profile = "Logitech G29 (Standard)"
self._invert_gas = False
self._invert_brake = False
self._square_input = False
self._square_area = 0.5 # Default: 50% square area
self._deadzone_enabled = False # Default: deadzone disabled
self._deadzone = 0.0 # Default: 0% deadzone
self._auto_start = False
self._start_minimized = False
self._launched_from_startup = False
self._initializing = True # Flag to prevent saving during initialization
self._update_available = False
self._latest_version = CURRENT_VERSION
self._current_version = CURRENT_VERSION
self._new_version = "" # Initialize the new version string
self._update_manifest = None
self._vigem_installed = True # Will be checked properly after pygame init
self._sensitivity_curve = "linear" # linear, smooth, or aggressive
self._steering_range = 900 # Steering range in degrees (default 900 = full lock-to-lock)
self._center_spring = 0.0 # Center spring strength (0 = off, 1 = max)
self._center_spring_width = 0.05 # Center spring zone width (fraction of total range)
self._center_spring_ramp = False # Enable hardware spring ramp near steering lock
self._center_spring_ramp_width = 5.0 # Ramp zone width in degrees
self._neutral_steering_button = -1
self._steering_action_button = -1
self._steering_action_max_percent = 0
self._steering_action_mode = "hold"
self._steering_action_toggle_active = False
self._steering_action_last_pressed = False
self._last_hardware_spring = -1.0 # Track last sent spring to avoid flooding HID
self._last_hardware_spring_sent_at = 0.0
self._hardware_steering_range_active = False
self._last_hardware_range = None
self._hardware_range_test_log = []
self._theme_mode = "light"
self._install_id = _get_or_create_install_id()
self._profile_source = "default"
self._update_check_in_progress = False
self._last_update_check_at = 0.0
self._session_start_time = None # Track when emulation started
self._session_total_seconds = 0 # Total session time
# Macro system
self._macros = {}
self._macro_recording = False
self._macro_record_name = ""
self._macro_record_data = []
self._macro_record_start_time = 0
self._macro_playing = False
self._macro_play_thread = None
self._macro_triggers = {}
self._active_macro_buttons = set()
self._pressed_macro_keys = set()
self._macro_play_lock = threading.Lock()
self.mapper_thread = None
self.input_reader = None
self.vg_gamepad = None
self.joystick = None
self.button_mapping = {}
self._axis_values = []
self._button_states = []
self._last_preview_values = [None, None, None]
# Track user-selected profiles per device (device_name -> profile_name)
# When a user manually changes a profile, we store it here to prevent auto-detection
self._user_profile_overrides = {}
# Track user-modified button mappings per device (device_name -> {button_index: xbox_button})
# When a user manually changes button mappings, we store them here per device
self._user_button_overrides = {}
# Track which devices have been explicitly configured by the user (device_name -> True)
# This distinguishes between "never configured" and "configured but all disabled"
self._user_configured_devices = {}
# Flag to prevent marking auto-loaded button mappings as user overrides
self._loading_button_mappings = False
# Multi-device slots (max 4). Slot 0 is the primary (backward compatible) slot.
self._device_slots = [DeviceSlot(slot_id=0)]
self._active_slot_index = 0 # Which slot the UI is currently editing
self._extra_input_readers = [] # Extra InputReaders for additional slots
self._extra_mapper_threads = [] # Extra mapper threads for additional slots
self._last_mapping_error_at = 0.0
self._last_process_input_error_at = 0.0
# Initialize keyboard controller if available
if KEYBOARD_AVAILABLE:
self.keyboard = KeyboardController()
self._pressed_keys = set() # Track currently pressed keyboard keys
else:
self.keyboard = None
self._pressed_keys = set()
# Initialize auto-start manager
self.auto_start_manager = AutoStartManager()
# Initialize only the pygame subsystems needed for controller polling.
# Full pygame.init() also touches audio and other modules, which can add
# unnecessary startup cost.
if not pygame.display.get_init():
pygame.display.init()
if not pygame.joystick.get_init():
pygame.joystick.init()
# Load configuration
self.load_button_mapping()
self.load_settings()
self.load_macros()
# Create input reader thread
self.input_reader = InputReader()
self.input_reader.axisValuesChanged.connect(self.update_axis_values)
self.input_reader.buttonStatesChanged.connect(self.update_button_states)
self.input_reader.deviceDisconnected.connect(self._on_device_disconnected)
QTimer.singleShot(200, self._start_input_reader)
# Reconnect timer for auto-reconnection after device disconnect
self._reconnect_timer = QTimer()
self._reconnect_timer.timeout.connect(self._attempt_reconnect)
self._reconnect_device_name = None
# Initialize tray manager
self.tray_manager = SystemTrayManager(backend=self)
self.tray_manager.load_settings()
# Start update timer for processing inputs
self.update_timer = QTimer()
self.update_timer.timeout.connect(self.process_inputs)
self.update_timer.start(16) # ~60 FPS for smooth updates
# Scan devices after QML has had a chance to paint the first window.
QTimer.singleShot(100, self.refresh_devices)
# Restore saved device selection after a short delay (to let devices enumerate)
if self._current_device_index >= 0:
QTimer.singleShot(900, self.restore_device_selection)
# Check ViGEm installation status
QTimer.singleShot(1000, self.update_vigem_status)
# Check for updates on startup (after 3 seconds)
QTimer.singleShot(3000, self.check_for_updates)
QTimer.singleShot(5200, self._send_startup_telemetry)
self.update_check_timer = QTimer()
self.update_check_timer.timeout.connect(lambda: self._check_for_updates(quiet=True))
self.update_check_timer.start(UPDATE_CHECK_INTERVAL_MS)
self.telemetry_heartbeat_timer = QTimer()
self.telemetry_heartbeat_timer.timeout.connect(self._send_presence_telemetry)
self.telemetry_heartbeat_timer.start(TELEMETRY_HEARTBEAT_INTERVAL_MS)
self.hardware_reassert_timer = QTimer()
self.hardware_reassert_timer.timeout.connect(self._reassert_hardware_controls)
self.hardware_reassert_timer.start(HARDWARE_REASSERT_INTERVAL_MS)
QTimer.singleShot(1600, lambda: self._reassert_hardware_controls(pulse=True))
QTimer.singleShot(1800, self._consume_update_state)
# Mark initialization as complete after a delay (to let QML finish loading)
QTimer.singleShot(1000, lambda: setattr(self, '_initializing', False))
def _start_input_reader(self):
"""Start the polling worker after the first UI paint."""
try:
if self.input_reader and not self.input_reader.isRunning():
self.input_reader.start()
except Exception as e:
print(f"Could not start input reader: {e}")
@Property(bool, notify=isRunningChanged)
def running(self):
return self._running
@Property(list, notify=devicesChanged)
def devices(self):
return self._devices
@Property(list, constant=True)
def profileNames(self):
return list(PROFILES.keys())
@Property(str, notify=currentProfileChanged)
def current_profile(self):
return self._current_profile
@Property(int, notify=currentDeviceIndexChanged)
def current_device_index(self):
return self._current_device_index
@Property(bool, notify=invertGasChanged)
def invert_gas(self):
return self._invert_gas
@Property(bool, notify=invertBrakeChanged)
def invert_brake(self):
return self._invert_brake
@Property(bool, notify=squareInputChanged)
def square_input(self):
return self._square_input
@Property(float, notify=squareAreaChanged)
def square_area(self):
return self._square_area
@Property(bool, notify=deadzoneEnabledChanged)
def deadzone_enabled(self):
return self._deadzone_enabled
@Property(float, notify=deadzoneChanged)
def deadzone(self):
return self._deadzone
@Property(list, constant=True)
def xboxButtonNames(self):
return XBOX_BUTTON_NAMES
@Property(bool, constant=True)
def keyboardAvailable(self):
return KEYBOARD_AVAILABLE
@Property(str, notify=currentVersionChanged)
def currentVersion(self):
return self._current_version
@Property(str, notify=updateAvailableChanged)
def latestVersion(self):
return self._latest_version
@Property(bool, notify=updateAvailableChanged)
def updateAvailable(self):
return self._update_available
@Property(bool, notify=vigEmInstalledChanged)
def vigEmInstalled(self):
return self._vigem_installed
@Property(int, notify=numAxesChanged)
def numAxes(self):
if self.joystick:
try:
return self.joystick.get_numaxes()
except Exception:
pass
return 0
@Property(int, notify=numButtonsChanged)
def numButtons(self):
if self.joystick:
try:
return self.joystick.get_numbuttons()
except Exception:
pass
return 0
@Property(str, notify=currentVersionChanged)
def currentVersion(self):
return self._current_version
@Property(bool, notify=updateAvailableChanged)
def updateAvailable(self):
return self._update_available
@Property(str, notify=updateAvailableChanged)
def newVersion(self):
return self._latest_version
@Property(str, notify=sensitivityCurveChanged)
def sensitivityCurve(self):
return self._sensitivity_curve
@Property(str, notify=themeModeChanged)
def themeMode(self):
return self._theme_mode
@Property(int, notify=steeringRangeChanged)
def steeringRange(self):
return self._steering_range
@Property(float, notify=centerSpringChanged)
def centerSpring(self):
return self._center_spring
@Property(float, notify=centerSpringWidthChanged)
def centerSpringWidth(self):
return self._center_spring_width
@Property(bool, notify=centerSpringRampChanged)
def centerSpringRamp(self):
return self._center_spring_ramp
@Property(float, notify=centerSpringRampWidthChanged)
def centerSpringRampWidth(self):
return self._center_spring_ramp_width
@Property(int, notify=neutralSteeringButtonChanged)
def neutralSteeringButton(self):
return self._neutral_steering_button
@Property(str, notify=neutralSteeringButtonChanged)
def neutralSteeringButtonLabel(self):
if self._neutral_steering_button < 0:
return "Not set"
return self.get_button_name(self._neutral_steering_button)
@Property(int, notify=steeringActionChanged)
def steeringActionButton(self):
return self._steering_action_button
@Property(str, notify=steeringActionChanged)
def steeringActionButtonLabel(self):
if self._steering_action_button < 0:
return "Not set"
return self.get_button_name(self._steering_action_button)
@Property(int, notify=steeringActionChanged)
def steeringActionMaxPercent(self):
return int(self._steering_action_max_percent)
@Property(str, notify=steeringActionChanged)
def steeringActionMode(self):
return self._steering_action_mode
@Property(bool, notify=steeringActionChanged)
def steeringActionActive(self):
return bool(self._steering_action_toggle_active)
@Property(str, notify=sessionTimeChanged)
def sessionTime(self):
if self._session_start_time and self._running:
elapsed = int(time.time() - self._session_start_time)
hours = elapsed // 3600
minutes = (elapsed % 3600) // 60
seconds = elapsed % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
return "0:00"
@Property(list, constant=True)
def sensitivityCurveOptions(self):
return ["Linear", "Smooth", "Aggressive", "S-Curve"]
@Slot(int, result=float)
def getAxisValue(self, index):
try:
if index < len(self._axis_values):
value = float(self._axis_values[index])
# Ensure value is a valid number
if not (value >= 0 and value <= 1):
value = max(0.0, min(1.0, value))
return value
except (ValueError, TypeError):
pass
return 0.5
@Slot(int, result=bool)
def getButtonState(self, index):
if index < len(self._button_states):
return bool(self._button_states[index])
return False
@Slot(int)
def set_neutral_steering_button(self, button_index):
try:
button_index = int(button_index)
except (TypeError, ValueError):
button_index = -1
if button_index < 0:
self.clear_neutral_steering_button()
return
self._neutral_steering_button = button_index
self.neutralSteeringButtonChanged.emit()
self.save_settings()
self.statusChanged.emit(f"Neutral steering button set to {self.get_button_name(button_index)}", "#22c55e")
@Slot()
def clear_neutral_steering_button(self):
self._neutral_steering_button = -1
self.neutralSteeringButtonChanged.emit()
self.save_settings()
self.statusChanged.emit("Neutral steering button cleared", "#71717a")
@Slot(int)
def set_steering_action_button(self, button_index):
try:
button_index = int(button_index)
except (TypeError, ValueError):
button_index = -1
if button_index < 0:
self.clear_steering_action_button()
return
self._steering_action_button = button_index
self._reset_steering_action_toggle()
self.steeringActionChanged.emit()
self.save_settings()
self.statusChanged.emit(f"Steering limit button set to {self.get_button_name(button_index)}", "#22c55e")
@Slot()
def clear_steering_action_button(self):
self._steering_action_button = -1
self._reset_steering_action_toggle()
self.steeringActionChanged.emit()
self.save_settings()
self.statusChanged.emit("Steering limit cleared", "#71717a")
@Slot(int)
def set_steering_action_max_percent(self, value):
try:
value = int(round(float(value)))
except (TypeError, ValueError):
value = 0
self._steering_action_max_percent = max(0, min(100, value))
self.steeringActionChanged.emit()
self.save_settings()
@Slot(str)
def set_steering_action_mode(self, mode):
mode = str(mode or "hold").strip().lower()
if mode not in ("hold", "toggle"):
mode = "hold"
self._steering_action_mode = mode
self._reset_steering_action_toggle()
self.steeringActionChanged.emit()
self.save_settings()
@Slot(int, result=str)
def get_button_mapping(self, button_index):
return self.button_mapping.get(str(button_index), "DISABLED")
@Slot(int, str)
def set_button_mapping(self, button_index, xbox_button):
print(f"[BUTTON CONFIG] set_button_mapping called: btn={button_index}, value={xbox_button}, loading_flag={self._loading_button_mappings}")
# Ignore section headers
if xbox_button in ["--- XBOX BUTTONS ---", "--- KEYBOARD KEYS ---"]:
return
if xbox_button == "DISABLED":
if str(button_index) in self.button_mapping:
del self.button_mapping[str(button_index)]
else:
self.button_mapping[str(button_index)] = xbox_button
print(f"[BUTTON CONFIG] Current button_mapping after change: {self.button_mapping}")
# Only mark as user override if not auto-loading
if not self._loading_button_mappings:
# Mark this as a user override for the current device
if self.joystick:
device_name = self.joystick.get_name()
# Store the current button mapping state for this device
self._user_button_overrides[device_name] = self.button_mapping.copy()
# Mark this device as explicitly configured by the user
self._user_configured_devices[device_name] = True
print(f"[BUTTON CONFIG] User modified button mapping for device '{device_name}'")
print(f"[BUTTON CONFIG] Stored in _user_button_overrides: {self._user_button_overrides[device_name]}")
# Auto-save button mapping changes (only if not during initialization and not auto-loading)
if not self._loading_button_mappings:
self.save_button_mapping()
# Persist the per-device override markers immediately as well.
# The app usually dies with the Windows session (tray app), so
# waiting for a clean exit would lose these on PC shutdown.
self.save_settings(force=True)
@Slot()
def save_button_mapping(self, force=False):
# Don't save during initialization to prevent overwriting loaded settings
if not force and hasattr(self, '_initializing') and self._initializing:
return
try:
_atomic_json_save(BUTTON_MAPPING_FILE, self.button_mapping)
self.update_button_status()
except Exception as e:
print(f"Error saving button mapping: {e}")
def load_button_mapping(self):
try:
self._loading_button_mappings = True
data = _load_json_with_backup(BUTTON_MAPPING_FILE)
if data is not None:
self.button_mapping = data
self.update_button_status()
self._loading_button_mappings = False
except Exception as e:
self._loading_button_mappings = False
print(f"Error loading button mapping: {e}")
def load_settings(self):
"""Load application settings"""
try:
settings = _load_json_with_backup(SETTINGS_FILE)
if settings is None:
return
# Load auto-start setting
if 'auto_start' in settings:
self._auto_start = settings['auto_start']
self.autoStartChanged.emit(self._auto_start)
# Load start minimized setting
if 'start_minimized' in settings:
self._start_minimized = settings['start_minimized']
self.startMinimizedChanged.emit(self._start_minimized)
# Repair startup commands after the first window is visible. When
# startup is disabled, defer cleanup so app launch stays snappy.
QTimer.singleShot(1500 if self._auto_start else 12000, self.apply_auto_start_setting)
# Load user profile overrides
if 'user_profile_overrides' in settings:
self._user_profile_overrides = settings['user_profile_overrides']
print(f"Loaded user profile overrides: {self._user_profile_overrides}")
# Load user button overrides
if 'user_button_overrides' in settings:
self._user_button_overrides = settings['user_button_overrides']
print(f"Loaded user button overrides for {len(self._user_button_overrides)} devices")
# Load user configured devices tracking
if 'user_configured_devices' in settings:
self._user_configured_devices = settings['user_configured_devices']
print(f"Loaded user configured devices: {list(self._user_configured_devices.keys())}")
# Load other settings
if 'invert_gas' in settings:
self._invert_gas = settings['invert_gas']
self.invertGasChanged.emit(self._invert_gas)
if 'invert_brake' in settings:
self._invert_brake = settings['invert_brake']
self.invertBrakeChanged.emit(self._invert_brake)
if 'square_input' in settings:
self._square_input = settings['square_input']
self.squareInputChanged.emit(self._square_input)
if 'square_area' in settings:
self._square_area = settings['square_area']
self.squareAreaChanged.emit(self._square_area)
if 'deadzone_enabled' in settings:
self._deadzone_enabled = settings['deadzone_enabled']
self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
if 'deadzone' in settings:
self._deadzone = settings['deadzone']
self.deadzoneChanged.emit(self._deadzone)
if 'sensitivity_curve' in settings:
self._sensitivity_curve = settings['sensitivity_curve']
self.sensitivityCurveChanged.emit(self._sensitivity_curve)
if 'theme_mode' in settings:
theme_mode = str(settings['theme_mode']).strip().lower()
self._theme_mode = theme_mode if theme_mode in ("light", "dark") else "light"
self.themeModeChanged.emit(self._theme_mode)
if 'steering_range' in settings:
self._steering_range = settings['steering_range']
self.steeringRangeChanged.emit(self._steering_range)
if 'center_spring' in settings:
self._center_spring = settings['center_spring']
self.centerSpringChanged.emit(self._center_spring)
if 'center_spring_width' in settings:
self._center_spring_width = settings['center_spring_width']
self.centerSpringWidthChanged.emit(self._center_spring_width)
if 'center_spring_ramp' in settings:
self._center_spring_ramp = settings['center_spring_ramp'] if SOFT_RAMP_AVAILABLE else False
self.centerSpringRampChanged.emit(self._center_spring_ramp)
if 'center_spring_ramp_width' in settings:
self._center_spring_ramp_width = settings['center_spring_ramp_width']
self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
if 'neutral_steering_button' in settings:
self._neutral_steering_button = int(settings.get('neutral_steering_button', -1) or -1)
self.neutralSteeringButtonChanged.emit()
if 'steering_action_button' in settings:
self._steering_action_button = int(settings.get('steering_action_button', -1) or -1)
self.steeringActionChanged.emit()
if 'steering_action_max_percent' in settings:
self._steering_action_max_percent = max(0, min(100, int(settings.get('steering_action_max_percent', 0) or 0)))
self.steeringActionChanged.emit()
if 'steering_action_mode' in settings:
mode = str(settings.get('steering_action_mode', 'hold') or 'hold').lower()
self._steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
self.steeringActionChanged.emit()
if 'current_profile' in settings:
self._current_profile = settings['current_profile']
self._profile_source = "saved"
self.currentProfileChanged.emit(self._current_profile)
desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
self.profileDescChanged.emit(desc)
if 'current_device_index' in settings:
self._current_device_index = settings['current_device_index']
self.currentDeviceIndexChanged.emit(self._current_device_index)
# Auto-start emulation if auto-start is enabled and device exists
QTimer.singleShot(1000, self.try_auto_start_emulation)
# Load multi-device slots
if 'device_slots' in settings:
slot_data_list = settings['device_slots']
self._device_slots = []
for i, slot_data in enumerate(slot_data_list):
slot = DeviceSlot(slot_id=i)
slot.from_dict(slot_data)
self._device_slots.append(slot)
if not self._device_slots:
self._device_slots = [DeviceSlot(slot_id=0)]
self._active_slot_index = settings.get('active_slot_index', 0)
if 0 <= self._active_slot_index < len(self._device_slots):
self._neutral_steering_button = self._device_slots[self._active_slot_index].neutral_steering_button
self._steering_action_button = self._device_slots[self._active_slot_index].steering_action_button
self._steering_action_max_percent = self._device_slots[self._active_slot_index].steering_action_max_percent
self._steering_action_mode = self._device_slots[self._active_slot_index].steering_action_mode
self._reset_steering_action_toggle()
self.neutralSteeringButtonChanged.emit()
self.steeringActionChanged.emit()
print(f"Loaded {len(self._device_slots)} device slot(s)")
else:
# Migration: create slot 0 from existing single-device settings
slot0 = self._device_slots[0]
slot0.profile = self._current_profile
slot0.button_mapping = self.button_mapping.copy()
slot0.invert_gas = self._invert_gas
slot0.invert_brake = self._invert_brake
slot0.square_input = self._square_input
slot0.square_area = self._square_area
slot0.deadzone_enabled = self._deadzone_enabled
slot0.deadzone = self._deadzone
slot0.sensitivity_curve = self._sensitivity_curve
slot0.neutral_steering_button = self._neutral_steering_button
slot0.steering_action_button = self._steering_action_button
slot0.steering_action_max_percent = self._steering_action_max_percent
slot0.steering_action_mode = self._steering_action_mode
slot0.device_index = self._current_device_index
print("Migrated single-device settings to slot 0")
self.deviceSlotsChanged.emit()
except Exception as e:
print(f"Error loading settings: {e}")
@Slot()
def refresh_ui_from_settings(self):
"""Re-emit all signals to update QML UI with current backend state"""
self.currentProfileChanged.emit(self._current_profile)
desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
self.profileDescChanged.emit(desc)
self.currentDeviceIndexChanged.emit(self._current_device_index)
self.invertGasChanged.emit(self._invert_gas)
self.invertBrakeChanged.emit(self._invert_brake)
self.squareInputChanged.emit(self._square_input)
self.squareAreaChanged.emit(self._square_area)
self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
self.deadzoneChanged.emit(self._deadzone)
self.sensitivityCurveChanged.emit(self._sensitivity_curve)
self.themeModeChanged.emit(self._theme_mode)
self.steeringRangeChanged.emit(self._steering_range)
self.centerSpringChanged.emit(self._center_spring)
self.centerSpringWidthChanged.emit(self._center_spring_width)
if not SOFT_RAMP_AVAILABLE:
self._center_spring_ramp = False
self.centerSpringRampChanged.emit(self._center_spring_ramp)
self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
self.neutralSteeringButtonChanged.emit()
self.steeringActionChanged.emit()
self.autoStartChanged.emit(self._auto_start)
self.startMinimizedChanged.emit(self._start_minimized)
self.update_button_status() # Refresh button mapping status
# Also refresh tray manager settings to update UI
if self.tray_manager:
self.tray_manager.hideToTrayEnabledChanged.emit(self.tray_manager._hide_to_tray_enabled)
def try_auto_start_emulation(self):
"""Try to auto-start emulation with saved device"""
if not getattr(self, '_launched_from_startup', False):
return
if not self._auto_start or not self._start_minimized:
return
try:
# Check if the saved device index is valid
if self._current_device_index < len(self._devices) and self._devices[self._current_device_index] != "No devices found":
# Select the device
self.select_device(self._current_device_index)
# Small delay to ensure device is ready
QTimer.singleShot(500, self.start_mapping)
print(f"Auto-starting emulation with device index {self._current_device_index}")
except Exception as e:
print(f"Failed to auto-start emulation: {e}")
def restore_device_selection(self):
"""Restore the saved device selection after startup"""
try:
# Check if the saved device index is valid
if 0 <= self._current_device_index < len(self._devices):
if self._devices[self._current_device_index] != "No devices found":
# Select the device without saving (to avoid triggering save during load)
self.select_device(self._current_device_index)
print(f"Restored device selection: {self._devices[self._current_device_index]}")
except Exception as e:
print(f"Failed to restore device selection: {e}")
def save_settings(self, force=False):
"""Save application settings"""
# Don't save during initialization to prevent overwriting loaded settings
if not force and hasattr(self, '_initializing') and self._initializing:
return
try:
# Load existing settings to preserve keys we don't manage here (e.g. tray_settings)
settings = _load_json_with_backup(SETTINGS_FILE) or {}
# Sync current UI state back to the active slot before saving
self._sync_slot_from_ui(self._active_slot_index)
settings.update({
'auto_start': self._auto_start,
'start_minimized': self._start_minimized,
'invert_gas': self._invert_gas,
'invert_brake': self._invert_brake,
'square_input': self._square_input,
'square_area': self._square_area,
'deadzone_enabled': self._deadzone_enabled,
'deadzone': self._deadzone,
'sensitivity_curve': self._sensitivity_curve,
'theme_mode': self._theme_mode,
'steering_range': self._steering_range,
'center_spring': self._center_spring,
'center_spring_width': self._center_spring_width,
'center_spring_ramp': self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
'center_spring_ramp_width': self._center_spring_ramp_width,
'neutral_steering_button': self._neutral_steering_button,
'steering_action_button': self._steering_action_button,
'steering_action_max_percent': self._steering_action_max_percent,
'steering_action_mode': self._steering_action_mode,
'current_profile': self._current_profile,
'current_device_index': self._current_device_index,
'user_profile_overrides': self._user_profile_overrides,
'user_button_overrides': self._user_button_overrides,
'user_configured_devices': self._user_configured_devices,
'device_slots': [slot.to_dict() for slot in self._device_slots],
'active_slot_index': self._active_slot_index,
})
_atomic_json_save(SETTINGS_FILE, settings)
except Exception as e:
print(f"Error saving settings: {e}")
# --- Multi-device slot management ---
@Property(int, notify=activeSlotIndexChanged)
def activeSlotIndex(self):
return self._active_slot_index
@Property(int, notify=deviceSlotsChanged)
def slotCount(self):
return len(self._device_slots)
@Slot(result=list)
def getSlotInfo(self):
"""Return a list of slot info dicts for QML"""
info = []
for slot in self._device_slots:
info.append({
'id': slot.slot_id,
'device_name': slot.device_name or "No device",
'profile': slot.profile,
'running': slot.running,
'combined_mode': slot.combined_mode,
'combined_pedal_device': slot.combined_pedal_device_name,
})
return info
@Slot(int)
def setActiveSlot(self, index):
"""Switch the UI to show/edit a different slot"""
if 0 <= index < len(self._device_slots):
# Save current slot state back
self._sync_slot_from_ui(self._active_slot_index)
self._active_slot_index = index
self.activeSlotIndexChanged.emit(index)
# Load new slot state into UI
self._sync_ui_from_slot(index)
@Slot()
def addDeviceSlot(self):
"""Add a new device slot (max 4)"""
if len(self._device_slots) >= 4:
self.statusChanged.emit("Maximum 4 device slots allowed", "#f59e0b")
return
new_id = len(self._device_slots)
slot = DeviceSlot(slot_id=new_id)
self._device_slots.append(slot)
self.deviceSlotsChanged.emit()
self.statusChanged.emit(f"Added device slot {new_id + 1}", "#22c55e")
self.save_settings()
@Slot(int)
def removeDeviceSlot(self, index):
"""Remove a device slot (cannot remove slot 0)"""
if index <= 0 or index >= len(self._device_slots):
return
slot = self._device_slots[index]
# Stop emulation on this slot if running
if slot.running:
self._stop_slot_emulation(slot)
# Clean up
if slot.input_reader:
slot.input_reader.stop()
if slot.combined_pedal_input_reader:
slot.combined_pedal_input_reader.stop()
self._device_slots.pop(index)
# Reindex remaining slots
for i, s in enumerate(self._device_slots):
s.slot_id = i
if self._active_slot_index >= len(self._device_slots):
self._active_slot_index = 0
self.activeSlotIndexChanged.emit(0)
self._sync_ui_from_slot(0)
self.deviceSlotsChanged.emit()
self.save_settings()
@Slot(int, int)
def assignDeviceToSlot(self, slot_index, device_index):
"""Assign a physical device (by device list index) to a specific slot"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
if device_index < 0 or device_index >= len(self._devices):
return
slot = self._device_slots[slot_index]
if slot.running:
self.statusChanged.emit("Stop emulation before changing device", "#f59e0b")
return
slot.device_index = device_index
# Parse device name
try:
slot.device_name = self._devices[device_index].split(" (ID:")[0]
except Exception:
slot.device_name = self._devices[device_index]
# If this is the active slot, also update the main UI
if slot_index == self._active_slot_index:
self.select_device(device_index)
self.deviceSlotsChanged.emit()
self.save_settings()
@Slot(int, bool)
def setSlotCombinedMode(self, slot_index, enabled):
"""Enable/disable combined device mode for a slot"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
self._device_slots[slot_index].combined_mode = enabled
self.deviceSlotsChanged.emit()
self.save_settings()
@Slot(int, int)
def setSlotPedalDevice(self, slot_index, device_index):
"""Set the pedal device for combined mode"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
if device_index < 0 or device_index >= len(self._devices):
return
slot = self._device_slots[slot_index]
try:
slot.combined_pedal_device_name = self._devices[device_index].split(" (ID:")[0]
except Exception:
slot.combined_pedal_device_name = self._devices[device_index]
self.deviceSlotsChanged.emit()
self.save_settings()
@Slot(int)
def startSlotEmulation(self, slot_index):
"""Start emulation on a specific slot"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
slot = self._device_slots[slot_index]
if slot_index == 0:
# Slot 0 uses the main start_mapping flow
if self._active_slot_index != 0:
self._sync_slot_from_ui(self._active_slot_index)
self._sync_ui_from_slot(0)
self.start_mapping()
slot.running = self._running
else:
self._start_extra_slot_emulation(slot)
self.deviceSlotsChanged.emit()
@Slot(int)
def stopSlotEmulation(self, slot_index):
"""Stop emulation on a specific slot"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
slot = self._device_slots[slot_index]
if slot_index == 0:
self.stop_mapping()
slot.running = False
else:
self._stop_slot_emulation(slot)
self.deviceSlotsChanged.emit()
def _start_extra_slot_emulation(self, slot):
"""Start emulation for a non-primary slot"""
if not VIGEM_AVAILABLE or vg is None:
self.statusChanged.emit("ViGEm driver not available", "#ef4444")
return
if slot.device_index < 0:
self.statusChanged.emit(f"Slot {slot.slot_id + 1}: No device assigned", "#ef4444")
return
try:
# Initialize joystick for this slot
device_id = int(self._devices[slot.device_index].split("(ID: ")[1].rstrip(")"))
slot.joystick = pygame.joystick.Joystick(device_id)
slot.joystick.init()
# Create virtual gamepad
slot.vg_gamepad = vg.VX360Gamepad()
# Create input reader for this slot
slot.input_reader = InputReader()
slot.input_reader.set_joystick(slot.joystick)
slot.input_reader.axisValuesChanged.connect(
lambda vals, s=slot: self._update_slot_axes(s, vals))
slot.input_reader.buttonStatesChanged.connect(
lambda states, s=slot: self._update_slot_buttons(s, states))
slot.input_reader.start()
# Handle combined mode - set up pedal device reader
if slot.combined_mode and slot.combined_pedal_device_name:
self._setup_combined_pedal_reader(slot)
# Start mapping thread
self._reset_steering_action_toggle(slot)
slot.running = True
slot.mapper_thread = threading.Thread(
target=self._slot_mapping_loop, args=(slot,), daemon=True)
slot.mapper_thread.start()
self.statusChanged.emit(
f"Slot {slot.slot_id + 1}: Emulating controller", "#22c55e")
except Exception as e:
print(f"Error starting slot {slot.slot_id}: {e}")
self.statusChanged.emit(
f"Slot {slot.slot_id + 1}: Failed to start - {e}", "#ef4444")
def _stop_slot_emulation(self, slot):
"""Stop emulation for a non-primary slot"""
slot.running = False
self._reset_steering_action_toggle(slot)
if slot.mapper_thread and slot.mapper_thread.is_alive():
slot.mapper_thread.join(timeout=2)
if slot.input_reader:
slot.input_reader.stop()
slot.input_reader = None
if slot.combined_pedal_input_reader:
slot.combined_pedal_input_reader.stop()
slot.combined_pedal_input_reader = None
if slot.vg_gamepad:
try:
del slot.vg_gamepad
except Exception:
pass
slot.vg_gamepad = None
if slot.joystick:
try:
slot.joystick.quit()
except Exception:
pass
slot.joystick = None
def _setup_combined_pedal_reader(self, slot):
"""Set up a separate InputReader for the pedal device in combined mode"""
# Find the pedal device by name
pedal_device_id = None
for i, dev_str in enumerate(self._devices):
if slot.combined_pedal_device_name in dev_str:
try:
pedal_device_id = int(dev_str.split("(ID: ")[1].rstrip(")"))
except Exception:
pedal_device_id = i
break
if pedal_device_id is None:
print(f"Combined mode: pedal device '{slot.combined_pedal_device_name}' not found")
return
try:
slot.combined_pedal_joystick = pygame.joystick.Joystick(pedal_device_id)
slot.combined_pedal_joystick.init()
slot.combined_pedal_input_reader = InputReader()
slot.combined_pedal_input_reader.set_joystick(slot.combined_pedal_joystick)
# Pedal reader updates a separate axis list; the mapping loop merges them
slot.combined_pedal_input_reader.axisValuesChanged.connect(
lambda vals, s=slot: setattr(s, '_pedal_axis_values', vals))
slot.combined_pedal_input_reader.start()
slot._pedal_axis_values = []
except Exception as e:
print(f"Failed to initialize pedal device: {e}")
def _update_slot_axes(self, slot, values):
slot.axis_values = values
def _update_slot_buttons(self, slot, states):
slot.button_states = states
def _slot_mapping_loop(self, slot):
"""Mapping loop for extra slots (non-primary)"""
mapping = PROFILES.get(slot.profile, PROFILES["Logitech G29 (Standard)"])["axes"]
import math
while slot.running:
try:
if slot.input_reader:
axis_values, button_states = slot.input_reader.snapshot()
else:
axis_values = slot.axis_values.copy() if slot.axis_values else []
button_states = slot.button_states.copy() if slot.button_states else []
# In combined mode, use pedal device axes for gas/brake
if slot.combined_mode and slot.combined_pedal_input_reader:
pedal_axes, _ = slot.combined_pedal_input_reader.snapshot()
if not pedal_axes and hasattr(slot, '_pedal_axis_values') and slot._pedal_axis_values:
pedal_axes = slot._pedal_axis_values.copy()
elif slot.combined_mode and hasattr(slot, '_pedal_axis_values') and slot._pedal_axis_values:
pedal_axes = slot._pedal_axis_values.copy()
else:
pedal_axes = None
# STEERING
if mapping[0] < len(axis_values):
raw_s = (axis_values[mapping[0]] * 2) - 1
if slot.deadzone_enabled and abs(raw_s) < slot.deadzone:
raw_s = 0.0
elif slot.deadzone_enabled and slot.deadzone > 0:
sign = 1 if raw_s > 0 else -1
raw_s = sign * ((abs(raw_s) - slot.deadzone) / (1.0 - slot.deadzone))
raw_s = max(-1.0, min(1.0, raw_s))
if self._neutral_steering_active(button_states, slot.neutral_steering_button):
raw_s = 0.0
else:
raw_s = self._apply_steering_action_limit(raw_s, button_states, slot=slot)
slot.vg_gamepad.left_joystick(x_value=int(raw_s * 32767), y_value=0)
# GAS - from pedal device if combined, else from main device
gas_axes = pedal_axes if pedal_axes else axis_values
if mapping[1] < len(gas_axes):
norm_g = gas_axes[mapping[1]]
if slot.invert_gas:
norm_g = 1.0 - norm_g
slot.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))
# BRAKE - from pedal device if combined, else from main device
brake_axes = pedal_axes if pedal_axes else axis_values
if mapping[2] < len(brake_axes):
norm_b = brake_axes[mapping[2]]
if slot.invert_brake:
norm_b = 1.0 - norm_b
slot.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))
# BUTTONS
for btn_idx_str, mapping_name in list(slot.button_mapping.items()):
try:
btn_idx = int(btn_idx_str)
except (TypeError, ValueError):
continue
if btn_idx < len(button_states):
xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
if xbox_obj:
if button_states[btn_idx]:
slot.vg_gamepad.press_button(xbox_obj)
else:
slot.vg_gamepad.release_button(xbox_obj)
slot.vg_gamepad.update()
time.sleep(MAPPING_LOOP_SLEEP_SECONDS)
except (pygame.error, OSError) as e:
print(f"Device error in slot {slot.slot_id}: {e}")
slot.running = False
break
except Exception as e:
now = time.time()
if now - getattr(slot, '_last_mapping_error_at', 0.0) > 1.0:
print(f"Error in slot {slot.slot_id} mapping loop: {e}")
slot._last_mapping_error_at = now
time.sleep(0.05)
# Clean up
if slot.vg_gamepad:
try:
for btn_name, btn_obj in XBOX_BUTTON_MAP.items():
if btn_obj:
slot.vg_gamepad.release_button(btn_obj)
slot.vg_gamepad.update()
except Exception:
pass
def _sync_slot_from_ui(self, slot_index):
"""Save current UI state back into a slot"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
slot = self._device_slots[slot_index]
slot.profile = self._current_profile
slot.button_mapping = self.button_mapping.copy()
slot.invert_gas = self._invert_gas
slot.invert_brake = self._invert_brake
slot.square_input = self._square_input
slot.square_area = self._square_area
slot.deadzone_enabled = self._deadzone_enabled
slot.deadzone = self._deadzone
slot.sensitivity_curve = self._sensitivity_curve
slot.steering_range = self._steering_range
slot.center_spring = self._center_spring
slot.center_spring_width = self._center_spring_width
slot.center_spring_ramp = self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False
slot.center_spring_ramp_width = self._center_spring_ramp_width
slot.neutral_steering_button = self._neutral_steering_button
slot.steering_action_button = self._steering_action_button
slot.steering_action_max_percent = self._steering_action_max_percent
slot.steering_action_mode = self._steering_action_mode
slot.device_index = self._current_device_index
def _sync_ui_from_slot(self, slot_index):
"""Load a slot's state into the UI"""
if slot_index < 0 or slot_index >= len(self._device_slots):
return
slot = self._device_slots[slot_index]
self._current_profile = slot.profile
self.currentProfileChanged.emit(self._current_profile)
desc = PROFILES.get(self._current_profile, {}).get("desc", "")
self.profileDescChanged.emit(desc)
self.button_mapping = slot.button_mapping.copy()
self.buttonMappingsChanged.emit()
self.update_button_status()
self._invert_gas = slot.invert_gas
self.invertGasChanged.emit(self._invert_gas)
self._invert_brake = slot.invert_brake
self.invertBrakeChanged.emit(self._invert_brake)
self._square_input = slot.square_input
self.squareInputChanged.emit(self._square_input)
self._square_area = slot.square_area
self.squareAreaChanged.emit(self._square_area)
self._deadzone_enabled = slot.deadzone_enabled
self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
self._deadzone = slot.deadzone
self.deadzoneChanged.emit(self._deadzone)
self._sensitivity_curve = slot.sensitivity_curve
self.sensitivityCurveChanged.emit(self._sensitivity_curve)
self._steering_range = slot.steering_range
self.steeringRangeChanged.emit(self._steering_range)
self._center_spring = slot.center_spring
self.centerSpringChanged.emit(self._center_spring)
self._center_spring_width = slot.center_spring_width
self.centerSpringWidthChanged.emit(self._center_spring_width)
self._center_spring_ramp = slot.center_spring_ramp if SOFT_RAMP_AVAILABLE else False
self.centerSpringRampChanged.emit(self._center_spring_ramp)
self._center_spring_ramp_width = slot.center_spring_ramp_width
self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
self._neutral_steering_button = slot.neutral_steering_button
self.neutralSteeringButtonChanged.emit()
self._steering_action_button = slot.steering_action_button
self._steering_action_max_percent = slot.steering_action_max_percent
self._steering_action_mode = slot.steering_action_mode
self._reset_steering_action_toggle()
self.steeringActionChanged.emit()
if slot.device_index >= 0 and slot.device_index < len(self._devices):
self._current_device_index = slot.device_index
self.currentDeviceIndexChanged.emit(self._current_device_index)
@Slot()
def force_save_settings(self):
"""Force save settings, callable from QML"""
self.save_settings(force=True)
@Slot(bool)
def set_auto_start(self, enabled):
"""Set auto-start with Windows"""
self._auto_start = enabled
self.autoStartChanged.emit(enabled)
if enabled:
success = self.auto_start_manager.enable_auto_start(self._start_minimized)
if success:
self.statusChanged.emit("Auto-start enabled", "#22c55e")
else:
self.statusChanged.emit("Failed to enable auto-start", "#ef4444")
self._auto_start = False
self.autoStartChanged.emit(False)
else:
success = self.auto_start_manager.disable_auto_start()
if success:
self.statusChanged.emit("Auto-start disabled", "#71717a")
else:
self.statusChanged.emit("Failed to disable auto-start", "#ef4444")
self._auto_start = True
self.autoStartChanged.emit(True)
self.save_settings()
@Slot(bool)
def set_start_minimized(self, enabled):
"""Set start minimized to tray"""
self._start_minimized = enabled
self.startMinimizedChanged.emit(enabled)
if self._auto_start:
self.apply_auto_start_setting()
self.save_settings()
def apply_auto_start_setting(self):
"""Apply the current auto-start setting"""
if os.environ.get("TRUEAXIS_SKIP_STARTUP_SYNC") == "1":
return
if self._auto_start:
success = self.auto_start_manager.enable_auto_start(self._start_minimized)
if not success:
print("Warning: failed to repair auto-start registration")
else:
self.auto_start_manager.disable_auto_start()
def update_button_status(self):
count = len(self.button_mapping)
if count == 0:
status = "No buttons mapped"
elif count == 1:
status = "1 button mapped"
else:
status = f"{count} buttons mapped"
self.buttonStatusChanged.emit(status)
@Slot()
def refresh_devices(self):
# Stop emulation if running
if self._running:
self.stop_mapping()
# Clear current joystick from input reader
if self.input_reader:
self.input_reader.set_joystick(None)
try:
# Reinitialize pygame joystick system
pygame.joystick.quit()
pygame.joystick.init()
# Get available devices
count = pygame.joystick.get_count()
self._devices = []
for i in range(count):
try:
joystick = pygame.joystick.Joystick(i)
joystick.init() # Initialize to get name
device_name = joystick.get_name()
self._devices.append(f"{device_name} (ID: {i})")
joystick.quit() # Quit to avoid conflicts
except Exception as e:
print(f"Error initializing device {i}: {e}")
self._devices.append(f"Device {i} (Error)")
if not self._devices:
self._devices = ["No devices found"]
self.devicesChanged.emit(self._devices)
# Reset current joystick
self.joystick = None
self._axis_values = []
self._button_states = []
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.axisValuesChanged.emit()
self.buttonStatesChanged.emit()
# Reset live preview values
self.steeringChanged.emit(0.5)
self.gasChanged.emit(0.0)
self.brakeChanged.emit(0.0)
if count > 0:
# Don't auto-select during initialization if we have a saved device selection
# The restore_device_selection() will handle selecting the saved device
if not (hasattr(self, '_initializing') and self._initializing):
# Only auto-select during normal refresh (not during startup)
self.select_device(0)
self.statusChanged.emit("Ready to activate", "#71717a")
else:
self.statusChanged.emit("No input device detected", "#f59e0b")
except Exception as e:
print(f"Error refreshing devices: {e}")
self._devices = ["Error scanning devices"]
self.devicesChanged.emit(self._devices)
self.statusChanged.emit("Error scanning devices", "#ef4444")
@Slot(int)
def select_device(self, index):
if self._running:
# Don't allow device change while emulating
self.statusChanged.emit("Stop emulation before changing device", "#f59e0b")
return
if 0 <= index < len(self._devices):
# FIRST: Save the current device's button mappings before switching
if self.joystick and hasattr(self, '_user_configured_devices'):
old_device_name = self.joystick.get_name()
# If this device has been configured by the user, save its current state
if old_device_name in self._user_configured_devices and self._user_configured_devices[old_device_name]:
self._user_button_overrides[old_device_name] = self.button_mapping.copy()
print(f"[DEVICE SWITCH] Saved button state for {old_device_name}: {self.button_mapping}")
self._current_device_index = index
self.currentDeviceIndexChanged.emit(index)
try:
# Clean up old joystick if exists
if self.joystick:
try:
self.joystick.quit()
except Exception:
pass
# Extract device ID from the string (format: "Device Name (ID: X)")
try:
device_id = int(self._devices[index].split("(ID: ")[1].rstrip(")"))
except Exception:
device_id = index
# Initialize new joystick
self.joystick = pygame.joystick.Joystick(device_id)
self.joystick.init()
# Update input reader with new joystick
if self.input_reader:
self.input_reader.set_joystick(self.joystick)
# Initialize arrays with default values
num_axes = self.joystick.get_numaxes()
num_buttons = self.joystick.get_numbuttons()
self._axis_values = [0.5] * num_axes
self._button_states = [False] * num_buttons
# Emit signals for UI updates
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.axisValuesChanged.emit()
self.buttonStatesChanged.emit()
device_name = self.joystick.get_name()
print(f"[DEVICE SWITCH] Selected device: {device_name} with {num_axes} axes and {num_buttons} buttons")
# Hardware settings are refreshed after the profile and mappings are resolved.
# Check if user has a saved profile override for this device
if device_name in self._user_profile_overrides:
# Use the user's saved preference
override_profile = self._user_profile_overrides[device_name]
if override_profile in PROFILES:
self._current_profile = override_profile
self._profile_source = "user_override"
self.currentProfileChanged.emit(override_profile)
desc = PROFILES[override_profile]["desc"]
self.profileDescChanged.emit(desc)
print(f"[DEVICE SWITCH] Loaded user override profile: {override_profile}")
else:
# Auto-detect profile if no user override exists
detected_profile = self._detect_profile_from_device(device_name)
if detected_profile:
self._current_profile = detected_profile
self._profile_source = "auto_detected"
self.currentProfileChanged.emit(detected_profile)
desc = PROFILES[detected_profile]["desc"]
self.profileDescChanged.emit(desc)
print(f"[DEVICE SWITCH] Auto-detected profile: {detected_profile}")
# Handle button mappings: check for user overrides or auto-detect
# Set flag BEFORE making any changes to prevent marking these as user overrides
self._loading_button_mappings = True
print(f"[DEVICE SWITCH] Set _loading_button_mappings = True")
# Check if this device has ever been explicitly configured by the user
if device_name in self._user_configured_devices and self._user_configured_devices[device_name]:
# User has explicitly configured this device's buttons
# Load their saved configuration (even if empty - respects user's choice)
if device_name in self._user_button_overrides:
self.button_mapping = self._user_button_overrides[device_name].copy()
else:
self.button_mapping = {}
print(f"[DEVICE SWITCH] Loaded user-configured button mappings for {device_name}: {self.button_mapping}")
else:
# Device has never been explicitly configured - load defaults for this specific device
default_buttons = self._detect_default_buttons_from_device(device_name)
if default_buttons:
self.button_mapping = default_buttons
print(f"[DEVICE SWITCH] Auto-detected default button mappings for {device_name}: {self.button_mapping}")
else:
# No default mapping found, start with empty mappings
self.button_mapping = {}
print(f"[DEVICE SWITCH] No default button mappings found for {device_name}")
# Emit signal to update UI
self.buttonMappingsChanged.emit()
print(f"[DEVICE SWITCH] Emitted buttonMappingsChanged signal")
# Update button status display
self.update_button_status()
# Reset the loading flag after a short delay to allow UI to update
QTimer.singleShot(500, lambda: self._reset_loading_flag())
self.statusChanged.emit("Ready to activate", "#71717a")
# Save device selection (but not during initialization)
if not (hasattr(self, '_initializing') and self._initializing):
self.save_settings()
self._reassert_hardware_controls(pulse=True)
except Exception as e:
print(f"Error selecting device: {e}")
self.joystick = None
self._axis_values = []
self._button_states = []
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.statusChanged.emit(f"Error selecting device: {str(e)}", "#ef4444")
def _reset_loading_flag(self):
"""Helper method to reset loading flag"""
self._loading_button_mappings = False
print(f"[DEVICE SWITCH] Set _loading_button_mappings = False")
@Slot()
def start_button_calibration(self):
"""Start the automatic button calibration wizard"""
if not self.joystick:
self.statusChanged.emit("No device selected", "#ef4444")
return
# This will be triggered from QML
# The QML dialog will guide the user through pressing each button
device_name = self.joystick.get_name()
print(f"[CALIBRATION] Starting button calibration for {device_name}")
self.statusChanged.emit("Starting button calibration...", "#22c55e")
@Slot(result=list)
def get_pressed_buttons(self):
"""Get list of currently pressed button indices"""
_, button_states = self.input_reader.snapshot() if self.input_reader else ([], [])
if not button_states:
button_states = self._button_states
pressed = []
for i, state in enumerate(button_states):
if state:
pressed.append(i)
return pressed
@Slot(int, str)
def calibrate_button(self, button_index, xbox_button_name):
"""Called during calibration to map a detected button to an Xbox button"""
print(f"[CALIBRATION] Mapping button {button_index} -> {xbox_button_name}")
self.button_mapping[str(button_index)] = xbox_button_name
# Mark device as configured
if self.joystick:
device_name = self.joystick.get_name()
self._user_button_overrides[device_name] = self.button_mapping.copy()
self._user_configured_devices[device_name] = True
# Update UI
self.buttonMappingsChanged.emit()
self.update_button_status()
@Slot()
def finish_calibration(self):
"""Finish calibration and save mappings"""
if self.joystick:
device_name = self.joystick.get_name()
print(f"[CALIBRATION] Finished calibration for {device_name}")
print(f"[CALIBRATION] Final mappings: {self.button_mapping}")
self.save_settings()
self.save_button_mapping()
self.statusChanged.emit("Button calibration complete!", "#22c55e")
def _detect_profile_from_device(self, device_name):
"""Detect appropriate profile based on device name"""
device_name_lower = device_name.lower()
if any(token in device_name_lower for token in GAMEPAD_HID_NAME_TOKENS) and not any(token in device_name_lower for token in WHEEL_HID_NAME_TOKENS):
return "Generic Gamepad (Xbox/XInput)"
for keywords, profile_name in DEVICE_PROFILE_PATTERNS:
for keyword in keywords:
if keyword.lower() in device_name_lower:
return profile_name
return None # No match found
def _detect_default_buttons_from_device(self, device_name):
"""Detect default button mappings based on device name"""
device_name_lower = device_name.lower()
# Check for specific device matches (more specific first)
if "dualsense" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["dualsense"].copy()
elif "dualshock" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["dualshock"].copy()
elif "wireless controller" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["wireless controller"].copy()
elif "xbox 360" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["xbox 360"].copy()
elif "xbox" in device_name_lower or "xinput" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["xbox"].copy()
elif "g29" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["g29"].copy()
elif "g920" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["g920"].copy()
elif "g923" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["g923"].copy()
elif "g27" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["g27"].copy()
elif "thrustmaster" in device_name_lower or "t300" in device_name_lower or "t150" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["thrustmaster"].copy()
elif "fanatec" in device_name_lower or "csl" in device_name_lower or "clubsport" in device_name_lower:
return DEVICE_DEFAULT_BUTTONS["fanatec"].copy()
return None # No match found
@Slot(str)
def select_profile(self, profile_name):
if profile_name in PROFILES:
self._current_profile = profile_name
self._profile_source = "user_selected"
self.currentProfileChanged.emit(profile_name)
desc = PROFILES[profile_name]["desc"]
self.profileDescChanged.emit(desc)
# Mark this as a user override for the current device
if self.joystick:
device_name = self.joystick.get_name()
self._user_profile_overrides[device_name] = profile_name
print(f"User selected profile '{profile_name}' for device '{device_name}'")
self.save_settings()
@Slot(bool)
def set_invert_gas(self, value):
self._invert_gas = value
self.invertGasChanged.emit(value)
self.save_settings()
@Slot(bool)
def set_invert_brake(self, value):
self._invert_brake = value
self.invertBrakeChanged.emit(value)
self.save_settings()
@Slot(bool)
def set_square_input(self, value):
self._square_input = value
self.squareInputChanged.emit(value)
self.save_settings()
@Slot(float)
def set_square_area(self, value):
self._square_area = value
self.squareAreaChanged.emit(value)
self.save_settings()
@Slot(bool)
def set_deadzone_enabled(self, value):
self._deadzone_enabled = value
self.deadzoneEnabledChanged.emit(value)
self.save_settings()
@Slot(float)
def set_deadzone(self, value):
self._deadzone = value
self.deadzoneChanged.emit(value)
self.save_settings()
@Slot(str)
def set_sensitivity_curve(self, curve):
"""Set the steering sensitivity curve type"""
self._sensitivity_curve = curve.lower()
self.sensitivityCurveChanged.emit(self._sensitivity_curve)
self.save_settings()
@Slot(str)
def set_theme_mode(self, mode):
mode = str(mode or "").strip().lower()
if mode not in ("light", "dark"):
mode = "light"
if self._theme_mode == mode:
return
self._theme_mode = mode
self.themeModeChanged.emit(mode)
self.save_settings()
@Slot()
def toggle_theme_mode(self):
self.set_theme_mode("dark" if self._theme_mode != "dark" else "light")
def _current_device_name(self):
try:
if self.joystick:
return self.joystick.get_name()
except Exception:
pass
if 0 <= self._current_device_index < len(self._devices):
return self._devices[self._current_device_index].split(" (ID: ")[0]
return ""
def _current_device_category(self):
name = (self._current_device_name() or "").lower()
if not name or "no devices found" in name or "scanning devices" in name:
return "no_device"
wheel_tokens = (
"wheel", "g29", "g920", "g923", "g27", "g25", "driving force",
"thrustmaster", "t300", "t150", "t248", "t818", "tmx", "t-gt",
"moza", "fanatec", "csl", "clubsport", "pxn", "hori racing",
"hori wheel", "racing wheel apex", "simucube",
"simagic", "asetek", "vrs", "cammus", "momo"
)
gamepad_tokens = (
"xbox", "controller", "gamepad", "dualshock", "dualsense",
"8bitdo", "stadia", "wireless controller", "game sir", "gamesir",
"horipad"
)
if any(token in name for token in wheel_tokens):
return "wheel"
if any(token in name for token in gamepad_tokens):
return "gamepad"
return "unknown_device"
def _current_device_is_logitech_wheel(self):
name = (self._current_device_name() or "").lower()
return any(keyword in name for keyword in (
"logitech", "g29", "g920", "g923", "g27", "g25",
"driving force", "momo"
)) and self._current_device_category() == "wheel"
def _current_max_steering_range(self):
return detect_max_steering_range(self._current_device_name(), self._current_profile)
def _uses_logitech_hid(self):
device_name = (self._current_device_name() or "").lower()
if device_name:
return self._current_device_is_logitech_wheel()
return self._current_profile in LOGITECH_HID_PROFILE_NAMES
def _hardware_control_vendor(self):
device_text = (self._current_device_name() or "").lower()
profile_text = (self._current_profile or "").lower()
category = self._current_device_category()
text = device_text or profile_text
if category == "gamepad":
return "gamepad"
if self._uses_logitech_hid():
return "logitech"
if "moza" in text:
return "moza"
if "fanatec" in text:
return "fanatec"
if "thrustmaster" in text or any(name in text for name in ("t300", "t500", "t150", "t248", "t-gt", "t818", "tmx")):
return "thrustmaster"
if "pxn" in text:
return "pxn"
if "hori" in text:
return "hori"
if any(name in text for name in ("simucube", "simagic", "asetek", "invicta", "vrs", "cammus", "directforce", "la prima", "forte")):
return "directdrive"
return "unknown"
def _logitech_target_hint(self):
return f"{self._current_device_name()} {self._current_profile}"
def _logitech_detected_protocols(self, target_hint=None):
try:
if target_hint is None and not self._uses_logitech_hid():
return []
hint = self._logitech_target_hint() if target_hint is None else target_hint
return sorted(set(d.get('protocol') for d in _filtered_logitech_hid_devices(hint) if d.get('protocol')))
except Exception:
return []
def _hardware_range_protocols(self):
vendor = self._hardware_control_vendor()
if vendor == "logitech":
return self._logitech_detected_protocols()
if vendor == "moza":
return ["moza_sdk"] if _moza_sdk_ready() else ["moza_diagnostic"]
if vendor in ("thrustmaster", "fanatec", "pxn", "hori", "directdrive"):
return [f"{vendor}_diagnostic"]
return []
def _hardware_steering_range_supported(self):
vendor = self._hardware_control_vendor()
if vendor == "moza":
return _moza_sdk_ready()
if vendor != "logitech" or not self._uses_logitech_hid():
return False
protocols = self._logitech_detected_protocols()
return any(protocol in protocols for protocol in ("lg4ff", "dfp", "g923_ps_mode", "hidpp_g920"))
def _hardware_center_spring_supported(self):
if not self._uses_logitech_hid():
return False
protocols = self._logitech_detected_protocols()
return any(protocol in protocols for protocol in ("lg4ff", "dfp"))
def _hardware_controls_supported(self):
return self._hardware_steering_range_supported() or self._hardware_center_spring_supported()
def _hardware_support_reason(self):
category = self._current_device_category()
vendor = self._hardware_control_vendor()
protocols = self._logitech_detected_protocols()
if category == "no_device":
return "no_device"
if category == "gamepad":
return "gamepad_only"
if vendor == "logitech":
if self._hardware_steering_range_supported():
return "logitech_hardware_supported"
return "logitech_no_supported_endpoint"
if vendor == "moza":
return "moza_sdk_ready" if _moza_sdk_ready() else "moza_sdk_missing"
if vendor in ("fanatec", "thrustmaster", "directdrive", "pxn", "hori"):
return f"{vendor}_sdk_required"
if category == "wheel":
return "wheel_detected_no_protocol"
if protocols:
return "protocol_detected_wrong_selection"
return "unknown_device"
def _hardware_range_provider_summary(self):
vendor = self._hardware_control_vendor()
reason = self._hardware_support_reason()
protocols = self._logitech_detected_protocols()
if vendor == "logitech" and self._hardware_steering_range_supported():
return "logitech_hid", reason, "Logitech hardware range provider ready"
if vendor == "logitech":
return "logitech_hid", reason, self._hardware_unsupported_message("steering range")
if vendor == "moza" and self._hardware_steering_range_supported():
return "moza_sdk", reason, "MOZA SDK hardware range provider ready"
if vendor == "moza":
return "moza_sdk", reason, self._hardware_unsupported_message("steering range")
if vendor in ("moza", "fanatec", "thrustmaster", "directdrive", "pxn", "hori"):
return f"{vendor}_diagnostic", reason, self._hardware_unsupported_message("steering range")
if vendor in ("gamepad", "unknown"):
return "diagnostic_only", reason, self._hardware_unsupported_message("steering range")
return f"{vendor}_diagnostic", reason, self._hardware_unsupported_message("steering range")
def _hardware_unsupported_message(self, control_name):
vendor = self._hardware_control_vendor()
category = self._current_device_category()
if category == "no_device":
return "No input device selected"
if category == "gamepad":
return "Selected device is a controller; hardware steering range only applies to force-feedback wheels"
if vendor == "logitech":
protocols = ", ".join(self._logitech_detected_protocols()) or "none"
if control_name == "center spring":
return f"Logitech center spring needs an lg4ff-compatible wheel (detected: {protocols})"
return f"Logitech hardware {control_name} needs a supported HID range provider (detected: {protocols})"
if vendor == "moza":
return f"MOZA {control_name} needs the MOZA SDK bridge ({_moza_sdk_status()})"
if vendor == "fanatec":
return f"Fanatec {control_name} needs official Fanatec SDK support"
if vendor == "thrustmaster":
return f"Thrustmaster {control_name} needs the Thrustmaster Control Panel, My Thrustmaster, or private SDK access"
if vendor == "pxn":
return f"PXN {control_name} needs the PXN Wheel app or a verified vendor protocol"
if vendor == "hori":
return f"HORI {control_name} needs the HORI Device Manager app or a verified vendor protocol"
if vendor == "directdrive":
return f"{control_name} needs this wheelbase vendor's app or SDK"
return f"Hardware {control_name} not supported for this wheel yet"
def _apply_hardware_steering_range(self):
"""Try hardware steering range when a supported wheel provider is selected."""
if not self._hardware_steering_range_supported():
self._hardware_steering_range_active = False
self._last_hardware_range = None
return False
vendor = self._hardware_control_vendor()
if vendor == "moza":
hardware_range = min(2000, int(self._steering_range))
ok = set_moza_wheel_range(hardware_range)
self._hardware_steering_range_active = ok
self._last_hardware_range = MOZA_LAST_RANGE_APPLIED if ok else None
if ok:
print(f"[WHEEL RANGE] MOZA hardware range active: {self._last_hardware_range} deg")
else:
print("[WHEEL RANGE] MOZA hardware range unavailable")
return ok
hardware_range = min(1080, int(self._steering_range))
target_hint = self._logitech_target_hint()
protocols = self._logitech_detected_protocols(target_hint)
ok = set_logitech_wheel_range(hardware_range, target_hint=target_hint, allow_mode_switch=("g923_ps_mode" in protocols))
self._hardware_steering_range_active = ok
self._last_hardware_range = LOGITECH_LAST_RANGE_APPLIED if ok else None
if ok:
print(f"[WHEEL RANGE] Hardware range active: {self._last_hardware_range} deg")
else:
print("[WHEEL RANGE] Hardware range unavailable for this wheel")
return ok
def _apply_hardware_center_spring(self, strength):
"""Only Logitech HID exposes the center-spring command used by TrueAxis."""
if not self._hardware_center_spring_supported():
return False
return set_logitech_wheel_autocenter(strength)
def _center_spring_target(self):
return max(0.0, min(1.0, float(self._center_spring)))
def _pulse_hardware_center_spring(self):
"""Nudge autocenter away and back so wheel firmware reloads the baseline."""
if not self._hardware_center_spring_supported():
return False
target = self._center_spring_target()
pulse_value = 0.0 if target >= CENTER_SPRING_PULSE_STRENGTH else CENTER_SPRING_PULSE_STRENGTH
def pulse_worker():
try:
self._apply_hardware_center_spring(pulse_value)
time.sleep(CENTER_SPRING_PULSE_DELAY_SECONDS)
if self._apply_hardware_center_spring(target):
self._last_hardware_spring = round(target, 2)
self._last_hardware_spring_sent_at = time.time()
except Exception as e:
print(f"[WHEEL HID] Center spring pulse failed: {e}")
threading.Thread(target=pulse_worker, daemon=True).start()
return True
def _apply_center_spring_baseline(self, force=False, pulse=False):
"""Apply the configured hardware autocenter strength, including 0%."""
if not self._hardware_center_spring_supported():
return False
if pulse:
return self._pulse_hardware_center_spring()
target = self._center_spring_target()
now = time.time()
target_rounded = round(target, 2)
due_for_reassert = (now - self._last_hardware_spring_sent_at) >= (HARDWARE_REASSERT_INTERVAL_MS / 1000.0)
if not force and not due_for_reassert and abs(target_rounded - self._last_hardware_spring) < 0.005:
return True
ok = self._apply_hardware_center_spring(target)
if ok:
self._last_hardware_spring = target_rounded
self._last_hardware_spring_sent_at = now
return ok
@Slot()
def reapply_hardware_settings(self):
self._reassert_hardware_controls(pulse=True, show_status=True)
def _reassert_hardware_controls(self, pulse=False, show_status=False):
"""Best-effort hardware refresh for wheel firmware that forgets settings."""
if not self._hardware_controls_supported():
return False
range_ok = self._apply_hardware_steering_range()
if self._hardware_center_spring_supported() and self._center_spring_ramp and self._running:
self._last_hardware_spring = -1.0 if pulse else self._last_hardware_spring
spring_ok = self.update_hardware_center_spring(self._current_raw_steering_input())
elif self._hardware_center_spring_supported():
spring_ok = self._apply_center_spring_baseline(force=not pulse, pulse=pulse)
else:
spring_ok = False
if show_status:
if range_ok or spring_ok:
self.statusChanged.emit("Wheel hardware settings refreshed", "#22c55e")
else:
self.statusChanged.emit("Wheel hardware refresh failed", "#f59e0b")
return range_ok or spring_ok
def _hardware_range_snapshot_payload(self):
hint = self._logitech_target_hint()
vendor = self._hardware_control_vendor()
if HID_AVAILABLE and vendor == "logitech" and self._uses_logitech_hid():
provider_devices = _filtered_logitech_hid_devices(hint)
elif HID_AVAILABLE and vendor == "moza":
provider_devices = [d for d in _wheel_hid_candidates(hint) if _hid_vendor_label(d) == "moza"]
else:
provider_devices = []
diagnostic_devices = _wheel_hid_candidates(hint) if HID_AVAILABLE else []
candidates = [_hid_report_summary(d) for d in provider_devices]
diagnostic_candidates = [_hid_report_summary(d) for d in diagnostic_devices]
protocols = self._hardware_range_protocols()
supported = self._hardware_steering_range_supported()
provider, support_reason, provider_message = self._hardware_range_provider_summary()
if not HID_AVAILABLE:
message = "HID support is not available in this build"
elif supported:
message = "Ready to test real hardware range"
elif diagnostic_candidates:
message = provider_message
else:
message = provider_message
return {
"selectedDevice": self._current_device_name() or "No device selected",
"profile": self._current_profile,
"hint": hint,
"supported": supported,
"provider": provider,
"supportReason": support_reason,
"deviceCategory": self._current_device_category(),
"profileSource": self._profile_source,
"protocols": protocols,
"candidates": candidates,
"diagnosticHidCandidates": diagnostic_candidates,
"message": message,
"lastApplied": self._last_hardware_range if self._last_hardware_range is not None else "",
"joysticks": self._joystick_inventory_payload(),
"testLog": self._hardware_range_test_log[-8:],
}
def _hardware_range_json(self, payload):
try:
return json.dumps(payload)
except Exception as e:
return json.dumps({"ok": False, "status": f"Could not encode hardware report: {e}"})
@Slot(result=str)
def hardware_range_snapshot_json(self):
return self._hardware_range_json(self._hardware_range_snapshot_payload())
@Slot(int, result=str)
def run_hardware_range_test_json(self, degrees):
degrees = max(10, min(int(self._current_max_steering_range()), int(degrees)))
hint = self._logitech_target_hint()
requested_at = time.strftime("%Y-%m-%d %H:%M:%S")
ok = False
error_status = ""
if not self._hardware_steering_range_supported():
error_status = self._hardware_unsupported_message("steering range")
else:
vendor = self._hardware_control_vendor()
if vendor == "moza":
ok = set_moza_wheel_range(degrees)
if ok:
self._last_hardware_range = MOZA_LAST_RANGE_APPLIED
self._hardware_steering_range_active = True
self.steeringRangeChanged.emit(self._steering_range)
else:
self._hardware_steering_range_active = False
self._last_hardware_range = None
error_status = MOZA_LAST_RANGE_STATUS or "MOZA hardware steering range command failed"
else:
protocols = self._logitech_detected_protocols(hint)
ok = set_logitech_wheel_range(degrees, target_hint=hint, allow_mode_switch=("g923_ps_mode" in protocols))
if ok:
self._last_hardware_range = LOGITECH_LAST_RANGE_APPLIED
self._hardware_steering_range_active = True
self.steeringRangeChanged.emit(self._steering_range)
else:
self._hardware_steering_range_active = False
self._last_hardware_range = None
error_status = LOGITECH_LAST_RANGE_STATUS or "Hardware steering range command failed"
applied = self._last_hardware_range if ok and self._last_hardware_range is not None else ""
status = f"Range command sent: {applied} deg" if ok else error_status
entry = {
"time": requested_at,
"type": "range",
"requested": degrees,
"applied": applied,
"ok": ok,
"status": status,
"device": self._current_device_name(),
"profile": self._current_profile,
"protocols": self._hardware_range_protocols(),
}
self._hardware_range_test_log.append(entry)
self._hardware_range_test_log = self._hardware_range_test_log[-20:]
self.statusChanged.emit(status, "#22c55e" if ok else "#f59e0b")
payload = self._hardware_range_snapshot_payload()
payload.update(entry)
return self._hardware_range_json(payload)
@Slot(result=str)
def run_g923_mode_switch_test_json(self):
hint = self._logitech_target_hint()
requested_at = time.strftime("%Y-%m-%d %H:%M:%S")
target = max(180, min(900, int(self._steering_range or 900)))
ok = set_logitech_wheel_range(target, target_hint=hint, allow_mode_switch=True)
applied = LOGITECH_LAST_RANGE_APPLIED if ok else ""
status = LOGITECH_LAST_RANGE_STATUS or ("G923 mode switch command sent" if ok else "G923 mode switch failed")
entry = {
"time": requested_at,
"type": "g923_mode_switch",
"requested": target,
"applied": applied,
"ok": ok,
"status": status,
"device": self._current_device_name(),
"profile": self._current_profile,
"protocols": self._hardware_range_protocols(),
}
self._hardware_range_test_log.append(entry)
self._hardware_range_test_log = self._hardware_range_test_log[-20:]
self.statusChanged.emit(status, "#22c55e" if ok else "#f59e0b")
payload = self._hardware_range_snapshot_payload()
payload.update(entry)
return self._hardware_range_json(payload)
@Slot(int, str, result=str)
def record_hardware_range_confirmation_json(self, degrees, confirmation):
normalized = str(confirmation or "skip").lower()
if normalized not in ("yes", "no", "skip"):
normalized = "skip"
command_entry = next((
item for item in reversed(self._hardware_range_test_log)
if item.get("type") in ("range", "g923_mode_switch") and int(item.get("requested") or 0) == int(degrees)
), None)
command_applied = command_entry.get("applied") if isinstance(command_entry, dict) else ""
entry = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"type": "physical_confirmation",
"requested": int(degrees),
"applied": command_applied if command_applied != "" else (self._last_hardware_range if self._last_hardware_range is not None else ""),
"commandStatus": command_entry.get("status", "") if isinstance(command_entry, dict) else "",
"ok": normalized == "yes",
"status": f"Physical lock confirmation: {normalized}",
"device": self._current_device_name(),
"profile": self._current_profile,
"protocols": self._hardware_range_protocols(),
}
self._hardware_range_test_log.append(entry)
self._hardware_range_test_log = self._hardware_range_test_log[-20:]
self._send_hardware_range_report_async(auto=True)
return self._hardware_range_json(entry)
@Slot(result=str)
def copy_hardware_range_report_json(self):
report = self._hardware_range_snapshot_payload()
report["copiedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
report["appVersion"] = CURRENT_VERSION
text = json.dumps(report, indent=2)
try:
QGuiApplication.clipboard().setText(text)
return self._hardware_range_json({"ok": True, "status": "Hardware report copied"})
except Exception as e:
return self._hardware_range_json({"ok": False, "status": f"Could not copy report: {e}"})
def _json_post_to_endpoints(self, urls, payload, timeout=6):
body = json.dumps(payload).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": f"TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})",
}
last_error = "No endpoint configured"
for url in urls:
try:
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=timeout) as response:
status = getattr(response, "status", response.getcode())
if 200 <= int(status) < 300:
return True, url, int(status), response.read(2048).decode("utf-8", errors="replace")
last_error = f"HTTP {status} from {url}"
except urllib.error.HTTPError as e:
last_error = f"HTTP {e.code} from {url}"
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
return False, "", 0, last_error
def _base_telemetry_payload(self, event, details=None):
return {
"schema": 1,
"app": "trueaxis",
"channel": UPDATE_CHANNEL,
"platform": UPDATE_PLATFORM,
"version": CURRENT_VERSION,
"event": str(event or "").strip(),
"install_id": self._install_id,
"sent_at": int(time.time()),
"details": details if isinstance(details, dict) else {},
}
def _joystick_inventory_payload(self):
devices = []
try:
count = pygame.joystick.get_count()
except Exception:
count = 0
for index in range(min(int(count or 0), 12)):
joystick = None
try:
joystick = pygame.joystick.Joystick(index)
if not joystick.get_init():
joystick.init()
devices.append({
"index": index,
"name": str(joystick.get_name() or ""),
"axes": int(joystick.get_numaxes()),
"buttons": int(joystick.get_numbuttons()),
"hats": int(joystick.get_numhats()),
})
except Exception as e:
devices.append({
"index": index,
"name": "Unknown device",
"error": str(e)[:120],
})
return devices
def _telemetry_context_details(self):
joystick_devices = self._joystick_inventory_payload()
protocols = self._logitech_detected_protocols()
selected_device = self._current_device_name() or ""
return {
"selected_device": selected_device,
"profile": self._current_profile,
"device_count": len(joystick_devices),
"devices": joystick_devices,
"protocols": protocols,
"hardware_vendor": self._hardware_control_vendor(),
"hardware_supported": self._hardware_controls_supported(),
"hardware_reason": self._hardware_support_reason(),
"device_category": self._current_device_category(),
"profile_source": self._profile_source,
"steering_range": int(self._steering_range),
"center_spring": round(float(self._center_spring), 3),
"theme": self._theme_mode,
"running": bool(self._running),
}
def _send_telemetry_event_async(self, event, details=None, on_success=None):
if os.getenv("TRUEAXIS_DISABLE_TELEMETRY") == "1":
return
payload = self._base_telemetry_payload(event, details)
def send_thread():
ok, url, status, result = self._json_post_to_endpoints(TELEMETRY_URLS, payload, timeout=5)
if ok and callable(on_success):
try:
on_success()
except Exception as e:
print(f"Telemetry success callback failed: {e}")
elif not ok:
print(f"Telemetry '{event}' failed: {result}")
threading.Thread(target=send_thread, daemon=True).start()
def _send_startup_telemetry(self):
state = _load_json_with_backup(TELEMETRY_STATE_FILE) or {}
if not isinstance(state, dict):
state = {}
install_sent = bool(state.get("install_sent"))
version_key = f"version_seen:{CURRENT_VERSION}"
def save_state_field(key, value=True):
current = _load_json_with_backup(TELEMETRY_STATE_FILE) or {}
if not isinstance(current, dict):
current = {}
current[key] = value
current["updated_at"] = int(time.time())
try:
_atomic_json_save(TELEMETRY_STATE_FILE, current)
except Exception as e:
print(f"Could not save telemetry state: {e}")
if not install_sent:
self._send_telemetry_event_async("install", on_success=lambda: save_state_field("install_sent"))
if not state.get(version_key):
self._send_telemetry_event_async(
"version_seen",
{"previous_version": str(state.get("last_version") or "")},
on_success=lambda: (save_state_field(version_key), save_state_field("last_version", CURRENT_VERSION)),
)
self._send_telemetry_event_async("app_start", self._telemetry_context_details())
def _send_presence_telemetry(self):
details = self._telemetry_context_details()
details["heartbeat_interval_ms"] = TELEMETRY_HEARTBEAT_INTERVAL_MS
self._send_telemetry_event_async("heartbeat", details)
def _hardware_range_report_payload(self):
report = self._hardware_range_snapshot_payload()
report.update({
"schema": 1,
"app": "trueaxis",
"channel": UPDATE_CHANNEL,
"platform": UPDATE_PLATFORM,
"appVersion": CURRENT_VERSION,
"install_id": self._install_id,
"reportedAt": int(time.time()),
})
return report
def _send_hardware_range_report_async(self, auto=False):
payload = self._hardware_range_report_payload()
payload["auto"] = bool(auto)
def send_thread():
ok, url, status, result = self._json_post_to_endpoints(HARDWARE_REPORT_URLS, payload, timeout=8)
if ok:
message = "Hardware report sent"
self.hardwareReportStatusChanged.emit(message, "#22c55e")
if not auto:
self.statusChanged.emit(message, "#22c55e")
else:
message = "Could not send hardware report"
self.hardwareReportStatusChanged.emit(message, "#f59e0b")
if not auto:
self.statusChanged.emit(f"{message}: {result}", "#f59e0b")
threading.Thread(target=send_thread, daemon=True).start()
@Slot(result=str)
def submit_hardware_range_report_json(self):
self._send_hardware_range_report_async(auto=False)
return self._hardware_range_json({"ok": True, "status": "Sending hardware report..."})
@Slot(int)
def set_steering_range(self, degrees):
"""Set steering range on the physical wheel hardware when supported."""
max_range = self._current_max_steering_range()
self._steering_range = max(10, min(int(max_range), int(degrees)))
if not self._hardware_steering_range_supported():
self.statusChanged.emit(self._hardware_unsupported_message("steering range"), "#f59e0b")
elif self._apply_hardware_steering_range():
applied = LOGITECH_LAST_RANGE_APPLIED or self._steering_range
self.statusChanged.emit(f"Hardware steering range set: {applied} deg", "#22c55e")
else:
self.statusChanged.emit(LOGITECH_LAST_RANGE_STATUS or "Hardware steering range command failed", "#f59e0b")
self.steeringRangeChanged.emit(self._steering_range)
self.save_settings()
@Slot(float)
def set_center_spring(self, value):
"""Set the center spring strength (0.0 = off, 1.0 = maximum).
When ramp is enabled, the hardware spring is dynamically updated by
update_hardware_center_spring() in the mapping loop. This call just
stores the target and resets the last value so the next frame picks it up.
When ramp is disabled, this sends directly to the wheel hardware.
"""
self._center_spring = max(0.0, min(1.0, value))
if not self._center_spring_ramp:
if not self._hardware_center_spring_supported():
if self._center_spring > 0:
self.statusChanged.emit(self._hardware_unsupported_message("center spring"), "#f59e0b")
elif not self._apply_center_spring_baseline(force=True):
self.statusChanged.emit("Hardware center spring command failed", "#f59e0b")
else:
self.statusChanged.emit(f"Hardware center spring set: {int(self._center_spring * 100)}%", "#22c55e")
else:
# Reset last value so the ramp immediately applies the new baseline.
self._last_hardware_spring = -1.0
if self._hardware_center_spring_supported():
self.update_hardware_center_spring(self._current_raw_steering_input())
self.centerSpringChanged.emit(self._center_spring)
self.save_settings()
@Slot(float)
def set_center_spring_width(self, value):
"""Deprecated - center spring now applies to full steering range via hardware"""
pass
@Slot(bool)
def set_center_spring_ramp(self, enabled):
"""Set center spring ramp enabled/disabled.
When enabled: hardware spring is dynamically updated by the mapping loop.
When disabled: hardware spring is sent directly at the current centerSpring value.
"""
if not SOFT_RAMP_AVAILABLE:
self._center_spring_ramp = False
self._last_hardware_spring = -1.0
self.centerSpringRampChanged.emit(False)
self.save_settings()
return
if enabled and not self._hardware_center_spring_supported():
self._center_spring_ramp = False
self._last_hardware_spring = -1.0
self.centerSpringRampChanged.emit(False)
self.statusChanged.emit(self._hardware_unsupported_message("soft ramp"), "#f59e0b")
self.save_settings()
return
self._center_spring_ramp = enabled
self._last_hardware_spring = -1.0 # Reset so next frame picks it up
self.centerSpringRampChanged.emit(self._center_spring_ramp)
self.save_settings()
if enabled:
if self.update_hardware_center_spring(self._current_raw_steering_input()):
self.statusChanged.emit("Hardware soft ramp enabled", "#22c55e")
else:
self.statusChanged.emit("Hardware soft ramp command failed", "#f59e0b")
else:
if self._apply_center_spring_baseline(force=True):
self.statusChanged.emit("Hardware soft ramp disabled", "#22c55e")
@Slot(float)
def set_center_spring_ramp_width(self, value):
"""Set center spring ramp zone width in degrees"""
self._center_spring_ramp_width = max(1.0, min(90.0, value))
self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
if self._center_spring_ramp:
self._last_hardware_spring = -1.0
self.update_hardware_center_spring(self._current_raw_steering_input())
self.save_settings()
@Slot(result=str)
def export_settings(self):
"""Export all settings to a JSON file"""
try:
import datetime
from pathlib import Path
# Create export data
export_data = {
'version': CURRENT_VERSION,
'exported_at': datetime.datetime.now().isoformat(),
'settings': {
'auto_start': self._auto_start,
'start_minimized': self._start_minimized,
'invert_gas': self._invert_gas,
'invert_brake': self._invert_brake,
'square_input': self._square_input,
'square_area': self._square_area,
'deadzone_enabled': self._deadzone_enabled,
'deadzone': self._deadzone,
'sensitivity_curve': self._sensitivity_curve,
'theme_mode': self._theme_mode,
'steering_range': self._steering_range,
'center_spring': self._center_spring,
'center_spring_width': self._center_spring_width,
'center_spring_ramp': self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
'center_spring_ramp_width': self._center_spring_ramp_width,
'neutral_steering_button': self._neutral_steering_button,
'steering_action_button': self._steering_action_button,
'steering_action_max_percent': self._steering_action_max_percent,
'steering_action_mode': self._steering_action_mode,
'current_profile': self._current_profile,
'user_profile_overrides': self._user_profile_overrides,
'user_button_overrides': self._user_button_overrides,
'user_configured_devices': self._user_configured_devices,
'button_mapping': self.button_mapping,
'device_slots': [slot.to_dict() for slot in self._device_slots],
'active_slot_index': self._active_slot_index,
},
'tray_settings': {
'hide_to_tray': self.tray_manager._hide_to_tray_enabled if hasattr(self, 'tray_manager') else False
}
}
# Save to documents folder
docs_path = Path.home() / 'Documents'
export_path = docs_path / f'TrueAxis_Settings_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
with open(export_path, 'w') as f:
json.dump(export_data, f, indent=2)
self.exportStatusChanged.emit(f"Settings exported to {export_path.name}")
return str(export_path)
except Exception as e:
self.exportStatusChanged.emit(f"Export failed: {str(e)}")
return ""
@Slot()
def export_settings_dialog(self):
"""Let the user pick a destination, then export settings there."""
try:
import datetime
from pathlib import Path
from PySide6.QtWidgets import QFileDialog
default_path = str(
Path.home() / 'Documents' /
f'TrueAxis_Settings_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
)
file_path, _ = QFileDialog.getSaveFileName(
None,
"Export TrueAxis settings",
default_path,
"TrueAxis settings (*.json);;All files (*.*)",
)
if not file_path:
self.statusChanged.emit("Export cancelled", "#71717a")
return
exported = self.export_settings()
if not exported:
self.statusChanged.emit("Export failed", "#ef4444")
return
if os.path.abspath(exported) != os.path.abspath(file_path):
shutil.move(exported, file_path)
self.statusChanged.emit(f"Settings exported to {file_path}", "#22c55e")
except Exception as e:
self.statusChanged.emit(f"Export failed: {e}", "#ef4444")
@Slot()
def import_settings_dialog(self):
"""Let the user pick a TrueAxis settings file to import."""
try:
from pathlib import Path
from PySide6.QtWidgets import QFileDialog
file_path, _ = QFileDialog.getOpenFileName(
None,
"Import TrueAxis settings",
str(Path.home() / 'Documents'),
"TrueAxis settings (*.json);;All files (*.*)",
)
if not file_path:
self.statusChanged.emit("Import cancelled", "#71717a")
return
if self.import_settings(file_path):
self.statusChanged.emit(f"Settings imported from {os.path.basename(file_path)}", "#22c55e")
else:
self.statusChanged.emit("Import failed: not a valid TrueAxis settings file", "#ef4444")
except Exception as e:
self.statusChanged.emit(f"Import failed: {e}", "#ef4444")
@Slot(str, result=bool)
def import_settings(self, file_path):
"""Import settings from a JSON file"""
try:
with open(file_path, 'r') as f:
import_data = json.load(f)
# Validate the import file
if 'settings' not in import_data:
self.exportStatusChanged.emit("Invalid settings file")
return False
settings = import_data['settings']
# Apply settings
if 'invert_gas' in settings:
self._invert_gas = settings['invert_gas']
self.invertGasChanged.emit(self._invert_gas)
if 'invert_brake' in settings:
self._invert_brake = settings['invert_brake']
self.invertBrakeChanged.emit(self._invert_brake)
if 'square_input' in settings:
self._square_input = settings['square_input']
self.squareInputChanged.emit(self._square_input)
if 'square_area' in settings:
self._square_area = settings['square_area']
self.squareAreaChanged.emit(self._square_area)
if 'deadzone_enabled' in settings:
self._deadzone_enabled = settings['deadzone_enabled']
self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
if 'deadzone' in settings:
self._deadzone = settings['deadzone']
self.deadzoneChanged.emit(self._deadzone)
if 'sensitivity_curve' in settings:
self._sensitivity_curve = settings['sensitivity_curve']
self.sensitivityCurveChanged.emit(self._sensitivity_curve)
if 'theme_mode' in settings:
theme_mode = str(settings['theme_mode']).strip().lower()
self._theme_mode = theme_mode if theme_mode in ("light", "dark") else "light"
self.themeModeChanged.emit(self._theme_mode)
if 'steering_range' in settings:
self._steering_range = settings['steering_range']
self.steeringRangeChanged.emit(self._steering_range)
if 'center_spring' in settings:
self._center_spring = settings['center_spring']
self.centerSpringChanged.emit(self._center_spring)
if 'center_spring_width' in settings:
self._center_spring_width = settings['center_spring_width']
self.centerSpringWidthChanged.emit(self._center_spring_width)
if 'center_spring_ramp' in settings:
self._center_spring_ramp = settings['center_spring_ramp'] if SOFT_RAMP_AVAILABLE else False
self.centerSpringRampChanged.emit(self._center_spring_ramp)
if 'center_spring_ramp_width' in settings:
self._center_spring_ramp_width = settings['center_spring_ramp_width']
self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
if 'neutral_steering_button' in settings:
self._neutral_steering_button = int(settings.get('neutral_steering_button', -1) or -1)
self.neutralSteeringButtonChanged.emit()
if 'steering_action_button' in settings:
self._steering_action_button = int(settings.get('steering_action_button', -1) or -1)
self.steeringActionChanged.emit()
if 'steering_action_max_percent' in settings:
self._steering_action_max_percent = max(0, min(100, int(settings.get('steering_action_max_percent', 0) or 0)))
self.steeringActionChanged.emit()
if 'steering_action_mode' in settings:
mode = str(settings.get('steering_action_mode', 'hold') or 'hold').lower()
self._steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
self._reset_steering_action_toggle()
self.steeringActionChanged.emit()
if 'button_mapping' in settings:
self.button_mapping = settings['button_mapping']
self.buttonMappingsChanged.emit()
self.update_button_status()
if 'user_profile_overrides' in settings:
self._user_profile_overrides = settings['user_profile_overrides']
if 'user_button_overrides' in settings:
self._user_button_overrides = settings['user_button_overrides']
if 'user_configured_devices' in settings:
self._user_configured_devices = settings['user_configured_devices']
if 'device_slots' in settings and isinstance(settings.get('device_slots'), list):
imported_slots = []
for i, slot_data in enumerate(settings.get('device_slots', [])[:4]):
if isinstance(slot_data, dict):
slot = DeviceSlot(slot_id=i)
slot.from_dict(slot_data)
imported_slots.append(slot)
if imported_slots:
self._device_slots = imported_slots
self._active_slot_index = max(0, min(int(settings.get('active_slot_index', 0) or 0), len(self._device_slots) - 1))
self._sync_ui_from_slot(self._active_slot_index)
self.activeSlotIndexChanged.emit(self._active_slot_index)
self.deviceSlotsChanged.emit()
self.save_settings(force=True)
self.exportStatusChanged.emit("Settings imported successfully!")
return True
except Exception as e:
self.exportStatusChanged.emit(f"Import failed: {str(e)}")
return False
@Slot()
def toggle_mapping(self):
if self._running:
self.stop_mapping()
else:
self.start_mapping()
def update_axis_values(self, values):
"""Update axis values from input reader thread"""
if values != self._axis_values:
self._axis_values = values
self.axisValuesChanged.emit()
def update_button_states(self, states):
"""Update button states from input reader thread"""
if states != self._button_states:
self._button_states = states
self.buttonStatesChanged.emit()
def _emit_preview_value(self, index, value, signal):
value = max(0.0, min(1.0, float(value)))
last_value = self._last_preview_values[index]
if last_value is None or abs(last_value - value) >= PREVIEW_EMIT_EPSILON:
self._last_preview_values[index] = value
signal.emit(value)
def _neutral_steering_active(self, button_states, button_index=None):
if button_index is None:
button_index = self._neutral_steering_button
try:
button_index = int(button_index)
except (TypeError, ValueError):
return False
return 0 <= button_index < len(button_states) and bool(button_states[button_index])
def _reset_steering_action_toggle(self, slot=None):
if slot is None:
self._steering_action_toggle_active = False
self._steering_action_last_pressed = False
return
slot.steering_action_toggle_active = False
slot.steering_action_last_pressed = False
def _steering_action_pressed(self, button_states, button_index):
try:
button_index = int(button_index)
except (TypeError, ValueError):
return False
return 0 <= button_index < len(button_states) and bool(button_states[button_index])
def _apply_steering_action_limit(self, value, button_states, slot=None, update_toggle=True):
"""Limit virtual steering output while the configured steering action is active."""
if slot is None:
button_index = self._steering_action_button
max_percent = self._steering_action_max_percent
mode = self._steering_action_mode
else:
button_index = slot.steering_action_button
max_percent = slot.steering_action_max_percent
mode = slot.steering_action_mode
try:
button_index = int(button_index)
except (TypeError, ValueError):
return value
if button_index < 0:
return value
try:
limit = max(0.0, min(1.0, float(max_percent) / 100.0))
except (TypeError, ValueError):
limit = 0.0
pressed = self._steering_action_pressed(button_states, button_index)
mode = str(mode or "hold").lower()
if mode == "toggle":
if slot is None:
if update_toggle:
if pressed and not self._steering_action_last_pressed:
self._steering_action_toggle_active = not self._steering_action_toggle_active
self._steering_action_last_pressed = pressed
active = self._steering_action_toggle_active
else:
if update_toggle:
if pressed and not slot.steering_action_last_pressed:
slot.steering_action_toggle_active = not slot.steering_action_toggle_active
slot.steering_action_last_pressed = pressed
active = slot.steering_action_toggle_active
else:
active = pressed
if not active:
return value
return max(-limit, min(limit, value))
def process_inputs(self):
"""Process input values for display and emulation"""
if not self.joystick or not self._axis_values:
return
try:
# Always update live input preview, regardless of emulation state
if self._current_profile in PROFILES:
mapping = PROFILES[self._current_profile]["axes"]
def get_axis_value(idx):
if idx < len(self._axis_values):
return self._axis_values[idx]
return 0.5
# Steering with square input
if mapping[0] < len(self._axis_values):
raw_value = (self._axis_values[mapping[0]] * 2) - 1 # Convert to -1 to 1
# Apply deadzone (if enabled)
if self._deadzone_enabled:
if abs(raw_value) < self._deadzone:
raw_value = 0.0
elif self._deadzone > 0:
# Scale the remaining range
sign = 1 if raw_value > 0 else -1
raw_value = sign * ((abs(raw_value) - self._deadzone) / (1.0 - self._deadzone))
if self._square_input:
# Apply square steering transformation
normalized = abs(raw_value)
square_threshold = self._square_area
if normalized <= square_threshold:
# Inside square area - linear response
scaled = normalized / square_threshold
else:
# Outside square area - jump to 100%
scaled = 1.0
# Apply direction
final_s = scaled if raw_value >= 0 else -scaled
steer = (final_s + 1) / 2
else:
# Normal circular steering (with deadzone applied)
steer = (raw_value + 1) / 2
if self._neutral_steering_active(self._button_states):
steer = 0.5
else:
preview_s = (steer * 2.0) - 1.0
preview_s = self._apply_steering_action_limit(
preview_s,
self._button_states,
update_toggle=False,
)
steer = (preview_s + 1.0) / 2.0
self._emit_preview_value(0, steer, self.steeringChanged)
# Gas with invert
if mapping[1] < len(self._axis_values):
gas = self._axis_values[mapping[1]]
if self._invert_gas:
gas = 1.0 - gas
self._emit_preview_value(1, gas, self.gasChanged)
# Brake with invert
if mapping[2] < len(self._axis_values):
brake = self._axis_values[mapping[2]]
if self._invert_brake:
brake = 1.0 - brake
self._emit_preview_value(2, brake, self.brakeChanged)
except Exception as e:
now = time.time()
if now - self._last_process_input_error_at > 1.0:
print(f"Error processing inputs: {e}")
self._last_process_input_error_at = now
@Slot()
def start_mapping(self):
if not self.joystick:
self.statusChanged.emit("No input device selected", "#ef4444")
return
# Check ViGEm before trying to create the gamepad
if not self.check_vigem_installed():
self._vigem_installed = False
self.vigEmInstalledChanged.emit(False)
self.vigEmStatusChanged.emit("not installed")
return
self._running = True
self._reset_steering_action_toggle()
self._session_start_time = time.time() # Start session timer
self.isRunningChanged.emit(True)
self.statusChanged.emit("Active: Emulating controller", "#22c55e")
# Apply hardware settings on start
self._reassert_hardware_controls(pulse=True)
# Start session timer updates
if not hasattr(self, 'session_timer'):
self.session_timer = QTimer()
self.session_timer.timeout.connect(self._update_session_time)
self.session_timer.start(1000) # Update every second
# Set up combined pedal reader for slot 0 if enabled
slot0 = self._device_slots[0] if self._device_slots else None
if slot0 and slot0.combined_mode and slot0.combined_pedal_device_name:
self._setup_combined_pedal_reader(slot0)
self._slot0_pedal_axes = []
if slot0.combined_pedal_input_reader:
slot0.combined_pedal_input_reader.axisValuesChanged.connect(
lambda vals: setattr(self, '_slot0_pedal_axes', vals))
else:
self._slot0_pedal_axes = None
# Start mapping thread
self.mapper_thread = threading.Thread(target=self.mapping_loop, daemon=True)
self.mapper_thread.start()
@Slot()
def stop_mapping(self):
self._running = False
self._reset_steering_action_toggle()
if self._macro_playing:
self.stop_macro_playback()
self._session_start_time = None # Stop session timer
if hasattr(self, 'session_timer'):
self.session_timer.stop()
self.sessionTimeChanged.emit("0:00")
self.isRunningChanged.emit(False)
self._restore_center_spring_baseline()
self._release_all_outputs()
# Clean up combined pedal reader for slot 0
slot0 = self._device_slots[0] if self._device_slots else None
if slot0 and slot0.combined_pedal_input_reader:
slot0.combined_pedal_input_reader.stop()
slot0.combined_pedal_input_reader = None
self._slot0_pedal_axes = None
self.statusChanged.emit("Ready to activate", "#71717a")
def _release_all_outputs(self):
"""Release virtual controller and keyboard outputs so nothing can stay latched."""
if self.vg_gamepad:
try:
self.vg_gamepad.left_joystick(x_value=0, y_value=0)
self.vg_gamepad.right_trigger(value=0)
self.vg_gamepad.left_trigger(value=0)
for btn_obj in XBOX_BUTTON_MAP.values():
if btn_obj:
self.vg_gamepad.release_button(btn_obj)
self.vg_gamepad.update()
except Exception:
pass
if KEYBOARD_AVAILABLE and self.keyboard:
for key_set in (self._pressed_keys, self._pressed_macro_keys):
for key_id in list(key_set):
mapping_name = key_id.split('_', 2)[-1] if '_' in key_id else ""
if mapping_name in KEYBOARD_MAP:
try:
self.keyboard.release(KEYBOARD_MAP[mapping_name])
except Exception:
pass
key_set.clear()
def _prepare_update_handoff(self):
"""Put hardware/output state in a safe state before the hidden updater takes over."""
try:
self._macro_recording = False
self._macro_playing = False
self._running = False
self.save_settings(force=True)
self.save_button_mapping(force=True)
self.save_macros()
if hasattr(self, "tray_manager"):
self.tray_manager.save_settings()
except Exception:
pass
self._restore_center_spring_baseline()
self._release_all_outputs()
def _on_device_disconnected(self):
"""Handle device disconnection detected by InputReader"""
was_running = self._running
# Remember device name for reconnection
if self.joystick:
try:
self._reconnect_device_name = self.joystick.get_name()
except Exception:
self._reconnect_device_name = None
# Stop emulation gracefully
if was_running:
self.stop_mapping()
self.joystick = None
self.deviceDisconnected.emit()
self.statusChanged.emit("Device disconnected - attempting to reconnect...", "#f59e0b")
print("Device disconnected, starting reconnect timer")
# Start periodic reconnect attempts
self._reconnect_timer.start(3000) # Try every 3 seconds
def _attempt_reconnect(self):
"""Periodically try to find and reconnect the disconnected device"""
try:
pygame.joystick.quit()
pygame.joystick.init()
count = pygame.joystick.get_count()
if count == 0:
return # No devices yet, keep trying
# Look for the previously connected device by name
for i in range(count):
try:
js = pygame.joystick.Joystick(i)
js.init()
name = js.get_name()
if self._reconnect_device_name and name == self._reconnect_device_name:
# Found it - reconnect
self._reconnect_timer.stop()
self.joystick = js
if self.input_reader:
self.input_reader.set_joystick(self.joystick)
# Rebuild device list
self.refresh_devices()
# Re-select this device
for idx, dev_str in enumerate(self._devices):
if self._reconnect_device_name in dev_str:
self.select_device(idx)
break
self.statusChanged.emit("Device reconnected!", "#22c55e")
print(f"Reconnected to {name}")
self._reconnect_device_name = None
return
js.quit()
except Exception:
pass
# Device not found by name, but there are devices - refresh list
# and let user pick
if not self._reconnect_device_name:
self._reconnect_timer.stop()
self.refresh_devices()
self.statusChanged.emit("Devices available - please select one", "#f59e0b")
except Exception as e:
print(f"Reconnect attempt failed: {e}")
def _update_session_time(self):
"""Update session time display"""
if self._session_start_time and self._running:
elapsed = int(time.time() - self._session_start_time)
hours = elapsed // 3600
minutes = (elapsed % 3600) // 60
seconds = elapsed % 60
if hours > 0:
time_str = f"{hours}:{minutes:02d}:{seconds:02d}"
else:
time_str = f"{minutes}:{seconds:02d}"
self.sessionTimeChanged.emit(time_str)
def apply_deadzone(self, raw_input):
"""Apply deadzone to input value"""
if not self._deadzone_enabled:
return raw_input
if abs(raw_input) < self._deadzone:
return 0.0
elif self._deadzone > 0:
# Scale the remaining range
sign = 1 if raw_input > 0 else -1
return sign * ((abs(raw_input) - self._deadzone) / (1.0 - self._deadzone))
return raw_input
def apply_square_steering(self, raw_input):
"""Apply square steering transformation based on configurable area"""
if not self._square_input:
return raw_input
normalized = abs(raw_input)
square_threshold = self._square_area
if normalized <= square_threshold:
# Inside square area - linear response
scaled = normalized / square_threshold
else:
# Outside square area - jump to 100%
scaled = 1.0
# Apply direction
return scaled if raw_input >= 0 else -scaled
def apply_steering_range(self, raw_input):
"""Hardware-only steering range. Intentionally no virtual scaling."""
return raw_input
def apply_center_spring(self, raw_input):
"""No-op: center spring is now set via hardware HID autocenter command."""
return raw_input
def _current_raw_steering_input(self):
"""Return the current physical steering axis as -1.0..1.0 before output transforms."""
try:
mapping = PROFILES.get(self._current_profile, PROFILES["Logitech G29 (Standard)"])["axes"]
axis_index = mapping[0]
if axis_index < len(self._axis_values):
return (float(self._axis_values[axis_index]) * 2.0) - 1.0
except Exception:
pass
return 0.0
def _soft_ramp_target_spring(self, raw_input):
"""Calculate baseline-to-bumpstop spring strength for the current steering input."""
base_spring = max(0.0, min(1.0, float(self._center_spring)))
half_range = max(5.0, self._steering_range / 2.0)
degrees = abs(max(-1.0, min(1.0, raw_input))) * half_range
ramp_width = max(1.0, min(float(self._center_spring_ramp_width), half_range))
ramp_start = max(0.0, half_range - ramp_width)
if degrees <= ramp_start:
return base_spring
ramp_progress = min(1.0, (degrees - ramp_start) / ramp_width)
smooth_progress = ramp_progress * ramp_progress * (3.0 - 2.0 * ramp_progress)
return base_spring + (1.0 - base_spring) * smooth_progress
def _restore_center_spring_baseline(self):
"""Leave the wheel at the configured baseline instead of the last bumpstop force."""
self._apply_center_spring_baseline(force=True)
def update_hardware_center_spring(self, raw_input):
"""Ramp Logitech center spring from baseline to full strength near steering lock."""
if not self._center_spring_ramp or not self._hardware_center_spring_supported():
return False
if not self._uses_logitech_hid():
return False
spring = round(max(0.0, min(1.0, self._soft_ramp_target_spring(raw_input))), 2)
now = time.time()
if abs(spring - self._last_hardware_spring) >= 0.02 and (now - self._last_hardware_spring_sent_at) >= 0.05:
self._last_hardware_spring = spring
self._last_hardware_spring_sent_at = now
return self._apply_hardware_center_spring(spring)
return True
def apply_sensitivity_curve(self, raw_input):
"""Apply sensitivity curve transformation to input value"""
import math
# Get absolute value and preserve sign
sign = 1 if raw_input >= 0 else -1
value = abs(raw_input)
curve = self._sensitivity_curve.lower()
if curve == 'linear':
# No transformation - direct 1:1 response
result = value
elif curve == 'smooth':
# Smooth curve - less sensitive at center, more gradual
# Using square root for gentler response
result = math.pow(value, 0.7)
elif curve == 'aggressive':
# Aggressive curve - more sensitive near center
# Using power function for sharper response
result = math.pow(value, 1.5)
elif curve == 's-curve':
# S-curve - smooth at extremes, responsive in middle
# Using sigmoid-like transformation
if value < 0.5:
result = 2 * math.pow(value, 2)
else:
result = 1 - 2 * math.pow(1 - value, 2)
else:
result = value # Default to linear
return sign * result
def mapping_loop(self):
"""Main emulation loop - runs in separate thread"""
if not VIGEM_AVAILABLE or vg is None:
self._running = False
self.statusChanged.emit("ViGEm driver not available", "#ef4444")
return
if not self.vg_gamepad:
self.vg_gamepad = vg.VX360Gamepad()
mapping = PROFILES[self._current_profile]["axes"]
while self._running:
try:
# Let macro playback own the virtual controller while it is active.
if self._macro_playing:
time.sleep(0.01)
continue
# Read directly from the input thread snapshot so controller
# output is not gated by queued UI-thread signal delivery.
if self.input_reader:
axis_values, button_states = self.input_reader.snapshot()
else:
axis_values = self._axis_values.copy() if self._axis_values else []
button_states = self._button_states.copy() if self._button_states else []
# STEERING with configurable square area
if mapping[0] < len(axis_values):
raw_s = (axis_values[mapping[0]] * 2) - 1 # Convert to -1 to 1
# Apply dynamic hardware center spring ramp near steering lock.
self.update_hardware_center_spring(raw_s)
# Apply deadzone
raw_s = self.apply_deadzone(raw_s)
# Apply square steering transformation
raw_s = self.apply_square_steering(raw_s)
# Apply sensitivity curve
final_s = self.apply_sensitivity_curve(raw_s)
# Clamp to valid range
final_s = max(-1.0, min(1.0, final_s))
if self._neutral_steering_active(button_states):
final_s = 0.0
else:
final_s = self._apply_steering_action_limit(final_s, button_states)
self.vg_gamepad.left_joystick(x_value=int(final_s * 32767), y_value=0)
# In combined mode, use pedal device axes for gas/brake
pedal_axes = None
slot0 = self._device_slots[0] if self._device_slots else None
if slot0 and slot0.combined_mode and slot0.combined_pedal_input_reader:
pedal_axes, _ = slot0.combined_pedal_input_reader.snapshot()
if not pedal_axes and hasattr(self, '_slot0_pedal_axes') and self._slot0_pedal_axes:
pedal_axes = self._slot0_pedal_axes.copy()
elif hasattr(self, '_slot0_pedal_axes') and self._slot0_pedal_axes:
pedal_axes = self._slot0_pedal_axes.copy()
# GAS with invert (from pedal device if combined)
gas_axes = pedal_axes if pedal_axes else axis_values
if mapping[1] < len(gas_axes):
norm_g = gas_axes[mapping[1]]
if self._invert_gas:
norm_g = 1.0 - norm_g
self.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))
# BRAKE with invert (from pedal device if combined)
brake_axes = pedal_axes if pedal_axes else axis_values
if mapping[2] < len(brake_axes):
norm_b = brake_axes[mapping[2]]
if self._invert_brake:
norm_b = 1.0 - norm_b
self.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))
# BUTTONS - Handle both Xbox buttons and keyboard keys
for btn_idx_str, mapping_name in list(self.button_mapping.items()):
try:
btn_idx = int(btn_idx_str)
except (TypeError, ValueError):
continue
if btn_idx < len(button_states):
button_pressed = button_states[btn_idx]
# Check if it's an Xbox button
xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
if xbox_obj:
# Handle Xbox button
if button_pressed:
self.vg_gamepad.press_button(xbox_obj)
else:
self.vg_gamepad.release_button(xbox_obj)
# Check if it's a keyboard key
elif KEYBOARD_AVAILABLE and mapping_name in KEYBOARD_MAP:
key = KEYBOARD_MAP[mapping_name]
key_id = f"{btn_idx}_{mapping_name}"
if button_pressed and key_id not in self._pressed_keys:
# Button just pressed - press key
try:
self.keyboard.press(key)
self._pressed_keys.add(key_id)
except Exception:
pass
elif not button_pressed and key_id in self._pressed_keys:
# Button just released - release key
try:
self.keyboard.release(key)
self._pressed_keys.remove(key_id)
except Exception:
pass
# Update virtual gamepad
self.vg_gamepad.update()
# Check macro button-combo triggers after applying current input state.
self.check_macro_triggers(button_states)
time.sleep(MAPPING_LOOP_SLEEP_SECONDS)
except (pygame.error, OSError) as e:
# Device disconnection during emulation
print(f"Device error in mapping loop: {e}")
self._running = False
self.isRunningChanged.emit(False)
# Trigger disconnect handler on the main thread
QTimer.singleShot(0, self._on_device_disconnected)
break
except Exception as e:
now = time.time()
if now - self._last_mapping_error_at > 1.0:
print(f"Error in mapping loop: {e}")
self._last_mapping_error_at = now
time.sleep(0.05)
# Clean up when stopping
self._release_all_outputs()
@Slot()
@Slot()
def check_for_updates(self):
"""Check if a new version is available"""
self._check_for_updates(quiet=False)
def _check_for_updates(self, quiet=False):
if self._update_check_in_progress:
return
self._update_check_in_progress = True
def check_update_thread():
try:
manifest = self._fetch_update_manifest()
latest_version = manifest["version"]
was_available = self._update_available
self._latest_version = latest_version
self._new_version = latest_version
self._update_manifest = manifest
if manifest.get("app") != "trueaxis":
raise ValueError("Update manifest is for a different app")
if manifest.get("channel") != UPDATE_CHANNEL:
raise ValueError("Update manifest is for a different channel")
if manifest.get("update_blocked"):
self._update_available = False
self.updateAvailableChanged.emit(False)
if not quiet:
self.statusChanged.emit("Up to date", "#22c55e")
return
if self._compare_versions(latest_version, CURRENT_VERSION) > 0:
self._update_available = True
self.updateAvailableChanged.emit(True)
if not was_available:
self._send_telemetry_event_async("update_available", {"latest_version": latest_version})
if not was_available or not quiet:
self.statusChanged.emit(f"Update available: v{latest_version}", "#22c55e")
else:
self._update_available = False
self.updateAvailableChanged.emit(False)
if not quiet:
self.statusChanged.emit("Up to date", "#22c55e")
except Exception as e:
print(f"Update check failed: {e}")
if not quiet:
self.statusChanged.emit("Couldn't check for updates", "#ef4444")
finally:
self._last_update_check_at = time.time()
self._update_check_in_progress = False
# Run in separate thread to not block UI
threading.Thread(target=check_update_thread, daemon=True).start()
def _consume_update_state(self):
"""Show the result written by the hidden updater helper after restart."""
try:
state = _load_json_with_backup(UPDATE_STATE_FILE)
if not isinstance(state, dict):
return
status = str(state.get("status", "")).lower()
version = str(state.get("version") or CURRENT_VERSION).lstrip("v")
message = str(state.get("message") or "")
install_dir = str(state.get("install_dir") or "").strip()
target = str(state.get("target") or "").strip()
if status == "success":
if install_dir:
self.statusChanged.emit(f"Updated to v{version} in {install_dir}", "#22c55e")
else:
self.statusChanged.emit(f"Updated to v{version}", "#22c55e")
self._send_telemetry_event_async("update_completed", {"target_version": version, "install_dir": install_dir, "target": target})
elif status == "failed":
self.statusChanged.emit(message or "Update failed; previous version restored", "#ef4444")
self._send_telemetry_event_async("update_failed", {"target_version": version, "message": message})
finally:
try:
if os.path.exists(UPDATE_STATE_FILE):
os.remove(UPDATE_STATE_FILE)
except Exception:
pass
def _compare_versions(self, v1, v2):
"""Compare two version strings. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal"""
try:
key1 = _version_sort_key(v1)
key2 = _version_sort_key(v2)
if key1 > key2:
return 1
if key1 < key2:
return -1
return 0
except Exception:
return 0
def _fetch_update_manifest(self):
manifest_url = VERSION_CHECK_URL + ("&" if "?" in VERSION_CHECK_URL else "?") + f"_={int(time.time())}"
request = urllib.request.Request(
manifest_url,
headers={
'User-Agent': f'TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
)
with urllib.request.urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
return self._normalize_update_manifest(data)
def _normalize_update_manifest(self, data):
if not isinstance(data, dict):
raise ValueError("Update manifest is not valid JSON")
version = str(data.get("version", "")).strip().lstrip("v")
if not version:
raise ValueError("Update manifest is missing version")
manifest_channel = str(data.get("channel") or data.get("edition") or UPDATE_CHANNEL).strip().lower()
if manifest_channel in ("comp", "competitive"):
manifest_channel = "competitive"
platform = str(data.get("platform") or UPDATE_PLATFORM).strip().lower()
if platform not in ("windows", "windows-x64", "win64", UPDATE_PLATFORM):
raise ValueError(f"Unsupported update platform: {platform}")
raw_download_url = str(data.get("download_url") or data.get("url") or "").strip()
if not raw_download_url:
raise ValueError("Update manifest is missing download_url")
release_notes = data.get("release_notes", data.get("changelog", []))
if isinstance(release_notes, str):
release_notes = [release_notes]
elif not isinstance(release_notes, list):
release_notes = []
size = data.get("size", data.get("size_bytes"))
try:
size = int(size) if size is not None else None
except (TypeError, ValueError):
size = None
installer_args = data.get("installer_args", DEFAULT_INSTALLER_ARGS)
if isinstance(installer_args, str):
installer_args = [installer_args]
elif not isinstance(installer_args, list):
installer_args = DEFAULT_INSTALLER_ARGS
safe_installer_args = []
for raw_arg in installer_args:
arg = str(raw_arg).strip()
normalized_arg = arg.upper()
if arg == normalized_arg and arg in ALLOWED_INSTALLER_ARGS:
safe_installer_args.append(arg)
elif normalized_arg in ALLOWED_INSTALLER_ARGS:
safe_installer_args.append(normalized_arg)
installer_args = safe_installer_args
download_url = urljoin(VERSION_CHECK_URL, raw_download_url)
parsed_download = urlparse(download_url)
if parsed_download.scheme != "https" or parsed_download.hostname not in TRUSTED_UPDATE_HOSTS:
raise ValueError("Update download URL is not trusted")
return {
"schema": int(data.get("schema", 1)),
"app": str(data.get("app", "trueaxis")).strip().lower(),
"channel": manifest_channel,
"platform": platform,
"version": version,
"released_at": data.get("released_at") or data.get("release_date", ""),
"min_supported_version": str(data.get("min_supported_version") or data.get("minimum_version") or "0").strip(),
"auto_update_min_version": str(data.get("auto_update_min_version") or data.get("min_supported_version") or data.get("minimum_version") or "0").strip(),
"mandatory": bool(data.get("mandatory", False)),
"update_blocked": bool(data.get("update_blocked", False)),
"update_block_reason": str(data.get("update_block_reason") or "").strip(),
"download_url": download_url,
"sha256": str(data.get("sha256", "")).strip().lower(),
"size": size,
"installer_type": str(data.get("installer_type", "inno")).strip().lower(),
"installer_args": installer_args,
"changelog": [str(item) for item in release_notes],
}
def _validate_install_manifest(self, manifest):
if manifest.get("app") != "trueaxis":
raise ValueError("Update manifest is for a different app")
if manifest.get("channel") != UPDATE_CHANNEL:
raise ValueError("Update manifest is for a different channel")
if manifest.get("update_blocked"):
reason = manifest.get("update_block_reason") or "Manual website download required"
raise ValueError(reason)
latest_version = manifest.get("version", "")
if self._compare_versions(latest_version, CURRENT_VERSION) <= 0:
raise ValueError("No newer update is available")
min_supported = manifest.get("min_supported_version") or "0"
auto_min = manifest.get("auto_update_min_version") or min_supported
if min_supported and self._compare_versions(CURRENT_VERSION, min_supported) < 0:
raise ValueError("This version is too old for automatic update; download from the website")
if auto_min and self._compare_versions(CURRENT_VERSION, auto_min) < 0:
raise ValueError("Automatic update is not supported from this version; download from the website")
parsed_download = urlparse(manifest.get("download_url", ""))
if parsed_download.scheme != "https" or parsed_download.hostname not in TRUSTED_UPDATE_HOSTS:
raise ValueError("Update download URL is not trusted")
@Slot()
def download_update(self):
"""Download the latest version from the website"""
import webbrowser
try:
manifest_url = self._update_manifest.get("download_url") if self._update_manifest else ""
webbrowser.open(manifest_url or DOWNLOAD_URL)
self.statusChanged.emit("Opening download page...", "#22c55e")
except Exception as e:
print(f"Failed to open download page: {e}")
self.statusChanged.emit("Failed to open download page", "#ef4444")
@Slot()
def install_update(self):
"""Automatically download and install the update with progress window"""
def update_thread():
update_file = None
batch_file = None
launcher_file = None
config_file = None
try:
# Show update window
self.updateWindowVisibleChanged.emit(True)
self.updateProgressChanged.emit(0)
self.updateStatusTextChanged.emit("Preparing update...")
time.sleep(0.5)
# Step 1: Download and validate release manifest
self.updateStatusTextChanged.emit("Checking release manifest...")
self.updateProgressChanged.emit(5)
manifest = self._fetch_update_manifest()
self._validate_install_manifest(manifest)
self._update_manifest = manifest
download_url = manifest["download_url"]
new_version = manifest["version"]
self._send_telemetry_event_async("update_started", {"target_version": new_version})
expected_sha256 = manifest.get("sha256", "")
expected_size = manifest.get("size")
if not expected_sha256:
raise ValueError("Automatic updates require a SHA-256 checksum")
# Step 2: Download update file
self.updateStatusTextChanged.emit("Downloading update...")
self.updateProgressChanged.emit(10)
download_request = urllib.request.Request(
download_url,
headers={'User-Agent': f'TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})'}
)
update_response = urllib.request.urlopen(download_request, timeout=120)
temp_dir = tempfile.gettempdir()
update_stamp = int(time.time())
update_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.exe")
batch_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.ps1")
launcher_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.vbs")
config_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.json")
log_file = UPDATE_HELPER_LOG_FILE
try:
with open(log_file, "w", encoding="utf-8") as helper_log:
helper_log.write(f"TrueAxis updater prepared for v{new_version}\n")
except Exception:
pass
# Download with progress and optional integrity checks.
total_size = int(update_response.headers.get('Content-Length') or 0)
downloaded = 0
hasher = hashlib.sha256()
with open(update_file, 'wb') as f:
while True:
chunk = update_response.read(8192)
if not chunk:
break
f.write(chunk)
hasher.update(chunk)
downloaded += len(chunk)
if total_size > 0:
progress = 10 + int((downloaded / total_size) * 40) # 10-50%
self.updateProgressChanged.emit(progress)
if total_size >= 1024 * 1024: # Only show MB if > 1MB
self.updateStatusTextChanged.emit(f"Downloading... {downloaded//1024//1024}MB / {total_size//1024//1024}MB")
else:
self.updateStatusTextChanged.emit(f"Downloading... {downloaded//1024}KB / {total_size//1024}KB")
update_response.close()
if expected_size and downloaded != expected_size:
raise ValueError(f"Downloaded {downloaded} bytes, expected {expected_size}")
downloaded_sha256 = hasher.hexdigest()
if expected_sha256:
self.updateStatusTextChanged.emit("Verifying download...")
self.updateProgressChanged.emit(52)
if downloaded_sha256.lower() != expected_sha256.lower():
raise ValueError("Downloaded update failed SHA-256 verification")
else:
print("Update manifest has no sha256; legacy update will continue without checksum verification.")
# Make sure file is fully written and closed
time.sleep(0.5)
# Step 3: Save settings
self.updateStatusTextChanged.emit("Saving settings...")
self.updateProgressChanged.emit(55)
self._prepare_update_handoff()
time.sleep(0.5)
# Step 4: Keep the UI responsive and hand off quickly. The helper
# process will close this PID if Windows keeps the exe locked.
self.updateStatusTextChanged.emit("Preparing restart...")
self.updateProgressChanged.emit(60)
time.sleep(0.1)
# Step 5: Get current executable path
self.updateStatusTextChanged.emit("Locating current installation...")
self.updateProgressChanged.emit(65)
# Get the path of the currently running executable
if getattr(sys, 'frozen', False):
# Running as compiled exe
current_exe = sys.executable
is_exe = True
else:
# Running as Python script
current_exe = os.path.abspath(__file__)
is_exe = False
current_process_name = os.path.basename(current_exe)
parent_pid = os.getpid()
time.sleep(0.3)
# Step 6: Create bulletproof update script
self.updateStatusTextChanged.emit("Creating update script...")
self.updateProgressChanged.emit(70)
# Determine where TrueAxis will be installed. For the onedir
# installer, only reuse the current folder when it already
# looks like a managed TrueAxis install; a portable exe in
# Downloads must not turn Downloads into the install folder.
default_install_dir = _default_trueaxis_install_dir()
default_install_path = os.path.join(default_install_dir, 'TrueAxis.exe')
installer_type = manifest.get("installer_type", "inno")
portable_update = installer_type in ("exe", "portable", "standalone", "pyinstaller")
current_basename = os.path.basename(current_exe).lower()
current_dir = os.path.dirname(current_exe)
managed_current_dir = is_exe and current_basename == "trueaxis.exe" and _looks_like_trueaxis_onedir_install(current_dir)
if installer_type == "trueaxis_silent":
current_install_dir = current_dir if managed_current_dir else default_install_dir
else:
current_install_dir = current_dir if (is_exe and current_basename == "trueaxis.exe") else default_install_dir
primary_start_path = current_exe if (portable_update and is_exe) else os.path.join(current_install_dir, "TrueAxis.exe")
primary_start_dir = os.path.dirname(primary_start_path) or os.getcwd()
state_file = UPDATE_STATE_FILE
mode = "replace" if (portable_update and is_exe) else ("dev" if portable_update else "installer")
start_paths = []
for candidate in (
primary_start_path,
default_install_path,
current_exe,
os.path.join(os.getenv('LOCALAPPDATA', ''), 'TrueAxis', 'TrueAxis.exe'),
os.path.join(os.getenv('APPDATA', ''), 'TrueAxis', 'TrueAxis.exe'),
os.path.join(os.getenv('ProgramFiles', ''), 'TrueAxis', 'TrueAxis.exe'),
os.path.join(os.getenv('ProgramFiles(x86)', ''), 'TrueAxis', 'TrueAxis.exe'),
r"C:\Program Files\TrueAxis\TrueAxis.exe",
):
if candidate and candidate not in start_paths:
start_paths.append(candidate)
manifest_installer_args = manifest.get("installer_args")
if manifest_installer_args is None:
manifest_installer_args = DEFAULT_INSTALLER_ARGS if installer_type == "inno" else []
helper_config = {
"new_version": new_version,
"mode": mode,
"parent_pid": parent_pid,
"current_exe": current_exe,
"current_process_name": current_process_name,
"update_file": update_file,
"state_file": state_file,
"log_file": log_file,
"launcher_file": launcher_file,
"config_file": config_file,
"primary_start_path": primary_start_path,
"primary_start_dir": primary_start_dir,
"start_paths": start_paths,
"installer_type": installer_type,
"installer_args": manifest_installer_args,
"install_dir": primary_start_dir,
"installer_log_file": log_file + ".installer.log",
}
with open(config_file, "w", encoding="utf-8") as f:
json.dump(helper_config, f)
# Create a hidden PowerShell helper for the update handoff.
batch_content = r'''
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
$configPath = '__CONFIG_FILE__'
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
function Log-Update([string]$message) {
try {
$line = ('{0} {1}' -f (Get-Date -Format o), $message)
Add-Content -LiteralPath $config.log_file -Value $line -Encoding UTF8
} catch {}
}
function Write-UpdateState([string]$status, [string]$message) {
try {
$targetPath = Join-Path -Path ([string]$config.install_dir) -ChildPath 'TrueAxis.exe'
$state = [ordered]@{
status = $status
version = [string]$config.new_version
install_dir = [string]$config.install_dir
target = [string]$targetPath
}
if ($message) { $state.message = $message }
($state | ConvertTo-Json -Compress) | Set-Content -LiteralPath $config.state_file -Encoding UTF8
} catch {
Log-Update ('Could not write state: ' + $_.Exception.Message)
}
}
function Start-TrueAxis([string]$path) {
try {
if ($path -and (Test-Path -LiteralPath $path)) {
$workdir = Split-Path -Parent $path
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
Start-Sleep -Milliseconds 750
Start-Process -FilePath $path -WorkingDirectory $workdir -WindowStyle Normal
return $true
}
} catch {
Log-Update ('Start failed for ' + $path + ': ' + $_.Exception.Message)
}
return $false
}
function Start-AnyTrueAxis {
foreach ($path in @($config.start_paths)) {
if (Start-TrueAxis ([string]$path)) { return $true }
}
return $false
}
Log-Update ('TrueAxis update helper started for v' + $config.new_version)
Log-Update ('Current exe: ' + $config.current_exe)
Log-Update ('Parent PID: ' + $config.parent_pid)
for ($i = 0; $i -lt 12; $i++) {
try {
$parent = Get-Process -Id ([int]$config.parent_pid) -ErrorAction Stop
Start-Sleep -Milliseconds 250
} catch {
break
}
}
try {
$parent = Get-Process -Id ([int]$config.parent_pid) -ErrorAction SilentlyContinue
if ($parent) {
Log-Update 'Parent process still alive; force-closing it by PID.'
Stop-Process -Id ([int]$config.parent_pid) -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
}
} catch {}
$installOk = $false
if ($config.mode -eq 'replace') {
$newExe = ([string]$config.current_exe) + '.new'
$backupExe = ([string]$config.current_exe) + '.bak'
for ($i = 1; $i -le 24; $i++) {
try {
Remove-Item -LiteralPath $newExe -Force -ErrorAction SilentlyContinue
Copy-Item -LiteralPath $config.update_file -Destination $newExe -Force
if (Test-Path -LiteralPath $config.current_exe) {
Copy-Item -LiteralPath $config.current_exe -Destination $backupExe -Force -ErrorAction SilentlyContinue
}
Move-Item -LiteralPath $newExe -Destination $config.current_exe -Force
if (Test-Path -LiteralPath $config.current_exe) {
$installOk = $true
break
}
} catch {
Log-Update ('Replace retry ' + $i + ' failed: ' + $_.Exception.Message)
try {
if ((Test-Path -LiteralPath $backupExe) -and -not (Test-Path -LiteralPath $config.current_exe)) {
Copy-Item -LiteralPath $backupExe -Destination $config.current_exe -Force -ErrorAction SilentlyContinue
}
} catch {}
Start-Sleep -Milliseconds 500
}
}
} elseif ($config.mode -eq 'dev') {
$config.start_paths = @($config.update_file)
$installOk = $true
} else {
try {
$args = @($config.installer_args)
if ($config.installer_type -eq 'inno') {
$args += ('/LOG="' + $config.installer_log_file + '"')
} elseif ($config.installer_type -eq 'trueaxis_silent') {
$args += ('/LOG="' + $config.installer_log_file + '"')
$args += ('/INSTALLDIR="' + $config.install_dir + '"')
$args += '/NOSTART'
}
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
$proc = Start-Process -FilePath $config.update_file -ArgumentList $args -Wait -PassThru -WindowStyle Hidden
$installOk = ($null -eq $proc.ExitCode -or $proc.ExitCode -eq 0)
if (-not $installOk) {
Log-Update ('Installer exit code: ' + $proc.ExitCode)
}
} catch {
Log-Update ('Installer failed: ' + $_.Exception.Message)
}
}
if ($installOk) {
Write-UpdateState 'success' ''
} else {
Write-UpdateState 'failed' 'Could not install the downloaded update'
Start-AnyTrueAxis | Out-Null
exit 1
}
try {
if ($config.mode -ne 'dev') {
Remove-Item -LiteralPath $config.update_file -Force -ErrorAction SilentlyContinue
}
} catch {}
Start-Sleep -Milliseconds 250
if (-not (Start-AnyTrueAxis)) {
Write-UpdateState 'failed' 'Updated file installed but TrueAxis could not restart automatically'
}
Log-Update 'TrueAxis update helper finished.'
try { Remove-Item -LiteralPath $config.launcher_file -Force -ErrorAction SilentlyContinue } catch {}
try { Remove-Item -LiteralPath $config.config_file -Force -ErrorAction SilentlyContinue } catch {}
try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}
'''.replace("__CONFIG_FILE__", config_file.replace("'", "''"))
powershell_exe = os.path.join(
os.environ.get("SystemRoot", r"C:\Windows"),
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
)
if not os.path.exists(powershell_exe):
powershell_exe = "powershell.exe"
powershell_args = [
powershell_exe,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
batch_file,
]
ps_command = subprocess.list2cmdline(powershell_args)
launcher_content = (
'Set shell = CreateObject("WScript.Shell")\r\n'
'shell.Environment("PROCESS")("PYINSTALLER_RESET_ENVIRONMENT") = "1"\r\n'
f'shell.Run "{ps_command.replace(chr(34), chr(34) + chr(34))}", 0, False\r\n'
)
# Write hidden helper files.
with open(batch_file, 'w', encoding="utf-8") as f:
f.write(batch_content)
with open(launcher_file, 'w', encoding="utf-8") as f:
f.write(launcher_content)
time.sleep(0.3)
# Step 7: Show restart message
self.updateStatusTextChanged.emit("Installing in background...")
self.updateProgressChanged.emit(80)
time.sleep(0.2)
self.updateStatusTextChanged.emit("Restarting now...")
self.updateProgressChanged.emit(100)
time.sleep(0.15)
# Step 8: Launch updater
if sys.platform == 'win32':
# Launch the PowerShell helper without a console. WScript is
# kept only as a fallback for unusual Windows launch errors.
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
with open(log_file, "a", encoding="utf-8", errors="replace") as helper_log:
helper_log.write("Launching hidden PowerShell update helper.\n")
helper_log.flush()
helper_env = os.environ.copy()
helper_env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
try:
subprocess.Popen(
powershell_args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
startupinfo=startupinfo,
close_fds=True,
cwd=temp_dir,
env=helper_env,
)
except Exception as launch_error:
helper_log.write(f"Direct PowerShell launch failed: {launch_error}\n")
helper_log.write("Falling back to hidden WScript launcher.\n")
helper_log.flush()
subprocess.Popen(
["wscript.exe", launcher_file],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
startupinfo=startupinfo,
close_fds=True,
cwd=temp_dir,
env=helper_env,
)
# Step 9: Hard-exit after the helper has its own process. Do not
# emit more Qt signals here; the UI stack may already be winding
# down, and update reliability matters more than graceful teardown.
time.sleep(0.05)
os._exit(0)
except Exception as e:
import traceback
print(f"Update installation failed: {e}")
print(traceback.format_exc())
error_msg = str(e)
try:
failed_version = (self._update_manifest or {}).get("version") or ""
self._send_telemetry_event_async("update_failed", {"target_version": failed_version, "message": error_msg})
except Exception:
pass
if "Permission denied" in error_msg:
self.updateStatusTextChanged.emit("Permission error - Try running as admin")
else:
self.updateStatusTextChanged.emit(f"Update failed: {error_msg[:50]}")
self.updateProgressChanged.emit(0)
time.sleep(3)
self.updateWindowVisibleChanged.emit(False)
self.statusChanged.emit(f"Update failed: {error_msg}", "#ef4444")
# Clean up files on error
if update_file and os.path.exists(update_file):
try:
os.remove(update_file)
except Exception:
pass
if batch_file and os.path.exists(batch_file):
try:
os.remove(batch_file)
except Exception:
pass
if launcher_file and os.path.exists(launcher_file):
try:
os.remove(launcher_file)
except Exception:
pass
if config_file and os.path.exists(config_file):
try:
os.remove(config_file)
except Exception:
pass
# Run in separate thread
threading.Thread(target=update_thread, daemon=True).start()
@Slot(result=bool)
def check_vigem_installed(self):
"""Check if ViGEm driver is installed on the system"""
if sys.platform != 'win32':
return True # Only check on Windows
if not VIGEM_AVAILABLE:
return False # vgamepad package itself failed to import
try:
# Try to create a virtual gamepad - if it works, ViGEm is installed
test_gamepad = vg.VX360Gamepad()
del test_gamepad
return True
except Exception as e:
print(f"ViGEm check failed: {e}")
return False
@Slot()
def update_vigem_status(self):
"""Update the ViGEm installation status"""
self._vigem_installed = self.check_vigem_installed()
self.vigEmInstalledChanged.emit(self._vigem_installed)
if not self._vigem_installed:
self.vigEmStatusChanged.emit("not installed")
self.statusChanged.emit("ViGEm driver not found", "#f59e0b")
else:
self.statusChanged.emit("ViGEm driver detected", "#22c55e")
@Slot()
def install_vigem_driver(self):
"""Automatically download and install ViGEm driver (silent, no user interaction)"""
def install_thread():
try:
self.vigEmStatusChanged.emit("Downloading ViGEm driver...")
# Download the installer (User-Agent required, GitHub rejects bare requests)
request = urllib.request.Request(
VIGEM_INSTALLER_URL,
headers={'User-Agent': 'TrueAxis/' + CURRENT_VERSION}
)
with urllib.request.urlopen(request, timeout=60) as response:
installer_data = response.read()
# Save to temp directory
temp_dir = tempfile.gettempdir()
installer_path = os.path.join(temp_dir, "ViGEmBus_Setup.exe")
with open(installer_path, 'wb') as f:
f.write(installer_data)
self.vigEmStatusChanged.emit("Launching ViGEm installer...")
# Launch the installer with visible UI so user can click through it.
# The installer will show its own prompts including reboot notification.
result = subprocess.run(
[installer_path],
capture_output=True,
timeout=300 # Increased timeout since user needs to click through
)
# Clean up the installer
try:
os.remove(installer_path)
except Exception:
pass
# Check if install actually worked.
# If vgamepad failed to import at startup, re-attempt it now
# that the driver has been installed.
global vg, VIGEM_AVAILABLE
if not VIGEM_AVAILABLE:
try:
import vgamepad as vg
VIGEM_AVAILABLE = True
except Exception as e:
print(f"Re-import of vgamepad failed after install: {e}")
if self.check_vigem_installed():
self._vigem_installed = True
self.vigEmInstalledChanged.emit(True)
self.vigEmStatusChanged.emit("installed")
self.statusChanged.emit("ViGEm driver installed", "#22c55e")
else:
self.vigEmStatusChanged.emit("Installation complete. Please restart your computer if prompted.")
except subprocess.TimeoutExpired:
self.vigEmStatusChanged.emit("Installation timed out. Please try again.")
except Exception as e:
print(f"ViGEm installation failed: {e}")
self.vigEmStatusChanged.emit(f"Installation failed: {str(e)}")
# Run in separate thread
threading.Thread(target=install_thread, daemon=True).start()
# ========== MACRO SYSTEM ==========
def _coerce_macro_duration_ms(self, value, default=0):
try:
duration = int(float(value))
except (TypeError, ValueError):
duration = int(default)
return max(0, min(300000, duration))
def _normalize_macro_action(self, action):
if not isinstance(action, dict):
return None
action_type = str(action.get('type', '')).strip().lower()
if action_type not in ('hold', 'tap', 'press', 'release', 'wait'):
return None
normalized = {'type': action_type}
if action_type != 'wait':
buttons = []
for btn in action.get('buttons', []):
try:
buttons.append(int(btn))
except (TypeError, ValueError):
continue
if not buttons:
return None
normalized['buttons'] = buttons
if action_type in ('wait', 'hold', 'tap'):
duration = action.get('duration_ms', action.get('duration', 0))
normalized['duration'] = self._coerce_macro_duration_ms(duration)
return normalized
def _normalize_macro_combo(self, button_combo):
buttons = []
for part in str(button_combo or "").split('+'):
part = part.strip()
if part.isdigit():
buttons.append(int(part))
if not buttons:
return ""
return '+'.join(str(btn) for btn in sorted(set(buttons)))
def _normalize_macro_payload(self, payload):
"""Normalize old and new macro files into the current schema."""
if not isinstance(payload, dict):
payload = {}
raw_macros = payload.get('macros', {})
if not isinstance(raw_macros, dict):
raw_macros = {}
normalized_macros = {}
for name, macro in raw_macros.items():
macro_name = str(name).strip()
if not macro_name or not isinstance(macro, dict):
continue
if 'actions' in macro:
actions = []
total_ms = 0
for action in macro.get('actions', []):
normalized_action = self._normalize_macro_action(action)
if not normalized_action:
continue
actions.append(normalized_action)
if normalized_action['type'] in ('wait', 'hold', 'tap'):
total_ms += normalized_action.get('duration', 0)
if not actions:
continue
normalized_macros[macro_name] = {
'actions': actions,
'duration': total_ms / 1000.0,
'created': macro.get('created', 'Unknown'),
'type': 'custom',
}
continue
data = macro.get('data', [])
if isinstance(data, list):
normalized_macros[macro_name] = {
'data': data,
'duration': float(macro.get('duration', 0) or 0),
'created': macro.get('created', 'Unknown'),
'type': 'recorded',
}
raw_triggers = payload.get('triggers', {})
normalized_triggers = {}
if isinstance(raw_triggers, dict):
for combo, macro_name in raw_triggers.items():
macro_name = str(macro_name).strip()
if macro_name not in normalized_macros:
continue
normalized_combo = self._normalize_macro_combo(combo)
if normalized_combo:
normalized_triggers[normalized_combo] = macro_name
return {
'schema_version': 2,
'macros': normalized_macros,
'triggers': normalized_triggers,
}
def _load_legacy_macro_payloads(self):
payloads = []
for filepath in LEGACY_MACRO_FILES:
if os.path.abspath(filepath) == os.path.abspath(MACRO_FILE):
continue
if os.path.exists(filepath):
payloads.append(_load_json_with_backup(filepath) or {})
return payloads
def load_macros(self):
"""Load recorded and custom macros from disk."""
try:
raw_data = _load_json_with_backup(MACRO_FILE)
# If the file exists but could not be read (locked at boot, AV scan,
# transient IO error), never treat that as "no macros" - a later
# save would overwrite the user's data with an empty payload.
read_failed = raw_data is None and os.path.exists(MACRO_FILE)
self._macros_load_failed = read_failed
raw_data = raw_data or {}
current_is_migrated = isinstance(raw_data, dict) and raw_data.get('schema_version') == 2
data = self._normalize_macro_payload(raw_data)
changed = not current_is_migrated
# Legacy files are one-time migration sources. Once the stable
# schema-2 macro file exists, do not merge legacy triggers again:
# a missing trigger can be an intentional "Clear Trigger" choice.
if not current_is_migrated:
for legacy_payload in self._load_legacy_macro_payloads():
legacy_data = self._normalize_macro_payload(legacy_payload)
for name, macro in legacy_data['macros'].items():
if name not in data['macros']:
data['macros'][name] = macro
changed = True
for combo, macro_name in legacy_data['triggers'].items():
if combo not in data['triggers'] and macro_name in data['macros']:
data['triggers'][combo] = macro_name
changed = True
self._macros = data['macros']
self._macro_triggers = data['triggers']
if changed and not read_failed:
self.save_macros()
self.macrosChanged.emit()
print(f"Loaded {len(self._macros)} macros and {len(self._macro_triggers)} triggers")
except Exception as e:
print(f"Error loading macros: {e}")
self._macros = {}
self._macro_triggers = {}
self._macros_load_failed = True
def save_macros(self):
"""Save recorded and custom macros to disk."""
try:
# Guard against wiping a good file after a failed load: if loading
# failed and we still have nothing in memory, keep the disk file.
if getattr(self, '_macros_load_failed', False) and not self._macros and not self._macro_triggers:
print("Skipping macro save: load failed earlier and memory is empty")
return
self._macros_load_failed = False
_atomic_json_save(MACRO_FILE, {
'schema_version': 2,
'macros': self._macros,
'triggers': self._macro_triggers,
})
except Exception as e:
print(f"Error saving macros: {e}")
@Slot(str)
def start_macro_recording(self, name):
"""Start recording axis and button input frames."""
name = (name or "").strip()
if not name:
self.statusChanged.emit("Macro name is required", "#ef4444")
return
if self._macro_playing:
self.statusChanged.emit("Cannot record while a macro is playing", "#ef4444")
return
if self._macro_recording:
self.statusChanged.emit("A macro is already recording", "#ef4444")
return
if not self.joystick and not self._axis_values and not self._button_states:
self.statusChanged.emit("Select an input device before recording", "#ef4444")
return
self._macro_recording = True
self._macro_record_name = name
self._macro_record_data = []
self._macro_record_start_time = time.time()
self.macroRecordingChanged.emit(True)
self.statusChanged.emit(f"Recording macro '{name}'...", "#f59e0b")
threading.Thread(target=self._macro_record_loop, daemon=True).start()
def _macro_record_loop(self):
"""Record input snapshots at roughly 20 FPS."""
while self._macro_recording:
try:
elapsed = time.time() - self._macro_record_start_time
if self.input_reader:
axis_values, button_states = self.input_reader.snapshot()
else:
axis_values = self._axis_values.copy() if self._axis_values else []
button_states = self._button_states.copy() if self._button_states else []
if not self._macro_record_data or elapsed - self._macro_record_data[-1]['time'] >= 0.05:
self._macro_record_data.append({
'time': elapsed,
'axes': axis_values,
'buttons': button_states,
})
time.sleep(0.01)
except Exception as e:
print(f"Error during macro recording: {e}")
time.sleep(0.1)
@Slot()
def stop_macro_recording(self):
"""Stop recording and save the macro."""
if not self._macro_recording:
return
self._macro_recording = False
self.macroRecordingChanged.emit(False)
has_input_frames = any(frame.get('axes') or frame.get('buttons') for frame in self._macro_record_data)
if self._macro_record_data and has_input_frames:
self._macros[self._macro_record_name] = {
'data': self._macro_record_data,
'duration': self._macro_record_data[-1]['time'],
'created': time.strftime('%Y-%m-%d %H:%M:%S'),
'type': 'recorded',
}
self.save_macros()
self.macrosChanged.emit()
self.statusChanged.emit(
f"Macro '{self._macro_record_name}' saved ({len(self._macro_record_data)} frames)",
"#22c55e",
)
else:
self.statusChanged.emit("Recording cancelled: no input data", "#ef4444")
self._macro_record_name = ""
self._macro_record_data = []
@Slot(str)
def play_macro(self, name):
"""Play a recorded or custom macro."""
if name not in self._macros:
self.statusChanged.emit(f"Macro '{name}' not found", "#ef4444")
return
if self._macro_recording:
self.statusChanged.emit("Stop recording before playing a macro", "#ef4444")
return
if not self._running:
self.statusChanged.emit("Start emulation before playing macros", "#ef4444")
return
with self._macro_play_lock:
if self._macro_playing:
self.statusChanged.emit("A macro is already playing", "#ef4444")
return
self._macro_playing = True
self.macroPlayingChanged.emit(True)
self.statusChanged.emit(f"Playing macro '{name}'...", "#d4d4d4")
self._macro_play_thread = threading.Thread(
target=self._macro_play_loop,
args=(name,),
daemon=True,
)
self._macro_play_thread.start()
def _macro_play_loop(self, name):
"""Run macro playback on a background thread."""
pressed_buttons = set()
try:
macro = self._macros[name]
if 'actions' in macro:
pressed_buttons = self._play_custom_macro_actions(macro['actions'])
else:
pressed_buttons = self._play_recorded_macro_data(macro.get('data', []))
if self._running and self._macro_playing:
self.statusChanged.emit(f"Macro '{name}' completed", "#22c55e")
except Exception as e:
print(f"Error playing macro: {e}")
self.statusChanged.emit(f"Macro playback error: {e}", "#ef4444")
finally:
for btn_idx in pressed_buttons:
try:
self._set_virtual_button(btn_idx, False, update=False)
except Exception:
pass
self._release_all_outputs()
with self._macro_play_lock:
self._macro_playing = False
self.macroPlayingChanged.emit(False)
def _macro_wait_ms(self, duration_ms):
"""Wait for a macro duration while staying responsive to stop requests."""
end_time = time.time() + max(0, int(duration_ms)) / 1000.0
while time.time() < end_time:
if not self._macro_playing or not self._running:
return False
time.sleep(min(0.01, max(0.001, end_time - time.time())))
return self._macro_playing and self._running
def _play_custom_macro_actions(self, actions):
"""Play a custom macro made of button and wait actions."""
pressed_buttons = set()
for action in actions:
if not self._macro_playing or not self._running:
break
action_type = action.get('type')
buttons = [int(btn) for btn in action.get('buttons', [])]
duration = self._coerce_macro_duration_ms(action.get('duration_ms', action.get('duration', 0)))
if action_type == 'press':
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
pressed_buttons.add(btn_idx)
elif action_type == 'release':
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
pressed_buttons.discard(btn_idx)
elif action_type == 'wait':
if not self._macro_wait_ms(duration):
break
elif action_type == 'hold':
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
pressed_buttons.add(btn_idx)
self._macro_wait_ms(duration)
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
pressed_buttons.discard(btn_idx)
if not self._macro_playing or not self._running:
break
elif action_type == 'tap':
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
pressed_buttons.add(btn_idx)
self._macro_wait_ms(duration)
for i, btn_idx in enumerate(buttons):
self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
pressed_buttons.discard(btn_idx)
if not self._macro_playing or not self._running:
break
return pressed_buttons
def _set_virtual_button(self, button_index, state, update=True):
"""Set a mapped Xbox or keyboard output during macro playback."""
btn_idx = int(button_index)
mapping_name = self.button_mapping.get(str(btn_idx))
if not mapping_name:
return
xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
if xbox_obj and self.vg_gamepad:
if state:
self.vg_gamepad.press_button(xbox_obj)
else:
self.vg_gamepad.release_button(xbox_obj)
if update:
self.vg_gamepad.update()
return
if KEYBOARD_AVAILABLE and self.keyboard and mapping_name in KEYBOARD_MAP:
key = KEYBOARD_MAP[mapping_name]
key_id = f"macro_{btn_idx}_{mapping_name}"
try:
if state and key_id not in self._pressed_macro_keys:
self.keyboard.press(key)
self._pressed_macro_keys.add(key_id)
elif not state and key_id in self._pressed_macro_keys:
self.keyboard.release(key)
self._pressed_macro_keys.remove(key_id)
except Exception:
pass
def _release_macro_keys(self):
"""Release keyboard keys pressed by macro playback."""
if not (KEYBOARD_AVAILABLE and self.keyboard):
self._pressed_macro_keys.clear()
return
for key_id in list(self._pressed_macro_keys):
mapping_name = key_id.split('_', 2)[2] if key_id.count('_') >= 2 else ""
if mapping_name in KEYBOARD_MAP:
try:
self.keyboard.release(KEYBOARD_MAP[mapping_name])
except Exception:
pass
self._pressed_macro_keys.clear()
def _play_recorded_macro_data(self, data):
"""Play back a recorded macro with full axis and button data."""
mapping = PROFILES.get(self._current_profile, PROFILES["Logitech G29 (Standard)"])["axes"]
pressed_buttons = set()
start_time = time.time()
for frame in data:
if not self._macro_playing or not self._running:
break
target_time = start_time + float(frame.get('time', 0))
while time.time() < target_time and self._macro_playing and self._running:
time.sleep(0.001)
if not self._macro_playing or not self._running:
break
axes = frame.get('axes', [])
buttons = frame.get('buttons', [])
if self.vg_gamepad:
if mapping[0] < len(axes):
raw_s = (axes[mapping[0]] * 2) - 1
self.update_hardware_center_spring(raw_s)
raw_s = self.apply_deadzone(raw_s)
raw_s = self.apply_square_steering(raw_s)
final_s = self.apply_sensitivity_curve(raw_s)
final_s = max(-1.0, min(1.0, final_s))
try:
_, live_buttons = self.input_reader.snapshot() if self.input_reader else ([], self._button_states)
except Exception:
live_buttons = self._button_states
if self._neutral_steering_active(live_buttons):
final_s = 0.0
else:
final_s = self._apply_steering_action_limit(final_s, live_buttons)
self.vg_gamepad.left_joystick(x_value=int(final_s * 32767), y_value=0)
if mapping[1] < len(axes):
norm_g = axes[mapping[1]]
if self._invert_gas:
norm_g = 1.0 - norm_g
self.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))
if mapping[2] < len(axes):
norm_b = axes[mapping[2]]
if self._invert_brake:
norm_b = 1.0 - norm_b
self.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))
for btn_idx_str in list(self.button_mapping.keys()):
try:
btn_idx = int(btn_idx_str)
except ValueError:
continue
if btn_idx < len(buttons):
button_pressed = bool(buttons[btn_idx])
self._set_virtual_button(btn_idx, button_pressed, update=False)
if button_pressed:
pressed_buttons.add(btn_idx)
else:
pressed_buttons.discard(btn_idx)
if self.vg_gamepad:
self.vg_gamepad.update()
return pressed_buttons
@Slot()
def stop_macro_playback(self):
"""Stop the currently playing macro."""
if self._macro_playing:
with self._macro_play_lock:
self._macro_playing = False
self._release_all_outputs()
self.macroPlayingChanged.emit(False)
self.statusChanged.emit("Macro playback stopped", "#71717a")
@Slot(str)
def delete_macro(self, name):
"""Delete a macro and any triggers pointing at it."""
if name in self._macros:
del self._macros[name]
for trigger in [k for k, v in self._macro_triggers.items() if v == name]:
del self._macro_triggers[trigger]
self.save_macros()
self.macrosChanged.emit()
self.statusChanged.emit(f"Macro '{name}' deleted", "#71717a")
@Slot(str, str)
def set_macro_trigger(self, button_combo, macro_name):
"""Assign a pressed button combination to a macro."""
button_combo = self._normalize_macro_combo(button_combo)
macro_name = (macro_name or "").strip()
if not button_combo:
self.statusChanged.emit("Press a button combo first", "#ef4444")
return
if not macro_name:
if button_combo in self._macro_triggers:
del self._macro_triggers[button_combo]
self.save_macros()
self.macrosChanged.emit()
self.statusChanged.emit(f"Trigger {button_combo} cleared", "#71717a")
return
if macro_name not in self._macros:
self.statusChanged.emit(f"Macro '{macro_name}' not found", "#ef4444")
return
for existing_combo in [k for k, v in self._macro_triggers.items() if v == macro_name and k != button_combo]:
del self._macro_triggers[existing_combo]
self._active_macro_buttons.discard(existing_combo)
self._macro_triggers[button_combo] = macro_name
self._active_macro_buttons.discard(button_combo)
self.save_macros()
self.macrosChanged.emit()
self.statusChanged.emit(f"Trigger {button_combo} -> '{macro_name}'", "#22c55e")
@Slot(str)
def clear_macro_triggers(self, macro_name):
"""Clear all button-combo triggers for a macro."""
macro_name = (macro_name or "").strip()
if not macro_name:
self.statusChanged.emit("No macro selected", "#ef4444")
return
removed = [k for k, v in self._macro_triggers.items() if v == macro_name]
for trigger in removed:
del self._macro_triggers[trigger]
self._active_macro_buttons.discard(trigger)
self.save_macros()
self.macrosChanged.emit()
if removed:
self.statusChanged.emit(f"Cleared triggers for '{macro_name}'", "#71717a")
else:
self.statusChanged.emit(f"No triggers set for '{macro_name}'", "#71717a")
@Slot(result=str)
def get_macro_list(self):
"""Return macro metadata as a JSON string for QML."""
macro_list = []
for name, data in self._macros.items():
triggers = [k for k, v in self._macro_triggers.items() if v == name]
frames = len(data.get('data', data.get('actions', [])))
macro_list.append({
'name': name,
'duration': round(float(data.get('duration', 0)), 2),
'frames': frames,
'created': data.get('created', 'Unknown'),
'triggers': triggers,
'type': data.get('type', 'recorded'),
})
return json.dumps(macro_list)
@Slot(int, result=str)
@Slot(str, result=str)
def get_button_name(self, button_index):
"""Get a readable name for a physical button index."""
btn_str = str(button_index)
if btn_str in self.button_mapping:
return f"Btn {btn_str} ({self.button_mapping[btn_str]})"
return f"Button {btn_str}"
@Property(bool, notify=macroRecordingChanged)
def macro_recording(self):
return self._macro_recording
@Property(bool, notify=macroPlayingChanged)
def macro_playing(self):
return self._macro_playing
@Slot(str, str, result=bool)
def create_custom_macro(self, name, actions_json):
"""Create a custom macro from a JSON action list."""
name = (name or "").strip()
if not name:
self.statusChanged.emit("Macro name is required", "#ef4444")
return False
try:
actions = json.loads(actions_json)
if not isinstance(actions, list) or not actions:
self.statusChanged.emit("Add at least one macro action", "#ef4444")
return False
total_duration = 0
normalized_actions = []
for action in actions:
normalized = self._normalize_macro_action(action)
if not normalized:
self.statusChanged.emit("Invalid macro action", "#ef4444")
return False
if normalized['type'] in ['wait', 'hold', 'tap']:
total_duration += normalized.get('duration', 0)
normalized_actions.append(normalized)
self._macros[name] = {
'actions': normalized_actions,
'duration': total_duration / 1000.0,
'created': time.strftime('%Y-%m-%d %H:%M:%S'),
'type': 'custom',
}
self.save_macros()
self.macrosChanged.emit()
self.statusChanged.emit(f"Custom macro '{name}' created", "#22c55e")
return True
except json.JSONDecodeError as e:
self.statusChanged.emit(f"Invalid JSON: {str(e)}", "#ef4444")
return False
except Exception as e:
self.statusChanged.emit(f"Error creating macro: {str(e)}", "#ef4444")
return False
def check_macro_triggers(self, button_states=None):
"""Start macros when their configured button combinations are pressed."""
if button_states is None:
_, button_states = self.input_reader.snapshot() if self.input_reader else ([], [])
if not button_states:
button_states = self._button_states
if not self._running or not button_states or self._macro_playing or self._macro_recording:
return
pressed = {str(i) for i, state in enumerate(button_states) if state}
matches = []
for trigger_combo, macro_name in list(self._macro_triggers.items()):
trigger_buttons = {part for part in trigger_combo.split('+') if part}
if trigger_buttons and trigger_buttons.issubset(pressed):
matches.append((trigger_combo, macro_name, len(trigger_buttons)))
elif trigger_combo in self._active_macro_buttons:
self._active_macro_buttons.remove(trigger_combo)
if not matches:
return
trigger_combo, macro_name, _ = max(matches, key=lambda item: (item[2], len(item[0])))
if trigger_combo not in self._active_macro_buttons:
self._active_macro_buttons.add(trigger_combo)
threading.Thread(target=lambda name=macro_name: self.play_macro(name), daemon=True).start()
class MainWindowHandler(QObject):
"""Handles main window events to intercept minimize button"""
def __init__(self, main_window, tray_manager, start_minimized=False):
super().__init__()
self.main_window = main_window
self.tray_manager = tray_manager
self.start_minimized = start_minimized
def eventFilter(self, obj, event):
"""Intercept window state change events to handle minimize button"""
if obj is self.main_window and event.type() == QEvent.Type.WindowStateChange:
# Check if window was minimized
if self.main_window.windowState() == Qt.WindowState.WindowMinimized:
# Check if tray feature is enabled
if self.tray_manager.hideToTrayEnabled and self.tray_manager.trayAvailable:
# Hide window to tray
self.main_window.hide()
self.tray_manager.show_tray()
return True # Event handled
return False # Event not handled
if __name__ == "__main__":
# Check for command line arguments
launched_from_startup = "--startup" in sys.argv
start_minimized = "--minimized" in sys.argv
# Use QApplication instead of QGuiApplication to support QSystemTrayIcon
app = QApplication(sys.argv)
app.setWindowIcon(create_app_icon())
# Set application style and fonts
app.setStyle("Fusion")
# Don't quit when last window is closed (we'll manage this manually)
app.setQuitOnLastWindowClosed(False)
# Create and initialize backend
backend = TrueAxisBackend()
backend._launched_from_startup = launched_from_startup
# Persist state when the process is asked to quit for ANY reason,
# including Windows shutdown/restart (WM_ENDSESSION -> Qt quit). The QML
# onClosing handler never runs in that case for a tray-resident app, so
# without this hook, anything not yet saved is lost on PC restart.
def _save_all_on_quit():
try:
backend.force_save_settings()
except Exception as exc:
print(f"Save-on-quit settings failed: {exc}")
try:
backend.save_button_mapping(force=True)
except Exception as exc:
print(f"Save-on-quit buttons failed: {exc}")
try:
backend.save_macros()
except Exception as exc:
print(f"Save-on-quit macros failed: {exc}")
try:
backend.tray_manager.save_settings()
except Exception as exc:
print(f"Save-on-quit tray failed: {exc}")
app.aboutToQuit.connect(_save_all_on_quit)
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("backend", backend)
engine.rootContext().setContextProperty("trayManager", backend.tray_manager)
engine.loadData(QML_CODE.encode())
if not engine.rootObjects():
print("ERROR: Failed to load QML!")
sys.exit(-1)
# Get the main window and set it in the tray manager
main_window = engine.rootObjects()[0]
backend.tray_manager.main_window = main_window
# Refresh UI to sync with loaded settings (do this after QML is ready)
QTimer.singleShot(500, backend.refresh_ui_from_settings)
# Install event filter to intercept minimize button
window_handler = MainWindowHandler(main_window, backend.tray_manager, backend._start_minimized)
main_window.installEventFilter(window_handler)
# Set window icon
main_window.setIcon(create_app_icon())
# Handle start minimized
if backend._start_minimized or start_minimized:
# Check if tray is available
if backend.tray_manager.trayAvailable:
main_window.hide()
backend.tray_manager.show_tray()
backend.statusChanged.emit("Running in system tray", "#22c55e")
else:
# If tray not available, just minimize to taskbar
main_window.showMinimized()
# Save settings before app quits
app.aboutToQuit.connect(backend._restore_center_spring_baseline)
app.aboutToQuit.connect(lambda: backend.save_settings(force=True))
app.aboutToQuit.connect(lambda: backend.tray_manager.save_settings())
sys.exit(app.exec())