import copy import json import uuid from pathlib import Path CONFIG_PATH = Path(__file__).with_name("wheel_bindings.json") ACTION_ORDER = [ "spotify pause play", "spotify next track", "spotify previous track", "spotify vol up", "spotify vol down", "spotify mute toggle", ] ACTION_LABELS = { "": "None", "spotify pause play": "Pause / Play", "spotify next track": "Next Track", "spotify previous track": "Previous Track", "spotify vol up": "Volume Up", "spotify vol down": "Volume Down", "spotify mute toggle": "Mute Toggle", } DEFAULT_WHEEL_PROFILE_ID = "friend_directinput_wheel" DEFAULT_WHEEL_BINDINGS = { "button:33": "spotify vol up", "button:31": "spotify vol down", "button:22": "spotify next track", "button:16": "spotify previous track", "button:21": "spotify pause play", "button:19": "spotify mute toggle", } XBOX_360_BINDINGS = { "button:0": "spotify pause play", "button:1": "spotify next track", "button:2": "spotify previous track", "button:3": "spotify vol up", "button:4": "spotify vol down", "button:5": "spotify mute toggle", } def makeDefaultConfig(): return { "version": 1, "default_profile_id": DEFAULT_WHEEL_PROFILE_ID, "button_presses_logged": True, "global_settings": { "debounce_seconds": 0.25, "volume_increment": 5, "polling_interval_ms": 120, }, "profiles": [ { "id": DEFAULT_WHEEL_PROFILE_ID, "name": "Friend DirectInput Wheel", "controller": None, "bindings": copy.deepcopy(DEFAULT_WHEEL_BINDINGS), "button_settings": {}, }, { "id": "xbox_360_default", "name": "Xbox 360 Default", "controller": None, "bindings": copy.deepcopy(XBOX_360_BINDINGS), "button_settings": {}, }, ], } def loadConfig(): if not CONFIG_PATH.exists(): config = makeDefaultConfig() saveConfig(config) return config with CONFIG_PATH.open("r", encoding="utf-8") as file: data = json.load(file) if isFlatBindingFile(data): config = makeDefaultConfig() config["profiles"][0]["bindings"] = data saveConfig(config) return config config = normalizeConfig(data) saveConfig(config) return config def saveConfig(config): with CONFIG_PATH.open("w", encoding="utf-8") as file: json.dump(config, file, indent=4) file.write("\n") def isFlatBindingFile(data): if not isinstance(data, dict): return False if "profiles" in data: return False return all(key.startswith("button:") for key in data.keys()) def normalizeConfig(config): defaultConfig = makeDefaultConfig() if not isinstance(config, dict): return defaultConfig config.setdefault("version", 1) config.setdefault("default_profile_id", DEFAULT_WHEEL_PROFILE_ID) config.setdefault("button_presses_logged", True) config.setdefault("global_settings", {}) config["global_settings"].setdefault("debounce_seconds", 0.25) config["global_settings"].setdefault("volume_increment", 5) config["global_settings"].setdefault("polling_interval_ms", 120) config.setdefault("profiles", []) existingProfileIds = {profile.get("id") for profile in config["profiles"] if isinstance(profile, dict)} for defaultProfile in defaultConfig["profiles"]: if defaultProfile["id"] not in existingProfileIds: config["profiles"].append(defaultProfile) for profile in config["profiles"]: profile.setdefault("id", makeProfileId(profile.get("name", "Controller"))) profile.setdefault("name", "Controller") profile.setdefault("controller", None) profile.setdefault("bindings", {}) profile.setdefault("button_settings", {}) return config def getProfile(config, profileId): for profile in config.get("profiles", []): if profile.get("id") == profileId: return profile return None def getDefaultProfile(config): profile = getProfile(config, config.get("default_profile_id")) if profile is not None: return profile return config["profiles"][0] def makeProfileId(profileName): cleaned = "".join(character.lower() if character.isalnum() else "_" for character in profileName) cleaned = "_".join(part for part in cleaned.split("_") if part) if not cleaned: cleaned = "controller" return f"{cleaned}_{uuid.uuid4().hex[:8]}" def controllerFromJoystick(joystick): joystick.init() return { "index": joystick.get_id(), "instance_id": joystick.get_instance_id(), "name": joystick.get_name(), "guid": joystick.get_guid(), "numaxes": joystick.get_numaxes(), "numbuttons": joystick.get_numbuttons(), "numhats": joystick.get_numhats(), } def stableControllerIdentity(controller): return { "name": controller.get("name"), "guid": controller.get("guid"), "numaxes": controller.get("numaxes"), "numbuttons": controller.get("numbuttons"), "numhats": controller.get("numhats"), } def profileHasController(profile): controller = profile.get("controller") return isinstance(controller, dict) and bool(controller.get("name") or controller.get("guid")) def controllerMatches(profile, controller): storedController = profile.get("controller") if not isinstance(storedController, dict): return False if storedController.get("guid") and controller.get("guid"): return storedController.get("guid") == controller.get("guid") return ( storedController.get("name") == controller.get("name") and storedController.get("numaxes") == controller.get("numaxes") and storedController.get("numbuttons") == controller.get("numbuttons") and storedController.get("numhats") == controller.get("numhats") ) def findMatchingProfile(config, controller): for profile in config.get("profiles", []): if controllerMatches(profile, controller): return profile return None def saveControllerProfile(config, controller, profileName, bindings, buttonSettings=None, existingProfileId=None): profile = getProfile(config, existingProfileId) if existingProfileId else None if profile is None or not profileHasController(profile): profile = { "id": makeProfileId(profileName), "name": profileName, "controller": stableControllerIdentity(controller), "bindings": {}, "button_settings": {}, } config["profiles"].append(profile) profile["name"] = profileName profile["controller"] = stableControllerIdentity(controller) profile["bindings"] = copy.deepcopy(bindings) profile["button_settings"] = copy.deepcopy(buttonSettings or {}) saveConfig(config) return profile