diff --git a/README.md b/README.md index 6deec25..98446bd 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,571 @@ -# __Keyboard Chattering Fix for Linux__ +# __Keyboard & Mouse Chattering 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, and faulty scroll wheel encoders will randomly glitch and scroll in the wrong direction. ## 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. -This also means it works across the system, without depending on X. +*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 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. +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. ## 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. `cd` into the extracted folder. + +Due to PEP 668 on modern Linux distributions, globally installing Python packages via `pip` is restricted to prevent breaking system tools. You have two options to install the required `libevdev` dependency: + +### Option 1: Python Virtual Environment (Recommended) +Using the built-in `venv` module is the safest and cleanest way to run this tool. +```shell +# 1. Create a virtual environment named 'venv' inside the project folder +python3 -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 +``` +### Option 2: Global Install (Quickest) +If you do not want to use a virtual environment, you can override the system protection flag. ```shell -sudo pip3 install -r requirements.txt +sudo pip3 install -r requirements.txt --break-system-packages ``` ## Usage -`cd` inside the location of the KeyboardChatteringFix-Linux-master extracted folder and enter the command below to run. +Because keyboards and mice are handled differently by the OS, they are executed as separate modules. +**If you used a Virtual Environment (Option 1):** +Because `sudo` drops your local path, you must point `sudo` directly to your virtual environment's Python binary: ```shell -sudo python3 -m src +sudo venv/bin/python3 -m src.keyboard_main +sudo venv/bin/python3 -m src.mouse_main +``` + +**If you installed Globally (Option 2):** +```shell +sudo python3 -m src.keyboard_main +sudo python3 -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. +- `-r`, `--reconnect` + - Runs an infinite retry loop to wait for disconnected devices. Use this if running manually in a terminal or using `cron`, but **DO NOT** use this if using Systemd! Systemd natively handles restarts much cleaner in the background. +- `--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`. + +### Advanced Mouse & Sensor Filtering Features +If you have a faulty mouse sensor or a broken scroll wheel, you can pass these additional arguments to `mouse_main`: +- `-sr SCROLL_REV`, `--scroll-reverse SCROLL_REV` + - Fixes scroll wheels that jump in the opposite direction. Filters direction changes that occur faster than the threshold. Default=0 (Disabled). Try `100` to `150` for glitchy wheels. +- `-sd SCROLL_DBL`, `--scroll-double SCROLL_DBL` + - Fixes worn encoders firing two ticks for one physical notch. Filters identical scrolls happening too fast. Default=0 (Disabled). *(Caution: Do not use this if your mouse has an infinite free-spinning scroll wheel!)* +- `-jl JUMP`, `--jump-limit JUMP` + - Blocks massive teleporting cursor jumps caused by dirty laser sensors or hairs. Drops frames exceeding X pixels (e.g., `300`). + +### Hotplugging, Remapping & Per-Key Thresholds +The configuration files (`src/keyboard_config.py` and `src/mouse_config.py`) contain powerful advanced options: +- **Per-Key Thresholds:** Keys physically wear differently. You can set your heavy Spacebar to a `50ms` delay to prevent chatter, while leaving your `A` key at `15ms` for fast gaming. +- **Remapping / Macros:** Because this intercepts kernel events, you can natively remap buttons (e.g. swap `KEY_CAPSLOCK` to `KEY_LEFTCTRL`, or `BTN_SIDE` to `BTN_MIDDLE`). This works flawlessly on both X11 and Wayland. + +### 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 a mouse, a keyboard, and a multimedia controller all at once! + +Because of this, both the keyboard and mouse scripts will list *all* available event endpoints to give you maximum flexibility. Here is a guide on which one to choose: + +- **`-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 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. + +**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 service currently running! The script requires an exclusive lock on the hardware. Stop your background service to release the lock before testing manually. + +## Automation (Systemd) + +Starting the scripts manually every time is not ideal. You should set them up as background Systemd services. + +### 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. *(Note: If using a venv, replace `python3` with `venv/bin/python3`).* + +**Example `keyboard_chattering.sh`:** +```shell +cd /home/foouser/Downloads/HardwareChatteringFix-Linux/ && sudo python3 -m src.keyboard_main -k usb-Logitech_Keyboard-event-kbd -t 40 --keys KEY_E,KEY_SPACE +``` + +**Example `mouse_chattering.sh`:** +```shell +cd /home/foouser/Downloads/HardwareChatteringFix-Linux/ && sudo python3 -m src.mouse_main -m usb-Logitech_Mouse-event-mouse -t 50 --buttons BTN_LEFT,BTN_RIGHT +``` + +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` to point `ExecStart` to the absolute path of your `.sh` files. + +**Example keyboard_chattering.service:** +```shell +ExecStart=/home/foouser/Downloads/HardwareChatteringFix-Linux/keyboard_chattering.sh +``` -- -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. +**Example mouse_chattering.service:** +```shell +ExecStart=/home/foouser/Downloads/HardwareChatteringFix-Linux/mouse_chattering.sh +``` -- -v {0,1,2}, --verbosity {0,1,2} +### Step 3: Enable the Services (Separately or Combined) -## Automation +Copy the `.service` files to your systemd folder: +```shell +sudo cp keyboard_chattering.service /etc/systemd/system/ +sudo cp mouse_chattering.service /etc/systemd/system/ +``` -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: +**To enable ONLY the keyboard fix:** ```shell -cd /home/foouser/Downloads/KeyboardChatteringFix-Linux-master/ && sudo python3 -m src -k usb-SINO_WEALTH_USB_KEYBOARD-event-kbd -t 50 +sudo systemctl enable --now keyboard_chattering ``` -Also, make sure to change the file permission of `chattering_fix.sh` so that it is executable. + +**To enable ONLY the mouse fix:** +```shell +sudo systemctl enable --now mouse_chattering +``` + +**To run BOTH concurrently:** +Simply run both enable commands! They operate completely independently of one another. +```shell +sudo systemctl enable --now keyboard_chattering +sudo systemctl enable --now mouse_chattering +``` + +### Step 4: Checking Status and Logs + +You can check if the scripts are running properly by checking their independent statuses: + +**For the Keyboard:** ```shell -chmod +x chattering_fix.sh +systemctl status keyboard_chattering.service +journalctl -xeu keyboard_chattering.service ``` -The `chattering_fix.service` file should also be edited. The `ExecStart` should be the absolute path of the `chattering_fix.sh`. For example: + +**For the Mouse:** ```shell -ExecStart=/home/foouser/Downloads/KeyboardChatteringFix-Linux-master/chattering_fix.sh +systemctl status mouse_chattering.service +journalctl -xeu mouse_chattering.service ``` -Then, copy the `chattering_fix.service` to `/etc/systemd/system/` and enable it with the command below. + +### Step 5: Applying Changes + +How you apply changes depends on which files you modified. + +**Scenario A: You edited the Python code, `config.py`, or the `.sh` shell scripts** + +Systemd doesn't need to reload its own configuration; it just needs to restart the service to execute the newly saved scripts. ```shell -systemctl enable --now chattering_fix +# For the Keyboard: +sudo systemctl restart keyboard_chattering.service + +# For the Mouse: +sudo systemctl restart mouse_chattering.service ``` -You can check if the systemd unit file is properly working using + +**Scenario B: You edited the `.service` files themselves** + +If you changed settings inside the `.service` files (like `Restart=`, `ExecStart=`, etc.), you must tell systemd to re-read those files from disk before restarting. ```shell -systemctl status chattering_fix.service +# For the Keyboard: +sudo systemctl stop keyboard_chattering.service # Safely stops the current running instance +sudo systemctl daemon-reload # Tells systemd to read the updated .service file +sudo systemctl restart keyboard_chattering.service # Starts the service using the new configuration + +# For the Mouse: +sudo systemctl stop mouse_chattering.service +sudo systemctl daemon-reload +sudo systemctl restart mouse_chattering.service ``` -You can also use + +**Scenario C: You edited the `[Install]` section of the `.service` files** + +The `[Install]` section dictates *when* and *how* the service starts at boot (via `WantedBy=`). If you changed this section, you must re-enable the service to update the boot symlinks. ```shell -journalctl -xeu chattering_fix.service +# For the Keyboard: +sudo systemctl stop keyboard_chattering.service +sudo systemctl daemon-reload +sudo systemctl reenable keyboard_chattering.service # Removes old boot symlinks and creates new ones +sudo systemctl restart keyboard_chattering.service + +# For the Mouse: +sudo systemctl stop mouse_chattering.service +sudo systemctl daemon-reload +sudo systemctl reenable mouse_chattering.service +sudo systemctl restart mouse_chattering.service +``` + +--- + +## Automation (Non-Systemd & BSD) + +Because the Python scripts rely natively on the OS Kernel (`evdev` and `uinput`), the code works perfectly on non-systemd distributions and BSD variants. Ensure your `.sh` scripts are configured and executable (`chmod +x`), then use the guide below for your specific init system. + +**IMPORTANT SUSPEND/SLEEP WARNING:** +Systemd natively restarts scripts when a PC wakes from sleep. Non-systemd init systems **do not**. Therefore, if you use *any* of the methods below, you **MUST** append the `-r` (Auto-Reconnect) flag to the execution line inside your `.sh` scripts! +```bash +# Example: The -r flag ensures the script survives sleep/suspend cycles! +cd /path/to/folder && sudo python3 -m src.keyboard_main -k -t 30 -r +``` + +> **💡 PRO-TIP FOR LOGGING:** Since non-systemd systems lack `journalctl`, you should modify your `.sh` scripts to redirect output so you can read the logs. Append a redirect to the execution lines in your `.sh` files: +> ```bash +> # For the keyboard script: +> cd /absolute/path/to/project && sudo python3 -m src.keyboard_main -k -t 30 >> /var/log/keyboard_fix.log 2>&1 +> +> # For the mouse script: +> cd /absolute/path/to/project && sudo python3 -m src.mouse_main -m -t 30 >> /var/log/mouse_fix.log 2>&1 +> ``` +> You can then read your logs anytime using `cat /var/log/keyboard_fix.log` or `cat /var/log/mouse_fix.log`. + +--- + +### Cron (Universal Fallback) + +Cron does not restart scripts natively. You **MUST** add the `-r` flag to your `.sh` scripts. + +The easiest way to run the scripts on any system without Systemd is using `cron`'s `@reboot` directive. + +1. Because `cron` does not auto-restart failed scripts, you **MUST** add the `-r` flag to your `.sh` scripts so they survive hardware disconnects! + ```bash + # Example addition inside your .sh files: + python3 -m src.keyboard_main -k -t 30 -r + python3 -m src.mouse_main -m -t 30 -r + ``` +2. Open the root crontab: `sudo crontab -e` +3. Add both scripts to run in the background (using `&`): + ```text + @reboot /absolute/path/to/keyboard_chattering.sh & + @reboot /absolute/path/to/mouse_chattering.sh & + ``` + +**Operational Commands:** +* **Start Right Now:** + ```bash + sudo /absolute/path/to/keyboard_chattering.sh & + sudo /absolute/path/to/mouse_chattering.sh & + ``` +* **Stop:** + ```bash + sudo pkill -f keyboard_main + sudo pkill -f mouse_main + ``` +* **Status/Logs:** + ```bash + ps aux | grep -E 'keyboard_main|mouse_main' + cat /var/log/keyboard_fix.log + cat /var/log/mouse_fix.log + ``` +* **Restart:** + ```bash + sudo pkill -f keyboard_main; sudo /absolute/path/to/keyboard_chattering.sh & + sudo pkill -f mouse_main; sudo /absolute/path/to/mouse_chattering.sh & + ``` +* **Reenable (Boot Integration):** Run `sudo crontab -e` and update the `@reboot` lines. Cron applies changes automatically on next boot. + +--- + +### OpenRC (Artix, Alpine, Gentoo) + +Even though OpenRC supports native respawning, you **MUST** add the `-r` flag to your `.sh` scripts. This prevents the script from crash-looping when the USB bus briefly drops during sleep/wake cycles. + +1. Create two files: `sudo nano /etc/init.d/keyboard_fix` and `sudo nano /etc/init.d/mouse_fix` +2. Paste the appropriate template below into each file: + +**Keyboard Template (`/etc/init.d/keyboard_fix`):** +```bash +#!/sbin/openrc-run +name="Keyboard Chattering Fix" +command="/absolute/path/to/keyboard_chattering.sh" +command_background=true +pidfile="/run/keyboard_fix.pid" + +# Enable native systemd-like auto-restarts! +respawn=true +respawn_delay=5 + +depend() { need localmount } +``` + +**Mouse Template (`/etc/init.d/mouse_fix`):** +```bash +#!/sbin/openrc-run +name="Mouse Chattering Fix" +command="/absolute/path/to/mouse_chattering.sh" +command_background=true +pidfile="/run/mouse_fix.pid" + +respawn=true +respawn_delay=5 + +depend() { need localmount } +``` + +3. Make them executable: `sudo chmod +x /etc/init.d/keyboard_fix /etc/init.d/mouse_fix` +4. Enable at boot: + ```bash + sudo rc-update add keyboard_fix default + sudo rc-update add mouse_fix default + ``` + +**Operational Commands:** +* **Start Right Now:** + ```bash + sudo rc-service keyboard_fix start + sudo rc-service mouse_fix start + ``` +* **Stop:** + ```bash + sudo rc-service keyboard_fix stop + sudo rc-service mouse_fix stop + ``` +* **Status/Logs:** + ```bash + sudo rc-service keyboard_fix status + sudo rc-service mouse_fix status + cat /var/log/keyboard_fix.log + cat /var/log/mouse_fix.log + ``` +* **Restart:** + ```bash + sudo rc-service keyboard_fix restart + sudo rc-service mouse_fix restart + ``` +* **Reenable (Boot Integration):** + ```bash + sudo rc-update del keyboard_fix default && sudo rc-update add keyboard_fix default + sudo rc-update del mouse_fix default && sudo rc-update add mouse_fix default + ``` + +--- + +### Runit (Void Linux) + +Even though Runit supports native respawning, you **MUST** add the `-r` flag to your `.sh` scripts. This prevents the script from crash-looping when the USB bus briefly drops during sleep/wake cycles. + +1. Create service directories: + ```bash + sudo mkdir -p /etc/sv/keyboard_fix /etc/sv/mouse_fix + ``` +2. Create a run file for the keyboard: `sudo nano /etc/sv/keyboard_fix/run` + ```bash + #!/bin/sh + exec /absolute/path/to/keyboard_chattering.sh + ``` +3. Create a run file for the mouse: `sudo nano /etc/sv/mouse_fix/run` + ```bash + #!/bin/sh + exec /absolute/path/to/mouse_chattering.sh + ``` +4. Make both executable: + ```bash + sudo chmod +x /etc/sv/keyboard_fix/run /etc/sv/mouse_fix/run + ``` +5. Enable them (symlink to runit's service directory): + ```bash + sudo ln -s /etc/sv/keyboard_fix /var/service/ + sudo ln -s /etc/sv/mouse_fix /var/service/ + ``` + +**Operational Commands:** +* **Start Right Now:** Runit detects the symlinks and starts them automatically! +* **Stop:** + ```bash + sudo sv stop keyboard_fix + sudo sv stop mouse_fix + ``` +* **Status/Logs:** + ```bash + sudo sv status keyboard_fix mouse_fix + cat /var/log/keyboard_fix.log + cat /var/log/mouse_fix.log + ``` +* **Restart:** + ```bash + sudo sv restart keyboard_fix + sudo sv restart mouse_fix + ``` +* **Reenable (Boot Integration):** + ```bash + sudo rm /var/service/keyboard_fix && sudo ln -s /etc/sv/keyboard_fix /var/service/ + sudo rm /var/service/mouse_fix && sudo ln -s /etc/sv/mouse_fix /var/service/ + ``` + +--- + +### SysVinit (Devuan, Older Distros) + +SysVinit does not restart scripts natively. You **MUST** add the `-r` flag to your `.sh` scripts. + +Simply add the executable scripts to your `/etc/rc.local` file before the `exit 0` line: +```bash +/absolute/path/to/keyboard_chattering.sh & +/absolute/path/to/mouse_chattering.sh & +exit 0 +``` + +**Operational Commands:** +* **Start Right Now:** + ```bash + sudo /etc/rc.local + ``` +* **Stop:** + ```bash + sudo pkill -f keyboard_main + sudo pkill -f mouse_main + ``` +* **Status/Logs:** + ```bash + ps aux | grep -E 'keyboard_main|mouse_main' + cat /var/log/keyboard_fix.log + cat /var/log/mouse_fix.log + ``` +* **Restart:** + ```bash + sudo pkill -f keyboard_main; sudo /absolute/path/to/keyboard_chattering.sh & + sudo pkill -f mouse_main; sudo /absolute/path/to/mouse_chattering.sh & + ``` +* **Reenable (Boot Integration):** *(If using `update-rc.d` instead of `rc.local`)* + ```bash + sudo update-rc.d -f keyboard_fix remove && sudo update-rc.d keyboard_fix defaults + sudo update-rc.d -f mouse_fix remove && sudo update-rc.d mouse_fix defaults + ``` + +--- + +### FreeBSD / BSD Family + +FreeBSD does not restart scripts natively. You **MUST** add the `-r` flag to your `.sh` scripts. + +FreeBSD has native support for `evdev`, but you must load the modules and adjust device paths. + +**1. Load evdev modules:** Add these to `/boot/loader.conf` and reboot (or `kldload` them now): +```text +evdev_load="YES" +uinput_load="YES" +``` + +**2. Find your Device Path:** +FreeBSD does not use Linux's `udev` naming conventions. The folder `/dev/input/by-id/` does not exist on BSD! Instead, FreeBSD lists devices as raw event nodes (`/dev/input/event0`, `event1`, etc.). +Update your `.sh` scripts to pass the raw absolute path directly to `-k` or `-m` (which overrides the auto-search scripts): +```bash +# Example keyboard_chattering.sh +cd /path/to/folder && sudo python3 -m src.keyboard_main -k /dev/input/event0 -t 30 >> /var/log/keyboard_fix.log 2>&1 + +# Example mouse_chattering.sh +cd /path/to/folder && sudo python3 -m src.mouse_main -m /dev/input/event1 -t 30 >> /var/log/mouse_fix.log 2>&1 ``` -just to make sure that there are no errors. \ No newline at end of file + +**3. Automate using `rc.d` scripts:** +Create two files: `sudo nano /usr/local/etc/rc.d/keyboard_fix` and `sudo nano /usr/local/etc/rc.d/mouse_fix`. + +**Keyboard Template (`/usr/local/etc/rc.d/keyboard_fix`):** +```bash +#!/bin/sh +# REQUIRE: DAEMON +# PROVIDE: keyboard_fix + +. /etc/rc.subr + +name="keyboard_fix" +rcvar="keyboard_fix_enable" + +# Use daemon to securely background the script. +# We pass the -r flag to 'daemon' so it auto-restarts the script if it dies! +command="/usr/sbin/daemon" +command_args="-r -P /var/run/keyboard_fix.pid -f /absolute/path/to/keyboard_chattering.sh" + +load_rc_config $name +run_rc_command "$1" +``` + +**Mouse Template (`/usr/local/etc/rc.d/mouse_fix`):** +```bash +#!/bin/sh +# REQUIRE: DAEMON +# PROVIDE: mouse_fix + +. /etc/rc.subr + +name="mouse_fix" +rcvar="mouse_fix_enable" + +command="/usr/sbin/daemon" +command_args="-r -P /var/run/mouse_fix.pid -f /absolute/path/to/mouse_chattering.sh" + +load_rc_config $name +run_rc_command "$1" +``` + +4. Make them executable: `sudo chmod +x /usr/local/etc/rc.d/keyboard_fix /usr/local/etc/rc.d/mouse_fix` +5. Enable them in your `/etc/rc.conf`: + ```text + keyboard_fix_enable="YES" + mouse_fix_enable="YES" + ``` + +**Operational Commands:** +* **Start Right Now:** + ```bash + sudo service keyboard_fix start + sudo service mouse_fix start + ``` +* **Stop:** + ```bash + sudo service keyboard_fix stop + sudo service mouse_fix stop + ``` +* **Status/Logs:** + ```bash + sudo service keyboard_fix status + sudo service mouse_fix status + cat /var/log/keyboard_fix.log + cat /var/log/mouse_fix.log + ``` +* **Restart:** + ```bash + sudo service keyboard_fix restart + sudo service mouse_fix restart + ``` +* **Reenable (Boot Integration):** Ensure `/etc/rc.conf` contains the enable variables. You can quickly force them using: + ```bash + sudo sysrc keyboard_fix_enable="YES" + sudo sysrc mouse_fix_enable="YES" + ``` 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..ab1d431 --- /dev/null +++ b/keyboard_chattering.service @@ -0,0 +1,19 @@ +[Unit] +Description=Keyboard Chattering Fix service +# Ensures that if systemd restarts this during wake-up, the system is fully awake first. +After=sleep.target + +[Service] +# Wait 3 seconds before starting the script to give the USB bus and Wayland/X11 time to fully wake up +ExecStartPre=/bin/sleep 3 + +# Change ExecStart to the absolute path of your shell script +ExecStart=/absolute/path/to/keyboard_chattering.sh + +# Systemd will automatically restart the script if it crashes, or if it exits due to a USB sleep/disconnect +Restart=always +RestartSec=5 + +[Install] +# Start at normal boot. Systemd handles the restarts on wake automatically. +WantedBy=multi-user.target diff --git a/keyboard_chattering.sh b/keyboard_chattering.sh new file mode 100644 index 0000000..dc02a9e --- /dev/null +++ b/keyboard_chattering.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# ============================================================================== +# KEYBOARD CHATTERING FIX - STARTUP SCRIPT +# ============================================================================== +# Change the `cd` path below to the absolute path of this project folder. +# Ensure this script is executable: chmod +x keyboard_chattering.sh +# +# AVAILABLE ARGUMENTS: +# -k "usb-id-here" : The device ID of your keyboard in /dev/input/by-id/ +# -t 30 : Bounce filter threshold in milliseconds (Default: 30) +# --keys KEY_A,KEY_SPACE : Explicitly filter only these keys (Leave blank for all) +# -r : Enable auto-reconnect infinite loop (Required for Cron/SysVinit) +# DO NOT USE `-r` if using Systemd! Systemd handles restarts natively. +# ============================================================================== + +cd /absolute/path/to/project && 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..4c9b0ad --- /dev/null +++ b/mouse_chattering.service @@ -0,0 +1,19 @@ +[Unit] +Description=Mouse Chattering Fix service +# Ensures that if systemd restarts this during wake-up, the system is fully awake first. +After=sleep.target + +[Service] +# Wait 3 seconds before starting the script to give the USB bus and Wayland/X11 time to fully wake up +ExecStartPre=/bin/sleep 3 + +# Change ExecStart to the absolute path of your shell script +ExecStart=/absolute/path/to/mouse_chattering.sh + +# Systemd will automatically restart the script if it crashes, or if it exits due to a USB sleep/disconnect +Restart=always +RestartSec=5 + +[Install] +# Start at normal boot. Systemd handles the restarts on wake automatically. +WantedBy=multi-user.target diff --git a/mouse_chattering.sh b/mouse_chattering.sh new file mode 100644 index 0000000..625c1d9 --- /dev/null +++ b/mouse_chattering.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# ============================================================================== +# MOUSE CHATTERING FIX - STARTUP SCRIPT +# ============================================================================== +# Change the `cd` path below to the absolute path of this project folder. +# Ensure this script is executable: chmod +x mouse_chattering.sh +# +# AVAILABLE ARGUMENTS: +# -m "usb-id-here" : The device ID of your mouse in /dev/input/by-id/ +# -t 30 : Button double-click threshold in ms (Default: 30) +# --buttons BTN_LEFT,BTN_SIDE : Explicitly filter only these buttons (Leave blank for all) +# +# ADVANCED SENSOR ARGUMENTS: +# -sr 150 : Block scroll wheel jumping in reverse direction (ms) +# -sd 30 : Block scroll wheel firing twice in same direction (ms) +# -jl 300 : Block massive cursor teleports exceeding X pixels per frame +# +# -r : Enable auto-reconnect infinite loop (Required for Cron/SysVinit) +# DO NOT USE `-r` if using Systemd! Systemd handles restarts natively. +# ============================================================================== + +cd /absolute/path/to/project && 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..3f2c487 --- /dev/null +++ b/src/keyboard_config.py @@ -0,0 +1,73 @@ +""" +Constants for keyboard chattering filter configuration. + +PRECEDENCE RULES: +1. Command Line (--keys / -k): Highest priority. If provided, this file is ignored. +2. This File: Used if no command line argument is provided. +3. Interactive Prompt: Used ONLY if both CLI and this file are empty. +""" +import libevdev + +# ========================================== +# 0. DEFAULT DEVICE +# ========================================== +# Set this to avoid the interactive prompt on boot/startup. +# Leave as "" to be asked every time if running manually. +# Example: DEVICE_NAME = "usb-Corsair_Corsair_K70_RGB-event-kbd" +DEVICE_NAME = "" + +# ========================================== +# 1. SPECIFIC KEY FILTERING (Allowlist) +# ========================================== +# To filter specific keys, add them to this set. +# Example: FILTERED_KEYS = {"KEY_A", "KEY_SPACE"} +# Leave it empty as set() to filter ALL keys by default. +FILTERED_KEYS = set() + +# ========================================== +# 2. PER-KEY THRESHOLDS +# ========================================== +# Override the default threshold for specific keys. +# Great for heavy spacebars (needs more debounce) vs light gaming keys (needs less lag). +KEY_THRESHOLDS = { + # "KEY_SPACE": 50, + # "KEY_A": 15, +} + +# ========================================== +# 3. KEY REMAPPING / MACROS +# ========================================== +# Swap keys at the kernel level. Format: {"KEY_PRESSED": "KEY_OUTPUT"} +KEY_MAP = { + # "KEY_CAPSLOCK": "KEY_LEFTCTRL", # Example: Make CapsLock act as Left Ctrl +} + + +# ========================================== +# 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..586774c --- /dev/null +++ b/src/keyboard_filtering.py @@ -0,0 +1,87 @@ +import logging +from collections import defaultdict +from typing import DefaultDict, Dict, NoReturn, List +import time +import libevdev + +def filter_chattering(evdev: libevdev.Device, default_threshold: int, keys_to_filter: List[libevdev.EventCode], + key_thresholds: dict, key_map: dict) -> NoReturn: + + # Reset global states on reconnect to prevent ghost "stuck" keys + global _last_key_code + _last_key_up.clear() + _key_pressed.clear() + _last_key_code = None + + time.sleep(1) # Delay to allow Enter key to release natively after running script + evdev.grab() + ui_dev = evdev.create_uinput_device() + logging.info("Listening to keyboard input events...") + + while True: + try: + for e in evdev.events(): + # Process the event. _from_keystroke returns either the (modified) event, or None to drop it. + processed_event = _from_keystroke(e, default_threshold, keys_to_filter, key_thresholds, key_map) + if processed_event: + ui_dev.send_events([processed_event, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + except libevdev.EventsDroppedException: + # If the user presses too many keys simultaneously (NKRO overflow), resync the buffer + logging.debug("Kernel buffer overflowed. Resyncing...") + for e in evdev.sync(): + processed_event = _from_keystroke(e, default_threshold, keys_to_filter, key_thresholds, key_map) + if processed_event: + ui_dev.send_events([processed_event, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + +def _from_keystroke(event: libevdev.InputEvent, default_threshold: int, keys_to_filter: List[libevdev.EventCode], + key_thresholds: dict, key_map: dict): + global _last_key_code + + # Ignore sync/misc events natively + if event.matches(libevdev.EV_SYN) or event.matches(libevdev.EV_MSC): + return None + + # REMAPPING: Safely construct a brand new event object to prevent mutating C-bindings + if event.matches(libevdev.EV_KEY) and event.code.name in key_map: + target_key_name = key_map[event.code.name] + event = libevdev.InputEvent(libevdev.evbit(target_key_name), event.value, event.sec, event.usec) + if event.value == 1: # Only log on the key-down to prevent log spam + logging.debug(f'REMAPPED to {target_key_name}') + + # Do not filter modifier combinations or natively held keys (value > 1) + if not event.matches(libevdev.EV_KEY) or event.value > 1: + return event + + # If an allowlist is provided and this key isn't in it, forward it without filtering + if keys_to_filter and event.code not in keys_to_filter: + return event + + # PER-KEY THRESHOLDS: Check if this specific key has a custom delay, else use default. + threshold = key_thresholds.get(event.code.name, default_threshold) + + # Process standard Key Up (0) events + if event.value == 0: + if _key_pressed[event.code]: + logging.debug(f'FORWARDING {event.code.name} up') + _last_key_up[event.code] = event.sec * 1E6 + event.usec + _key_pressed[event.code] = False + return event + else: + return None + + prev = _last_key_up.get(event.code) + now = event.sec * 1E6 + event.usec + + # Check _last_key_code to prevent filtering fast alternating letters (e.g., e -> v -> e) + if prev is None or now - prev > threshold * 1E3 or _last_key_code != event.code: + logging.debug(f'FORWARDING {event.code.name} down') + _key_pressed[event.code] = True + _last_key_code = event.code + return event + + logging.info(f'FILTERED {event.code.name} down: bounced within {threshold}ms') + return None + +_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..623441f --- /dev/null +++ b/src/keyboard_main.py @@ -0,0 +1,111 @@ +import argparse +import logging +import sys +import os +import time +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 DEVICE_NAME, FILTERED_KEYS, KEY_THRESHOLDS, KEY_MAP +except ImportError: + DEVICE_NAME, FILTERED_KEYS, KEY_THRESHOLDS, KEY_MAP = "", set(), {}, {} + +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('-r', '--reconnect', action='store_true', help="Loop infinitely and wait for device reconnection instead of exiting") + 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") + + # PREVENT SILENT FAILURES: Validate config thresholds/maps at startup + for key_name in list(KEY_THRESHOLDS.keys()): + try: + libevdev.evbit(key_name) + except Exception: + logging.warning(f"KEY_THRESHOLDS typo or invalid key: '{key_name}' will be ignored.") + + for key_name in list(KEY_MAP.keys()): + try: + libevdev.evbit(key_name) + except Exception: + logging.warning(f"KEY_MAP typo or invalid key: '{key_name}' will be ignored.") + + 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: + if key: keys_to_filter.append(libevdev.evbit(key)) + except Exception as e: + logging.warning(f"Key '{key}' ignored: {e}") + + if KEY_MAP: logging.info(f"Loaded {len(KEY_MAP)} Key Mappings: {KEY_MAP}") + if KEY_THRESHOLDS: logging.info(f"Loaded {len(KEY_THRESHOLDS)} Custom Thresholds: {KEY_THRESHOLDS}") + + # CONFIG PRECEDENCE: CLI args > keyboard_config.py > Interactive Prompt + # This prevents Systemd from freezing at boot if it hits an input() prompt! + if args.keyboard: + device_name_str = args.keyboard + elif DEVICE_NAME: + device_name_str = DEVICE_NAME + else: + logging.warning("No device specified in CLI or config. Falling back to interactive prompt.") + device_name_str = retrieve_keyboard_name() + + device_path = abs_keyboard_path(device_name_str) + + if args.reconnect: + # INFINITE RETRY LOOP (Best for Cron, SysVinit, and Manual Terminal usage) + while True: + if not os.path.exists(device_path): + logging.info(f"Waiting for keyboard '{device_name_str}' to connect...") + time.sleep(2) + continue + + try: + with open(device_path, 'rb') as fd: + device = libevdev.Device(fd) + logging.info(f"Successfully connected to '{device_name_str}'") + filter_chattering(device, args.threshold, keys_to_filter, KEY_THRESHOLDS, KEY_MAP) + except OSError as e: + if e.errno == 19: + logging.warning("Keyboard disconnected. Waiting for reconnect...") + else: + logging.error(f"OS Error: {e}. Retrying in 2s...") + time.sleep(2) + except Exception as e: + logging.error(f"Unexpected Error: {e}. Retrying in 2s...") + time.sleep(2) + else: + # FAIL & EXIT MODE (Default: Best for Systemd, OpenRC, and Runit) + if not os.path.exists(device_path): + logging.critical(f"Keyboard '{device_name_str}' not found. Exiting cleanly.") + sys.exit(0) + + try: + with open(device_path, 'rb') as fd: + device = libevdev.Device(fd) + logging.info(f"Successfully connected to '{device_name_str}'") + filter_chattering(device, args.threshold, keys_to_filter, KEY_THRESHOLDS, KEY_MAP) + except OSError as e: + if e.errno == 19: + logging.critical("Keyboard disconnected. Exiting cleanly.") + sys.exit(0) + else: + raise e diff --git a/src/keyboard_retrieval.py b/src/keyboard_retrieval.py index e3cfcb5..99be65a 100644 --- a/src/keyboard_retrieval.py +++ b/src/keyboard_retrieval.py @@ -1,35 +1,32 @@ import logging import os -from typing import Final, List +from typing import Final +# Use 'by-id' because device names here are persistent across reboots/USB ports INPUT_DEVICES_PATH: Final = '/dev/input/by-id' def retrieve_keyboard_name() -> str: - # List all devices in the directory + """Lists all valid input devices and prompts the user to select one.""" 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)) - - n_devices = len(keyboard_devices) + + # Filter to ONLY show valid modern event nodes. + # 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) 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:") - for idx, device in enumerate(sorted(keyboard_devices), start=1): + print("Select a keyboard device:") + + # Sort the devices alphabetically so they are easy to read + for idx, device in enumerate(sorted(device_list), start=1): print(f"{idx}. {device}") selected_idx = -1 + while selected_idx < 1 or selected_idx > n_devices: try: selected_idx = int(input("Enter your choice (number): ")) @@ -38,7 +35,8 @@ def retrieve_keyboard_name() -> str: except ValueError: print("Please enter a valid number") - return keyboard_devices[selected_idx - 1] + sorted_devices = sorted(device_list) + return sorted_devices[selected_idx - 1] def abs_keyboard_path(device: str) -> str: 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..a8672f3 --- /dev/null +++ b/src/mouse_config.py @@ -0,0 +1,76 @@ +""" +Constants for mouse chattering filter configuration. + +PRECEDENCE RULES: +1. Command Line (--buttons / -m): Highest priority. If provided, this file is ignored. +2. This File: Used if no command line argument is provided. +3. Interactive Prompt: Used ONLY if both CLI and this file are empty. +""" +import libevdev + +# ========================================== +# 0. DEFAULT DEVICE +# ========================================== +# Set this to avoid the interactive prompt on boot/startup. +# Leave as "" to be asked every time if running manually. +# Example: DEVICE_NAME = "usb-Logitech_Gaming_Mouse-event-mouse" +DEVICE_NAME = "" + +# ========================================== +# 1. SPECIFIC BUTTON FILTERING (Allowlist) +# ========================================== +# 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() + +# ========================================== +# 2. SCROLL AXIS FILTERING (Allowlist) +# ========================================== +# To filter specific scroll directions, add their libevdev codes to this set. +# Leave it empty as set() to filter ALL scroll axes by default. +# Example: If your Logitech MX horizontal wheel glitches, but vertical is fine: +# FILTERED_SCROLL_AXES = {"REL_HWHEEL", "REL_HWHEEL_HI_RES"} +FILTERED_SCROLL_AXES = set() + +# ========================================== +# 3. PER-BUTTON THRESHOLDS +# ========================================== +# Override the default threshold for specific buttons. +BUTTON_THRESHOLDS = { + # "BTN_LEFT": 30, + # "BTN_SIDE": 50, +} + +# ========================================== +# 4. BUTTON REMAPPING / MACROS +# ========================================== +# Swap buttons at the kernel level. Format: {"BUTTON_PRESSED": "BUTTON_OUTPUT"} +BUTTON_MAP = { + # "BTN_SIDE": "BTN_MIDDLE", # Example: Make a side thumb button act as a middle click +} + + +# ========================================== +# REFERENCE: COMMON MOUSE BUTTON & AXIS 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 +# +# Scroll Axes: +# REL_WHEEL (Standard Vertical Scroll) +# REL_HWHEEL (Standard Horizontal Scroll) +# REL_WHEEL_HI_RES (High-Resolution Vertical Scroll) +# REL_HWHEEL_HI_RES (High-Resolution Horizontal Scroll) diff --git a/src/mouse_filtering.py b/src/mouse_filtering.py new file mode 100644 index 0000000..37b3e37 --- /dev/null +++ b/src/mouse_filtering.py @@ -0,0 +1,134 @@ +import logging +from collections import defaultdict +from typing import DefaultDict, Dict, NoReturn, List +import time +import libevdev + +def filter_mouse_chattering(evdev: libevdev.Device, default_threshold: int, scroll_rev_threshold: int, + scroll_interval: int, jump_limit: int, + buttons_to_filter: List[libevdev.EventCode], scroll_axes_to_filter: List[libevdev.EventCode], + btn_thresholds: dict, btn_map: dict) -> NoReturn: + + # Reset global states on reconnect to prevent ghost "stuck" buttons + global _last_btn_code + _last_btn_up.clear() + _btn_pressed.clear() + _last_btn_code = None + _last_scroll_time.clear() + _last_scroll_dir.clear() + + time.sleep(1) + evdev.grab() + ui_dev = evdev.create_uinput_device() + logging.info("Listening to mouse events...") + + while True: + try: + for e in evdev.events(): + processed_event = _from_event(e, default_threshold, scroll_rev_threshold, scroll_interval, jump_limit, + buttons_to_filter, scroll_axes_to_filter, btn_thresholds, btn_map) + if processed_event: + ui_dev.send_events([processed_event, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + + except libevdev.EventsDroppedException: + # High-polling-rate gaming mice (1000Hz+) can overflow the kernel buffer. + # We catch the exception, resync the buffer, and process the recovered events natively. + logging.debug("Kernel buffer overflowed (high polling rate). Resyncing...") + for e in evdev.sync(): + processed_event = _from_event(e, default_threshold, scroll_rev_threshold, scroll_interval, jump_limit, + buttons_to_filter, scroll_axes_to_filter, btn_thresholds, btn_map) + if processed_event: + ui_dev.send_events([processed_event, libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]) + +def _from_event(event: libevdev.InputEvent, default_threshold: int, scroll_rev_threshold: int, scroll_interval: int, + jump_limit: int, buttons_to_filter: List[libevdev.EventCode], + scroll_axes_to_filter: List[libevdev.EventCode], btn_thresholds: dict, btn_map: dict): + global _last_btn_code + + if event.matches(libevdev.EV_SYN) or event.matches(libevdev.EV_MSC): + return None + + # === SCROLL & MOVEMENT (EV_REL) === + if event.matches(libevdev.EV_REL): + # 1. Jump filtering for Cursor X/Y + if event.code in (libevdev.EV_REL.REL_X, libevdev.EV_REL.REL_Y): + if jump_limit > 0 and abs(event.value) >= jump_limit: + logging.warning(f"BLOCKED TELEPORT: Cursor jumped {event.value} pixels in 1 frame!") + return None + return event + + # 2. Scroll Wheel Filtering + if event.code in (libevdev.EV_REL.REL_WHEEL, libevdev.EV_REL.REL_HWHEEL, + libevdev.EV_REL.REL_WHEEL_HI_RES, libevdev.EV_REL.REL_HWHEEL_HI_RES): + + if scroll_axes_to_filter and event.code not in scroll_axes_to_filter: + return event + + axis = event.code + now = event.sec * 1E6 + event.usec + direction = 1 if event.value > 0 else -1 + + last_time = _last_scroll_time.get(axis) + last_dir = _last_scroll_dir.get(axis) + + # Scroll Reverse Glitch (Faulty encoder bouncing backwards) + if last_dir != direction and last_time is not None: + if scroll_rev_threshold > 0 and (now - last_time) < scroll_rev_threshold * 1E3: + logging.info(f"BLOCKED REVERSE SCROLL: Encoder glitched backwards!") + return None + + # Scroll Double-Action (Worn encoder firing twice in the same direction) + if last_dir == direction and last_time is not None: + if scroll_interval > 0 and (now - last_time) < scroll_interval * 1E3: + logging.info(f"BLOCKED DOUBLE-SCROLL: Same direction too fast!") + return None + + _last_scroll_time[axis] = now + _last_scroll_dir[axis] = direction + return event + + return event + + # === BUTTON CLICKS (EV_KEY) === + # REMAPPING: Safely construct a brand new event object + if event.matches(libevdev.EV_KEY) and event.code.name in btn_map: + target_btn = btn_map[event.code.name] + event = libevdev.InputEvent(libevdev.evbit(target_btn), event.value, event.sec, event.usec) + if event.value == 1: + logging.debug(f'REMAPPED to {target_btn}') + + # Do not filter Native held clicks (value > 1) or Absolute movement (EV_ABS) + if event.matches(libevdev.EV_ABS) or not event.matches(libevdev.EV_KEY) or event.value > 1: + return event + + if buttons_to_filter and event.code not in buttons_to_filter: + return event + + # Check for custom button threshold, fallback to default + threshold = btn_thresholds.get(event.code.name, default_threshold) + + # Process Button Up (0) + 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 event + return None + + 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 event + + logging.info(f'FILTERED {event.code.name} double-click!') + return None + +_last_btn_up: Dict[libevdev.EventCode, int] = {} +_btn_pressed: DefaultDict[libevdev.EventCode, bool] = defaultdict(bool) +_last_btn_code = None +_last_scroll_time: Dict[libevdev.EventCode, int] = {} +_last_scroll_dir: Dict[libevdev.EventCode, int] = {} diff --git a/src/mouse_main.py b/src/mouse_main.py new file mode 100644 index 0000000..b24e7fb --- /dev/null +++ b/src/mouse_main.py @@ -0,0 +1,125 @@ +import argparse +import logging +import sys +import os +import time +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 DEVICE_NAME, FILTERED_BUTTONS, FILTERED_SCROLL_AXES, BUTTON_THRESHOLDS, BUTTON_MAP +except ImportError: + DEVICE_NAME, FILTERED_BUTTONS, FILTERED_SCROLL_AXES, BUTTON_THRESHOLDS, BUTTON_MAP = "", set(), set(), {}, {} + +def parse_list(data_str): + """Parses comma-separated CLI arguments into a list of strings.""" + if not data_str: return [] + return [d.strip() for d in data_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('-sr', '--scroll-reverse', type=int, default=0, help="Block scroll reversing directions (ms)") + parser.add_argument('-sd', '--scroll-double', type=int, default=0, help="Block scroll same direction (ms)") + parser.add_argument('-jl', '--jump-limit', type=int, default=0, help="Block teleporting cursor >= X pixels") + parser.add_argument('-r', '--reconnect', action='store_true', help="Loop infinitely and wait for device reconnection instead of exiting") + parser.add_argument('--buttons', type=parse_list, 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") + + # PREVENT SILENT FAILURES: Validate config thresholds/maps at startup + for btn_name in list(BUTTON_THRESHOLDS.keys()): + try: + libevdev.evbit(btn_name) + except Exception: + logging.warning(f"BUTTON_THRESHOLDS typo or invalid key: '{btn_name}' will be ignored.") + + for btn_name in list(BUTTON_MAP.keys()): + try: + libevdev.evbit(btn_name) + except Exception: + logging.warning(f"BUTTON_MAP typo or invalid key: '{btn_name}' will be ignored.") + + buttons_list = args.buttons if args.buttons else list(FILTERED_BUTTONS) + axes_list = list(FILTERED_SCROLL_AXES) + + buttons_to_filter = [] + for b in buttons_list: + try: + if b: buttons_to_filter.append(libevdev.evbit(b)) + except Exception as e: + logging.warning(f"Button '{b}' ignored: {e}") + + axes_to_filter = [] + for a in axes_list: + try: + if a: axes_to_filter.append(libevdev.evbit(a)) + except Exception as e: + logging.warning(f"Axis '{a}' ignored: {e}") + + if BUTTON_MAP: logging.info(f"Loaded {len(BUTTON_MAP)} Button Mappings: {BUTTON_MAP}") + if BUTTON_THRESHOLDS: logging.info(f"Loaded {len(BUTTON_THRESHOLDS)} Custom Thresholds: {BUTTON_THRESHOLDS}") + + # CONFIG PRECEDENCE: CLI args > mouse_config.py > Interactive Prompt + # This prevents Systemd from freezing at boot if it hits an input() prompt! + if args.mouse: + device_name_str = args.mouse + elif DEVICE_NAME: + device_name_str = DEVICE_NAME + else: + logging.warning("No device specified in CLI or config. Falling back to interactive prompt.") + device_name_str = retrieve_mouse_name() + + device_path = abs_mouse_path(device_name_str) + + if args.reconnect: + # INFINITE RETRY LOOP (Best for Cron, SysVinit, and Manual Terminal usage) + while True: + if not os.path.exists(device_path): + logging.info(f"Waiting for mouse '{device_name_str}' to connect...") + time.sleep(2) + continue + + try: + with open(device_path, 'rb') as fd: + device = libevdev.Device(fd) + logging.info(f"Successfully connected to '{device_name_str}'") + filter_mouse_chattering(device, args.threshold, args.scroll_reverse, args.scroll_double, + args.jump_limit, buttons_to_filter, axes_to_filter, + BUTTON_THRESHOLDS, BUTTON_MAP) + except OSError as e: + if e.errno == 19: + logging.warning("Mouse disconnected. Waiting for reconnect...") + else: + logging.error(f"OS Error: {e}. Retrying in 2s...") + time.sleep(2) + except Exception as e: + logging.error(f"Unexpected Error: {e}. Retrying in 2s...") + time.sleep(2) + else: + # FAIL & EXIT MODE (Default: Best for Systemd, OpenRC, and Runit) + if not os.path.exists(device_path): + logging.critical(f"Mouse '{device_name_str}' not found. Exiting cleanly.") + sys.exit(0) + + try: + with open(device_path, 'rb') as fd: + device = libevdev.Device(fd) + logging.info(f"Successfully connected to '{device_name_str}'") + filter_mouse_chattering(device, args.threshold, args.scroll_reverse, args.scroll_double, + args.jump_limit, buttons_to_filter, axes_to_filter, + BUTTON_THRESHOLDS, BUTTON_MAP) + except OSError as e: + if e.errno == 19: + logging.critical("Mouse disconnected. Exiting cleanly.") + sys.exit(0) + else: + raise e diff --git a/src/mouse_retrieval.py b/src/mouse_retrieval.py new file mode 100644 index 0000000..1167192 --- /dev/null +++ b/src/mouse_retrieval.py @@ -0,0 +1,41 @@ +import logging +import os +from typing import Final + +# Use 'by-id' because device names here are persistent across reboots/USB ports +INPUT_DEVICES_PATH: Final = '/dev/input/by-id' + +def retrieve_mouse_name() -> str: + """Lists all valid input devices and prompts the user to select one.""" + all_devices = os.listdir(INPUT_DEVICES_PATH) + + # We intentionally do NOT filter for the word 'mouse' here. + # Advanced gaming mice often split their macro buttons into virtual + # keyboard endpoints. Showing all '-event-' nodes ensures you can find it. + valid_devices = [d for d in all_devices if '-event-' in d] + device_list = list(set(valid_devices)) + n_devices = len(device_list) + + if n_devices == 0: + raise ValueError(f"Couldn't find any devices in '{INPUT_DEVICES_PATH}'. Please provide it manually with -m.") + + print("Select a mouse device:") + + for idx, device in enumerate(sorted(device_list), start=1): + print(f"{idx}. {device}") + + selected_idx = -1 + + while selected_idx < 1 or selected_idx > n_devices: + try: + selected_idx = int(input("Enter your choice (number): ")) + if selected_idx < 1 or selected_idx > n_devices: + print(f"Please select a number between 1 and {n_devices}") + except ValueError: + print("Please enter a valid number") + + sorted_devices = sorted(device_list) + return sorted_devices[selected_idx - 1] + +def abs_mouse_path(device: str) -> str: + return os.path.join(INPUT_DEVICES_PATH, device)