Add game-based onboard profile switching and detector management

This commit is contained in:
doppelkool 2026-03-15 08:02:33 +01:00
parent 7520c9cc28
commit f2b3fc5764
5 changed files with 478 additions and 0 deletions

View File

@ -0,0 +1,164 @@
import json
import logging
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import psutil
CONFIG_PATH = Path.home() / ".config" / "solaar" / "game_dpi_profiles.json"
POLL_SECONDS = 1.0
DEBOUNCE_REQUIRED = 2
COOLDOWN_SECONDS = 5.0
PROFILE_CHOICES = {f"profile {i}": f"Profile {i}" for i in range(1, 6)}
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("solaar-game-profile-detector")
def _load_config():
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
return {}
def _alive_pids(pids):
alive = set()
for pid in pids:
if psutil.pid_exists(pid):
alive.add(pid)
return alive
def _iter_processes():
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
try:
info = proc.info
name = (info.get("name") or "").lower()
cmdline = [part.lower() for part in (info.get("cmdline") or [])]
yield info.get("pid"), name, cmdline
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
def _match_alias(alias, proc_name, cmdline):
alias = alias.lower()
joined = " ".join(cmdline)
if alias == "minecraft":
return proc_name == "java" and "mojang" in joined
if alias == proc_name:
return True
if alias in proc_name:
return True
return alias in joined
def _find_target(config):
for device_name, device_cfg in config.items():
profiles = device_cfg.get("profiles", [])
for profile in profiles:
if profile.get("is_default"):
continue
aliases = profile.get("aliases", [])
if not aliases:
continue
matched_pids = set()
for pid, proc_name, cmdline in _iter_processes():
if any(_match_alias(alias, proc_name, cmdline) for alias in aliases):
matched_pids.add(pid)
if matched_pids:
return {
"device_name": device_name,
"profile_name": profile.get("name") or profile.get("onboard_profile"),
"onboard_profile": PROFILE_CHOICES.get(profile.get("onboard_profile", "").lower(), profile.get("onboard_profile", "Profile 1")),
"matched_pids": matched_pids,
}
return None
def _default_for_device(config, device_name):
profiles = config.get(device_name, {}).get("profiles", [])
for profile in profiles:
if profile.get("is_default"):
return {
"device_name": device_name,
"profile_name": profile.get("name") or profile.get("onboard_profile"),
"onboard_profile": PROFILE_CHOICES.get(profile.get("onboard_profile", "").lower(), profile.get("onboard_profile", "Profile 1")),
}
return None
def _solaar_cmd():
return shutil.which("solaar") or str(Path.home() / ".local" / "bin" / "solaar")
def _apply_profile(binding):
cmd = [_solaar_cmd(), "config", binding["device_name"], "onboard_profiles", binding["onboard_profile"]]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def main():
last_applied = None
active_game = None
last_switch = 0.0
pending_key = None
pending_hits = 0
while True:
config = _load_config()
now = time.monotonic()
if active_game is not None:
alive = _alive_pids(active_game.get("matched_pids", set()))
if alive:
active_game["matched_pids"] = alive
time.sleep(POLL_SECONDS)
continue
default_binding = _default_for_device(config, active_game["device_name"])
if default_binding and default_binding != last_applied and now - last_switch >= COOLDOWN_SECONDS:
_apply_profile(default_binding)
logger.info(f"reverted {default_binding['device_name']} to {default_binding['onboard_profile']}")
last_applied = default_binding
last_switch = now
active_game = None
pending_key = None
pending_hits = 0
time.sleep(POLL_SECONDS)
continue
target = _find_target(config)
if target is None:
pending_key = None
pending_hits = 0
time.sleep(POLL_SECONDS)
continue
key = (target["device_name"], target["onboard_profile"])
if key == pending_key:
pending_hits += 1
else:
pending_key = key
pending_hits = 1
if pending_hits >= DEBOUNCE_REQUIRED and target != last_applied and now - last_switch >= COOLDOWN_SECONDS:
_apply_profile(target)
logger.info(f"activated {target['device_name']} -> {target['onboard_profile']} for {target['profile_name']}")
active_game = target
last_applied = {
"device_name": target["device_name"],
"profile_name": target["profile_name"],
"onboard_profile": target["onboard_profile"],
}
last_switch = now
pending_key = None
pending_hits = 0
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,255 @@
import json
import subprocess
import sys
from pathlib import Path
import gi
from solaar.i18n import _
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
CONFIG_PATH = Path.home() / ".config" / "solaar" / "game_dpi_profiles.json"
SERVICE_PATH = Path.home() / ".config" / "systemd" / "user" / "solaar-game-profile-detector.service"
PROFILE_CHOICES = [f"Profile {i}" for i in range(1, 6)]
SERVICE_TEMPLATE = """[Unit]
Description=Solaar Game Profile Detector
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/solaar-game-profile-detector
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
"""
def _run_systemctl(*args):
return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True)
def _load_config():
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
return {}
def _save_config(data):
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
def _device_key(device):
return getattr(device, "name", None) or getattr(device, "codename", None) or "Unknown Device"
def _service_status_text():
active = _run_systemctl("is-active", "solaar-game-profile-detector.service")
enabled = _run_systemctl("is-enabled", "solaar-game-profile-detector.service")
return f"{_('Status')}: {active.stdout.strip() or active.stderr.strip() or 'unknown'} / {_('Autostart')}: {enabled.stdout.strip() or enabled.stderr.strip() or 'disabled'}"
def _service_enabled():
result = _run_systemctl("is-enabled", "solaar-game-profile-detector.service")
return result.returncode == 0 and result.stdout.strip() == "enabled"
class _GameProfilesDialog(Gtk.Dialog):
def __init__(self, parent, device):
super().__init__(title=_("Game DPI Profiles"), transient_for=parent, flags=0)
self.device = device
self.set_modal(True)
self.set_default_size(760, 420)
self.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
self.add_button(_("Save"), Gtk.ResponseType.OK)
area = self.get_content_area()
area.set_spacing(10)
area.set_margin_top(10)
area.set_margin_bottom(10)
area.set_margin_start(10)
area.set_margin_end(10)
intro = Gtk.Label(label=_("Bind running games to onboard mouse profiles. Default profile is restored when the matched game exits."))
intro.set_xalign(0)
intro.set_line_wrap(True)
area.pack_start(intro, False, False, 0)
service_frame = Gtk.Frame(label=_("Background detector"))
service_box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 8)
service_box.set_border_width(8)
self.status_label = Gtk.Label(label=_service_status_text())
self.status_label.set_xalign(0)
service_box.pack_start(self.status_label, False, False, 0)
self.autostart = Gtk.CheckButton(label=_("Start detector on login"))
self.autostart.set_active(_service_enabled())
service_box.pack_start(self.autostart, False, False, 0)
btn_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6)
for label, callback in [
(_("Install/repair service"), self._install_service),
(_("Start detector"), self._start_service),
(_("Stop detector"), self._stop_service),
(_("Refresh status"), self._refresh_status),
]:
btn = Gtk.Button(label=label)
btn.connect("clicked", callback)
btn_box.pack_start(btn, False, False, 0)
service_box.pack_start(btn_box, False, False, 0)
service_frame.add(service_box)
area.pack_start(service_frame, False, False, 0)
self.store = Gtk.ListStore(str, str, str, bool)
self._load_rows()
tree = Gtk.TreeView(model=self.store)
tree.set_hexpand(True)
tree.set_vexpand(True)
self.tree = tree
name_renderer = Gtk.CellRendererText()
name_renderer.set_property("editable", True)
name_renderer.connect("edited", self._on_name_edited)
tree.append_column(Gtk.TreeViewColumn(_("Name"), name_renderer, text=0))
profile_renderer = Gtk.CellRendererCombo()
profile_renderer.set_property("editable", True)
profile_renderer.set_property("has-entry", False)
combo_model = Gtk.ListStore(str)
for choice in PROFILE_CHOICES:
combo_model.append([choice])
profile_renderer.set_property("model", combo_model)
profile_renderer.set_property("text-column", 0)
profile_renderer.connect("edited", self._on_profile_edited)
tree.append_column(Gtk.TreeViewColumn(_("Onboard profile"), profile_renderer, text=1))
aliases_renderer = Gtk.CellRendererText()
aliases_renderer.set_property("editable", True)
aliases_renderer.connect("edited", self._on_aliases_edited)
tree.append_column(Gtk.TreeViewColumn(_("Game aliases"), aliases_renderer, text=2))
default_renderer = Gtk.CellRendererToggle()
default_renderer.connect("toggled", self._on_default_toggled)
tree.append_column(Gtk.TreeViewColumn(_("Desktop default"), default_renderer, active=3))
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroller.add(tree)
area.pack_start(scroller, True, True, 0)
row_buttons = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6)
add_btn = Gtk.Button(label=_("Add binding"))
add_btn.connect("clicked", self._add_row)
remove_btn = Gtk.Button(label=_("Remove selected"))
remove_btn.connect("clicked", self._remove_selected)
row_buttons.pack_start(add_btn, False, False, 0)
row_buttons.pack_start(remove_btn, False, False, 0)
area.pack_start(row_buttons, False, False, 0)
self.show_all()
def _device_config(self):
data = _load_config()
key = _device_key(self.device)
return data, key, data.get(key, {})
def _load_rows(self):
self.store.clear()
_data, _key, device_cfg = self._device_config()
profiles = device_cfg.get("profiles", [])
if not profiles:
self.store.append([_("Desktop"), "Profile 1", "", True])
self.store.append([_("Minecraft"), "Profile 2", "minecraft", False])
return
for profile in profiles:
self.store.append([
profile.get("name", ""),
profile.get("onboard_profile", "Profile 1"),
",".join(profile.get("aliases", [])),
bool(profile.get("is_default", False)),
])
def _refresh_status(self, *_args):
self.status_label.set_text(_service_status_text())
self.autostart.set_active(_service_enabled())
def _install_service(self, *_args):
SERVICE_PATH.parent.mkdir(parents=True, exist_ok=True)
SERVICE_PATH.write_text(SERVICE_TEMPLATE)
_run_systemctl("daemon-reload")
self._refresh_status()
def _start_service(self, *_args):
self._apply_autostart_choice()
_run_systemctl("start", "solaar-game-profile-detector.service")
self._refresh_status()
def _stop_service(self, *_args):
_run_systemctl("stop", "solaar-game-profile-detector.service")
self._refresh_status()
def _apply_autostart_choice(self):
if self.autostart.get_active():
_run_systemctl("enable", "solaar-game-profile-detector.service")
else:
_run_systemctl("disable", "solaar-game-profile-detector.service")
def _add_row(self, *_args):
self.store.append([_("New profile"), "Profile 1", "", False])
def _remove_selected(self, *_args):
selection = self.tree.get_selection()
model, treeiter = selection.get_selected()
if treeiter is not None:
model.remove(treeiter)
def _on_name_edited(self, _renderer, path, text):
self.store[path][0] = text
def _on_profile_edited(self, _renderer, path, text):
self.store[path][1] = text
def _on_aliases_edited(self, _renderer, path, text):
self.store[path][2] = text
def _on_default_toggled(self, _renderer, path):
for idx, row in enumerate(self.store):
row[3] = str(idx) == path
def save(self):
self._apply_autostart_choice()
data, key, _device_cfg = self._device_config()
profiles = []
has_default = False
for row in self.store:
name = row[0].strip()
onboard_profile = row[1].strip() or "Profile 1"
aliases = [a.strip().lower() for a in row[2].split(",") if a.strip()]
is_default = bool(row[3])
has_default = has_default or is_default
profiles.append({
"name": name or onboard_profile,
"onboard_profile": onboard_profile,
"aliases": aliases,
"is_default": is_default,
})
if profiles and not has_default:
profiles[0]["is_default"] = True
data[key] = {"profiles": profiles}
_save_config(data)
def show(parent, device):
if device is None:
return
dialog = _GameProfilesDialog(parent, device)
response = dialog.run()
if response == Gtk.ResponseType.OK:
dialog.save()
dialog.destroy()

View File

@ -34,6 +34,7 @@ from solaar.i18n import ngettext
from . import action from . import action
from . import config_panel from . import config_panel
from . import diversion_rules from . import diversion_rules
from . import game_profiles
from . import icons from . import icons
from .about import about from .about import about
from .common import ui_async from .common import ui_async
@ -327,6 +328,14 @@ def _create_window_layout():
bottom_buttons_box.add(quit_button) bottom_buttons_box.add(quit_button)
about_button = _new_button(_("About %s") % NAME, "help-about", _SMALL_BUTTON_ICON_SIZE, clicked=about.show) about_button = _new_button(_("About %s") % NAME, "help-about", _SMALL_BUTTON_ICON_SIZE, clicked=about.show)
bottom_buttons_box.add(about_button) bottom_buttons_box.add(about_button)
game_profiles_button = _new_button(
_("Game DPI Profiles"),
"applications-games",
_SMALL_BUTTON_ICON_SIZE,
tooltip=_("Configure automatic onboard profile switching for games"),
clicked=lambda *_trigger: game_profiles.show(_window, _find_selected_device()),
)
bottom_buttons_box.add(game_profiles_button)
diversion_button = _new_button( diversion_button = _new_button(
_("Rule Editor"), "", _SMALL_BUTTON_ICON_SIZE, clicked=lambda *_trigger: diversion_rules.show_window(_model) _("Rule Editor"), "", _SMALL_BUTTON_ICON_SIZE, clicked=lambda *_trigger: diversion_rules.show_window(_model)
) )
@ -378,6 +387,16 @@ def _find_selected_device_id():
return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER) return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER)
def _device_supports_game_profiles(device):
if not device or getattr(device, "kind", None) is None:
return False
settings = getattr(device, "settings", None) or []
for setting in settings:
if getattr(setting, "name", None) == "onboard_profiles":
return True
return False
# triggered by changing selection in the tree # triggered by changing selection in the tree
def _device_selected(selection): def _device_selected(selection):
model, item = selection.get_selected() model, item = selection.get_selected()
@ -755,6 +774,14 @@ def _update_info_panel(device, full=False):
_details.set_visible(False) _details.set_visible(False)
_info.set_visible(False) _info.set_visible(False)
_empty.set_visible(True) _empty.set_visible(True)
for child in _window.get_children():
vbox = child
if hasattr(vbox, "get_children"):
for sub in vbox.get_children():
if isinstance(sub, Gtk.HButtonBox):
for btn in sub.get_children():
if btn.get_tooltip_text() == _("Configure automatic onboard profile switching for games"):
btn.set_sensitive(False)
return return
# a receiver must be valid # a receiver must be valid
@ -777,6 +804,25 @@ def _update_info_panel(device, full=False):
_info._title.set_sensitive(is_online) _info._title.set_sensitive(is_online)
_update_device_panel(device, _info._device, _info._buttons, full) _update_device_panel(device, _info._device, _info._buttons, full)
for child in _window.get_children():
pass
button = None
for child in _window.get_children():
vbox = child
if hasattr(vbox, "get_children"):
for sub in vbox.get_children():
if isinstance(sub, Gtk.HButtonBox):
for btn in sub.get_children():
if btn.get_tooltip_text() == _("Configure automatic onboard profile switching for games"):
button = btn
break
if button:
break
if button:
break
if button is not None:
button.set_sensitive(_device_supports_game_profiles(device))
_empty.set_visible(False) _empty.set_visible(False)
_info.set_visible(True) _info.set_visible(True)

View File

@ -92,6 +92,7 @@ setup(
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
"solaar = solaar.gtk:main", "solaar = solaar.gtk:main",
"solaar-game-profile-detector = solaar.game_profile_detector:main",
], ],
}, },
) )

View File

@ -0,0 +1,12 @@
[Unit]
Description=Solaar Game Profile Detector
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/solaar-game-profile-detector
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target