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