diff --git a/README.md b/README.md index 6deec25..3f8c466 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,170 @@ -# __Keyboard Chattering Fix for Linux__ +# __Keyboard Chattering & Mouse Double-Click Fix for Linux__ [![GitHub](https://img.shields.io/github/license/w2sv/KeyboardChatteringFix-Linux?)](LICENSE) -__A tool for filtering mechanical keyboard chattering on Linux__ +__A tool for filtering mechanical keyboard chattering and mouse double-clicking on Linux__ ## The problem -Switches on mechanical keyboards occasionally start to "chatter", -meaning when you press a key with a faulty switch it erroneously detects -two or even more key presses. +Switches on mechanical keyboards occasionally start to "chatter" or "bounce", meaning when you press a key with a faulty switch it erroneously detects two or even more key presses. Similarly, mechanical switches on mice (especially gaming mice) frequently develop "double-click" issues where a single physical click registers as multiple rapid clicks. ## The existing solutions -Apart from buying a new keyboard, there have been ways to deal -with this problem using software methods. The idea is to filter key presses -that occur faster than a certain threshold. "Keyboard Chattering Fix v 0.0.1" -is a tool I had been using on Windows for a long time, and these days you also have -[Keyboard Chatter Blocker](https://github.com/mcmonkeyprojects/KeyboardChatterBlocker), -which is a nice open source tool with some additional functionality. It's actually what -I use myself when I use Windows. - -Unfortunately, all existing tools only work on Windows. -On Linux, the answer everyone seems to give is to use the Bounce Keys feature of X, -but it's not really useful in this way. For one, it resets the delay even on filtered -key presses, meaning that if you press the key fast enough, -*none* of the presses with pass through, ever. And if the key chatters, -this is bound to happen eventually and interfere with fast repeated key presses. +Apart from buying new hardware, there have been ways to deal with this problem using software methods. The idea is to filter inputs that occur faster than a certain threshold. "Keyboard Chattering Fix v 0.0.1" is a tool I had been using on Windows for a long time, and these days you also have [Keyboard Chatter Blocker](https://github.com/mcmonkeyprojects/KeyboardChatterBlocker). + +Unfortunately, all existing tools only work on Windows. On Linux, the answer everyone seems to give is to use the Bounce Keys feature of X, but it's not really useful in this way. For one, it resets the delay even on filtered key presses, meaning that if you press the key fast enough, *none* of the presses will pass through, ever. ## This project's solution -This tool attempts to solve any such problems that may arise by having full low-level access -and control over all keyboard events. -Using `libevdev`'s Python bindings, it grabs your keyboard's event device and processes its events, -then outputs the result back to the system using `/dev/uinput`, effectively emulating a keyboard - -one that doesn't chatter, unlike your real one! +This tool attempts to solve these hardware problems by having full low-level access and control over all input events. Using `libevdev`'s Python bindings, it grabs your device and processes its events, then outputs the result back to the system using `/dev/uinput`. This effectively emulates a flawless keyboard and mouse that doesn't chatter or double-click, unlike your real ones! + +This also means it works across the whole system, without depending on X11 or Wayland. + +*Note for Mice:* To ensure your mouse cursor remains flawlessly smooth, this tool uses completely separate logic for mice. It natively bypasses X/Y cursor movement and scrolling, applying the chatter filter *only* to physical button clicks (Left click, Right click, Side buttons, etc.). + +As for the filtering rule, what seems to work well is the time between the last "key up" event and the current "key down" event. When the switch chatters, that time is very low - around 10 ms. By filtering such anomalies, we remove chatter without impeding actual fast typing or clicking. + +### Understanding Linux Input Devices (Which one do I pick?) + +Modern gaming peripherals (like Corsair, Razer, or Logitech) are "composite USB devices". This means a single physical mouse might tell Linux it is actually 4 different devices! When you run the scripts manually, you will see a list of endpoints ending in different suffixes. -This also means it works across the system, without depending on X. +Here is a guide on which one to choose: -As for the filtering rule, what seems to work well is the time between the last key up event -and the current key down event. When the key chatters, that time seems to be very low - around 10 ms. -By filtering such anomalies, we can hopefully remove chatter without impeding actual fast key presses. +- **`-event-kbd`**: The primary endpoint for standard keystrokes. For keyboards, select this to fix chattering on standard keys (A-Z, 0-9). +- **`-event-mouse`**: The primary endpoint for standard mouse clicks (Left, Right, Middle) and X/Y movement. Select this to fix standard mouse double-clicking. +- **`-ifXX-event-kbd` (Virtual Mouse Keyboards)**: Advanced gaming mice often register a "virtual keyboard" to handle macro side-buttons. If your mouse's side buttons are double-clicking, you may need to point the mouse script at this endpoint instead of the standard mouse endpoint! +- **`-event-ifXX` (Interfaces)**: These handle multimedia controls (Volume wheels, Play/Pause) or vendor-specific data (RGB lighting). You rarely need to select these unless your volume wheel is bouncing. + +**Troubleshooting Manual Testing:** +If you run the script manually in the terminal and receive a `[Errno 16] Device or resource busy` error, it means you have a background Systemd service currently running! The script requires an exclusive lock on the hardware. Simply run `sudo systemctl stop keyboard_chattering` or `sudo systemctl stop mouse_chattering` to release the lock before testing manually. + +*Note: Legacy raw nodes (like those ending simply in `-mouse` or `-kbd` without the word `event`) are legacy X11 nodes and cannot be read by `libevdev`.* ## Installation -Download the repository as a zip and extract the file. The dependencies are listed in the requirements.txt. And you can install it with the command below. +Download the repository and extract the files. The dependencies are listed in `requirements.txt`. You can install them with the command below. + +*(Note: According to PEP 668, newer Linux distributions may require the `--break-system-packages` flag, or the use of a python `venv`)*. + +```shell +sudo pip install -r requirements.txt --break-system-packages +``` +### Python Virtual Environment +Using the built-in `venv` module is the safest and cleanest way to run this tool. ```shell -sudo pip3 install -r requirements.txt +# 1. Create a virtual environment named 'venv' inside the project folder +python -m venv venv + +# 2. Activate the virtual environment +source venv/bin/activate + +# 3. Install the dependencies inside the isolated environment +pip install -r requirements.txt ``` ## Usage -`cd` inside the location of the KeyboardChatteringFix-Linux-master extracted folder and enter the command below to run. +`cd` inside the location of the extracted folder. Because keyboards and mice are handled differently by the OS, they are executed as separate modules. Enter the commands below to run them manually: + +**To run the Keyboard fix:** +```shell +sudo python -m src.keyboard_main +``` +**To run the Mouse fix:** ```shell -sudo python3 -m src +sudo python -m src.mouse_main ``` ### Customization Options -- -k KEYBOARD, --keyboard KEYBOARD - - Name of your chattering keyboard device as listed in /dev/input/by-id. If left unset, will be attempted to be retrieved - automatically. The device is captured `by-id`, and therefore in a persistent way. +- `-k KEYBOARD`, `--keyboard KEYBOARD` + - Name of your chattering keyboard device as listed in `/dev/input/by-id`. If left unset, it will attempt to retrieve it automatically. +- `-m MOUSE`, `--mouse MOUSE` + - Name of your double-clicking mouse device. Works identically to the keyboard argument above. +- `-t THRESHOLD`, `--threshold THRESHOLD` + - Filter time threshold in milliseconds. Default=30ms. Note: This denotes the time between a key/button being *released* and pressed again. For reference, if you click really fast, this delay is around 50 ms. +- `--keys KEYS` (For Keyboard) + - Comma-separated list of specific keys to filter (e.g., `KEY_A,KEY_SPACE`). If provided, *only* these keys will be filtered, leaving the rest of your keyboard untouched. You can also permanently define these in `src/keyboard_config.py`. +- `--buttons BUTTONS` (For Mouse) + - Comma-separated list of specific buttons to filter (e.g., `BTN_LEFT,BTN_RIGHT`). You can also permanently define these in `src/mouse_config.py`. +- `-v {0,1,2}`, `--verbosity {0,1,2}` -- -t THRESHOLD, --threshold THRESHOLD - - Filter time threshold in milliseconds. Default=30ms. Note: This does not denote the time between key presses, but - between a key being - released and pressed again, so the number should probably be lower than you might think. For reference, if you - press the key really fast this delay is around 50 ms. +## Automation -- -v {0,1,2}, --verbosity {0,1,2} +Starting the scripts manually every time is not ideal. You should set them up as background Systemd services. Because the keyboard and mouse scripts are separate, they can run concurrently in the background without interfering with one another. -## Automation +### Step 1: Configure the shell scripts +Modify `keyboard_chattering.sh` and/or `mouse_chattering.sh` to `cd` into the absolute path of your downloaded folder, and input your device IDs and desired thresholds. + +**Example `keyboard_chattering.sh`:** +```shell +cd /home/foouser/Downloads/HardwareChatteringFix-Linux/ && sudo python3 -m src.keyboard_main -k usb-SINO_WEALTH_USB_KEYBOARD-event-kbd -t 40 --keys KEY_E,KEY_SPACE +``` -Starting the script manually every time doesn't sound like the greatest idea, so -you should probably consider something that does it for you. Modify the `chattering_fix.sh` to `cd` into the absolute path of the downloaded folder and input the keyboard id and the desired threshold. For example: +**Example `mouse_chattering.sh`:** ```shell -cd /home/foouser/Downloads/KeyboardChatteringFix-Linux-master/ && sudo python3 -m src -k usb-SINO_WEALTH_USB_KEYBOARD-event-kbd -t 50 +cd /home/foouser/Downloads/HardwareChatteringFix-Linux/ && sudo python3 -m src.mouse_main -m usb-Logitech_Gaming_Mouse-event-mouse -t 50 --buttons BTN_LEFT,BTN_RIGHT ``` -Also, make sure to change the file permission of `chattering_fix.sh` so that it is executable. + +Make sure to change the file permissions so they are executable: +```shell +chmod +x keyboard_chattering.sh mouse_chattering.sh +``` + +### Step 2: Configure the service files +Edit `keyboard_chattering.service` and `mouse_chattering.service`. The `ExecStart` should be the absolute path of the respective `.sh` file. + +**Example keyboard_chattering.service:** +```shell +ExecStart=/home/foouser/Downloads/HardwareChatteringFix-Linux/keyboard_chattering.sh +``` + +**Example mouse_chattering.service:** +```shell +ExecStart=/home/foouser/Downloads/HardwareChatteringFix-Linux/mouse_chattering.sh +``` + +### Step 3: Enable the Services (Separately or Combined) + +Copy the `.service` files to your systemd folder: ```shell -chmod +x chattering_fix.sh +sudo cp keyboard_chattering.service /etc/systemd/system/ +sudo cp mouse_chattering.service /etc/systemd/system/ ``` -The `chattering_fix.service` file should also be edited. The `ExecStart` should be the absolute path of the `chattering_fix.sh`. For example: + +**To enable ONLY the keyboard fix:** ```shell -ExecStart=/home/foouser/Downloads/KeyboardChatteringFix-Linux-master/chattering_fix.sh +sudo systemctl enable --now keyboard_chattering ``` -Then, copy the `chattering_fix.service` to `/etc/systemd/system/` and enable it with the command below. + +**To enable ONLY the mouse fix:** ```shell -systemctl enable --now chattering_fix +sudo systemctl enable --now mouse_chattering ``` -You can check if the systemd unit file is properly working using + +**To run BOTH concurrently:** +Simply run both enable commands! They operate completely independently of one another. ```shell -systemctl status chattering_fix.service +sudo systemctl enable --now keyboard_chattering +sudo systemctl enable --now mouse_chattering ``` -You can also use + +### Step 4: Checking Status and Logs + +You can check if the scripts are running properly by checking their independent statuses: + +**For the Keyboard:** ```shell -journalctl -xeu chattering_fix.service +systemctl status keyboard_chattering.service +journalctl -xeu keyboard_chattering.service ``` -just to make sure that there are no errors. \ No newline at end of file + +**For the Mouse:** +```shell +systemctl status mouse_chattering.service +journalctl -xeu mouse_chattering.service +``` + +*(Note: If your device disconnects, is unplugged, or goes to sleep, the Python script will gracefully exit. Systemd will then safely attempt to restart it every 5 seconds in the background until the device is reconnected, ensuring 0% CPU waste!)* diff --git a/chattering_fix.service b/chattering_fix.service deleted file mode 100644 index 1306e9f..0000000 --- a/chattering_fix.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Keyboard Chattering Fix service - -[Service] -# Change ExecStart to the absolute path of the file, executing chattering_fix.sh -ExecStart= - -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/chattering_fix.sh b/chattering_fix.sh deleted file mode 100644 index b993122..0000000 --- a/chattering_fix.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -# Change the line below to the absolute path of the folder -cd && sudo python3 -m src -k -t diff --git a/keyboard_chattering.service b/keyboard_chattering.service new file mode 100644 index 0000000..a51856b --- /dev/null +++ b/keyboard_chattering.service @@ -0,0 +1,12 @@ +[Unit] +Description=Keyboard Chattering Fix service + +[Service] +# Change ExecStart to the absolute path of the file, executing keyboard_chattering.sh +ExecStart= + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/keyboard_chattering.sh b/keyboard_chattering.sh new file mode 100644 index 0000000..5ddb8b0 --- /dev/null +++ b/keyboard_chattering.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Change the line below to the absolute path of the folder. +# You can append `--keys KEY_A,KEY_SPACE` at the very end to ONLY filter those specific keys. +# (If using modern Linux/Python, you may need to run: sudo pip3 install -r requirements.txt --break-system-packages) + +cd && sudo python3 -m src.keyboard_main -k -t 30 diff --git a/mouse_chattering.service b/mouse_chattering.service new file mode 100644 index 0000000..d300b5e --- /dev/null +++ b/mouse_chattering.service @@ -0,0 +1,12 @@ +[Unit] +Description=Mouse Chattering service + +[Service] +# Change ExecStart to the absolute path of the file, executing mouse_chattering.sh +ExecStart= + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/mouse_chattering.sh b/mouse_chattering.sh new file mode 100644 index 0000000..6ffcd3c --- /dev/null +++ b/mouse_chattering.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Change the line below to the absolute path of the folder. +# You can append `--buttons BTN_LEFT,BTN_SIDE` at the very end to ONLY filter those specific buttons. +# (If using modern Linux/Python, you may need to run: sudo pip3 install -r requirements.txt --break-system-packages) + +cd && sudo python3 -m src.mouse_main -m -t 30 diff --git a/src/__main__.py b/src/__main__.py deleted file mode 100755 index 96fc06d..0000000 --- a/src/__main__.py +++ /dev/null @@ -1,50 +0,0 @@ -import argparse -import logging -import sys -from contextlib import contextmanager - -import libevdev - -from src.filtering import filter_chattering -from src.keyboard_retrieval import retrieve_keyboard_name, INPUT_DEVICES_PATH, abs_keyboard_path - - -@contextmanager -def get_device_handle(keyboard_name: str) -> libevdev.Device: - """ Safely get an evdev device handle. """ - - fd = open(abs_keyboard_path(keyboard_name), 'rb') - evdev = libevdev.Device(fd) - try: - yield evdev - finally: - fd.close() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument('-k', '--keyboard', type=str, default=str(), - help=f"Name of your chattering keyboard device as listed in {INPUT_DEVICES_PATH}. " - f"If left unset, will be attempted to be retrieved automatically.") - parser.add_argument('-t', '--threshold', type=int, default=30, help="Filter time threshold in milliseconds. " - "Default=30ms.") - parser.add_argument('-v', '--verbosity', type=int, default=1, choices=[0, 1, 2]) - args = parser.parse_args() - - logging.basicConfig( - level={ - 0: logging.CRITICAL, - 1: logging.INFO, - 2: logging.DEBUG - }[args.verbosity], - handlers=[ - logging.StreamHandler( - sys.stdout - ) - ], - format="%(asctime)s - %(message)s", - datefmt="%H:%M:%S" - ) - - with get_device_handle(args.keyboard or retrieve_keyboard_name()) as device: - filter_chattering(device, args.threshold) diff --git a/src/filtering.py b/src/filtering.py deleted file mode 100644 index 7e59770..0000000 --- a/src/filtering.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from collections import defaultdict -from typing import DefaultDict, Dict, NoReturn - -import libevdev -import time - -def filter_chattering(evdev: libevdev.Device, threshold: int) -> NoReturn: - # add delay to allow enter key to work after execution - time.sleep(1) - # grab the device - now only we see the events it emits - evdev.grab() - # create a copy of the device that we can write to - this will emit the filtered events to anyone who listens - ui_dev = evdev.create_uinput_device() - - logging.info("Listening to input events...") - - while True: - # since the descriptor is blocking, this blocks until there are events available - for e in evdev.events(): - if _from_keystroke(e, threshold): - ui_dev.send_events([e, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) - - -def _from_keystroke(event: libevdev.InputEvent, threshold: int) -> bool: - # no need to relay those - we are going to emit our own - if event.matches(libevdev.EV_SYN) or event.matches(libevdev.EV_MSC): - return False - - # some events we don't want to filter, like EV_LED for toggling NumLock and the like, and also key hold events - if not event.matches(libevdev.EV_KEY) or event.value > 1: - logging.debug(f'FORWARDING {event.code}') - return True - - # the values are 0 for up, 1 for down and 2 for hold - if event.value == 0: - if _key_pressed[event.code]: - logging.debug(f'FORWARDING {event.code} up') - _last_key_up[event.code] = event.sec * 1E6 + event.usec - _key_pressed[event.code] = False - return True - else: - logging.info(f'FILTERING {event.code} up: key not pressed beforehand') - return False - - prev = _last_key_up.get(event.code) - now = event.sec * 1E6 + event.usec - - if prev is None or now - prev > threshold * 1E3: - logging.debug(f'FORWARDING {event.code} down') - _key_pressed[event.code] = True - return True - - logging.info( - f'FILTERED {event.code} down: last key up event happened {(now - prev) / 1E3} ms ago') - return False - - -_last_key_up: Dict[libevdev.EventCode, int] = {} -_key_pressed: DefaultDict[libevdev.EventCode, bool] = defaultdict(bool) diff --git a/src/keyboard_config.py b/src/keyboard_config.py new file mode 100644 index 0000000..d217b2e --- /dev/null +++ b/src/keyboard_config.py @@ -0,0 +1,43 @@ +""" +Constants for keyboard chattering filter configuration. + +PRECEDENCE RULES: +1. Command Line (--keys): Highest priority. If provided, this file is ignored. +2. This File (FILTERED_KEYS): Used if no command line argument is provided. +3. Empty: If BOTH the command line and this list are empty, ALL keys will be filtered. +""" + +# To filter specific keys, add them to this set. +# Example: FILTERED_KEYS = {"KEY_A", "KEY_SPACE", "KEY_ENTER"} +# Leave it empty as set() to filter ALL keys by default. +FILTERED_KEYS = set() + + +# ========================================== +# REFERENCE: COMMON KEY VALUES TO COPY/PASTE +# ========================================== +# Letters: +# KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, +# KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, +# KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z +# +# Numbers (Top Row): +# KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_MINUS, KEY_EQUAL +# +# Numpad: +# KEY_KP0 to KEY_KP9, KEY_KPMINUS, KEY_KPPLUS, KEY_KPASTERISK, KEY_KPDOT, KEY_KPENTER +# +# Special/Control: +# KEY_SPACE, KEY_ENTER, KEY_BACKSPACE, KEY_TAB, KEY_ESC, KEY_CAPSLOCK +# +# Modifiers: +# KEY_LEFTSHIFT, KEY_RIGHTSHIFT, KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_LEFTALT, KEY_RIGHTALT, KEY_LEFTMETA (Super/Windows) +# +# Arrows & Navigation: +# KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_HOME, KEY_END, KEY_PAGEUP, KEY_PAGEDOWN, KEY_INSERT, KEY_DELETE +# +# Function Keys: +# KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12 +# +# Punctuation: +# KEY_LEFTBRACE, KEY_RIGHTBRACE, KEY_SEMICOLON, KEY_APOSTROPHE, KEY_GRAVE, KEY_BACKSLASH, KEY_COMMA, KEY_DOT, KEY_SLASH diff --git a/src/keyboard_filtering.py b/src/keyboard_filtering.py new file mode 100644 index 0000000..dc23ec2 --- /dev/null +++ b/src/keyboard_filtering.py @@ -0,0 +1,86 @@ +import logging +from collections import defaultdict +from typing import DefaultDict, Dict, NoReturn, List +import time +import libevdev +import sys + +def filter_chattering(evdev: libevdev.Device, threshold: int, keys_to_filter: List[libevdev.EventCode] = None) -> NoReturn: + # Delay to allow the Enter key (used to execute the terminal command) + # to release natively before we grab the device. Prevents a "stuck" Enter key. + time.sleep(1) + + # Grab the physical device so only we see the events it emits + evdev.grab() + + # Create a virtual uinput device to emit our cleaned events back to the OS + ui_dev = evdev.create_uinput_device() + + logging.info("Listening to keyboard input events...") + + if not keys_to_filter: + keys_to_filter = [] + + while True: + # Descriptor is blocking; waits until physical events are available + try: + for e in evdev.events(): + if _from_keystroke(e, threshold, keys_to_filter): + ui_dev.send_events([e, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + except OSError as err: + # Errno 19 means "No such device". This happens if the USB is suddenly unplugged. + if err.errno == 19: + logging.critical("Keyboard disconnected while listening. Exiting gracefully.") + sys.exit(0) + else: + raise err + + +def _from_keystroke(event: libevdev.InputEvent, threshold: int, keys_to_filter: List[libevdev.EventCode]) -> bool: + global _last_key_code + + # Ignore sync/misc events. libevdev uinput handles syncing natively. + if event.matches(libevdev.EV_SYN) or event.matches(libevdev.EV_MSC): + return False + + # MODIFIER FIX: If the event isn't a key, or it's a "hold" event (event.value > 1), forward it immediately. + # This ensures held keys (like Shift or Ctrl) don't get interrupted by the chatter filter. + if not event.matches(libevdev.EV_KEY) or event.value > 1: + logging.debug(f'FORWARDING {event.code}') + return True + + # TARGETED FILTERING: If the user provided specific keys to fix, and this isn't one of them, forward it. + if keys_to_filter and event.code not in keys_to_filter: + logging.debug(f'FORWARDING {event.code} (not in targeted filter list)') + return True + + # Process standard Key Up (0) and Key Down (1) events + if event.value == 0: + if _key_pressed[event.code]: + logging.debug(f'FORWARDING {event.code} up') + _last_key_up[event.code] = event.sec * 1E6 + event.usec + _key_pressed[event.code] = False + return True + else: + logging.info(f'FILTERING {event.code} up: key not pressed beforehand') + return False + + prev = _last_key_up.get(event.code) + now = event.sec * 1E6 + event.usec + + # DOUBLE-LETTER FIX: Check `_last_key_code != event.code`. + # If a user types fast alternating letters (e.g. e -> v -> e), the second 'e' won't be + # mistakenly filtered, because the 'v' reset the _last_key_code. + if prev is None or now - prev > threshold * 1E3 or _last_key_code != event.code: + logging.debug(f'FORWARDING {event.code} down') + _key_pressed[event.code] = True + _last_key_code = event.code + return True + + logging.info(f'FILTERED {event.code} down: last key up event happened {(now - prev) / 1E3} ms ago') + return False + +# Global state trackers +_last_key_up: Dict[libevdev.EventCode, int] = {} +_key_pressed: DefaultDict[libevdev.EventCode, bool] = defaultdict(bool) +_last_key_code = None diff --git a/src/keyboard_main.py b/src/keyboard_main.py new file mode 100755 index 0000000..4321124 --- /dev/null +++ b/src/keyboard_main.py @@ -0,0 +1,64 @@ +import argparse +import logging +import sys +import os +from contextlib import contextmanager +import libevdev + +from src.keyboard_filtering import filter_chattering +from src.keyboard_retrieval import retrieve_keyboard_name, INPUT_DEVICES_PATH, abs_keyboard_path + +# Safely import the config file if it exists +try: + from src.keyboard_config import FILTERED_KEYS +except ImportError: + FILTERED_KEYS = set() + +@contextmanager +def get_device_handle(keyboard_name: str) -> libevdev.Device: + device_path = abs_keyboard_path(keyboard_name) + + # DISCONNECT FIX: Prevent 100% CPU exhaustion loop. + # If the keyboard is unplugged/sleeps, the path disappears. We exit cleanly (0). + # Systemd (Restart=always) will quietly check every 5 seconds until it returns. + if not os.path.exists(device_path): + logging.critical(f"Keyboard {keyboard_name} not connected. Exiting to prevent CPU loop.") + sys.exit(0) + + fd = open(device_path, 'rb') + evdev = libevdev.Device(fd) + try: + yield evdev + finally: + fd.close() + +def parse_keys(keys_str): + """Parses comma-separated CLI arguments into a list of strings.""" + if not keys_str: return [] + return [key.strip() for key in keys_str.split(',')] + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('-k', '--keyboard', type=str, default=str()) + parser.add_argument('-t', '--threshold', type=int, default=30) + parser.add_argument('--keys', type=parse_keys, default=[]) + parser.add_argument('-v', '--verbosity', type=int, default=1, choices=[0, 1, 2]) + args = parser.parse_args() + + logging.basicConfig(level={0: logging.CRITICAL, 1: logging.INFO, 2: logging.DEBUG}[args.verbosity], + handlers=[logging.StreamHandler(sys.stdout)], + format="%(asctime)s - %(message)s", datefmt="%H:%M:%S") + + # CONFIG PRECEDENCE: CLI args > keyboard_config.py > Empty (Filter All) + keys_list = args.keys if args.keys else list(FILTERED_KEYS) + keys_to_filter = [] + + # Convert string key names (e.g., "KEY_A") to libevdev.EventCode objects + for key in keys_list: + try: + keys_to_filter.append(libevdev.evbit(key)) + except Exception as e: + logging.warning(f"Key '{key}' ignored: {e}") + + with get_device_handle(args.keyboard or retrieve_keyboard_name()) as device: + filter_chattering(device, args.threshold, keys_to_filter) diff --git a/src/keyboard_retrieval.py b/src/keyboard_retrieval.py index e3cfcb5..7ac2052 100644 --- a/src/keyboard_retrieval.py +++ b/src/keyboard_retrieval.py @@ -1,44 +1,71 @@ import logging import os -from typing import Final, List +from typing import Final +# We use the 'by-id' folder because the device names here are persistent. +# If we used the standard '/dev/input/eventX', the numbers might change every time you reboot or plug in a USB. INPUT_DEVICES_PATH: Final = '/dev/input/by-id' -def retrieve_keyboard_name() -> str: - # List all devices in the directory - all_devices = os.listdir(INPUT_DEVICES_PATH) - keyboard_devices = [ - d for d in all_devices - ] - - # Remove duplicates just in case - keyboard_devices = list(set(keyboard_devices)) +def retrieve_keyboard_name() -> str: + """ + Lists all devices in the input directory and prompts the user to select one. + This is triggered when the script is run without the `-k` argument. + """ + # Filter to ONLY show valid modern event nodes. + #This safely hides legacy raw nodes (like '-mouse' or '-kbd') which would crash libevdev + #but keeps all virtual '-event-kbd' and '-event-mouse' nodes visible. + #valid_devices = [d for d in all_devices if '-event-' in d] + #device_list = list(set(valid_devices)) + #n_devices = len(device_list) + + # Read the directory to get a list of all connected input devices + all_devices = os.listdir(INPUT_DEVICES_PATH) + + # Deduplicate the list natively using a set to ensure clean output + keyboard_devices = list(set(all_devices)) n_devices = len(keyboard_devices) + # If no devices are found in the folder at all, abort the script if n_devices == 0: - raise ValueError(f"Couldn't find a keyboard in '{INPUT_DEVICES_PATH}'") - - if n_devices == 1: - logging.info(f"Found keyboard: {keyboard_devices[0]}") - return keyboard_devices[0] + raise ValueError(f"Couldn't find any devices in '{INPUT_DEVICES_PATH}'") - # Use native Python input for user selection - print("Select a device:") + # Present an interactive selection menu in the terminal. + # We list EVERYTHING because modern gaming keyboards and mice often register as multiple + # virtual devices (e.g. separate endpoints for macro keys, RGB controllers, etc). + print("Select a keyboard device:") + + # Sort the devices alphabetically so they are easy to read for idx, device in enumerate(sorted(keyboard_devices), start=1): print(f"{idx}. {device}") selected_idx = -1 + + # Loop continuously until the user inputs a valid number corresponding to the list while selected_idx < 1 or selected_idx > n_devices: try: + # Capture keyboard input from the user selected_idx = int(input("Enter your choice (number): ")) + + # Warn the user if they pick a number outside the valid range if selected_idx < 1 or selected_idx > n_devices: print(f"Please select a number between 1 and {n_devices}") + except ValueError: + # Warn the user if they type letters instead of numbers print("Please enter a valid number") - return keyboard_devices[selected_idx - 1] + # Return the string name of the selected device. + # We subtract 1 because our visual list started at 1, but Python arrays start at 0. + # We also must sort the list here identically to how we printed it, so the index matches. + sorted_devices = sorted(keyboard_devices) + return sorted_devices[selected_idx - 1] + def abs_keyboard_path(device: str) -> str: + """ + Helper function that combines the folder path and the device name + into a full absolute path (e.g., /dev/input/by-id/usb-keyboard-name) + """ return os.path.join(INPUT_DEVICES_PATH, device) diff --git a/src/mouse_config.py b/src/mouse_config.py new file mode 100644 index 0000000..d6fbf25 --- /dev/null +++ b/src/mouse_config.py @@ -0,0 +1,35 @@ +""" +Constants for mouse chattering filter configuration. + +PRECEDENCE RULES: +1. Command Line (--buttons): Highest priority. If provided, this file is ignored. +2. This File (FILTERED_BUTTONS): Used if no command line argument is provided. +3. Empty: If BOTH the command line and this list are empty, ALL buttons will be filtered. +""" + +# To filter specific buttons, add them to this set. +# Example: FILTERED_BUTTONS = {"BTN_LEFT", "BTN_SIDE"} +# Leave it empty as set() to filter ALL mouse buttons by default. +FILTERED_BUTTONS = set() + + +# ========================================== +# REFERENCE: COMMON MOUSE BUTTON VALUES +# ========================================== +# Standard Clicks: +# BTN_LEFT (Standard Left Click) +# BTN_RIGHT (Standard Right Click) +# BTN_MIDDLE (Scroll Wheel Click) +# +# Side / Gaming Buttons (Thumb buttons): +# BTN_SIDE (Often defaults to "Back" in browsers) +# BTN_EXTRA (Often defaults to "Forward" in browsers) +# BTN_FORWARD (Alternative Forward) +# BTN_BACK (Alternative Back) +# BTN_TASK (Sometimes used for DPI shifts or task views) +# +# Numbered Extra Buttons (For MMO mice like Razer Naga / Corsair Scimitar): +# BTN_0, BTN_1, BTN_2, BTN_3, BTN_4, BTN_5, BTN_6, BTN_7, BTN_8, BTN_9 +# +# Note: Scroll wheel *scrolling* (up/down) is treated as movement (EV_REL), +# not a button press, so it is natively bypassed by our script to prevent lag! diff --git a/src/mouse_filtering.py b/src/mouse_filtering.py new file mode 100644 index 0000000..44d0bd0 --- /dev/null +++ b/src/mouse_filtering.py @@ -0,0 +1,77 @@ +import logging +from collections import defaultdict +from typing import DefaultDict, Dict, NoReturn, List +import time +import libevdev +import sys + +def filter_mouse_chattering(evdev: libevdev.Device, threshold: int, buttons_to_filter: List[libevdev.EventCode] = None) -> NoReturn: + time.sleep(1) # Delay for clean startup + evdev.grab() + ui_dev = evdev.create_uinput_device() + + logging.info("Listening to mouse events...") + + if not buttons_to_filter: + buttons_to_filter = [] + + while True: + try: + for e in evdev.events(): + if _from_click(e, threshold, buttons_to_filter): + ui_dev.send_events([e, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + except OSError as err: + # Errno 19 means "No such device". This happens if the USB is suddenly unplugged. + if err.errno == 19: + logging.critical("Mouse disconnected while listening. Exiting gracefully.") + sys.exit(0) + else: + raise err + +def _from_click(event: libevdev.InputEvent, threshold: int, buttons_to_filter: List[libevdev.EventCode]) -> bool: + global _last_btn_code + + if event.matches(libevdev.EV_SYN) or event.matches(libevdev.EV_MSC): + return False + + # CRITICAL MOUSE FIX: Immediately forward all movement data. + # EV_REL = Relative movement (standard X/Y cursor movement and scroll wheel) + # EV_ABS = Absolute movement (drawing tablets, touchpads) + # Skipping this prevents the cursor from freezing or stuttering. + if event.matches(libevdev.EV_REL) or event.matches(libevdev.EV_ABS): + return True + + # In Linux, mouse clicks are classified as EV_KEY. + # If it isn't an EV_KEY, or it's a natively held click (value > 1), forward it. + if not event.matches(libevdev.EV_KEY) or event.value > 1: + return True + + # TARGETED FILTERING: If the user provided specific buttons to fix, forward everything else. + if buttons_to_filter and event.code not in buttons_to_filter: + return True + + # Process Button Up (0) and Button Down (1) + if event.value == 0: + if _btn_pressed[event.code]: + _last_btn_up[event.code] = event.sec * 1E6 + event.usec + _btn_pressed[event.code] = False + return True + else: + return False + + prev = _last_btn_up.get(event.code) + now = event.sec * 1E6 + event.usec + + # Check _last_btn_code to allow fast alternating clicks (e.g. Left -> Right -> Left) + if prev is None or now - prev > threshold * 1E3 or _last_btn_code != event.code: + _btn_pressed[event.code] = True + _last_btn_code = event.code + return True + + logging.info(f'FILTERED {event.code} down: last up event {(now - prev) / 1E3} ms ago') + return False + +# Global state trackers +_last_btn_up: Dict[libevdev.EventCode, int] = {} +_btn_pressed: DefaultDict[libevdev.EventCode, bool] = defaultdict(bool) +_last_btn_code = None diff --git a/src/mouse_main.py b/src/mouse_main.py new file mode 100644 index 0000000..90d5186 --- /dev/null +++ b/src/mouse_main.py @@ -0,0 +1,62 @@ +import argparse +import logging +import sys +import os +from contextlib import contextmanager +import libevdev + +from src.mouse_filtering import filter_mouse_chattering +from src.mouse_retrieval import retrieve_mouse_name, INPUT_DEVICES_PATH, abs_mouse_path + +# Safely import the config file if it exists +try: + from src.mouse_config import FILTERED_BUTTONS +except ImportError: + FILTERED_BUTTONS = set() + +@contextmanager +def get_device_handle(mouse_name: str) -> libevdev.Device: + device_path = abs_mouse_path(mouse_name) + + # DISCONNECT FIX: Prevent 100% CPU exhaustion loop if mouse is turned off/unplugged. + if not os.path.exists(device_path): + logging.critical(f"Mouse {mouse_name} not connected. Exiting to prevent CPU loop.") + sys.exit(0) + + fd = open(device_path, 'rb') + evdev = libevdev.Device(fd) + try: + yield evdev + finally: + fd.close() + +def parse_buttons(buttons_str): + """Parses comma-separated CLI arguments into a list of strings.""" + if not buttons_str: return [] + return [btn.strip() for btn in buttons_str.split(',')] + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('-m', '--mouse', type=str, default=str()) + parser.add_argument('-t', '--threshold', type=int, default=30) + parser.add_argument('--buttons', type=parse_buttons, default=[]) + parser.add_argument('-v', '--verbosity', type=int, default=1, choices=[0, 1, 2]) + args = parser.parse_args() + + logging.basicConfig(level={0: logging.CRITICAL, 1: logging.INFO, 2: logging.DEBUG}[args.verbosity], + handlers=[logging.StreamHandler(sys.stdout)], + format="%(asctime)s - %(message)s", datefmt="%H:%M:%S") + + # CONFIG PRECEDENCE: CLI args > mouse_config.py > Empty (Filter All) + buttons_list = args.buttons if args.buttons else list(FILTERED_BUTTONS) + buttons_to_filter = [] + + # Convert string button names (e.g., "BTN_LEFT") to libevdev.EventCode objects + for btn in buttons_list: + try: + buttons_to_filter.append(libevdev.evbit(btn)) + except Exception as e: + logging.warning(f"Button '{btn}' ignored: {e}") + + with get_device_handle(args.mouse or retrieve_mouse_name()) as device: + filter_mouse_chattering(device, args.threshold, buttons_to_filter) diff --git a/src/mouse_retrieval.py b/src/mouse_retrieval.py new file mode 100644 index 0000000..4b12dad --- /dev/null +++ b/src/mouse_retrieval.py @@ -0,0 +1,70 @@ +import logging +import os +from typing import Final + +# We use the 'by-id' folder because the device names here are persistent. +INPUT_DEVICES_PATH: Final = '/dev/input/by-id' + + +def retrieve_mouse_name() -> str: + """ + Lists all devices in the input directory and prompts the user to select one. + This is triggered when the script is run without the `-m` argument. + """ + + # Filter to ONLY show valid modern event nodes. + #This safely hides legacy raw nodes (like '-mouse' or '-kbd') which would crash libevdev, + #but keeps all virtual '-event-kbd' and '-event-mouse' nodes visible. + #valid_devices = [d for d in all_devices if '-event-' in d] + #device_list = list(set(valid_devices)) + #n_devices = len(device_list) + + # Read the directory to get a list of all connected input devices + all_devices = os.listdir(INPUT_DEVICES_PATH) + + # We intentionally do NOT filter for the word 'mouse' here. + # Advanced gaming mice (like Razer or Logitech) often split their buttons into virtual + # keyboard endpoints (e.g. '-if01-event-kbd'). Showing all devices ensures you can find it. + mouse_devices = list(set(all_devices)) + n_devices = len(mouse_devices) + + # If no devices are found, abort and tell the user they might need to provide it manually + if n_devices == 0: + raise ValueError(f"Couldn't find any devices in '{INPUT_DEVICES_PATH}'. Please provide it manually with -m.") + + # Present an interactive selection menu in the terminal. + print("Select a mouse device:") + + # Sort the devices alphabetically so they are easy to read + for idx, device in enumerate(sorted(mouse_devices), start=1): + print(f"{idx}. {device}") + + selected_idx = -1 + + # Loop continuously until the user inputs a valid number corresponding to the list + while selected_idx < 1 or selected_idx > n_devices: + try: + # Capture keyboard input from the user + selected_idx = int(input("Enter your choice (number): ")) + + # Warn the user if they pick a number outside the valid range + if selected_idx < 1 or selected_idx > n_devices: + print(f"Please select a number between 1 and {n_devices}") + + except ValueError: + # Warn the user if they type letters instead of numbers + print("Please enter a valid number") + + # Return the string name of the selected device. + # We subtract 1 because our visual list started at 1, but Python arrays start at 0. + # We also must sort the list here identically to how we printed it, so the index matches. + sorted_devices = sorted(mouse_devices) + return sorted_devices[selected_idx - 1] + + +def abs_mouse_path(device: str) -> str: + """ + Helper function that combines the folder path and the device name + into a full absolute path (e.g., /dev/input/by-id/usb-mouse-name) + """ + return os.path.join(INPUT_DEVICES_PATH, device)