Ramblings of an aging IT geek
← Ramblings of an aging IT geek
linux

Two Razer Naga Buttons, One Keycode

input-remapper couldn't tell two of my mouse's side buttons apart. No Linux tool can; the fix is a firmware mode toggle, not a remapper.

Listen to this post
A gaming mouse side-button grid with two identical buttons

I have a Razer Naga V2 HyperSpeed. The whole point of it is the twelve-button grid under your thumb, so I sat down to remap those buttons on Linux with input-remapper, expecting a five-minute job.

It went wrong immediately. Button 11 did exactly what button 6 did; button 12 did exactly what button 2 did. Not "similar": evtest showed them emitting byte-for-byte identical scancodes (7002dKEY_MINUS for both 6 and 11). input-remapper saw them as the same key, so there was nothing to remap.

The instinct is to go looking for a better remapper. That instinct is wrong, and it's worth seeing why. MSC_SCAN is the HID usage code, what the mouse literally puts on the wire. If two buttons send the same usage, they send the same scancode; there's no hidden serial number. evdev hands userspace (type, code, value) plus that scancode hint, and that's the entire vocabulary. If two events are identical across all of it, any userspace remapper is downstream of the collision. You can't branch on information that doesn't exist.

So the duplicate isn't made in software; it's made upstream, in the mouse, before Linux ever sees it. The Naga has onboard memory, and mine had a junk profile baked into it (button 1 was mapped to Shift+3, and two pairs of buttons collided onto one key). Normally you'd rewrite those slots with Razer Synapse on Windows. Nothing on Linux can: OpenRazer doesn't do button remapping, libratbag refuses Razer over the EULA, and the web tools don't cover this model.

The fix: driver mode

Razer mice expose a device_mode switch through the OpenRazer kernel driver. Normal mode (0x00) replays the onboard profile, my broken one. Driver mode (0x03) makes the firmware ignore the profile and emit its raw factory layout: twelve unique keys. One write does it:

echo -n -e '\x03\x00' | sudo tee /sys/bus/hid/devices/*1532*00B4*/device_mode

And evtest immediately goes clean: button 6 is KEY_6, button 11 is KEY_MINUS. The collision is gone because the firmware stopped lying.

It resets on power cycle, so you need to re-apply it. The cleanest way is to let OpenRazer own it: it re-applies on startup, hotplug and resume, by setting driver_mode = true per device in ~/.config/openrazer/razer.conf. (One trap: the section is keyed by serial, and this unit's serial read is flaky, so I declared both the real serial and the UNKNOWN_… fallback.) A udev rule on bind works too, but won't survive suspend.

That covers boot, hotplug and resume, which is most of it. Every so often a wireless wake still comes back with the broken profile reasserted, so rather than dig through anything mid-game I keep a one-tap "fix my mouse" bind. No sudo this time: I'm in the openrazer group, so the udev-owned sysfs is writable directly, and a tiny script forces driver mode then re-applies my DPI:

bindd = $mainMod, Pause, Naga driver mode, exec, ~/.local/bin/naga-driver-mode
#!/bin/sh
# ~/.local/bin/naga-driver-mode
for dm in /sys/bus/hid/devices/*1532*00B4*/device_mode; do
    [ -e "$dm" ] || continue
    printf '\003\000' > "$dm"
done
~/.config/hypr/scripts/naga-dpi-cycle.sh apply   # a wake can reset DPI too

A button starts lying, I hit the chord, and the mouse is honest again before I've lost the fight.

A few side effects come with bypassing the onboard profile. The dedicated DPI button stops cycling, so I drive DPI from Hyprland instead (below). Reading DPI and device_mode back both lie even though writes work fine. And the battery never shows up in UPower for this mouse, so I read the charge level straight from OpenRazer (charge_level in sysfs, 0–255, or over its D-Bus). None are deal breakers, just the tax for making the hardware honest.

Getting DPI back

Driver mode has one cost worth its own section: the onboard DPI button stops cycling, because the cycling was the onboard profile. OpenRazer can still set DPI by writing the dpi sysfs, though, so I moved the whole thing into Hyprland.

The two profile buttons either side of the wheel emit F13 and F14, which Hyprland sees as code:191 and code:192. I bind those to a small stage-cycler:

bindd = , code:191, Naga DPI down, exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh down
bindd = , code:192, Naga DPI up,   exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh up

The script keeps a list of DPI stages, tracks the current one in a state file (so it survives a reboot), and writes the chosen value to the dpi sysfs as four big-endian bytes (X-hi, X-lo, Y-hi, Y-lo). It never reads DPI back, because that's the part that lies; it only writes.

#!/usr/bin/env bash
# naga-dpi-cycle.sh [up|down|apply]: step the Naga through DPI stages.
# (Trimmed here of the device-not-found guard and the notify-send.)
set -eu
STAGES=(400 800 1000 1200 1600 3200)
state="${XDG_STATE_HOME:-$HOME/.local/state}/naga-dpi-stage"
dpi=$(ls /sys/bus/hid/devices/*1532*00B4*/dpi | head -1)
last=$(( ${#STAGES[@]} - 1 ))

idx=$(cat "$state" 2>/dev/null || echo 3)        # default: stage 4 (1200 dpi)
case "${1:-apply}" in
    up)   idx=$(( idx < last ? idx + 1 : last )) ;;
    down) idx=$(( idx > 0    ? idx - 1 : 0    )) ;;
esac
printf '%s' "$idx" > "$state"

val=${STAGES[idx]}; hi=$(( (val >> 8) & 0xff )); lo=$(( val & 0xff ))
printf "$(printf '\\x%02x\\x%02x\\x%02x\\x%02x' "$hi" "$lo" "$hi" "$lo")" > "$dpi"

One exec-once restores the last stage on login, since a wireless wake can reset that too:

exec-once = ~/.config/hypr/scripts/naga-dpi-cycle.sh apply

The short version: if two buttons send identical evdev events, stop blaming the remapper and go fix the mouse.

For now driver mode is the answer, and it holds. One day I might give in, boot Windows, and write a clean profile with Synapse just once, so the hardware behaves on its own and I can drop the whole arrangement. Or, better yet, Razer decides Linux is worth treating as a first-class citizen and none of this song and dance is necessary. I'm not holding my breath. I would, though, love to be wrong.