diff --git a/.gitignore b/.gitignore index 9cd9129..3cb5776 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ /extra *~ +.opencode diff --git a/oversteer/application.py b/oversteer/application.py index 4d8eb2f..8f7edd9 100644 --- a/oversteer/application.py +++ b/oversteer/application.py @@ -2,79 +2,236 @@ from locale import gettext as _ import logging import os +import signal import subprocess +import sys +import time from .device_manager import DeviceManager from .model import Model -import sys -from xdg.BaseDirectory import save_config_path +from .profile_auto_switcher import ProfileAutoSwitcher +from xdg.BaseDirectory import save_config_path, get_runtime_dir -class Application: +def _pid_path(): + try: + runtime = get_runtime_dir() + except Exception: + runtime = None + if runtime: + return os.path.join(runtime, "oversteer.pid") + return os.path.join(save_config_path("oversteer"), "oversteer.pid") + + +def _acquire_lock(): + """Acquire a single-instance lock. Returns (lock_file, True) on success, + (None, False) if another instance is running.""" + path = _pid_path() + try: + lock_fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644) + except OSError: + return None, False + try: + import fcntl + + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, ImportError): + os.close(lock_fd) + return None, False + os.truncate(lock_fd, 0) + os.write(lock_fd, str(os.getpid()).encode()) + return lock_fd, True + + +def _release_lock(lock_fd): + if lock_fd is None: + return + try: + import fcntl + + fcntl.flock(lock_fd, fcntl.LOCK_UN) + except (OSError, ImportError): + pass + os.close(lock_fd) + try: + os.remove(_pid_path()) + except OSError: + pass + + +class Application: def __init__(self, version, pkgdatadir, icondir): self.version = version self.datadir = pkgdatadir self.icondir = icondir - self.udev_path = self.datadir + '/udev/' - self.target_dir = '/etc/udev/rules.d/' - self.profile_path = os.path.join(save_config_path('oversteer'), 'profiles') + self.udev_path = self.datadir + "/udev/" + self.target_dir = "/etc/udev/rules.d/" + self.profile_path = None self.device_manager = None + self.args = None if not os.path.isdir(self.udev_path): self.udev_path = None def run(self, argv): - parser = argparse.ArgumentParser(prog=argv[0], description=_("Oversteer - Steering Wheel Manager")) - parser.add_argument('command', nargs='*', help=_("Run as command's companion")) - parser.add_argument('--device', help=_("Device path")) - parser.add_argument('--list', action='store_true', help=_("list connected devices")) - parser.add_argument('--mode', help=_("set the compatibility mode")) - parser.add_argument('--range', type=int, help=_("set the rotation range [40-900]")) - parser.add_argument('--combine-pedals', type=int, dest='combine_pedals', help=_("combine pedals [0-2]")) - parser.add_argument('--autocenter', type=int, help=_("set the autocenter strength [0-100]")) - parser.add_argument('--ff-gain', type=int, help=_("set the FF gain [0-100]")) - parser.add_argument('--spring-level', type=int, help=_("set the spring level [0-100]")) - parser.add_argument('--damper-level', type=int, help=_("set the damper level [0-100]")) - parser.add_argument('--friction-level', type=int, help=_("set the friction level [0-100]")) - parser.add_argument('--ffb-leds', action='store_true', default=None, help=_("enable FFBmeter leds")) - parser.add_argument('--no-ffb-leds', dest='ffb_leds', action='store_false', default=None, help=_("disable FFBmeter leds")) - parser.add_argument('--center-wheel', action='store_true', default=None, help=_("center wheel")) - parser.add_argument('--no-center-wheel', dest='center_wheel', action='store_false', default=None, help=_("don't center wheel")) - parser.add_argument('--start-manually', action='store_true', default=None, help=_("run command manually")) - parser.add_argument('--no-start-manually', dest='start_manually', action='store_false', default=None, - help=_("don't run command manually")) - parser.add_argument('-p', '--profile', help=_("load settings from a profile")) - parser.add_argument('-g', '--gui', action='store_true', help=_("start the GUI")) - parser.add_argument('--debug', action='store_true', help=_("enable debug output")) - parser.add_argument('--version', action='store_true', help=_("show version")) + parser = argparse.ArgumentParser( + prog=argv[0], description=_("Oversteer - Steering Wheel Manager") + ) + parser.add_argument("command", nargs="*", help=_("Run as command's companion")) + parser.add_argument("--device", help=_("Device path")) + parser.add_argument( + "--list", action="store_true", help=_("list connected devices") + ) + parser.add_argument("--mode", help=_("set the compatibility mode")) + parser.add_argument( + "--range", type=int, help=_("set the rotation range [40-900]") + ) + parser.add_argument( + "--combine-pedals", + type=int, + dest="combine_pedals", + help=_("combine pedals [0-2]"), + ) + parser.add_argument( + "--autocenter", type=int, help=_("set the autocenter strength [0-100]") + ) + parser.add_argument("--ff-gain", type=int, help=_("set the FF gain [0-100]")) + parser.add_argument( + "--spring-level", type=int, help=_("set the spring level [0-100]") + ) + parser.add_argument( + "--damper-level", type=int, help=_("set the damper level [0-100]") + ) + parser.add_argument( + "--friction-level", type=int, help=_("set the friction level [0-100]") + ) + parser.add_argument( + "--ffb-leds", + action="store_true", + default=None, + help=_("enable FFBmeter leds"), + ) + parser.add_argument( + "--no-ffb-leds", + dest="ffb_leds", + action="store_false", + default=None, + help=_("disable FFBmeter leds"), + ) + parser.add_argument( + "--center-wheel", action="store_true", default=None, help=_("center wheel") + ) + parser.add_argument( + "--no-center-wheel", + dest="center_wheel", + action="store_false", + default=None, + help=_("don't center wheel"), + ) + parser.add_argument("--profile", help=_("load a profile")) + parser.add_argument( + "--list-profiles", action="store_true", help=_("list profiles") + ) + parser.add_argument("--gui", action="store_true", help=_("start the GUI")) + parser.add_argument( + "--start-manually", + dest="start_manually", + action="store_true", + default=None, + help=_("start manually"), + ) + parser.add_argument( + "--no-start-manually", + dest="start_manually", + action="store_false", + default=None, + help=_("start automatically"), + ) + parser.add_argument( + "--watch", + action="store_true", + help=_("watch for games and auto-switch profiles (daemon mode)"), + ) + parser.add_argument( + "--watch-interval", + type=float, + default=2.0, + help=_("poll interval in seconds for --watch (default: 2)"), + ) + parser.add_argument( + "--default-profile", + help=_("default profile when no game is running (--watch mode)"), + ) + parser.add_argument( + "--profile-path", + help=_("profile directory path (default: user config dir)"), + ) args = parser.parse_args(argv[1:]) - argc = len(sys.argv[1:]) - - if args.version: - print("Oversteer v" + self.version) - exit(0) + self.args = args - if args.debug: - argc -= 1 + if args.profile_path: + self.profile_path = args.profile_path else: - logging.disable(level=logging.INFO) + sudo_user = os.environ.get("SUDO_USER") + if sudo_user and os.geteuid() == 0: + import pwd + + user_home = pwd.getpwnam(sudo_user).pw_dir + self.profile_path = os.path.join( + user_home, ".config", "oversteer", "profiles" + ) + else: + self.profile_path = os.path.join( + save_config_path("oversteer"), "profiles" + ) + + debug = bool(os.environ.get("OVERSTEER_DEBUG")) + log_level = logging.DEBUG if debug else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + force=True, + ) + if not debug: + logging.getLogger("matplotlib").setLevel(logging.WARNING) self.device_manager = DeviceManager() - self.device_manager.start() + self.device_manager.init_device_list() if args.list: - argc -= 1 - devices = self.device_manager.get_devices() - print(_("Devices found:")) - for device in devices: - print(" {}: {}".format(device.dev_name, device.name)) - exit(0) + for device in self.device_manager.get_devices(): + print(device.name + " " + device.dev_name) + return + + if args.list_profiles: + if os.path.isdir(self.profile_path): + for f in sorted(os.listdir(self.profile_path)): + if f.endswith(".ini"): + print(f[:-4]) + return + + lock_fd, locked = _acquire_lock() + if not locked: + print(_("Another instance of Oversteer is already running.")) + return 1 + + try: + return self._run_body(args, argv, lock_fd) + finally: + _release_lock(lock_fd) + + def _run_body(self, args, argv, lock_fd): + if args.watch: + return self._run_watch(args) + + # --- Normal / GUI mode --- + argc = len(argv) - 1 if args.profile is not None: - profile_file = os.path.join(self.profile_path, args.profile + '.ini') - if not os.path.exists(profile_file): + profile_file = os.path.join(self.profile_path, args.profile + ".ini") + if not os.path.isfile(profile_file): print(_("This profile doesn't exist.")) - exit(-1) + return 1 if args.device is not None: argc -= 1 @@ -90,12 +247,22 @@ def run(self, argv): if not start_gui and device and not device.check_permissions(): if self.udev_path: - print(_("You don't have the required permissions to change your wheel settings.") + " " + - _("You can fix it yourself by copying the files in {} to the {} directory and rebooting.") - .format(self.udev_path, self.target_dir)) + print( + _( + "You don't have the required permissions to change your wheel settings." + ) + + " " + + _( + "You can fix it yourself by copying the files in {} to the {} directory and rebooting." + ).format(self.udev_path, self.target_dir) + ) else: - print(_("You don't have the required permissions to change your wheel settings.")) - exit(-1) + print( + _( + "You don't have the required permissions to change your wheel settings." + ) + ) + return 1 if not device: print(_("No device available.")) @@ -103,7 +270,7 @@ def run(self, argv): model = Model(device) if args.profile is not None: - profile_file = os.path.join(self.profile_path, args.profile + '.ini') + profile_file = os.path.join(self.profile_path, args.profile + ".ini") model.load(profile_file) if args.mode is not None: model.set_mode(args.mode) @@ -129,6 +296,7 @@ def run(self, argv): if start_gui: self.args = args from oversteer.gui import Gui + Gui(self, model, argv) return @@ -136,3 +304,73 @@ def run(self, argv): if args.command: subprocess.Popen(args.command, shell=True) + def _run_watch(self, args): + """Headless daemon: watch for game processes and auto-switch profiles.""" + device = None + if args.device is not None: + if os.path.exists(args.device): + device = self.device_manager.get_device(os.path.realpath(args.device)) + else: + device = self.device_manager.first_device() + + if not device: + print(_("No device available.")) + return 1 + + if not device.check_permissions(): + print( + _( + "You don't have the required permissions to change your wheel settings." + ) + ) + return 1 + + model = Model(device) + + # Apply initial profile if specified + if args.profile is not None: + profile_file = os.path.join(self.profile_path, args.profile + ".ini") + if os.path.isfile(profile_file): + model.load(profile_file) + model.flush_device() + print(f"Loaded initial profile: {args.profile}") + + print(f"Oversteer watching for games (interval: {args.watch_interval}s)") + print(f"Device: {device.name}") + print(f"Profile path: {self.profile_path}") + if args.default_profile: + print(f"Default profile: {args.default_profile}") + print() + + stop_event = [] + + def handle_signal(signum, frame): + print("\nStopping watch...") + stop_event.append(True) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + switcher = ProfileAutoSwitcher( + model=model, + profile_path=self.profile_path, + poll_interval=args.watch_interval, + on_profile_change=lambda name: print( + f"Switched to profile: {name}" if name else "Reverted to DEFAULT" + ), + headless=True, + ) + + if args.default_profile: + switcher.set_default_profile(args.default_profile) + + switcher.start() + + try: + while not stop_event: + time.sleep(1) + except KeyboardInterrupt: + pass + + switcher.stop() + print("Watch stopped.") diff --git a/oversteer/auto_switch_ui.py b/oversteer/auto_switch_ui.py new file mode 100644 index 0000000..dec997d --- /dev/null +++ b/oversteer/auto_switch_ui.py @@ -0,0 +1,353 @@ +""" +Injects auto-switch widgets into the existing Tools tab programmatically. +No Glade modifications needed. +""" + +import os +from locale import gettext as _ + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk + +from .process_watcher import get_running_processes + + +class ProcessPickerDialog: + REFRESH_INTERVAL_MS = 2000 + + def __init__(self, parent_window, current_processes): + self.current_processes = set( + p.strip().lower() for p in current_processes.split(",") if p.strip() + ) + self.all_processes = sorted(get_running_processes()) + self.selected = set() + self._refresh_id = None + + self.dialog = Gtk.Dialog( + _("Pick running processes"), + parent_window, + Gtk.DialogFlags.MODAL, + ( + _("Cancel"), + Gtk.ResponseType.CANCEL, + _("Add selected"), + Gtk.ResponseType.OK, + ), + ) + self.dialog.set_default_size(420, 480) + self.dialog.set_default_response(Gtk.ResponseType.OK) + self.dialog.connect("response", self._on_response) + + vbox = self.dialog.get_content_area() + vbox.set_spacing(8) + vbox.set_margin_top(8) + vbox.set_margin_bottom(8) + vbox.set_margin_start(8) + vbox.set_margin_end(8) + + search_entry = Gtk.SearchEntry() + search_entry.set_visible(True) + search_entry.set_can_focus(True) + search_entry.set_placeholder_text(_("Filter processes…")) + vbox.pack_start(search_entry, False, False, 0) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_visible(True) + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_shadow_type(Gtk.ShadowType.IN) + scrolled.set_vexpand(True) + vbox.pack_start(scrolled, True, True, 0) + + self.listbox = Gtk.ListBox() + self.listbox.set_visible(True) + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.listbox.set_activate_on_single_click(False) + scrolled.add(self.listbox) + + self.check_rows = {} + self._rebuild_rows(self.all_processes) + + self.filter_entry = search_entry + self.filter_entry.connect("search-changed", self._on_filter_changed) + + self.listbox.set_filter_func(self._filter_func) + self.listbox.show_all() + + def _rebuild_rows(self, processes): + for child in self.listbox.get_children(): + self.listbox.remove(child) + self.check_rows.clear() + + for proc in processes: + row = Gtk.ListBoxRow() + row.set_visible(True) + row.set_activatable(True) + row.set_selectable(False) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + hbox.set_visible(True) + hbox.set_margin_start(6) + hbox.set_margin_end(6) + hbox.set_margin_top(4) + hbox.set_margin_bottom(4) + + check = Gtk.CheckButton() + check.set_visible(True) + check.set_can_focus(False) + if proc.lower() in self.current_processes or proc in self.selected: + check.set_active(True) + self.selected.add(proc) + hbox.pack_start(check, False, False, 0) + + label = Gtk.Label(label=proc) + label.set_visible(True) + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + hbox.pack_start(label, True, True, 0) + + row.add(hbox) + self.listbox.add(row) + self.check_rows[proc] = (row, check, label) + + check.connect("toggled", self._on_check_toggled, proc) + row.connect("activate", self._on_row_activated, check) + + def _refresh(self): + new_procs = sorted(get_running_processes()) + current_keys = set(self.check_rows.keys()) + new_keys = set(new_procs) + if new_keys != current_keys: + self._rebuild_rows(new_procs) + self.listbox.show_all() + self.all_processes = new_procs + return True + + def _on_check_toggled(self, check, proc): + if check.get_active(): + self.selected.add(proc) + else: + self.selected.discard(proc) + + def _on_row_activated(self, row, check): + check.set_active(not check.get_active()) + + def _on_filter_changed(self, entry): + self.listbox.invalidate_filter() + + def _filter_func(self, row): + text = self.filter_entry.get_text().strip().lower() + if not text: + return True + for proc, (r, check, label) in self.check_rows.items(): + if r == row: + return text in proc.lower() + return True + + def _on_response(self, dialog, response_id): + if self._refresh_id is not None: + GLib.source_remove(self._refresh_id) + self._refresh_id = None + + def run(self): + self._refresh_id = GLib.timeout_add(self.REFRESH_INTERVAL_MS, self._refresh) + response = self.dialog.run() + result = sorted(self.selected) if response == Gtk.ResponseType.OK else None + self.dialog.destroy() + return result + + +def setup_auto_switch_widgets(ui, handlers): + """ + Add an 'Auto-switch profiles' toggle and a 'Game processes' entry + to the Tools tab ListBox. Call after ui.start(). + """ + notebook = ui.builder.get_object("main_window").get_children()[0].get_children()[1] + tools_content = notebook.get_nth_page(2) + tools_listbox = tools_content.get_children()[0] + + # --- Separator --- + sep_row = Gtk.ListBoxRow() + sep_row.set_visible(True) + sep_row.set_selectable(False) + sep_row.set_activatable(False) + sep = Gtk.Separator() + sep.set_visible(True) + sep_row.add(sep) + tools_listbox.add(sep_row) + + # --- Auto-switch toggle row --- + switch_row = Gtk.ListBoxRow() + switch_row.set_visible(True) + switch_row.set_size_request(-1, 70) + switch_row.set_selectable(False) + switch_row.set_activatable(False) + + switch_box = Gtk.Box() + switch_box.set_visible(True) + switch_box.set_valign(Gtk.Align.CENTER) + switch_box.set_spacing(64) + switch_box.set_tooltip_text( + _("Automatically switch profiles when a game process is detected.") + ) + + switch_label = Gtk.Label() + switch_label.set_visible(True) + switch_label.set_halign(Gtk.Align.START) + switch_label.set_label(_("Auto-switch profiles")) + switch_box.pack_start(switch_label, True, True, 0) + + auto_switch_switch = Gtk.Switch() + auto_switch_switch.set_visible(True) + auto_switch_switch.set_can_focus(True) + auto_switch_switch.connect("state-set", handlers.on_auto_switch_state_set) + switch_box.pack_end(auto_switch_switch, False, True, 0) + + switch_row.add(switch_box) + tools_listbox.add(switch_row) + + # --- Game processes row (label above, entry + button below) --- + entry_row = Gtk.ListBoxRow() + entry_row.set_visible(True) + entry_row.set_selectable(False) + entry_row.set_activatable(False) + + entry_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + entry_vbox.set_visible(True) + entry_vbox.set_margin_top(6) + entry_vbox.set_margin_bottom(6) + entry_vbox.set_margin_start(12) + entry_vbox.set_margin_end(12) + + entry_label = Gtk.Label() + entry_label.set_visible(True) + entry_label.set_halign(Gtk.Align.START) + entry_label.set_label(_("Game processes")) + entry_vbox.pack_start(entry_label, False, False, 0) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + hbox.set_visible(True) + + game_processes_entry = Gtk.Entry() + game_processes_entry.set_visible(True) + game_processes_entry.set_can_focus(True) + game_processes_entry.set_hexpand(True) + game_processes_entry.set_placeholder_text(_("e.g. AMS2.exe, ForzaHorizon5.exe")) + game_processes_entry.connect("changed", handlers.on_game_processes_changed) + game_processes_entry.connect( + "focus-out-event", handlers.on_game_processes_focus_out + ) + hbox.pack_start(game_processes_entry, True, True, 0) + + pick_button = Gtk.Button(label=_("Pick…")) + pick_button.set_visible(True) + pick_button.set_can_focus(True) + pick_button.set_tooltip_text(_("Pick from running processes")) + hbox.pack_end(pick_button, False, False, 0) + + entry_vbox.pack_start(hbox, False, False, 0) + + # Tag box showing selected processes as removable pills + tag_box = Gtk.FlowBox() + tag_box.set_visible(True) + tag_box.set_max_children_per_line(100) + tag_box.set_selection_mode(Gtk.SelectionMode.NONE) + tag_box.set_min_children_per_line(0) + entry_vbox.pack_start(tag_box, False, False, 0) + + entry_row.add(entry_vbox) + tools_listbox.add(entry_row) + + tools_listbox.show_all() + + def refresh_tags(): + for child in tag_box.get_children(): + tag_box.remove(child) + text = game_processes_entry.get_text().strip() + if not text: + tag_box.set_visible(False) + return + tag_box.set_visible(True) + for part in text.split(","): + name = part.strip() + if not name: + continue + pill = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) + pill.set_visible(True) + lbl = Gtk.Label(label=name) + lbl.set_visible(True) + lbl.set_margin_start(6) + lbl.set_margin_end(2) + pill.pack_start(lbl, False, False, 0) + + btn = Gtk.Button() + btn.set_visible(True) + btn.set_can_focus(False) + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.set_label("✕") + btn.connect( + "clicked", + lambda _w, n=name: remove_process(n), + ) + pill.pack_end(btn, False, False, 0) + + flow_child = Gtk.FlowBoxChild() + flow_child.set_visible(True) + flow_child.set_can_focus(False) + flow_child.add(pill) + tag_box.add(flow_child) + tag_box.show_all() + + def remove_process(name): + text = game_processes_entry.get_text().strip() + parts = [p.strip() for p in text.split(",") if p.strip()] + parts = [p for p in parts if p != name] + game_processes_entry.set_text(", ".join(parts)) + refresh_tags() + + def on_pick_clicked(widget): + parent = ui.builder.get_object("main_window") + picker = ProcessPickerDialog(parent, game_processes_entry.get_text()) + result = picker.run() + if result is not None: + existing = [ + p.strip() + for p in game_processes_entry.get_text().split(",") + if p.strip() + ] + for proc in result: + if proc not in existing: + existing.append(proc) + game_processes_entry.set_text(", ".join(existing)) + refresh_tags() + + pick_button.connect("clicked", on_pick_clicked) + + game_processes_entry.connect("changed", lambda _w: refresh_tags()) + + refresh_tags() + + ui.auto_switch_switch = auto_switch_switch + ui.game_processes_entry = game_processes_entry + + def set_auto_switch(state): + auto_switch_switch.set_state(bool(state)) + + def get_auto_switch(): + return auto_switch_switch.get_active() + + def set_game_processes(value): + if value is None: + game_processes_entry.set_text("") + else: + game_processes_entry.set_text(str(value)) + refresh_tags() + + def get_game_processes(): + return game_processes_entry.get_text() or None + + ui.set_auto_switch = set_auto_switch + ui.get_auto_switch = get_auto_switch + ui.set_game_processes = set_game_processes + ui.get_game_processes = get_game_processes diff --git a/oversteer/device_manager.py b/oversteer/device_manager.py index f30a4f8..491346b 100644 --- a/oversteer/device_manager.py +++ b/oversteer/device_manager.py @@ -5,9 +5,10 @@ from .device import Device from . import wheel_ids as wid -class DeviceManager: +class DeviceManager: def __init__(self): + self.callback = None self.supported_wheels = { wid.CM_C5: 1080, wid.FT_CSL_DD: 1080, @@ -50,15 +51,16 @@ def __init__(self): wid.TS_PC: 1080, wid.TM_TX: 900, wid.XX_FFBOARD: 1080, - wid.FF_FLASHFIRE_900R: 900, + wid.FF_FLASHFIRE_900R: 900, } self.devices = {} self.changed = True - def start(self): + def start(self, callback=None): + self.callback = callback context = pyudev.Context() monitor = pyudev.Monitor.from_netlink(context) - monitor.filter_by('input') + monitor.filter_by("input") self.observer = pyudev.MonitorObserver(monitor, self.register_event) self.init_device_list() self.observer.start() @@ -71,14 +73,23 @@ def register_event(self, action, udevice): if id is None: return logging.debug("Udev event %s: %s", action, id) - if action == 'add': + if action == "add": self.update_device_list(udevice) device = self.get_device(id) if device: time.sleep(5) device.enable() self.changed = True - if action == 'remove': + if self.callback: + self.callback() + if action == "remove": + device = self.get_device(id) + if device: + device.disable() + self.changed = True + if self.callback: + self.callback() + if action == "remove": device = self.get_device(id) if device: device.disable() @@ -86,13 +97,15 @@ def register_event(self, action, udevice): def init_device_list(self): context = pyudev.Context() - for udevice in context.list_devices(subsystem='input', ID_INPUT_JOYSTICK=1): + for udevice in context.list_devices(subsystem="input", ID_INPUT_JOYSTICK=1): self.update_device_list(udevice) - logging.debug('Devices: %s', self.devices) - for key in self.devices: - logging.debug("%s: %s", key, vars(self.devices[key])) + logging.debug( + "Found device: %s (%s)", + self.devices[key].name, + self.devices[key].dev_name, + ) self.changed = True @@ -100,34 +113,37 @@ def update_device_list(self, udevice): id = udevice.device_path device_node = udevice.device_node - if not id or not device_node or not 'event' in udevice.get('DEVNAME'): + if not id or not device_node or not "event" in udevice.get("DEVNAME"): return - usb_id = str(udevice.get('ID_VENDOR_ID')) + ':' + str(udevice.get('ID_MODEL_ID')) + usb_id = ( + str(udevice.get("ID_VENDOR_ID")) + ":" + str(udevice.get("ID_MODEL_ID")) + ) if not usb_id in self.supported_wheels: return - logging.debug("update_device_list: %s %s", id, device_node) - if id not in self.devices: self.devices[id] = Device(self, {}) device = self.devices[id] - logging.debug("%s: ID_VENDOR_ID: %s ID_MODEL_ID: %s", device_node, - udevice.get('ID_VENDOR_ID'), udevice.get('ID_MODEL_ID')) - - device.set({ - 'id': id, - 'vendor_id': udevice.get('ID_VENDOR_ID'), - 'product_id': udevice.get('ID_MODEL_ID'), - 'usb_id': usb_id, - 'dev_name': device_node, - 'dev_path': os.path.realpath(os.path.join(udevice.sys_path, 'device', 'device')), - 'name': bytes(udevice.get('ID_VENDOR_ENC') + ' ' + udevice.get('ID_MODEL_ENC'), - 'utf-8').decode('unicode_escape'), - 'max_range': self.supported_wheels[usb_id], - }) + device.set( + { + "id": id, + "vendor_id": udevice.get("ID_VENDOR_ID"), + "product_id": udevice.get("ID_MODEL_ID"), + "usb_id": usb_id, + "dev_name": device_node, + "dev_path": os.path.realpath( + os.path.join(udevice.sys_path, "device", "device") + ), + "name": bytes( + udevice.get("ID_VENDOR_ENC") + " " + udevice.get("ID_MODEL_ENC"), + "utf-8", + ).decode("unicode_escape"), + "max_range": self.supported_wheels[usb_id], + } + ) def first_device(self): if self.devices: @@ -137,12 +153,17 @@ def first_device(self): def get_devices(self): return list(self.devices.values()) + def get_device_by_id(self, device_id): + return self.get_device(device_id) + def get_device(self, did): if did is None: return None if did in self.devices: return self.devices[did] - return next((item for item in self.devices.values() if item.dev_name == did), None) + return next( + (item for item in self.devices.values() if item.dev_name == did), None + ) def is_changed(self): changed = self.changed diff --git a/oversteer/gtk_handlers.py b/oversteer/gtk_handlers.py index e7bb146..7df74fe 100644 --- a/oversteer/gtk_handlers.py +++ b/oversteer/gtk_handlers.py @@ -2,11 +2,12 @@ from locale import gettext as _ import threading import traceback -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk -class GtkHandlers: +class GtkHandlers: @property def model(self): return self.controller.model @@ -19,6 +20,7 @@ def format_wheel_range_value(self, scale, value): return str(round(value * 10)) def on_main_window_destroy(self, *args): + self.controller.stop_auto_switch() self.ui.quit() def on_preferences_window_delete_event(self, *args): @@ -66,14 +68,14 @@ def on_wheel_range_value_changed(self, widget): self.model.set_range(wrange) self.ui.overlay_wheel_range.set_label(str(wrange)) - def on_overlay_decrange_clicked(self, widget): + def on_overlay_decrange_click(self, widget): adjustment = self.ui.wheel_range.get_adjustment() step = adjustment.get_step_increment() self.ui.wheel_range.set_value(self.ui.wheel_range.get_value() - step) wrange = int(self.ui.wheel_range.get_value() * 10) self.ui.overlay_wheel_range.set_label(str(wrange)) - def on_overlay_incrange_clicked(self, widget): + def on_overlay_incrange_click(self, widget): adjustment = self.ui.wheel_range.get_adjustment() step = adjustment.get_step_increment() self.ui.wheel_range.set_value(self.ui.wheel_range.get_value() + step) @@ -135,7 +137,9 @@ def on_wheel_buttons_state_set(self, widget, state): self.model.set_use_buttons(state) def on_center_wheel_state_set(self, widget, state): - threading.Thread(target = self.model.set_center_wheel, args = [state], daemon = True).start() + threading.Thread( + target=self.model.set_center_wheel, args=[state], daemon=True + ).start() def on_profile_changed(self, combobox): self.controller.load_profile(combobox.get_active_id()) @@ -145,7 +149,7 @@ def on_save_profile_clicked(self, widget): self.controller.save_profile(profile_name) def on_new_profile_clicked(self, widget): - self.ui.new_profile_name_entry.set_text('') + self.ui.new_profile_name_entry.set_text("") self.ui.new_profile_name_entry.show() self.ui.new_profile_name_entry.grab_focus() @@ -166,8 +170,10 @@ def on_new_profile_activate(self, widget): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - if str(e) != '': - self.ui.error_dialog(_('Error creating profile'), traceback.format_exc()) + if str(e) != "": + self.ui.error_dialog( + _("Error creating profile"), traceback.format_exc() + ) def on_rename_profile_clicked(self, widget): row = self.ui.profile_listbox.get_selected_row() @@ -199,11 +205,12 @@ def on_rename_profile_activate(widget): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - self.ui.error_dialog(_('Error renaming profile'), str(e)) + self.ui.error_dialog(_("Error renaming profile"), str(e)) + entry = Gtk.Entry() - entry.connect('activate', on_rename_profile_activate) - entry.connect('focus-out-event', on_rename_profile_focus_out) - entry.connect('key-release-event', on_rename_profile_key_release) + entry.connect("activate", on_rename_profile_activate) + entry.connect("focus-out-event", on_rename_profile_focus_out) + entry.connect("key-release-event", on_rename_profile_key_release) label = row.get_children()[0] row.remove(label) text = label.get_text() @@ -224,11 +231,13 @@ def on_delete_profile_clicked(self, widget): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - if str(e) != '': - self.ui.error_dialog(_('Error deleting profile'), str(e)) + if str(e) != "": + self.ui.error_dialog(_("Error deleting profile"), str(e)) def on_import_profile_clicked(self, widget): - profile_file = self.ui.file_chooser(_('Choose profile file to import'), 'open', file_type='ini') + profile_file = self.ui.file_chooser( + _("Choose profile file to import"), "open", file_type="ini" + ) if profile_file is None: return try: @@ -238,14 +247,16 @@ def on_import_profile_clicked(self, widget): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - self.ui.error_dialog(_('Error importing profile'), str(e)) + self.ui.error_dialog(_("Error importing profile"), str(e)) def on_export_profile_clicked(self, widget): row = self.ui.profile_listbox.get_selected_row() if row is None: return profile_name = row.get_children()[0].get_text() - export_file = self.ui.file_chooser(_('Export profile to'), 'save', profile_name + '.ini', file_type='ini') + export_file = self.ui.file_chooser( + _("Export profile to"), "save", profile_name + ".ini", file_type="ini" + ) if export_file is None: return try: @@ -253,7 +264,7 @@ def on_export_profile_clicked(self, widget): except (KeyboardInterrupt, SystemExit): raise except Exception as e: - self.ui.error_dialog(_('Error exporting profile'), str(e)) + self.ui.error_dialog(_("Error exporting profile"), str(e)) def on_test_start_clicked(self, widget): self.controller.start_test() @@ -286,3 +297,22 @@ def on_start_app_manually_state_set(self, widget, state): def on_start_app_clicked(self, widget): self.controller.start_app() + + # --- Auto-switch handlers --- + + def on_auto_switch_state_set(self, widget, state): + """Toggle the profile auto-switcher on/off.""" + if state: + self.controller.start_auto_switch() + else: + self.controller.stop_auto_switch() + + def on_game_processes_changed(self, widget): + """Update the game_processes model field when the entry changes.""" + text = widget.get_text().strip() + self.model.set_game_processes(text if text else None) + + def on_game_processes_focus_out(self, widget, event): + """Sync entry with model on focus loss.""" + text = widget.get_text().strip() + self.model.set_game_processes(text if text else None) diff --git a/oversteer/gtk_ui.py b/oversteer/gtk_ui.py index 8cd63b5..2242c28 100644 --- a/oversteer/gtk_ui.py +++ b/oversteer/gtk_ui.py @@ -5,11 +5,12 @@ import math import os from .gtk_handlers import GtkHandlers -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib -class GtkUi: +class GtkUi: def __init__(self, controller, argv): self.controller = controller @@ -19,16 +20,20 @@ def __init__(self, controller, argv): Gdk.init(argv) style_provider = Gtk.CssProvider() - style_provider.load_from_path(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'main.css')) + style_provider.load_from_path( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "main.css") + ) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), style_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self.builder = Gtk.Builder() - self.builder.set_translation_domain('oversteer') - self.builder.add_from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'main.ui')) + self.builder.set_translation_domain("oversteer") + self.builder.add_from_file( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "main.ui") + ) self._set_builder_objects() @@ -36,20 +41,20 @@ def __init__(self, controller, argv): cell_renderer = Gtk.CellRendererText() self.device_combobox.pack_start(cell_renderer, True) - self.device_combobox.add_attribute(cell_renderer, 'text', 1) + self.device_combobox.add_attribute(cell_renderer, "text", 1) self.device_combobox.set_id_column(0) cell_renderer = Gtk.CellRendererText() self.profile_combobox.pack_start(cell_renderer, True) - self.profile_combobox.add_attribute(cell_renderer, 'text', 0) + self.profile_combobox.add_attribute(cell_renderer, "text", 0) self.profile_combobox.set_id_column(0) cell_renderer = Gtk.CellRendererText() self.emulation_mode_combobox.pack_start(cell_renderer, True) - self.emulation_mode_combobox.add_attribute(cell_renderer, 'text', 1) + self.emulation_mode_combobox.add_attribute(cell_renderer, "text", 1) self.emulation_mode_combobox.set_id_column(0) - self.set_range_overlay('never') + self.set_range_overlay("never") self.disable_save_profile() def reset_view(self): @@ -74,30 +79,33 @@ def safe_call(self, callback, *args): GLib.idle_add(callback, *args) def confirmation_dialog(self, message): - dialog = Gtk.MessageDialog(self.window, 0, - Gtk.MessageType.WARNING, Gtk.ButtonsType.OK_CANCEL, message) + dialog = Gtk.MessageDialog( + self.window, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.OK_CANCEL, message + ) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK - def info_dialog(self, message, secondary_text = ''): - dialog = Gtk.MessageDialog(self.window, 0, Gtk.MessageType.INFO, - Gtk.ButtonsType.OK, message) + def info_dialog(self, message, secondary_text=""): + dialog = Gtk.MessageDialog( + self.window, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, message + ) dialog.format_secondary_text(secondary_text) dialog.run() dialog.destroy() - def error_dialog(self, message, secondary_text = ''): - dialog = Gtk.MessageDialog(self.window, 0, Gtk.MessageType.ERROR, - Gtk.ButtonsType.OK, message) + def error_dialog(self, message, secondary_text=""): + dialog = Gtk.MessageDialog( + self.window, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, message + ) dialog.format_secondary_text(secondary_text) dialog.run() dialog.destroy() - def file_chooser(self, title, action, default_name = None, file_type = 'all'): - if action == 'open': + def file_chooser(self, title, action, default_name=None, file_type="all"): + if action == "open": action = Gtk.FileChooserAction.OPEN - elif action == 'save': + elif action == "save": action = Gtk.FileChooserAction.SAVE else: return None @@ -112,15 +120,15 @@ def file_chooser(self, title, action, default_name = None, file_type = 'all'): file_filter = Gtk.FileFilter() - if file_type == 'csv': - file_filter.set_name('CSV') - file_filter.add_pattern('*.csv') - elif file_type == 'ini': - file_filter.set_name('INI') - file_filter.add_pattern('*.ini') - elif file_type == 'all': - file_filter.set_name(_('All files')) - file_filter.add_pattern('*') + if file_type == "csv": + file_filter.set_name("CSV") + file_filter.add_pattern("*.csv") + elif file_type == "ini": + file_filter.set_name("INI") + file_filter.add_pattern("*.ini") + elif file_type == "all": + file_filter.set_name(_("All files")) + file_filter.add_pattern("*") dialog.add_filter(file_filter) dialog.set_filter(file_filter) @@ -151,7 +159,7 @@ def set_app_icon(self, icon): def set_languages(self, languages): cell_renderer = Gtk.CellRendererText() self.languages_combobox.pack_start(cell_renderer, True) - self.languages_combobox.add_attribute(cell_renderer, 'text', 1) + self.languages_combobox.add_attribute(cell_renderer, "text", 1) self.languages_combobox.set_id_column(0) model = self.languages_combobox.get_model() model = Gtk.ListStore(str, str) @@ -198,12 +206,12 @@ def enable_controls(self): def update_profiles_combobox(self): model = self.profile_combobox.get_model() if model is None: - active_id = '' + active_id = "" model = Gtk.ListStore(str) else: active_id = self.profile_combobox.get_active_id() model.clear() - model.append(['']) + model.append(["DEFAULT"]) profiles = [] for row in self.profile_listbox.get_children(): @@ -211,7 +219,8 @@ def update_profiles_combobox(self): profiles.sort() for profile_name in profiles: - model.append([profile_name]) + if profile_name != "DEFAULT": + model.append([profile_name]) self.profile_combobox.set_model(model) self.profile_combobox.set_active_id(active_id) @@ -348,9 +357,9 @@ def set_range_overlay(self, sid): self.wheel_range_overlay_never.set_active(False) self.wheel_range_overlay_always.set_active(False) self.wheel_range_overlay_auto.set_active(False) - if sid == 'always': + if sid == "always": self.wheel_range_overlay_always.set_active(True) - elif sid == 'auto': + elif sid == "auto": self.wheel_range_overlay_auto.set_active(True) else: self.wheel_range_overlay_never.set_active(True) @@ -370,11 +379,15 @@ def set_new_profile_name(self, name): def set_steering_input(self, value): if value < 32768: - self.steering_left_input.set_value(self._round_input((32768 - value) / 32768, 3)) + self.steering_left_input.set_value( + self._round_input((32768 - value) / 32768, 3) + ) self.steering_right_input.set_value(0) else: self.steering_left_input.set_value(0) - self.steering_right_input.set_value(self._round_input((value - 32768) / 32768, 3)) + self.steering_right_input.set_value( + self._round_input((value - 32768) / 32768, 3) + ) def set_clutch_input(self, value): self.clutch_input.set_value(self._round_input((255 - value) / 255, 2)) @@ -401,9 +414,11 @@ def set_haty_input(self, value): self.hat_up_input.set_value(0) self.hat_down_input.set_value(value) - def set_btn_input(self, index, value, wait = None): + def set_btn_input(self, index, value, wait=None): if wait is not None: - GLib.timeout_add(wait, lambda index=index, value=value: self.set_btn_input(index, value)) + GLib.timeout_add( + wait, lambda index=index, value=value: self.set_btn_input(index, value) + ) else: self.btn_input[index].set_value(value) return False @@ -420,27 +435,37 @@ def reset_define_buttons_text(self): def get_wheel_range_overlay(self): wheel_range_overlay = None if self.wheel_range_overlay_never.get_active(): - wheel_range_overlay = 'never' + wheel_range_overlay = "never" elif self.wheel_range_overlay_always.get_active(): - wheel_range_overlay = 'always' + wheel_range_overlay = "always" elif self.wheel_range_overlay_auto.get_active(): - wheel_range_overlay = 'auto' + wheel_range_overlay = "auto" return wheel_range_overlay - def update_overlay(self, auto = False): + def update_overlay(self, auto=False): ffbmeter_overlay = self.ffbmeter_overlay.get_active() wheel_range_overlay = self.get_wheel_range_overlay() - if ffbmeter_overlay or wheel_range_overlay == 'always' or (wheel_range_overlay == 'auto' and auto): + if ( + ffbmeter_overlay + or wheel_range_overlay == "always" + or (wheel_range_overlay == "auto" and auto) + ): if not self.overlay_window.props.visible: self.overlay_window.show() - if not self.ffbmeter_timer and self.overlay_window.props.visible and ffbmeter_overlay: + if ( + not self.ffbmeter_timer + and self.overlay_window.props.visible + and ffbmeter_overlay + ): self.ffbmeter_timer = True GLib.timeout_add(250, self._update_ffbmeter_overlay) if ffbmeter_overlay: self._ffbmeter_overlay.show() else: self._ffbmeter_overlay.hide() - if wheel_range_overlay == 'always' or (wheel_range_overlay == 'auto' and auto): + if wheel_range_overlay == "always" or ( + wheel_range_overlay == "auto" and auto + ): self._wheel_range_overlay.show() else: self._wheel_range_overlay.hide() @@ -448,7 +473,7 @@ def update_overlay(self, auto = False): self.overlay_window.hide() def enable_save_profile(self): - if self.profile_combobox.get_active_id() != '': + if self.profile_combobox.get_active_id() != "": self.save_profile_button.set_sensitive(True) def disable_save_profile(self): @@ -495,7 +520,7 @@ def switch_test_panel(self, test_id): self.test_panel_buttons.set_visible(True) self.test_panel_warning.set_visible(True) - def show_test_running(self, test_id, data = None): + def show_test_running(self, test_id, data=None): self.test_panel_warning.set_visible(False) self.test_panel_buttons.set_visible(False) if test_id == 0: @@ -512,27 +537,30 @@ def show_test_running(self, test_id, data = None): self.test_container_stack.set_visible_child(self.test_panel_running) def _update_ffbmeter_overlay(self): - if not self.overlay_window.props.visible or not self.ffbmeter_overlay.props.visible: + if ( + not self.overlay_window.props.visible + or not self.ffbmeter_overlay.props.visible + ): self.ffbmeter_timer = False return False level = self.controller.read_ffbmeter() - if level < 2458: # < 7.5% + if level < 2458: # < 7.5% led_states = 0 - elif level < 8192: # < 25% + elif level < 8192: # < 25% led_states = 1 - elif level < 16384: # < 50% + elif level < 16384: # < 50% led_states = 3 - elif level < 24576: # < 75% + elif level < 24576: # < 75% led_states = 7 - elif level < 29491: # < 90% + elif level < 29491: # < 90% led_states = 15 - elif level <= 32768: # <= 100% + elif level <= 32768: # <= 100% led_states = 31 - elif level < 36045: # < 110% + elif level < 36045: # < 110% led_states = 30 - elif level < 40960: # < 125% + elif level < 40960: # < 125% led_states = 28 - elif level < 49152: # < 150% + elif level < 49152: # < 150% led_states = 24 else: led_states = 16 @@ -543,8 +571,8 @@ def _update_ffbmeter_overlay(self): self.overlay_led_4.set_value((led_states >> 4) & 1) return True - def _round_input(self, value, decimals = 0): - multiplier = 10 ** decimals + def _round_input(self, value, decimals=0): + multiplier = 10**decimals return math.floor(value * multiplier) / multiplier def show_test_chart(self, canvas, toolbar): @@ -566,68 +594,76 @@ def _screen_changed(self, widget, old_screen, userdata=None): self.overlay_window.set_visual(visual) def _set_builder_objects(self): - self.window = self.builder.get_object('main_window') - self.about_window = self.builder.get_object('about_window') - self.preferences_window = self.builder.get_object('preferences_window') - self.overlay_window = self.builder.get_object('overlay_window') + self.window = self.builder.get_object("main_window") + self.about_window = self.builder.get_object("about_window") + self.preferences_window = self.builder.get_object("preferences_window") + self.overlay_window = self.builder.get_object("overlay_window") self.overlay_window.set_keep_above(True) self.overlay_window.connect("screen-changed", self._screen_changed) self._screen_changed(self.overlay_window, None) - self.languages_combobox = self.builder.get_object('languages') - self.check_permissions = self.builder.get_object('check_permissions') - - self.device_combobox = self.builder.get_object('device') - self.profile_combobox = self.builder.get_object('profile') - self.new_profile_name_entry = self.builder.get_object('new_profile_name') - self.save_profile_button = self.builder.get_object('save_profile') - self.new_profile_name = self.builder.get_object('new_profile_name') - self.emulation_mode_combobox = self.builder.get_object('emulation_mode') - self.change_emulation_mode_button = self.builder.get_object('change_emulation_mode') - self.wheel_range = self.builder.get_object('wheel_range') - self.wheel_range_setup = self.builder.get_object('wheel_range_setup') - self.combine_none = self.builder.get_object('combine_none') - self.combine_brakes = self.builder.get_object('combine_brakes') - self.combine_clutch = self.builder.get_object('combine_clutch') - self.autocenter = self.builder.get_object('autocenter') - self.ff_gain = self.builder.get_object('ff_gain') - self.ff_spring_level = self.builder.get_object('ff_spring_level') - self.ff_damper_level = self.builder.get_object('ff_damper_level') - self.ff_friction_level = self.builder.get_object('ff_friction_level') - self.ffbmeter_leds = self.builder.get_object('ffbmeter_leds') - self.ffbmeter_overlay = self.builder.get_object('ffbmeter_overlay') - self.wheel_range_overlay_never = self.builder.get_object('wheel_range_overlay_never') - self.wheel_range_overlay_always = self.builder.get_object('wheel_range_overlay_always') - self.wheel_range_overlay_auto = self.builder.get_object('wheel_range_overlay_auto') - self._ffbmeter_overlay = self.builder.get_object('_ffbmeter_overlay') - self._wheel_range_overlay = self.builder.get_object('_wheel_range_overlay') - self.overlay_wheel_range = self.builder.get_object('overlay_wheel_range') - self.overlay_led_0 = self.builder.get_object('overlay_led_0') - self.overlay_led_1 = self.builder.get_object('overlay_led_1') - self.overlay_led_2 = self.builder.get_object('overlay_led_2') - self.overlay_led_3 = self.builder.get_object('overlay_led_3') - self.overlay_led_4 = self.builder.get_object('overlay_led_4') - self.wheel_buttons = self.builder.get_object('wheel_buttons') - self.center_wheel = self.builder.get_object('center_wheel') - self.start_define_buttons = self.builder.get_object('start_define_buttons') + self.languages_combobox = self.builder.get_object("languages") + self.check_permissions = self.builder.get_object("check_permissions") + + self.device_combobox = self.builder.get_object("device") + self.profile_combobox = self.builder.get_object("profile") + self.new_profile_name_entry = self.builder.get_object("new_profile_name") + self.save_profile_button = self.builder.get_object("save_profile") + self.new_profile_name = self.builder.get_object("new_profile_name") + self.emulation_mode_combobox = self.builder.get_object("emulation_mode") + self.change_emulation_mode_button = self.builder.get_object( + "change_emulation_mode" + ) + self.wheel_range = self.builder.get_object("wheel_range") + self.wheel_range_setup = self.builder.get_object("wheel_range_setup") + self.combine_none = self.builder.get_object("combine_none") + self.combine_brakes = self.builder.get_object("combine_brakes") + self.combine_clutch = self.builder.get_object("combine_clutch") + self.autocenter = self.builder.get_object("autocenter") + self.ff_gain = self.builder.get_object("ff_gain") + self.ff_spring_level = self.builder.get_object("ff_spring_level") + self.ff_damper_level = self.builder.get_object("ff_damper_level") + self.ff_friction_level = self.builder.get_object("ff_friction_level") + self.ffbmeter_leds = self.builder.get_object("ffbmeter_leds") + self.ffbmeter_overlay = self.builder.get_object("ffbmeter_overlay") + self.wheel_range_overlay_never = self.builder.get_object( + "wheel_range_overlay_never" + ) + self.wheel_range_overlay_always = self.builder.get_object( + "wheel_range_overlay_always" + ) + self.wheel_range_overlay_auto = self.builder.get_object( + "wheel_range_overlay_auto" + ) + self._ffbmeter_overlay = self.builder.get_object("_ffbmeter_overlay") + self._wheel_range_overlay = self.builder.get_object("_wheel_range_overlay") + self.overlay_wheel_range = self.builder.get_object("overlay_wheel_range") + self.overlay_led_0 = self.builder.get_object("overlay_led_0") + self.overlay_led_1 = self.builder.get_object("overlay_led_1") + self.overlay_led_2 = self.builder.get_object("overlay_led_2") + self.overlay_led_3 = self.builder.get_object("overlay_led_3") + self.overlay_led_4 = self.builder.get_object("overlay_led_4") + self.wheel_buttons = self.builder.get_object("wheel_buttons") + self.center_wheel = self.builder.get_object("center_wheel") + self.start_define_buttons = self.builder.get_object("start_define_buttons") self.define_buttons_text = self.start_define_buttons.get_label() - self.start_app = self.builder.get_object('start_app') - self.start_app_manually = self.builder.get_object('start_app_manually') - - self.steering_left_input = self.builder.get_object('steering_left_input') - self.steering_right_input = self.builder.get_object('steering_right_input') - self.clutch_input = self.builder.get_object('clutch_input') - self.accelerator_input = self.builder.get_object('accelerator_input') - self.brakes_input = self.builder.get_object('brakes_input') - self.hat_up_input = self.builder.get_object('hat_up_input') - self.hat_down_input = self.builder.get_object('hat_down_input') - self.hat_left_input = self.builder.get_object('hat_left_input') - self.hat_right_input = self.builder.get_object('hat_right_input') + self.start_app = self.builder.get_object("start_app") + self.start_app_manually = self.builder.get_object("start_app_manually") + + self.steering_left_input = self.builder.get_object("steering_left_input") + self.steering_right_input = self.builder.get_object("steering_right_input") + self.clutch_input = self.builder.get_object("clutch_input") + self.accelerator_input = self.builder.get_object("accelerator_input") + self.brakes_input = self.builder.get_object("brakes_input") + self.hat_up_input = self.builder.get_object("hat_up_input") + self.hat_down_input = self.builder.get_object("hat_down_input") + self.hat_left_input = self.builder.get_object("hat_left_input") + self.hat_right_input = self.builder.get_object("hat_right_input") self.btn_input = [None] * 30 for i in range(30): - self.btn_input[i] = self.builder.get_object('btn' + str(i) + '_input') + self.btn_input[i] = self.builder.get_object("btn" + str(i) + "_input") - self.profile_listbox = self.builder.get_object('profile_listbox') + self.profile_listbox = self.builder.get_object("profile_listbox") def sort_profiles(row1, row2): text1 = row1.get_children()[0].get_text().lower() @@ -640,84 +676,86 @@ def sort_profiles(row1, row2): self.profile_listbox.set_sort_func(sort_profiles) - self.test_container = self.builder.get_object('test_container') - self.test_container_stack = self.builder.get_object('test_container_stack') - self.test_chart_window = self.builder.get_object('test_chart_window') - self.test_chart_container = self.builder.get_object('test_chart_container') - self.test_chart_frame = self.builder.get_object('test_chart_frame') - self.test_start_button = self.builder.get_object('test_start_button') - self.test_open_chart_button = self.builder.get_object('test_open_chart_button') - self.test_export_csv_button = self.builder.get_object('test_export_csv_button') - self.test_import_csv_button = self.builder.get_object('test_import_csv_button') + self.test_container = self.builder.get_object("test_container") + self.test_container_stack = self.builder.get_object("test_container_stack") + self.test_chart_window = self.builder.get_object("test_chart_window") + self.test_chart_container = self.builder.get_object("test_chart_container") + self.test_chart_frame = self.builder.get_object("test_chart_frame") + self.test_start_button = self.builder.get_object("test_start_button") + self.test_open_chart_button = self.builder.get_object("test_open_chart_button") + self.test_export_csv_button = self.builder.get_object("test_export_csv_button") + self.test_import_csv_button = self.builder.get_object("test_import_csv_button") self.test_open_chart_button.set_sensitive(False) self.test_export_csv_button.set_sensitive(False) - self.test_max_velocity = self.builder.get_object('test_max_velocity') - self.test_latency = self.builder.get_object('test_latency') - self.test_max_accel = self.builder.get_object('test_max_accel') - self.test_max_decel = self.builder.get_object('test_max_decel') - self.test_time_to_max_accel = self.builder.get_object('test_time_to_max_accel') - self.test_time_to_max_decel = self.builder.get_object('test_time_to_max_decel') - self.test_mean_accel = self.builder.get_object('test_mean_accel') - self.test_mean_decel = self.builder.get_object('test_mean_decel') - self.test_residual_decel = self.builder.get_object('test_residual_decel') - self.test_estimated_snr = self.builder.get_object('test_estimated_snr') - self.test_minimum_level = self.builder.get_object('test_minimum_level') - self.test_panel_empty = self.builder.get_object('test_panel_empty') - self.test_panel_start1 = self.builder.get_object('test_panel_start1') - self.test_panel_start2 = self.builder.get_object('test_panel_start2') - self.test_panel_start3 = self.builder.get_object('test_panel_start3') - self.test_panel_running = self.builder.get_object('test_panel_running') - self.test_panel_running1 = self.builder.get_object('test_panel_running1') - self.test_panel_running1_ready = self.builder.get_object('test_panel_running1_ready') - self.test_panel_running1_go = self.builder.get_object('test_panel_running1_go') - self.test_panel_results = self.builder.get_object('test_panel_results') - self.test_panel_warning = self.builder.get_object('test_panel_warning') - self.test_panel_buttons = self.builder.get_object('test_panel_buttons') - self.test_panel_back = self.builder.get_object('test_panel_back') - self.test_panel_run = self.builder.get_object('test_panel_run') + self.test_max_velocity = self.builder.get_object("test_max_velocity") + self.test_latency = self.builder.get_object("test_latency") + self.test_max_accel = self.builder.get_object("test_max_accel") + self.test_max_decel = self.builder.get_object("test_max_decel") + self.test_time_to_max_accel = self.builder.get_object("test_time_to_max_accel") + self.test_time_to_max_decel = self.builder.get_object("test_time_to_max_decel") + self.test_mean_accel = self.builder.get_object("test_mean_accel") + self.test_mean_decel = self.builder.get_object("test_mean_decel") + self.test_residual_decel = self.builder.get_object("test_residual_decel") + self.test_estimated_snr = self.builder.get_object("test_estimated_snr") + self.test_minimum_level = self.builder.get_object("test_minimum_level") + self.test_panel_empty = self.builder.get_object("test_panel_empty") + self.test_panel_start1 = self.builder.get_object("test_panel_start1") + self.test_panel_start2 = self.builder.get_object("test_panel_start2") + self.test_panel_start3 = self.builder.get_object("test_panel_start3") + self.test_panel_running = self.builder.get_object("test_panel_running") + self.test_panel_running1 = self.builder.get_object("test_panel_running1") + self.test_panel_running1_ready = self.builder.get_object( + "test_panel_running1_ready" + ) + self.test_panel_running1_go = self.builder.get_object("test_panel_running1_go") + self.test_panel_results = self.builder.get_object("test_panel_results") + self.test_panel_warning = self.builder.get_object("test_panel_warning") + self.test_panel_buttons = self.builder.get_object("test_panel_buttons") + self.test_panel_back = self.builder.get_object("test_panel_back") + self.test_panel_run = self.builder.get_object("test_panel_run") def _set_markers(self): - self.autocenter.add_mark(20, Gtk.PositionType.BOTTOM, '20') - self.autocenter.add_mark(40, Gtk.PositionType.BOTTOM, '40') - self.autocenter.add_mark(60, Gtk.PositionType.BOTTOM, '60') - self.autocenter.add_mark(80, Gtk.PositionType.BOTTOM, '80') - self.autocenter.add_mark(100, Gtk.PositionType.BOTTOM, '100') - self.ff_gain.add_mark(20, Gtk.PositionType.BOTTOM, '20') - self.ff_gain.add_mark(40, Gtk.PositionType.BOTTOM, '40') - self.ff_gain.add_mark(60, Gtk.PositionType.BOTTOM, '60') - self.ff_gain.add_mark(80, Gtk.PositionType.BOTTOM, '80') - self.ff_gain.add_mark(100, Gtk.PositionType.BOTTOM, '100') - self.ff_spring_level.add_mark(20, Gtk.PositionType.BOTTOM, '20') - self.ff_spring_level.add_mark(40, Gtk.PositionType.BOTTOM, '40') - self.ff_spring_level.add_mark(60, Gtk.PositionType.BOTTOM, '60') - self.ff_spring_level.add_mark(80, Gtk.PositionType.BOTTOM, '80') - self.ff_spring_level.add_mark(100, Gtk.PositionType.BOTTOM, '100') - self.ff_damper_level.add_mark(20, Gtk.PositionType.BOTTOM, '20') - self.ff_damper_level.add_mark(40, Gtk.PositionType.BOTTOM, '40') - self.ff_damper_level.add_mark(60, Gtk.PositionType.BOTTOM, '60') - self.ff_damper_level.add_mark(80, Gtk.PositionType.BOTTOM, '80') - self.ff_damper_level.add_mark(100, Gtk.PositionType.BOTTOM, '100') - self.ff_friction_level.add_mark(20, Gtk.PositionType.BOTTOM, '20') - self.ff_friction_level.add_mark(40, Gtk.PositionType.BOTTOM, '40') - self.ff_friction_level.add_mark(60, Gtk.PositionType.BOTTOM, '60') - self.ff_friction_level.add_mark(80, Gtk.PositionType.BOTTOM, '80') - self.ff_friction_level.add_mark(100, Gtk.PositionType.BOTTOM, '100') + self.autocenter.add_mark(20, Gtk.PositionType.BOTTOM, "20") + self.autocenter.add_mark(40, Gtk.PositionType.BOTTOM, "40") + self.autocenter.add_mark(60, Gtk.PositionType.BOTTOM, "60") + self.autocenter.add_mark(80, Gtk.PositionType.BOTTOM, "80") + self.autocenter.add_mark(100, Gtk.PositionType.BOTTOM, "100") + self.ff_gain.add_mark(20, Gtk.PositionType.BOTTOM, "20") + self.ff_gain.add_mark(40, Gtk.PositionType.BOTTOM, "40") + self.ff_gain.add_mark(60, Gtk.PositionType.BOTTOM, "60") + self.ff_gain.add_mark(80, Gtk.PositionType.BOTTOM, "80") + self.ff_gain.add_mark(100, Gtk.PositionType.BOTTOM, "100") + self.ff_spring_level.add_mark(20, Gtk.PositionType.BOTTOM, "20") + self.ff_spring_level.add_mark(40, Gtk.PositionType.BOTTOM, "40") + self.ff_spring_level.add_mark(60, Gtk.PositionType.BOTTOM, "60") + self.ff_spring_level.add_mark(80, Gtk.PositionType.BOTTOM, "80") + self.ff_spring_level.add_mark(100, Gtk.PositionType.BOTTOM, "100") + self.ff_damper_level.add_mark(20, Gtk.PositionType.BOTTOM, "20") + self.ff_damper_level.add_mark(40, Gtk.PositionType.BOTTOM, "40") + self.ff_damper_level.add_mark(60, Gtk.PositionType.BOTTOM, "60") + self.ff_damper_level.add_mark(80, Gtk.PositionType.BOTTOM, "80") + self.ff_damper_level.add_mark(100, Gtk.PositionType.BOTTOM, "100") + self.ff_friction_level.add_mark(20, Gtk.PositionType.BOTTOM, "20") + self.ff_friction_level.add_mark(40, Gtk.PositionType.BOTTOM, "40") + self.ff_friction_level.add_mark(60, Gtk.PositionType.BOTTOM, "60") + self.ff_friction_level.add_mark(80, Gtk.PositionType.BOTTOM, "80") + self.ff_friction_level.add_mark(100, Gtk.PositionType.BOTTOM, "100") def _set_range_markers(self, max_range): self.wheel_range.clear_marks() if max_range >= 180: - self.wheel_range.add_mark(18, Gtk.PositionType.BOTTOM, '180') + self.wheel_range.add_mark(18, Gtk.PositionType.BOTTOM, "180") if max_range >= 270: - self.wheel_range.add_mark(27, Gtk.PositionType.BOTTOM, '270') + self.wheel_range.add_mark(27, Gtk.PositionType.BOTTOM, "270") if max_range >= 360: - self.wheel_range.add_mark(36, Gtk.PositionType.BOTTOM, '360') + self.wheel_range.add_mark(36, Gtk.PositionType.BOTTOM, "360") if max_range >= 450: - self.wheel_range.add_mark(45, Gtk.PositionType.BOTTOM, '450') + self.wheel_range.add_mark(45, Gtk.PositionType.BOTTOM, "450") if max_range >= 540: - self.wheel_range.add_mark(54, Gtk.PositionType.BOTTOM, '540') + self.wheel_range.add_mark(54, Gtk.PositionType.BOTTOM, "540") if max_range >= 720: - self.wheel_range.add_mark(72, Gtk.PositionType.BOTTOM, '720') + self.wheel_range.add_mark(72, Gtk.PositionType.BOTTOM, "720") if max_range >= 900: - self.wheel_range.add_mark(90, Gtk.PositionType.BOTTOM, '900') + self.wheel_range.add_mark(90, Gtk.PositionType.BOTTOM, "900") if max_range >= 1080: - self.wheel_range.add_mark(108, Gtk.PositionType.BOTTOM, '1080') + self.wheel_range.add_mark(108, Gtk.PositionType.BOTTOM, "1080") diff --git a/oversteer/gui.py b/oversteer/gui.py index 77e604a..d7a662e 100644 --- a/oversteer/gui.py +++ b/oversteer/gui.py @@ -20,9 +20,11 @@ from .combined_chart import CombinedChart from .linear_chart import LinearChart from .performance_chart import PerformanceChart +from .profile_auto_switcher import ProfileAutoSwitcher +from .auto_switch_ui import setup_auto_switch_widgets -class Gui: +class Gui: button_labels = [ _("Press toggle button/s"), _("Press button to set 270°"), @@ -36,22 +38,22 @@ class Gui: ] languages = [ - ('', _('System default')), - ('en_US', _('English')), - ('gl_ES', _('Galician')), - ('ru_RU', _('Russian')), - ('es_ES', _('Spanish')), - ('ca_ES', _('Valencian')), - ('fi_FI', _('Finnish')), - ('tr_TR', _('Turkish')), - ('de_DE', _('German')), - ('pl_PL', _('Polish')), - ('hu_HU', _('Hungarian')), + ("", _("System default")), + ("en_US", _("English")), + ("gl_ES", _("Galician")), + ("ru_RU", _("Russian")), + ("es_ES", _("Spanish")), + ("ca_ES", _("Catalan")), + ("tr_TR", _("Turkish")), + ("fi_FI", _("Finnish")), + ("de_DE", _("German")), + ("pl_PL", _("Polish")), + ("hu_HU", _("Hungarian")), ] def __init__(self, application, model, argv): self.app = application - self.locale = '' + self.locale = "" self.check_permissions = True self.model = model self.device_manager = self.app.device_manager @@ -65,10 +67,11 @@ def __init__(self, application, model, argv): self.button_config = [-1] * 9 self.button_config[0] = [-1] self.pressed_button_count = 0 + self.auto_switcher = None signal.signal(signal.SIGINT, self.sig_int_handler) - self.config_path = save_config_path('oversteer') + self.config_path = save_config_path("oversteer") self.load_preferences() @@ -77,481 +80,311 @@ def __init__(self, application, model, argv): self.ui = GtkUi(self, argv) self.ui.set_app_version(self.app.version) - self.ui.set_app_icon(os.path.join(self.app.icondir, 'io.github.berarma.Oversteer.svg')) + self.ui.set_app_icon(os.path.join(self.app.icondir, "oversteer.svg")) self.ui.set_languages(self.languages) - self.ui.set_language(self.locale) self.ui.set_check_permissions(self.check_permissions) - - self.models = {} - - self.ui.start() - self.model.set_ui(self.ui) + self.device_manager.start(self.device_changed) self.populate_window() - if self.app.args.profile is not None: - self.ui.set_profile(self.app.args.profile) + self.ui.start() - start_manually = self.app.args.start_manually - if start_manually is None: - start_manually = self.model.get_start_app_manually() + self.load_profile("DEFAULT") - if not model.device: - self.ui.info_dialog("No device available.") - start_manually = True + # Inject auto-switch widgets into Tools tab + from .gtk_handlers import GtkHandlers - if self.app.args.command: - if start_manually: - self.ui.enable_start_app() - else: - self.start_app() + handlers = GtkHandlers(self.ui, self) + setup_auto_switch_widgets(self.ui, handlers) - Thread(target=self.input_thread, daemon = True).start() + # Restore auto-switch state from preferences + if self.auto_switch_enabled: + self.ui.auto_switch_switch.set_state(True) + self.start_auto_switch() self.ui.main() - def start_app(self): - self.ui.disable_start_app() - Thread(target=self.run_command).start() - def sig_int_handler(self, signal, frame): - sys.exit(0) - - def install_udev_files(self): - while True: - affirmative = self.ui.confirmation_dialog(_("You don't have the " + - "required permissions to change your wheel settings.") + "\n\n" + _("You can " + - "fix it yourself by copying the files in {} to the {} directory " + - "and rebooting.").format(self.app.udev_path, self.app.target_dir) + "\n\n" + - _("Do you want us to make this change for you?")) - if affirmative: - copy_cmd = 'cp -f ' + self.app.udev_path + '* ' + self.app.target_dir + ' && ' - return_code = subprocess.call([ - 'pkexec', - '/bin/sh', - '-c', - copy_cmd + - 'udevadm control --reload-rules && udevadm trigger', - ]) - if return_code == 0: - self.ui.info_dialog(_("Permissions rules installed."), - _("In some cases, a system restart might be needed.")) - break - answer = self.ui.confirmation_dialog(_("Error installing " + - "permissions rules. Please, try again and make sure you " + - "use the right password for the administrator user.")) - if not answer: - break - else: - break - - def populate_devices(self): - logging.debug("populate_devices") - if self.device_manager.is_changed(): - device_list = [] - for device in self.device_manager.get_devices(): - if device.is_ready(): - device_list.append((device.get_id(), device.name)) - self.ui.set_devices(device_list) - - def populate_profiles(self): - profiles = [] - for profile_file in glob.iglob(os.path.join(self.app.profile_path, "*.ini")): - profile_name = os.path.splitext(os.path.basename(profile_file))[0] - profiles.append(profile_name) - self.ui.set_profiles(profiles) + self.stop_auto_switch() + self.ui.quit() + + def device_changed(self): + self.ui.safe_call(self.populate_window) def populate_window(self): - self.populate_devices() - self.populate_profiles() + devices = [] + for device in self.device_manager.get_devices(): + devices.append([device.id, device.name]) + self.ui.set_devices(devices) - def change_device(self, device_id): - self.device = self.device_manager.get_device(device_id) + if not self.device_manager.get_devices(): + self.ui.set_profiles([]) + return + + if self.device is not None: + self.device.close() - if self.device is None or not self.device.is_ready(): + self.device = self.device_manager.first_device() + if not self.device: return - if not self.device.check_permissions() and self.check_permissions: - if self.app.udev_path: - self.install_udev_files() - else: - self.ui.info_dialog(_("You don't have the required permissions to change your wheel settings.")) + self.model.set_device(self.device) + self.model.flush_ui() - if not self.models: - self.model.set_device(self.device) - self.models[self.device.get_id()] = self.model - if self.device.get_id() in self.models: - self.model = self.models[self.device.get_id()] - else: - self.model = Model(self.device, self.ui) - self.models[self.device.get_id()] = self.model + profiles = [] + if os.path.isdir(self.app.profile_path): + for filename in os.listdir(self.app.profile_path): + if filename.endswith(".ini"): + name = filename[:-4] + if name != "DEFAULT": + profiles.append(name) + self.ui.set_profiles(profiles) + self.ui.set_profile("DEFAULT") - self.ui.set_max_range(self.device.get_max_range()) - self.ui.set_modes(self.model.get_mode_list()) + def load_preferences(self): + config = configparser.ConfigParser() + config.read(os.path.join(self.config_path, "preferences.ini")) + if "DEFAULT" in config: + self.locale = config["DEFAULT"].get("locale", "") + self.check_permissions = bool( + int(config["DEFAULT"].get("check_permissions", 1)) + ) + self.auto_switch_enabled = bool( + int(config["DEFAULT"].get("auto_switch", 0)) + ) - if self.model.get_profile(): - self.ui.set_profile(self.model.get_profile()) - else: - self.model.flush_device() - self.model.flush_ui() + def save_preferences(self): + config = configparser.ConfigParser() + config["DEFAULT"] = { + "locale": self.locale, + "check_permissions": int(self.check_permissions), + "auto_switch": int(self.auto_switch_enabled), + } + with open(os.path.join(self.config_path, "preferences.ini"), "w") as configfile: + config.write(configfile) - def load_profile(self, profile_name): - if profile_name is None or profile_name == '': - return + def on_close_preferences(self): + self.save_preferences() - profile_file = os.path.join(self.app.profile_path, profile_name + '.ini') - if not os.path.exists(profile_file): - self.ui.info_dialog(_("Error opening profile"), _("The selected profile can't be loaded.")) - return + def set_check_permissions(self, state): + self.check_permissions = state - self.model.load(profile_file) - self.model.flush_device() - self.model.flush_ui() + def set_locale(self, locale): + self.locale = locale - def save_profile(self, profile_name, check_exists = False): - if self.device is None: + def change_device(self, device_id): + device = self.device_manager.get_device_by_id(device_id) + if device is None: return + self.stop_auto_switch() + self.device = device + self.model.set_device(device) + self.model.flush_ui() - if profile_name is None or profile_name == '': + def load_profile(self, profile_name): + if not profile_name: return + if profile_name == "DEFAULT": + profile_file = os.path.join(self.app.profile_path, "DEFAULT.ini") + if os.path.isfile(profile_file): + self.model.profile = None + self.model.load(profile_file) + else: + self.model.data = self.model.defaults.copy() + self.model.profile = None + self.model.update_from_device_settings() + self.model.save(profile_file) + self.model.flush_ui() + return + profile_file = os.path.join(self.app.profile_path, profile_name + ".ini") + self.model.load(profile_file) + self.model.flush_ui() - profile_file = os.path.join(self.app.profile_path, profile_name + '.ini') - if check_exists: - if os.path.exists(profile_file): - if not self.ui.confirmation_dialog(_("This profile already exists. Are you sure?")): - raise Exception() + def save_profile(self, profile_name, new=False): + if not profile_name: + return + profile_file = os.path.join(self.app.profile_path, profile_name + ".ini") + if profile_name != "DEFAULT" and os.path.exists(profile_file) and new: + raise Exception(_("This profile already exists.")) self.model.save(profile_file) - - def rename_profile(self, current_name, new_name): - current_file = os.path.join(self.app.profile_path, current_name + '.ini') - new_file = os.path.join(self.app.profile_path, new_name + '.ini') - os.rename(current_file, new_file) + self.ui.set_profile(profile_name) def delete_profile(self, profile_name): - if profile_name != '' and profile_name is not None: - profile_file = os.path.join(self.app.profile_path, profile_name + '.ini') - if self.ui.confirmation_dialog(_("This profile will be deleted, are you sure?")): - os.remove(profile_file) - else: - raise Exception() - - def import_profile(self, path): - if not path.endswith('.ini'): - raise Exception(_('Invalid extension.')) - profile_name = os.path.splitext(os.path.basename(path))[0] - profile_file = os.path.join(self.app.profile_path, profile_name + '.ini') - if os.path.exists(profile_file): - raise Exception(_('A profile with that name already exists.')) - shutil.copyfile(path, profile_file) + if profile_name == "DEFAULT": + raise Exception(_("The DEFAULT profile cannot be deleted.")) + profile_file = os.path.join(self.app.profile_path, profile_name + ".ini") + if not os.path.exists(profile_file): + raise Exception(_("This profile doesn't exist.")) + os.remove(profile_file) + + def rename_profile(self, source_name, target_name): + if not target_name: + raise Exception(_("Profile name cannot be empty.")) + source_file = os.path.join(self.app.profile_path, source_name + ".ini") + target_file = os.path.join(self.app.profile_path, target_name + ".ini") + if not os.path.exists(source_file): + raise Exception(_("Source profile doesn't exist.")) + if os.path.exists(target_file): + raise Exception(_("Target profile already exists.")) + os.rename(source_file, target_file) + + def import_profile(self, source_file): + profile_name = os.path.splitext(os.path.basename(source_file))[0] + target_file = os.path.join(self.app.profile_path, profile_name + ".ini") + shutil.copy(source_file, target_file) return profile_name - def export_profile(self, profile_name, path): - profile_file = os.path.join(self.app.profile_path, profile_name + '.ini') - if os.path.exists(path): - if not self.ui.confirmation_dialog(_('File already exists, overwrite?')): - raise Exception() - shutil.copyfile(profile_file, path) + def export_profile(self, profile_name, target_file): + profile_file = os.path.join(self.app.profile_path, profile_name + ".ini") + shutil.copy(profile_file, target_file) - def load_preferences(self): - config = configparser.ConfigParser() - config_file = os.path.join(self.config_path, 'config.ini') - config.read(config_file) - self.check_permissions = True - if 'DEFAULT' in config: - if 'locale' in config['DEFAULT'] and config['DEFAULT']['locale'] != '': - self.locale = config['DEFAULT']['locale'] - Locale.setlocale(Locale.LC_ALL, (self.locale, 'UTF-8')) - if 'check_permissions' in config['DEFAULT']: - self.check_permissions = config['DEFAULT']['check_permissions'] == '1' - if 'button_config' in config['DEFAULT'] and config['DEFAULT']['button_config'] != '': - if 'button_toggle' not in config['DEFAULT']: - self.button_config = list(map(int, config['DEFAULT']['button_config'].split(','))) - self.button_config[0] = [self.button_config[0]] - self.save_preferences() - else: - self.button_config[0] = list(map(int, config['DEFAULT']['button_toggle'].split(','))) - self.button_config[1:] = list(map(int, config['DEFAULT']['button_config'].split(','))) - - def set_locale(self, locale): - if locale is None: - locale = '' - if locale != '': - try: - Locale.setlocale(Locale.LC_ALL, (locale, 'UTF-8')) - self.locale = locale - except Locale.Error: - self.ui.info_dialog(_("Failed to change language"), - _("Make sure locale '{}.UTF8' is generated on your system.").format(str(locale))) - self.ui.set_language(self.locale) - self.save_preferences() + def read_ffbmeter(self): + if self.device is None: + return 0 + return self.device.get_peak_ffb_level() - def set_check_permissions(self, check_permissions): - self.check_permissions = check_permissions - self.save_preferences() + def start_test(self): + self.test = Test(self.device, self.model) + self.test.set_ui(self.ui) + self.test.start() - def save_preferences(self): - config = configparser.ConfigParser() - config['DEFAULT'] = { - 'locale': self.locale, - 'check_permissions': '1' if self.check_permissions else '0', - 'button_toggle': ','.join(map(str, self.button_config[0])), - 'button_config': ','.join(map(str, self.button_config[1:])), - } - config_file = os.path.join(self.config_path, 'config.ini') - with open(config_file, 'w') as file: - config.write(file) + def prev_test(self): + if self.test is not None: + self.test.prev() - def stop_button_setup(self): - self.button_setup_step = False - self.pressed_button_count = 0 - self.ui.safe_call(self.ui.reset_define_buttons_text) + def run_test(self): + if self.test is not None: + self.test.run() def start_stop_button_setup(self): - if self.button_setup_step is not False: - self.stop_button_setup() - else: - self.button_setup_step = 0 - self.button_config = [-1] * 9 - self.pressed_button_count = 0 - self.ui.set_define_buttons_text(self.button_labels[self.button_setup_step]) + self.button_setup_step = True + self.pressed_button_count = 0 + self.button_config = [-1] * 9 + self.button_config[0] = [-1] + self.ui.set_define_buttons_text(self.button_labels[0]) - def on_close_preferences(self): - self.stop_button_setup() - - def on_button_press(self, button, value): - if self.button_setup_step is not False: - if self.button_setup_step == 0: - if button < 100: - if value == 1: - self.pressed_button_count += 1 - if self.button_config[0] == -1: - self.button_config[0] = [] - self.button_config[0].append(button) - else: - self.pressed_button_count -= 1 - if self.pressed_button_count == 0: - self.button_setup_step += 1 - self.ui.safe_call(self.ui.set_define_buttons_text, self.button_labels[self.button_setup_step]) + def button_pressed(self, code): + if not self.button_setup_step: + if self.device is None: return - if value == 1: - self.button_config[self.button_setup_step] = button - self.button_setup_step += 1 - if self.button_setup_step >= len(self.button_config): - self.stop_button_setup() - self.save_preferences() - else: - self.ui.safe_call(self.ui.set_define_buttons_text, self.button_labels[self.button_setup_step]) return + if self.pressed_button_count == 0: + self.button_config[0] = [] + if code not in self.button_config[0]: + self.button_config[0].append(code) + else: + idx = self.pressed_button_count + self.button_config[idx] = code + self.pressed_button_count += 1 + if self.pressed_button_count >= len(self.button_labels): + self.button_setup_step = False + self.ui.reset_define_buttons_text() + self.save_buttons_config() + else: + self.ui.set_define_buttons_text( + self.button_labels[self.pressed_button_count] + ) - if self.model.get_use_buttons(): - if self.grab_input and self.pressed_button_count == 0 and value == 1: - if button == self.button_config[1]: - self.ui.safe_call(self.ui.set_range, 270) - if button == self.button_config[2]: - self.ui.safe_call(self.ui.set_range, 360) - if button == self.button_config[3]: - self.ui.safe_call(self.ui.set_range, 540) - if button == self.button_config[4]: - self.ui.safe_call(self.ui.set_range, 900) - if button == self.button_config[5]: - self.ui.safe_call(self.add_range, 10) - if button == self.button_config[6]: - self.ui.safe_call(self.add_range, -10) - if button == self.button_config[7]: - self.ui.safe_call(self.add_range, 90) - if button == self.button_config[8]: - self.ui.safe_call(self.add_range, -90) - if button in self.button_config[0]: - if value == 1: - self.pressed_button_count += 1 - if self.pressed_button_count == len(self.button_config[0]): - device = self.device.get_input_device() - if self.grab_input: - device.ungrab() - self.grab_input = False - self.ui.safe_call(self.ui.update_overlay, False) - else: - device.grab() - self.grab_input = True - self.ui.safe_call(self.ui.update_overlay, True) - else: - self.pressed_button_count -= 1 - - def add_range(self, delta): - max_range = self.device.get_max_range() - wrange = self.model.get_range() - wrange = wrange + delta - if wrange < 40: - wrange = 40 - if wrange > max_range: - wrange = max_range - self.ui.set_range(wrange) - - def read_ffbmeter(self): - level = self.device.get_peak_ffb_level() - if level is None: - return level - level = int(level) - if level > 0: - self.device.set_peak_ffb_level(0) - return level - - def process_events(self, events): - for event in events: - if event.type == ecodes.EV_ABS: - if event.code == ecodes.ABS_X: - self.last_wheel_axis_value = event.value - if self.test and self.test.is_collecting_data(): - self.test.append_data(event.timestamp(), event.value) - else: - self.ui.safe_call(self.ui.set_steering_input, event.value) - elif event.code == ecodes.ABS_Z: - self.ui.safe_call(self.ui.set_accelerator_input, event.value) - elif event.code == ecodes.ABS_RZ: - self.ui.safe_call(self.ui.set_brakes_input, event.value) - elif event.code == ecodes.ABS_Y: - self.ui.safe_call(self.ui.set_clutch_input, event.value) - elif event.code == ecodes.ABS_HAT0X: - self.ui.safe_call(self.ui.set_hatx_input, event.value) - if event.value == -1: - self.on_button_press(100, 1) - elif event.value == 1: - self.on_button_press(101, 1) - elif event.code == ecodes.ABS_HAT0Y: - self.ui.safe_call(self.ui.set_haty_input, event.value) - if event.value == -1: - self.on_button_press(102, 1) - elif event.value == 1: - self.on_button_press(103, 1) - if event.type == ecodes.EV_KEY: - if event.value: - delay = 0 - if self.test and self.test.is_awaiting_action(): - self.test.trigger_action() - else: - delay = 100 - - button = None - - if event.code >= 288 and event.code <= 303: - button = event.code - 288 - if event.code >= 304 and event.code <= 316: - button = event.code - 304 - if event.code >= 704 and event.code <= 715: - button = event.code - 688 - - if button is not None: - self.ui.safe_call(self.ui.set_btn_input, button, event.value, delay) - self.on_button_press(button, event.value) - - def input_thread(self): - while 1: - if self.device is not None and self.device.is_ready(): - try: - events = self.device.read_events(0.5) - if events is not None: - self.process_events(events) - except OSError as e: - logging.debug(e) - time.sleep(1) + def save_buttons_config(self): + config = configparser.ConfigParser() + config["buttons"] = {} + for idx, button_config in enumerate(self.button_config): + if isinstance(button_config, list): + config["buttons"][f"action_{idx}"] = ",".join(map(str, button_config)) else: - time.sleep(1) - self.ui.safe_call(self.populate_devices) - - def run_command(self): - proc = subprocess.Popen(self.app.args.command, shell=True) - returncode = proc.wait() - if returncode != 0: - self.ui.safe_call(self.ui.error_dialog, _('Command error'), - _("The supplied command failed:\n{}").format(self.app.args.command[0])) - else: - self.ui.safe_call(self.ui.quit) + config["buttons"][f"action_{idx}"] = str(button_config) + config_path = os.path.join(self.config_path, "buttons.ini") + with open(config_path, "w") as f: + config.write(f) - def start_test(self): - def test_callback(name = 'end'): - if name == 'end': - self.ui.safe_call(self.end_test) - elif name == 'running': - self.ui.safe_call(self.ui.show_test_running, self.test_run, 1) - self.test = Test(self.device, test_callback) - self.test_run = 0 - self.ui.switch_test_panel(self.test_run) - - def end_test(self): - if self.test_run == 0: - self.minimum_level = self.test.get_minimum_level() - elif self.test_run == 1: - self.linear_chart = LinearChart(self.test.get_input_values(), self.test.get_output_values(), - self.device.get_max_range()) - self.linear_chart.set_minimum_level(self.minimum_level) - elif self.test_run == 2: - self.performance_chart = PerformanceChart(self.test.get_input_values(), self.test.get_output_values(), - self.device.get_max_range()) - if self.performance_chart.get_latency() is None: - self.ui.error_dialog(_('Steering wheel not responding.'), _('No wheel movement could be registered.')) - self.ui.switch_test_panel(None) - return - self.combined_chart = CombinedChart(self.linear_chart, self.performance_chart) - self.test = None - self.test_run = None - self.show_test_results() + def start_app(self): + args = self.app.args + if args is not None and args.command: + subprocess.Popen(args.command, shell=True) + + # --- Auto-switch integration --- + + def start_auto_switch(self): + """Start the profile auto-switcher background thread.""" + if self.auto_switcher is not None and self.auto_switcher.is_running(): return - self.next_test() + self.auto_switcher = ProfileAutoSwitcher( + model=self.model, + profile_path=self.app.profile_path, + poll_interval=2.0, + on_profile_change=lambda name: self.ui.safe_call( + lambda n=name: self._on_auto_profile_change(n) + ), + ) + self.auto_switcher.start() + self.auto_switch_enabled = True + self.save_preferences() + logging.info("Auto-switch started from GUI") + + def stop_auto_switch(self): + """Stop the profile auto-switcher.""" + if self.auto_switcher is not None: + self.auto_switcher.stop() + self.auto_switcher = None + self.auto_switch_enabled = False + self.save_preferences() + logging.info("Auto-switch stopped") - def run_test(self): - self.ui.show_test_running(self.test_run) - self.test.run(self.test_run) + def _on_auto_profile_change(self, profile_name): + """Called (on main thread via safe_call) when auto-switch changes profile.""" + from .profile_auto_switcher import _format_settings - def prev_test(self): - self.test_run -= 1 - if self.test_run == -1: - self.test_run = None - self.ui.switch_test_panel(self.test_run) - if self.test_run is None and self.combined_chart is not None: - self.show_test_results() - - def next_test(self): - self.test_run += 1 - self.ui.switch_test_panel(self.test_run) - if self.test_run > 2: + if profile_name is None: + self.load_profile("DEFAULT") + logging.info("Auto-switch reverted to DEFAULT") + return + profile_file = os.path.join(self.app.profile_path, profile_name + ".ini") + if not os.path.exists(profile_file): return + self.model.profile = None + self.model.load(profile_file) + self.model.flush_device() + self.ui.set_profile(profile_name) + self.model.flush_ui() + logging.info( + "Auto-switch applied profile: %s\n\t%s", + profile_name, + _format_settings(self.model.data), + ) - def show_test_results(self): - self.ui.test_latency.set_text(format(1000 * self.performance_chart.get_latency(), '.0f')) - self.ui.test_max_velocity.set_text(format(self.performance_chart.get_max_velocity(), '.0f')) - self.ui.test_max_accel.set_text(format(self.performance_chart.get_max_accel(), '.0f')) - self.ui.test_max_decel.set_text(format(self.performance_chart.get_max_decel(), '.0f')) - self.ui.test_time_to_max_accel.set_text(format(1000 * self.performance_chart.get_time_to_max_accel(), '.0f')) - self.ui.test_time_to_max_decel.set_text(format(1000 * self.performance_chart.get_time_to_max_decel(), '.0f')) - self.ui.test_mean_accel.set_text(format(self.performance_chart.get_mean_accel(), '.0f')) - self.ui.test_mean_decel.set_text(format(self.performance_chart.get_mean_decel(), '.0f')) - self.ui.test_residual_decel.set_text(format(self.performance_chart.get_residual_decel(), '.0f')) - self.ui.test_estimated_snr.set_text(format(self.performance_chart.get_estimated_snr(), '.0f')) - self.ui.test_minimum_level.set_text(format(self.linear_chart.get_minimum_level_percent(), '.1f')) - self.ui.on_test_ready() + def open_test_chart(self): + if self.combined_chart is None: + return + canvas = self.combined_chart.get_canvas() + toolbar = self.combined_chart.get_navigation_toolbar(canvas) + self.ui.show_test_chart(canvas, toolbar) def import_test_values(self): - filename = self.ui.file_chooser(_('CSV file to import'), 'open', file_type='csv') + filename = self.ui.file_chooser( + _("CSV file to import"), "open", file_type="csv" + ) if filename is None: return - with open(filename) as csv_file: - lin_input_values = [] - lin_output_values = [] - perf_input_values = [] - perf_output_values = [] - data_block = 0 - csv_reader = csv.reader(csv_file, delimiter=',') + lin_input_values = [] + lin_output_values = [] + perf_input_values = [] + perf_output_values = [] + data_block = -1 + minimum_level = 0 + + with open(filename, mode="r") as csv_file: + csv_reader = csv.reader(csv_file) for row in csv_reader: - if row[0].startswith('#'): + if len(row) == 0: continue - if row[0] == 'minimum_level': - self.minimum_level = row[1] - elif row[0] == 'linear_data': + if row[0] == "minimum_level": + minimum_level = float(row[1]) + elif row[0] == "linear_data": data_block = 0 - elif row[0] == 'performance_data': + elif row[0] == "performance_data": data_block = 1 elif data_block == 0: lin_input_values.append((float(row[0]), float(row[1]))) @@ -560,42 +393,65 @@ def import_test_values(self): perf_input_values.append((float(row[0]), float(row[1]))) perf_output_values.append((float(row[2]), float(row[3]))) - self.linear_chart = LinearChart(lin_input_values, lin_output_values, self.device.get_max_range()) + self.linear_chart = LinearChart( + lin_input_values, lin_output_values, self.device.get_max_range() + ) self.linear_chart.set_minimum_level(self.minimum_level) - self.performance_chart = PerformanceChart(perf_input_values, perf_output_values, self.device.get_max_range()) + self.performance_chart = PerformanceChart( + perf_input_values, perf_output_values, self.device.get_max_range() + ) self.combined_chart = CombinedChart(self.linear_chart, self.performance_chart) self.show_test_results() - self.ui.info_dialog(_("Test data imported."), - _("New test data imported from CSV file.")) + self.ui.info_dialog( + _("Test data imported."), _("New test data imported from CSV file.") + ) def export_test_values(self): if self.combined_chart is None: return - default_filename = 'report-' + datetime.now().strftime('%Y%m%d%H%M%S') + '.csv' - filename = self.ui.file_chooser(_('CSV file to export'), 'save', default_filename, 'csv') + default_filename = "report-" + datetime.now().strftime("%Y%m%d%H%M%S") + ".csv" + filename = self.ui.file_chooser( + _("CSV file to export"), "save", default_filename, "csv" + ) if filename is None: return - with open(filename, mode='w') as csv_file: - csv_writer = csv.writer(csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) - csv_writer.writerow(['minimum_level', self.minimum_level]) - csv_writer.writerow(['linear_data']) - for v1, v2 in zip(self.linear_chart.get_input_values(), self.linear_chart.get_output_values()): - csv_writer.writerow([format(v1[0], '.5f'), format(v1[1], '.5f'), format(v2[0], '.5f'), format(v2[1], '.5f')]) - csv_writer.writerow(['performance_data']) - for v1, v2 in zip(self.performance_chart.get_input_values(), self.performance_chart.get_pos_values()): - csv_writer.writerow([format(v1[0], '.5f'), format(v1[1], '.5f'), format(v2[0], '.5f'), format(v2[1], '.5f')]) - - self.ui.info_dialog(_("Test data exported."), - _("Current test data has been exported to a CSV file.")) - - def open_test_chart(self): - if self.combined_chart is None: - return - - canvas = self.combined_chart.get_canvas() - toolbar = self.combined_chart.get_navigation_toolbar(canvas) - self.ui.show_test_chart(canvas, toolbar) + with open(filename, mode="w") as csv_file: + csv_writer = csv.writer( + csv_file, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL + ) + csv_writer.writerow(["minimum_level", self.minimum_level]) + csv_writer.writerow(["linear_data"]) + for v1, v2 in zip( + self.linear_chart.get_input_values(), + self.linear_chart.get_output_values(), + ): + csv_writer.writerow( + [ + format(v1[0], ".5f"), + format(v1[1], ".5f"), + format(v2[0], ".5f"), + format(v2[1], ".5f"), + ] + ) + csv_writer.writerow(["performance_data"]) + for v1, v2 in zip( + self.performance_chart.get_input_values(), + self.performance_chart.get_pos_values(), + ): + csv_writer.writerow( + [ + format(v1[0], ".5f"), + format(v1[1], ".5f"), + format(v2[0], ".5f"), + format(v2[1], ".5f"), + ] + ) + + self.ui.info_dialog( + _("Test data exported."), + _("Current test data has been exported to a CSV file."), + ) diff --git a/oversteer/main.ui b/oversteer/main.ui index b5ad218..e29b2b2 100644 --- a/oversteer/main.ui +++ b/oversteer/main.ui @@ -109,7 +109,7 @@ True True none - + False @@ -136,7 +136,7 @@ True True none - + False diff --git a/oversteer/model.py b/oversteer/model.py index efd3885..aca1475 100644 --- a/oversteer/model.py +++ b/oversteer/model.py @@ -1,46 +1,48 @@ import configparser import logging -class Model: +class Model: profile = None device = None defaults = { - 'mode': None, - 'range': None, - 'ff_gain': None, - 'autocenter': None, - 'combine_pedals': None, - 'spring_level': None, - 'damper_level': None, - 'friction_level': None, - 'ffb_leds': None, - 'ffb_overlay': None, - 'range_overlay': None, - 'use_buttons': None, - 'center_wheel': None, - 'start_app_manually': None, + "mode": None, + "range": None, + "ff_gain": None, + "autocenter": None, + "combine_pedals": None, + "spring_level": None, + "damper_level": None, + "friction_level": None, + "ffb_leds": None, + "ffb_overlay": None, + "range_overlay": None, + "use_buttons": None, + "center_wheel": None, + "start_app_manually": None, + "game_processes": None, } types = { - 'mode': 'string', - 'range': 'integer', - 'ff_gain': 'integer', - 'autocenter': 'integer', - 'combine_pedals': 'integer', - 'spring_level': 'integer', - 'damper_level': 'integer', - 'friction_level': 'integer', - 'ffb_leds': 'integer', - 'ffb_overlay': 'boolean', - 'range_overlay': 'string', - 'use_buttons': 'boolean', - 'center_wheel': 'boolean', - 'start_app_manually': 'boolean', + "mode": "string", + "range": "integer", + "ff_gain": "integer", + "autocenter": "integer", + "combine_pedals": "integer", + "spring_level": "integer", + "damper_level": "integer", + "friction_level": "integer", + "ffb_leds": "integer", + "ffb_overlay": "boolean", + "range_overlay": "string", + "use_buttons": "boolean", + "center_wheel": "boolean", + "start_app_manually": "boolean", + "game_processes": "string", } - def __init__(self, device = None, ui = None): + def __init__(self, device=None, ui=None): self.ui = ui self.reference_values = None self.data = self.defaults.copy() @@ -65,26 +67,31 @@ def update_save_profile_button(self): self.ui.disable_save_profile() if self.ui is None or self.reference_values is None: return - for (key, value) in self.reference_values.items(): + for key, value in self.reference_values.items(): if self.data[key] != value: self.ui.enable_save_profile() def read_device_settings(self): return { - 'mode': self.device.get_mode(), - 'range': self.device.get_range(), - 'ff_gain': self.device.get_ff_gain(), - 'autocenter': self.device.get_autocenter(), - 'combine_pedals': self.device.get_combine_pedals(), - 'spring_level': self.device.get_spring_level(), - 'damper_level': self.device.get_damper_level(), - 'friction_level': self.device.get_friction_level(), - 'ffb_leds': self.device.get_ffb_leds(), - 'ffb_overlay': False if self.device.get_peak_ffb_level() is not None else None, - 'range_overlay': 'never' if self.device.get_peak_ffb_level() is not None else None, - 'use_buttons': False if self.device.get_range() is not None else None, - 'center_wheel': False, - 'start_app_manually': False, + "mode": self.device.get_mode(), + "range": self.device.get_range(), + "ff_gain": self.device.get_ff_gain(), + "autocenter": self.device.get_autocenter(), + "combine_pedals": self.device.get_combine_pedals(), + "spring_level": self.device.get_spring_level(), + "damper_level": self.device.get_damper_level(), + "friction_level": self.device.get_friction_level(), + "ffb_leds": self.device.get_ffb_leds(), + "ffb_overlay": False + if self.device.get_peak_ffb_level() is not None + else None, + "range_overlay": "never" + if self.device.get_peak_ffb_level() is not None + else None, + "use_buttons": False if self.device.get_range() is not None else None, + "center_wheel": False, + "start_app_manually": False, + "game_processes": None, } def update_from_device_settings(self): @@ -94,52 +101,69 @@ def get_profile(self): return self.profile def load(self, profile_file): - logging.debug('Load') - if profile_file == self.profile: return config = configparser.ConfigParser() config.read(profile_file) data = self.defaults.copy() - for (key, value) in config['DEFAULT'].items(): + for key, value in config["DEFAULT"].items(): if key not in self.types: logging.warning("Unknown profile setting name: %s", key) continue if value is None: continue - if self.types[key] == 'string': + if self.types[key] == "string": data[key] = value - elif self.types[key] == 'integer': + elif self.types[key] == "integer": data[key] = int(value) - elif self.types[key] == 'boolean': + elif self.types[key] == "boolean": data[key] = bool(int(value)) - elif self.types[key] == 'tuple': - data[key] = tuple(map(int, value.split(','))) + elif self.types[key] == "tuple": + data[key] = tuple(map(int, value.split(","))) + + if config.has_section("profile") and "game_processes" in config["profile"]: + data["game_processes"] = config["profile"]["game_processes"] self.data = data self.save_reference_values() self.profile = profile_file - logging.debug("\n".join('{0} = {1}'.format(k, v) for k, v in self.data.items())) + save_defaults = { + "spring_level": 0, + "damper_level": 0, + "friction_level": 0, + "autocenter": 0, + "combine_pedals": 0, + } def save(self, profile_file): data = {} for key, value in self.data.items(): if value is None: - continue - if self.types[key] == 'string': + if key in self.save_defaults: + value = self.save_defaults[key] + else: + continue + if key == "game_processes": + continue # Handled separately in [profile] section + if self.types[key] == "string": data[key] = value - elif self.types[key] == 'integer': + elif self.types[key] == "integer": data[key] = int(value) - elif self.types[key] == 'boolean': + elif self.types[key] == "boolean": data[key] = int(bool(value)) - elif self.types[key] == 'tuple': - data[key] = ','.join(map(str, value)) + elif self.types[key] == "tuple": + data[key] = ",".join(map(str, value)) config = configparser.ConfigParser() - config['DEFAULT'] = data - with open(profile_file, 'w') as configfile: + config["DEFAULT"] = data + + # Write game_processes in a [profile] section for auto-switch + if self.data.get("game_processes"): + config["profile"] = {"game_processes": self.data["game_processes"]} + + with open(profile_file, "w") as configfile: config.write(configfile) self.save_reference_values() @@ -163,146 +187,152 @@ def get_mode_list(self): return self.device.list_modes() def set_mode(self, value): - if self.set_if_changed('mode', value): + if self.set_if_changed("mode", value): self.device.set_mode(value) def get_mode(self): - return self.data['mode'] + return self.data["mode"] def set_range(self, value): value = int(value) - if self.set_if_changed('range', value): + if self.set_if_changed("range", value): self.device.set_range(value) def get_range(self): - return self.data['range'] + return self.data["range"] def set_ff_gain(self, value): value = int(value) - if self.set_if_changed('ff_gain', value): + if self.set_if_changed("ff_gain", value): self.device.set_ff_gain(value) def get_ff_gain(self): - return self.data['ff_gain'] + return self.data["ff_gain"] def set_autocenter(self, value): value = int(value) - if self.set_if_changed('autocenter', value): + if self.set_if_changed("autocenter", value): self.device.set_autocenter(value) def get_autocenter(self): - return self.data['autocenter'] + return self.data["autocenter"] def set_combine_pedals(self, value): value = int(value) - if self.set_if_changed('combine_pedals', value): + if self.set_if_changed("combine_pedals", value): self.device.set_combine_pedals(value) def get_combine_pedals(self): - return self.data['combine_pedals'] + return self.data["combine_pedals"] def set_spring_level(self, value): value = int(value) - if self.set_if_changed('spring_level', value): + if self.set_if_changed("spring_level", value): self.device.set_spring_level(value) def get_spring_level(self): - return self.data['spring_level'] + return self.data["spring_level"] def set_damper_level(self, value): value = int(value) - if self.set_if_changed('damper_level', value): + if self.set_if_changed("damper_level", value): self.device.set_damper_level(value) def get_damper_level(self): - return self.data['damper_level'] + return self.data["damper_level"] def set_friction_level(self, value): value = int(value) - if self.set_if_changed('friction_level', value): + if self.set_if_changed("friction_level", value): self.device.set_friction_level(value) def get_friction_level(self): - return self.data['friction_level'] + return self.data["friction_level"] def set_ffb_leds(self, value): value = bool(value) - if self.set_if_changed('ffb_leds', value): + if self.set_if_changed("ffb_leds", value): self.device.set_ffb_leds(1 if value else 0) def get_ffb_leds(self): - return self.data['ffb_leds'] + return self.data["ffb_leds"] def set_ffb_overlay(self, value): - self.set_if_changed('ffb_overlay', bool(value)) + self.set_if_changed("ffb_overlay", bool(value)) def get_ffb_overlay(self): - return self.data['ffb_overlay'] + return self.data["ffb_overlay"] def set_range_overlay(self, value): - self.set_if_changed('range_overlay', value) + self.set_if_changed("range_overlay", value) def get_range_overlay(self): - return self.data['range_overlay'] + return self.data["range_overlay"] def set_use_buttons(self, value): - self.set_if_changed('use_buttons', bool(value)) + self.set_if_changed("use_buttons", bool(value)) def get_use_buttons(self): - return self.data['use_buttons'] + return self.data["use_buttons"] def set_center_wheel(self, value): value = bool(value) - if self.set_if_changed('center_wheel', value) and value: + if self.set_if_changed("center_wheel", value) and value: self.device.center_wheel() def set_start_app_manually(self, value): value = bool(value) - self.set_if_changed('start_app_manually', value) + self.set_if_changed("start_app_manually", value) def get_start_app_manually(self): - return self.data['start_app_manually'] + return self.data["start_app_manually"] + + def set_game_processes(self, value): + self.set_if_changed("game_processes", value) + + def get_game_processes(self): + return self.data["game_processes"] def flush_device(self): logging.debug("flush_device") - if self.data['mode'] is not None: - self.device.set_mode(self.data['mode']) - if self.data['range'] is not None: - self.device.set_range(self.data['range']) - if self.data['combine_pedals'] is not None: - self.device.set_combine_pedals(self.data['combine_pedals']) - if self.data['center_wheel']: + if self.data["mode"] is not None: + self.device.set_mode(self.data["mode"]) + if self.data["range"] is not None: + self.device.set_range(self.data["range"]) + if self.data["combine_pedals"] is not None: + self.device.set_combine_pedals(self.data["combine_pedals"]) + if self.data["center_wheel"]: self.device.center_wheel() - if self.data['autocenter'] is not None: - self.device.set_autocenter(self.data['autocenter']) - if self.data['ff_gain'] is not None: - self.device.set_ff_gain(self.data['ff_gain']) - if self.data['spring_level'] is not None: - self.device.set_spring_level(self.data['spring_level']) - if self.data['damper_level'] is not None: - self.device.set_damper_level(self.data['damper_level']) - if self.data['friction_level'] is not None: - self.device.set_friction_level(self.data['friction_level']) - if self.data['ffb_leds'] is not None: - self.device.set_ffb_leds(self.data['ffb_leds']) - - def flush_ui(self, data = None): - logging.debug("flush_ui") + if self.data["autocenter"] is not None: + self.device.set_autocenter(self.data["autocenter"]) + if self.data["ff_gain"] is not None: + self.device.set_ff_gain(self.data["ff_gain"]) + if self.data["spring_level"] is not None: + self.device.set_spring_level(self.data["spring_level"]) + if self.data["damper_level"] is not None: + self.device.set_damper_level(self.data["damper_level"]) + if self.data["friction_level"] is not None: + self.device.set_friction_level(self.data["friction_level"]) + if self.data["ffb_leds"] is not None: + self.device.set_ffb_leds(self.data["ffb_leds"]) + + def flush_ui(self, data=None): if data is None: data = self.data - self.ui.set_mode(data['mode']) - self.ui.set_range(data['range']) - self.ui.set_ff_gain(data['ff_gain']) - self.ui.set_autocenter(data['autocenter']) - self.ui.set_combine_pedals(data['combine_pedals']) - self.ui.set_spring_level(data['spring_level']) - self.ui.set_damper_level(data['damper_level']) - self.ui.set_friction_level(data['friction_level']) - self.ui.set_ffb_leds(data['ffb_leds']) - self.ui.set_ffb_overlay(data['ffb_overlay']) - self.ui.set_range_overlay(data['range_overlay']) - self.ui.set_use_buttons(data['use_buttons']) - self.ui.set_center_wheel(data['center_wheel']) - self.ui.set_start_app_manually(data['start_app_manually']) + self.ui.set_mode(data["mode"]) + self.ui.set_range(data["range"]) + self.ui.set_ff_gain(data["ff_gain"]) + self.ui.set_autocenter(data["autocenter"]) + self.ui.set_combine_pedals(data["combine_pedals"]) + self.ui.set_spring_level(data["spring_level"]) + self.ui.set_damper_level(data["damper_level"]) + self.ui.set_friction_level(data["friction_level"]) + self.ui.set_ffb_leds(data["ffb_leds"]) + self.ui.set_ffb_overlay(data["ffb_overlay"]) + self.ui.set_range_overlay(data["range_overlay"]) + self.ui.set_use_buttons(data["use_buttons"]) + self.ui.set_center_wheel(data["center_wheel"]) + self.ui.set_start_app_manually(data["start_app_manually"]) + if hasattr(self.ui, "set_game_processes"): + self.ui.set_game_processes(data["game_processes"]) self.update_save_profile_button() - diff --git a/oversteer/process_watcher.py b/oversteer/process_watcher.py new file mode 100644 index 0000000..de23fb7 --- /dev/null +++ b/oversteer/process_watcher.py @@ -0,0 +1,52 @@ +import logging +import os + +logger = logging.getLogger(__name__) + + +def get_running_processes(): + """Return a set of process names from /proc.""" + processes = set() + try: + for pid in os.listdir('/proc'): + if not pid.isdigit(): + continue + try: + comm_path = os.path.join('/proc', pid, 'comm') + with open(comm_path, 'r') as f: + name = f.read().strip() + if name: + processes.add(name) + # Also resolve exe symlink for Wine/Proton games + exe_path = os.path.join('/proc', pid, 'exe') + try: + exe = os.path.realpath(exe_path) + basename = os.path.basename(exe) + if basename and basename not in ('', 'None'): + processes.add(basename) + except (OSError, FileNotFoundError): + pass + except (OSError, FileNotFoundError, PermissionError): + continue + except OSError: + pass + return processes + + +def detect_game(processes, profile_processes_map): + """ + Given a set of running process names and a dict of + {profile_name: [process_names]}, return the matching profile name + or None. + """ + for profile_name, proc_list in profile_processes_map.items(): + for proc in proc_list: + # Case-insensitive match, handle both .exe and native names + proc_lower = proc.lower() + for running in processes: + if running.lower() == proc_lower: + return profile_name + # Partial match for Wine/Proton paths like "steamapps/common/AMS2" + if proc_lower.replace('.exe', '') in running.lower(): + return profile_name + return None diff --git a/oversteer/profile_auto_switcher.py b/oversteer/profile_auto_switcher.py new file mode 100644 index 0000000..2fd1321 --- /dev/null +++ b/oversteer/profile_auto_switcher.py @@ -0,0 +1,181 @@ +import configparser +import logging +import os +import subprocess +import threading +import time + +from .process_watcher import get_running_processes, detect_game + +logger = logging.getLogger(__name__) + +SETTINGS_KEYS = [ + ("range", "Range"), + ("ff_gain", "FF Gain"), + ("autocenter", "Autocenter"), + ("spring_level", "Spring"), + ("damper_level", "Damper"), + ("friction_level", "Friction"), + ("combine_pedals", "Combine Pedals"), + ("ffb_leds", "FFB LEDs"), +] + + +def _format_settings(data): + parts = [] + for key, label in SETTINGS_KEYS: + val = data.get(key) + if val is not None: + parts.append(f"{label}: {val}") + return ", ".join(parts) + + +def send_notification(title, message): + """Send a desktop notification via notify-send.""" + try: + subprocess.Popen( + ["notify-send", "-a", "Oversteer", "-i", "input-gaming", title, message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + logger.debug("notify-send not found, skipping notification") + + +def load_profile_processes(profile_path): + """ + Scan all profile INI files and return a dict: + {profile_name: [process_name, ...]} + Only profiles with a 'game_processes' key are included. + """ + result = {} + if not os.path.isdir(profile_path): + return result + for filename in os.listdir(profile_path): + if not filename.endswith(".ini"): + continue + profile_name = filename[:-4] + filepath = os.path.join(profile_path, filename) + config = configparser.ConfigParser() + config.read(filepath) + if "profile" in config and "game_processes" in config["profile"]: + raw = config["profile"]["game_processes"] + procs = [p.strip() for p in raw.split(",") if p.strip()] + if procs: + result[profile_name] = procs + return result + + +class ProfileAutoSwitcher: + """Background thread that watches for game processes and auto-switches profiles.""" + + def __init__( + self, + model, + profile_path, + poll_interval=2.0, + on_profile_change=None, + headless=False, + ): + self.model = model + self.profile_path = profile_path + self.poll_interval = poll_interval + self.on_profile_change = on_profile_change + self.headless = headless + self._thread = None + self._stop_event = threading.Event() + self._current_profile = None + self._default_profile = None + + def start(self): + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + logger.info("Profile auto-switcher started (poll: %.1fs)", self.poll_interval) + + def stop(self): + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + logger.info("Profile auto-switcher stopped") + + def is_running(self): + return self._thread is not None and self._thread.is_alive() + + def set_default_profile(self, profile_name): + self._default_profile = profile_name + + def _load_profile(self, profile_name): + """Load a profile INI and apply it to the device.""" + profile_file = os.path.join(self.profile_path, profile_name + ".ini") + if not os.path.exists(profile_file): + logger.warning("Profile not found: %s", profile_file) + return False + if self.headless: + self.model.load(profile_file) + self.model.flush_device() + logger.info( + "Auto-switch applied profile: %s\n\t%s", + profile_name, + _format_settings(self.model.data), + ) + return True + + def _apply_profile(self, profile_name): + """Switch to a profile and notify.""" + if profile_name == self._current_profile: + return + logger.info("Switching to profile: %s", profile_name) + if self._load_profile(profile_name): + self._current_profile = profile_name + send_notification( + "Oversteer — Profile changed", f"Switched to: {profile_name}" + ) + if self.on_profile_change: + self.on_profile_change(profile_name) + + def _revert_to_default(self): + """Revert to DEFAULT profile when no game is running.""" + logger.info("Reverting to DEFAULT profile") + if self.headless: + default_file = os.path.join(self.profile_path, "DEFAULT.ini") + if os.path.isfile(default_file): + self.model.load(default_file) + else: + self.model.data = self.model.defaults.copy() + self.model.profile = None + self.model.update_from_device_settings() + self.model.flush_device() + logger.info("\t%s", _format_settings(self.model.data)) + if self.on_profile_change: + self.on_profile_change(None) + + def _run(self): + """Main watch loop.""" + while not self._stop_event.is_set(): + try: + processes = get_running_processes() + profile_map = load_profile_processes(self.profile_path) + + if not profile_map: + logger.debug("No profiles with game_processes configured") + self._stop_event.wait(self.poll_interval) + continue + + matched = detect_game(processes, profile_map) + + if matched: + self._apply_profile(matched) + elif self._default_profile: + self._apply_profile(self._default_profile) + elif self._current_profile is not None: + self._current_profile = None + self._revert_to_default() + + except Exception as e: + logger.error("Auto-switch error: %s", e, exc_info=True) + + self._stop_event.wait(self.poll_interval) diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..c40592a --- /dev/null +++ b/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +python3 -c " +import sys +sys.path.insert(0, '/home/caz/Documents/oversteer') +from oversteer.application import Application +app = Application('dev', '/home/caz/Documents/oversteer/data', '/home/caz/Documents/oversteer/data') +sys.exit(app.run(sys.argv)) +" "$@" \ No newline at end of file