Add rsi-clicker.py
This commit is contained in:
506
rsi-clicker.py
Normal file
506
rsi-clicker.py
Normal file
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rsi-clicker — a Wayland-native autoclicker for Linux.
|
||||
|
||||
Clicks are injected through /dev/uinput, entering the kernel input stack at
|
||||
the same layer as your real mouse. That's why it works under Wayland and
|
||||
inside Proton games, where X11-era tools (xdotool, xbindkeys, AutoKey) do
|
||||
nothing.
|
||||
|
||||
Default binding: scroll wheel UP starts spamming left click, wheel DOWN
|
||||
stops it.
|
||||
|
||||
Requires: python-evdev (sudo pacman -S python-evdev)
|
||||
Run `rsi-clicker --doctor` first — it checks your setup and tells you
|
||||
exactly what's missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import grp
|
||||
import os
|
||||
import random
|
||||
import selectors
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import evdev
|
||||
from evdev import InputDevice, UInput
|
||||
from evdev import ecodes as e
|
||||
except ImportError:
|
||||
sys.exit("python-evdev is not installed.\n sudo pacman -S python-evdev\n")
|
||||
|
||||
|
||||
HI_RES_PER_DETENT = 120 # kernel convention for REL_WHEEL_HI_RES
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Device discovery
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def all_input_devices() -> list[InputDevice]:
|
||||
devices = []
|
||||
for path in sorted(evdev.list_devices()):
|
||||
try:
|
||||
devices.append(InputDevice(path))
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
return devices
|
||||
|
||||
|
||||
def has_key(dev: InputDevice, code: int) -> bool:
|
||||
return code in dev.capabilities().get(e.EV_KEY, [])
|
||||
|
||||
|
||||
def has_rel(dev: InputDevice, code: int) -> bool:
|
||||
return code in dev.capabilities().get(e.EV_REL, [])
|
||||
|
||||
|
||||
def is_mouse(dev: InputDevice) -> bool:
|
||||
"""A real pointing device: has a left button and moves in X."""
|
||||
return has_key(dev, e.BTN_LEFT) and has_rel(dev, e.REL_X)
|
||||
|
||||
|
||||
def has_wheel(dev: InputDevice) -> bool:
|
||||
return has_rel(dev, e.REL_WHEEL) or has_rel(dev, e.REL_WHEEL_HI_RES)
|
||||
|
||||
|
||||
def list_devices() -> None:
|
||||
devs = all_input_devices()
|
||||
if not devs:
|
||||
print("No readable input devices — run `rsi-clicker --doctor`.")
|
||||
return
|
||||
|
||||
print(f"{'PATH':<22} {'NAME':<44} NOTES")
|
||||
print("-" * 96)
|
||||
for dev in devs:
|
||||
notes = []
|
||||
if is_mouse(dev):
|
||||
notes.append("MOUSE")
|
||||
if has_wheel(dev):
|
||||
notes.append("wheel")
|
||||
if has_key(dev, e.BTN_SIDE):
|
||||
notes.append("side-btns")
|
||||
if has_key(dev, e.KEY_A):
|
||||
notes.append("keyboard")
|
||||
if not notes:
|
||||
continue
|
||||
print(f"{dev.path:<22} {dev.name[:42]:<44} {', '.join(notes)}")
|
||||
|
||||
print("\nThe device marked MOUSE + wheel is the one this will use.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Setup checker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def doctor() -> None:
|
||||
ok = True
|
||||
|
||||
def check(label, passed, fix=None):
|
||||
nonlocal ok
|
||||
print(f" [{'ok' if passed else 'XX'}] {label}")
|
||||
if not passed:
|
||||
ok = False
|
||||
if fix:
|
||||
for line in fix.strip().splitlines():
|
||||
print(f" {line}")
|
||||
return passed
|
||||
|
||||
print("rsi-clicker setup check\n")
|
||||
|
||||
check("python-evdev installed", True)
|
||||
|
||||
uinput_exists = Path("/dev/uinput").exists()
|
||||
check("uinput module loaded", uinput_exists, """
|
||||
fix: sudo modprobe uinput
|
||||
echo uinput | sudo tee /etc/modules-load.d/uinput.conf""")
|
||||
|
||||
if uinput_exists:
|
||||
writable = os.access("/dev/uinput", os.W_OK)
|
||||
check("/dev/uinput writable by you", writable, """
|
||||
fix: echo 'KERNEL=="uinput", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput"' \\
|
||||
| sudo tee /etc/udev/rules.d/99-uinput.rules
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger""")
|
||||
|
||||
try:
|
||||
in_input = grp.getgrnam("input").gr_gid in os.getgroups()
|
||||
except KeyError:
|
||||
in_input = False
|
||||
check("you are in the 'input' group", in_input, """
|
||||
fix: sudo usermod -aG input $USER
|
||||
then LOG OUT and back in — group changes need a new session""")
|
||||
|
||||
mice = [d for d in all_input_devices() if is_mouse(d) and has_wheel(d)]
|
||||
check(f"found a mouse with a scroll wheel ({len(mice)})", bool(mice), """
|
||||
fix: usually the same cause as the 'input' group check above""")
|
||||
for m in mice:
|
||||
print(f" {m.path} {m.name}")
|
||||
|
||||
print("\n" + ("All good — you're ready to run it." if ok
|
||||
else "Fix the XX lines above, then re-run --doctor."))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Code name resolution
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def resolve_key(name: str) -> int | None:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
if name.isdigit():
|
||||
return int(name)
|
||||
upper = name.upper()
|
||||
if not upper.startswith(("BTN_", "KEY_")):
|
||||
upper = "KEY_" + upper
|
||||
code = getattr(e, upper, None)
|
||||
if code is None:
|
||||
sys.exit(f"Unknown key: {name!r} — expected something like KEY_F12.")
|
||||
return code
|
||||
|
||||
|
||||
CLICK_BUTTONS = {"left": e.BTN_LEFT, "right": e.BTN_RIGHT, "middle": e.BTN_MIDDLE}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The clicker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Clicker:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.button = CLICK_BUTTONS[args.button]
|
||||
self.panic = resolve_key(args.panic)
|
||||
|
||||
self.interval = 1.0 / args.cps
|
||||
self.jitter = args.jitter
|
||||
self.hold_s = args.hold_ms / 1000.0
|
||||
|
||||
self.active = False
|
||||
self.pressed = False
|
||||
self.release_at = 0.0
|
||||
self.next_click = 0.0
|
||||
self.started_at = 0.0
|
||||
self.click_count = 0
|
||||
self.running = True
|
||||
|
||||
# High-res wheel accumulator, used only if the mouse lacks REL_WHEEL.
|
||||
self._hires_accum = 0
|
||||
|
||||
self.mouse = self._find_mouse()
|
||||
self.use_hires = not has_rel(self.mouse, e.REL_WHEEL)
|
||||
self.extra_devices = self._find_panic_devices()
|
||||
|
||||
self.grabbed = False
|
||||
self.ui = self._make_uinput()
|
||||
|
||||
if args.swallow_wheel:
|
||||
try:
|
||||
self.mouse.grab()
|
||||
self.grabbed = True
|
||||
except OSError as exc:
|
||||
sys.exit(
|
||||
f"Could not grab {self.mouse.path}: {exc}\n"
|
||||
"Another process may already hold it."
|
||||
)
|
||||
|
||||
self.selector = selectors.DefaultSelector()
|
||||
for dev in [self.mouse, *self.extra_devices]:
|
||||
self.selector.register(dev, selectors.EVENT_READ)
|
||||
|
||||
# -- device setup ------------------------------------------------------
|
||||
|
||||
def _find_mouse(self) -> InputDevice:
|
||||
if self.args.device:
|
||||
try:
|
||||
return InputDevice(self.args.device)
|
||||
except (PermissionError, OSError) as exc:
|
||||
sys.exit(f"Cannot open {self.args.device}: {exc}")
|
||||
|
||||
candidates = [d for d in all_input_devices() if is_mouse(d) and has_wheel(d)]
|
||||
if not candidates:
|
||||
sys.exit(
|
||||
"No mouse with a scroll wheel found.\n"
|
||||
"Run `rsi-clicker --doctor` to diagnose, or `--list` to pick "
|
||||
"one manually with --device."
|
||||
)
|
||||
if len(candidates) > 1 and self.args.verbose:
|
||||
print("Multiple mice found; using the first. Others:")
|
||||
for d in candidates[1:]:
|
||||
print(f" {d.path} {d.name}")
|
||||
return candidates[0]
|
||||
|
||||
def _find_panic_devices(self) -> list[InputDevice]:
|
||||
if self.panic is None:
|
||||
return []
|
||||
return [
|
||||
d for d in all_input_devices()
|
||||
if d.path != self.mouse.path and has_key(d, self.panic)
|
||||
]
|
||||
|
||||
def _make_uinput(self) -> UInput:
|
||||
"""
|
||||
Without --swallow-wheel we only need to emit clicks.
|
||||
With it, we've taken exclusive control of the mouse, so the virtual
|
||||
device has to reproduce everything the real one could do.
|
||||
"""
|
||||
caps = {
|
||||
e.EV_KEY: [e.BTN_LEFT, e.BTN_RIGHT, e.BTN_MIDDLE],
|
||||
e.EV_REL: [e.REL_X, e.REL_Y],
|
||||
}
|
||||
|
||||
if self.args.swallow_wheel:
|
||||
src = self.mouse.capabilities()
|
||||
keys = set(caps[e.EV_KEY]) | set(src.get(e.EV_KEY, []))
|
||||
rels = set(caps[e.EV_REL]) | set(src.get(e.EV_REL, []))
|
||||
caps = {e.EV_KEY: sorted(keys), e.EV_REL: sorted(rels)}
|
||||
if src.get(e.EV_MSC):
|
||||
caps[e.EV_MSC] = list(src[e.EV_MSC])
|
||||
|
||||
return UInput(caps, name="rsi-clicker virtual pointer",
|
||||
vendor=0x1209, product=0x0001, version=1)
|
||||
|
||||
# -- click emission ----------------------------------------------------
|
||||
|
||||
def _press(self):
|
||||
self.ui.write(e.EV_KEY, self.button, 1)
|
||||
self.ui.syn()
|
||||
self.pressed = True
|
||||
self.click_count += 1
|
||||
|
||||
def _release(self):
|
||||
self.ui.write(e.EV_KEY, self.button, 0)
|
||||
self.ui.syn()
|
||||
self.pressed = False
|
||||
|
||||
def _schedule_next(self, now):
|
||||
gap = self.interval
|
||||
if self.jitter > 0:
|
||||
gap *= 1.0 + random.uniform(-self.jitter, self.jitter)
|
||||
self.next_click = now + max(gap, self.hold_s + 0.002)
|
||||
|
||||
def _set_active(self, state, now):
|
||||
if state == self.active:
|
||||
return
|
||||
self.active = state
|
||||
if state:
|
||||
self.started_at = now
|
||||
self.next_click = now
|
||||
print(" ▶ clicking", flush=True)
|
||||
else:
|
||||
if self.pressed:
|
||||
self._release()
|
||||
print(f" ■ stopped ({self.click_count} clicks)", flush=True)
|
||||
self.click_count = 0
|
||||
|
||||
# -- event handling ----------------------------------------------------
|
||||
|
||||
def _wheel_direction(self, ev) -> int:
|
||||
"""Return +1 for up, -1 for down, 0 for 'not a full detent yet'."""
|
||||
if not self.use_hires:
|
||||
if ev.code == e.REL_WHEEL and ev.value != 0:
|
||||
return 1 if ev.value > 0 else -1
|
||||
return 0
|
||||
|
||||
if ev.code != e.REL_WHEEL_HI_RES:
|
||||
return 0
|
||||
self._hires_accum += ev.value
|
||||
if abs(self._hires_accum) >= HI_RES_PER_DETENT:
|
||||
direction = 1 if self._hires_accum > 0 else -1
|
||||
self._hires_accum = 0
|
||||
return direction
|
||||
return 0
|
||||
|
||||
def _handle_mouse_event(self, ev, now) -> bool:
|
||||
"""Returns True if the event should be forwarded (grab mode only)."""
|
||||
if ev.type == e.EV_KEY and ev.code == self.panic and ev.value == 1:
|
||||
self.running = False
|
||||
return True
|
||||
|
||||
if ev.type != e.EV_REL:
|
||||
return True
|
||||
|
||||
is_wheel = ev.code in (e.REL_WHEEL, e.REL_WHEEL_HI_RES)
|
||||
if not is_wheel:
|
||||
return True
|
||||
|
||||
direction = self._wheel_direction(ev)
|
||||
if direction > 0:
|
||||
self._set_active(True, now)
|
||||
elif direction < 0:
|
||||
self._set_active(False, now)
|
||||
|
||||
# In grab mode we eat wheel events so the game never sees them.
|
||||
return not self.args.swallow_wheel
|
||||
|
||||
def _handle_extra_event(self, ev):
|
||||
if (ev.type == e.EV_KEY and self.panic is not None
|
||||
and ev.code == self.panic and ev.value == 1):
|
||||
print("\npanic key — exiting")
|
||||
self.running = False
|
||||
|
||||
# -- main loop ---------------------------------------------------------
|
||||
|
||||
def run(self):
|
||||
signal.signal(signal.SIGINT, self._stop)
|
||||
signal.signal(signal.SIGTERM, self._stop)
|
||||
self._banner()
|
||||
|
||||
while self.running:
|
||||
now = time.monotonic()
|
||||
|
||||
deadlines = []
|
||||
if self.pressed:
|
||||
deadlines.append(self.release_at)
|
||||
elif self.active:
|
||||
deadlines.append(self.next_click)
|
||||
if self.active and self.args.max_duration:
|
||||
deadlines.append(self.started_at + self.args.max_duration)
|
||||
|
||||
timeout = 0.05
|
||||
if deadlines:
|
||||
timeout = min(0.05, max(0.0, min(deadlines) - now))
|
||||
|
||||
for key, _ in self.selector.select(timeout=timeout):
|
||||
dev = key.fileobj
|
||||
try:
|
||||
for ev in dev.read():
|
||||
if dev.path == self.mouse.path:
|
||||
forward = self._handle_mouse_event(ev, time.monotonic())
|
||||
if self.grabbed and forward:
|
||||
self.ui.write(ev.type, ev.code, ev.value)
|
||||
else:
|
||||
self._handle_extra_event(ev)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except OSError:
|
||||
self.selector.unregister(dev)
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
if (self.active and self.args.max_duration
|
||||
and now - self.started_at >= self.args.max_duration):
|
||||
print(" (auto-stop: max duration reached)")
|
||||
self._set_active(False, now)
|
||||
|
||||
if self.pressed and now >= self.release_at:
|
||||
self._release()
|
||||
self._schedule_next(now)
|
||||
elif self.active and not self.pressed and now >= self.next_click:
|
||||
self._press()
|
||||
self.release_at = now + self.hold_s
|
||||
|
||||
self.shutdown()
|
||||
|
||||
def _stop(self, *_):
|
||||
self.running = False
|
||||
|
||||
def shutdown(self):
|
||||
if self.pressed:
|
||||
self._release()
|
||||
if self.grabbed:
|
||||
try:
|
||||
self.mouse.ungrab()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception:
|
||||
pass
|
||||
for dev in [self.mouse, *self.extra_devices]:
|
||||
try:
|
||||
dev.close()
|
||||
except Exception:
|
||||
pass
|
||||
print("clean exit — mouse released")
|
||||
|
||||
def _banner(self):
|
||||
print("rsi-clicker running\n")
|
||||
print(f" wheel UP → start spamming {self.args.button} click")
|
||||
print(f" wheel DOWN → stop")
|
||||
print(f" rate : {self.args.cps:.1f}/sec "
|
||||
f"(±{self.jitter * 100:.0f}% jitter, {self.args.hold_ms:.0f}ms hold)")
|
||||
if self.args.swallow_wheel:
|
||||
print(" wheel : SWALLOWED — scrolling won't reach any app")
|
||||
else:
|
||||
print(" wheel : passed through — unbind weapon-swap in-game")
|
||||
if self.args.max_duration:
|
||||
print(f" auto-stop : after {self.args.max_duration:.0f}s")
|
||||
if self.panic is not None:
|
||||
print(f" panic key : {self.args.panic}")
|
||||
print(f" mouse : {self.mouse.name} ({self.mouse.path})")
|
||||
print("\n Ctrl+C to quit\n")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="rsi-clicker",
|
||||
description="Scroll-wheel-toggled autoclicker for Wayland.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""examples:
|
||||
rsi-clicker --doctor check your setup first
|
||||
rsi-clicker wheel up = go, wheel down = stop
|
||||
rsi-clicker --swallow-wheel also stop the wheel reaching the game
|
||||
rsi-clicker --cps 12 --hold-ms 40
|
||||
""",
|
||||
)
|
||||
p.add_argument("--doctor", action="store_true",
|
||||
help="check permissions/modules and print fixes, then exit")
|
||||
p.add_argument("--list", action="store_true",
|
||||
help="list input devices, then exit")
|
||||
p.add_argument("--button", choices=list(CLICK_BUTTONS), default="left",
|
||||
help="which button to spam (default: left)")
|
||||
p.add_argument("--cps", type=float, default=11.0,
|
||||
help="clicks per second (default: 11)")
|
||||
p.add_argument("--jitter", type=float, default=0.12,
|
||||
help="fractional timing variance 0-1 (default: 0.12)")
|
||||
p.add_argument("--hold-ms", type=float, default=25.0,
|
||||
help="how long each click is held, ms (default: 25); "
|
||||
"raise to 40 if the game misses clicks")
|
||||
p.add_argument("--swallow-wheel", action="store_true",
|
||||
help="take exclusive control of the mouse and stop wheel "
|
||||
"events reaching any application; everything else is "
|
||||
"passed through untouched")
|
||||
p.add_argument("--max-duration", type=float, default=0.0,
|
||||
help="auto-stop after N seconds of continuous clicking "
|
||||
"(0 = no limit)")
|
||||
p.add_argument("--panic", default="KEY_F12",
|
||||
help="key that instantly quits (default: KEY_F12; "
|
||||
"pass '' to disable)")
|
||||
p.add_argument("--device", help="explicit /dev/input/eventX for the mouse")
|
||||
p.add_argument("-v", "--verbose", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_parser().parse_args()
|
||||
|
||||
if args.doctor:
|
||||
doctor()
|
||||
return
|
||||
if args.list:
|
||||
list_devices()
|
||||
return
|
||||
|
||||
if not 0 < args.cps <= 100:
|
||||
sys.exit("--cps must be between 0 and 100.")
|
||||
if not 0.0 <= args.jitter < 1.0:
|
||||
sys.exit("--jitter must be between 0 and 1.")
|
||||
if not Path("/dev/uinput").exists():
|
||||
sys.exit("/dev/uinput missing. Run `rsi-clicker --doctor`.")
|
||||
|
||||
try:
|
||||
Clicker(args).run()
|
||||
except PermissionError:
|
||||
sys.exit("Permission denied. Run `rsi-clicker --doctor` for the fix.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user