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:
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.
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.
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.
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
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.