Fixing a broken mouse the hard way

Sun Nov 6 '22

Under two years ago I purchased a Razer Mamba wireless mouse, which is not a great mouse, for the following reasons:

  1. It prompts you to install the Razer software every time you plug it into Windows, unless you find some esoteric registry key that it reads and disable this. If you plug it into another USB bus you need to redo this fix.

  2. The battery life is dismal and it regularly doesn’t last an entire day without needing to be plugged in. This is probably because of the mandatory RGB lighting.

  3. Perhaps worst of all, about a month ago, the scroll wheel stopped behaving correctly and it now randomly will emit a scroll event opposite to the direction you are scrolling the wheel.

    Scrolling continuously down this post showing the jumpy scrolling up behavior.

Normally, I would recycle the mouse and buy a new one, but I wanted to see how far I could get on fixing bad hardware with even worse software, and maybe learn a bit about how device input works on linux.

Validating my hunch

Before diving into some kind of solution, I had to validate that it was indeed the hardware causing issues and not some random self-inflicted issue I had brought upon myself by running an esoteric setup.

I first noticed the issue in firefox when I would scroll down some document and it would randomly and aggravatingly jump up a few lines. I know enough (to be dangerous) that xev could show me what events were being captured from the mouse, but I’ve always found it difficult to sort through all the noise of other events.

The man page for xev describes a way to mask events. I want to isolate mouse events and button presses so that I don’t get mouse movements or keyboard button presses, but unfortunately that doesn’t seem to be an option.

$ man xev

 NAME
        xev - print contents of X events

 OPTIONS

        -event event_mask
                Select which events to display.  The -event option can be
                specified multiple times to select multiple types of events.
                When not specified, all events are selected.

                Available event masks: keyboard mouse expose visibility
                structure substructure focus property colormap
                owner_grab_button randr button

Specifying the button event mask will have to suffice, so I fire up xev with this mask and scroll down a single click on the mouse:

$ xev -event button
ButtonPress event, serial 25, synthetic NO, window 0x3a00001,
    root 0x1db, subw 0x0, time 350629635, (855,281), root:(3158,1260),
    state 0x0, button 5, same_screen YES

ButtonRelease event, serial 25, synthetic NO, window 0x3a00001,
    root 0x1db, subw 0x0, time 350629635, (855,281), root:(3158,1260),
    state 0x1000, button 5, same_screen YES

From this output we can see that we get both a ButtonPress and ButtonRelease event for button 5 each time we scroll down. Repeating the same for scrolling up, the output is very similar but labelled as button 4.

Isolating the issue

Now let’s prettify the output into something we can parse and find out what’s happening with scrolling by scrolling down several times.

$ xev -event button -1 | awk -F, '{print $1,$7,$13}' OFS='\t'
ButtonPress event   time 351593327  button 5
ButtonRelease event time 351593327  button 5
ButtonPress event   time 351593422  button 5
ButtonRelease event time 351593422  button 5
ButtonPress event   time 351593440  button 5
ButtonRelease event time 351593440  button 5
ButtonPress event   time 351593442  button 4
ButtonRelease event time 351593442  button 4
ButtonPress event   time 351593472  button 5
ButtonRelease event time 351593472  button 5
ButtonPress event   time 351593550  button 5
ButtonRelease event time 351593550  button 5
ButtonPress event   time 351593595  button 5
ButtonRelease event time 351593595  button 5
ButtonPress event   time 351593659  button 5
ButtonRelease event time 351593659  button 5
ButtonPress event   time 351593669  button 4
ButtonRelease event time 351593669  button 4
ButtonPress event   time 351593686  button 4
ButtonRelease event time 351593686  button 4
ButtonPress event   time 351593688  button 5
ButtonRelease event time 351593688  button 5
ButtonPress event   time 351593694  button 5
ButtonRelease event time 351593694  button 5

It’s clear from this output that we are getting some random events for button 4, which is scroll up. This is probably indicative of a failing sensor or perhaps just a dirty mouse, but let’s plow forward with a solution in software.

A naive solution would be to somehow intercept these events before they get to userspace, and filter out the opposite scroll events. Firstly, we need to determine if that’s even possible, and if it is, can we determine which events are the “opposite” of the intended direction?

interception-tools

While doing some light searching for ways to intercept events I came across interception-tools, which comes with a utility called intercept that can redirect device input events to stdout for a given /dev/input/eventX device.

To use intercept we need to specify the path to the actual device, so step one is to find[1] the /dev/input/ path that is providing the mouse button press events when scrolling.

$ readlink /dev/input/by-id/usb-Razer_Razer_Mamba_Wireless_000000000000-event-mouse
../event18

$ intercept -g /dev/input/event18
     h6hi6hi6hc
           i6hci6hci6hcY
                        i6hcYi6hcG
                                 i6hcG

                                      i6hcG
i6hcDn                                         i6hcDn

Okay so now we are seeing our mouse events using intercept. We can use uinput (also part of interception-tools) to redirect device input events from stdin to a virtual device. Now what’s missing is our intermediate tool to filter scroll events.

Fixing bad hardware with worse software

I can’t think of a way to handle a single event and know whether or not we should filter out an erroneous scroll, unless we somehow stored state that told our handler if we are scrolling up or down. Another approach would be to buffer scroll inputs for a brief period (in microseconds) and count how many scrolls in a specific direction we have versus what I’m calling “unscrolls”.

I cobbled together something in python using ctypes but it would have been easier to do this in C as per the example in the interception-tools README.

from ctypes import *
import sys
import time

class timeval(Structure):
    _fields_ = [
        ('tv_sec', c_uint64),
        ('tv_usec', c_uint64),
    ]

class input_event(Structure):
    # Cobbled from linux/input.h
    _pack_   = 1
    _fields_ = [
        ('time', timeval),
        ('type', c_uint16),
        ('code', c_uint16),
        ('value', c_int32),
    ]

if __name__ == '__main__':
    # Make sure you set PYTHONUNBUFFERED=1 or similarly disable buffering
    event = input_event()

    while True:
        bytes_read = sys.stdin.buffer.readinto(event)
        sys.stderr.write(f'{event.type} {event.code} {event.value}\n')
        sys.stdout.buffer.write(event)

The output is pretty unreadable but here’s what prints to stderr for scrolling down, and scrolling up, respectively:

$ intercept -g /dev/input/event18 \
   | PYTHONUNBUFFERED=1 /usr/local/bin/unfuck-scroll \
   | uinput -d /dev/input/event18

# Scrolling down
2 8 -1
2 11 -120
0 0 0

# Scrolling up
2 8 1
2 11 120
0 0 0

As an experiment, before going further, let’s see if simply throwing away all scroll up events would fix scrolling down. Sure enough, this “fixes” the issue, and we can see how many scroll-up events we suppressed:

$ intercept -g /dev/input/event18 \
   | PYTHONUNBUFFERED=1 /usr/local/bin/unfuck-scroll \
   | uinput -d /dev/input/event18

skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
skipping scroll up
The "fixed" behavior with random scrolling up removed.

Full speed ahead, captain

At this point, I had to implement the rest of the logic for unfuck-scroll. This took some testing to get right, but the basics of it is that I have a tiny state machine that tracks the current state (scrolling up or down) and rejects inputs that don’t match the current state, unless we haven’t seen an event in some configurable period of time.

Here is the code, in all its glory/shame. I won’t lie, it doesn’t work 100% to solve the issues, but it definitely improves things.


#!/usr/bin/env python3
import argparse
from ctypes import *
from dataclasses import dataclass
from enum import Enum
import copy
import sys
import time


class timeval(Structure):
    # Randomly permuted until something worked
    _fields_ = [
        ("tv_sec", c_uint64),
        ("tv_usec", c_uint64),
    ]


class input_event(Structure):
    # Cobbled from linux/input.h
    _pack_ = 1
    _fields_ = [
        ("time", timeval),
        ("type", c_uint16),
        ("code", c_uint16),
        ("value", c_int32),
    ]


class Scrolling(Enum):
    UNKNOWN = 0
    DOWN = 1
    UP = 2


@dataclass
class Scroll:
    current_state: Scrolling = Scrolling.UNKNOWN
    last_event: int = None
    debounce_ms: int = 500

    def event_within_debounce(self, this_event: int):
        return (this_event - self.last_event) < (self.debounce_ms / 1_000)

    def handle_event(
        self, event_time: int, event: input_event, desired_state: Scrolling
    ):
        # TODO use the input_event timeval
        if self.current_state in (Scrolling.UNKNOWN, desired_state):
            # We're okay with this
            self.current_state = desired_state
            self.emit(event)
            self.last_event = event_time
        else:
            # Hmmm, could be the dumb mouse, or maybe the dumb user?
            if self.event_within_debounce(event_time):
                # Too soon! User isn't this fast, must be the hardware
                if __debug__:
                    time_since_last = (event_time - self.last_event) * 1000
                    sys.stderr.write(
                        f"{desired_state} too soon by {time_since_last:.0f} ms!\n"
                    )
            else:
                # Switch state but don't emit an event, for some reason this
                # just feels better
                self.current_state = desired_state
                self.last_event = event_time

    def emit(self, event: input_event):
        sys.stdout.buffer.write(event)


def main(debounce_ms: int):
    event = input_event()
    current_state = Scrolling.UNKNOWN
    last_event = None

    scroll = Scroll(debounce_ms=debounce_ms)

    while True:
        bytes_read = sys.stdin.buffer.readinto(event)
        if event.type == 2 and event.code in (8, 11):
            event_time = time.time()
            if event.value in (1, 120):
                scroll.handle_event(event_time, event, desired_state=Scrolling.UP)
            elif event.value in (-1, -120):
                scroll.handle_event(event_time, event, desired_state=Scrolling.DOWN)
        else:
            # Dunno what this is, so pass it on
            scroll.emit(event)


if __name__ == "__main__":
    p = argparse.ArgumentParser(
        prog="unfuck-scroll",
        description=(
            "Tries to fix a mouse that emits spurious inverse scroll events by "
            "ignoring them if they don't match the current direction of scrolling"
        ),
        epilog=(
            "Make sure you set PYTHONUNBUFFERED=1 or similarly disable buffering. "
            "Also you can set PYTHONOPTIMIZE=1 to strip out debug printing to stderr."
        ),
    )
    p.add_argument("--debounce-ms", type=int, default=500)
    args = p.parse_args()
    main(args.debounce_ms)

Here is how I run it for testing, which shows me stderr logs whenever I get an event counter to the current scroll state that is within 500ms of the last matching scroll state:

$ intercept -g /dev/input/event16 \
   | PYTHONUNBUFFERED=1 PYTHONOPTIMIZE= /usr/local/bin/unfuck-scroll --debounce-ms 500 \
   | uinput -d /dev/input/event16

Filter on specific events

Now that we have a “working” solution, it’s time to have this run permanently, which udevmon—again, part of interception-tools can help with. As usual, ArchWiki has a great section on how to do this, which I won’t repeat, but here is my final unfuck-scroll.conf.yml file:

- JOB: intercept -g $DEVNODE | PYTHONUNBUFFERED=1 PYTHONOPTIMIZE= unfuck-scroll | uinput -d $DEVNODE
  DEVICE:
    LINK: /dev/input/by-id/usb-Razer_Razer_Mamba_Wireless_000000000000-event-mouse

Closing thoughts

Going to all this effort to save the life of a computer mouse that I don’t even like was probably not a good use of my time, especially since my solution doesn’t even work on Windows (where I occasionally use the same mouse to play games).

I did enjoy learning more about how devices work in linux, and interception-tools was a great find. I’ll use this workaround for a few days and see how much it fixes my annoyances with the mouse unscrolling, but so far I’m relatively happy with the fix.

There is definitely room for improvement in unfuck-scroll—one of the side effects of debouncing like this is that I must wait at least 500ms to switch scroll directions, and it remains to be seen if this is more of a problem than the scroll ghosting.