Skip to content

fix(groups): sort groups by unique_key to ensure stable group naming#1325

Merged
sezanzeb merged 6 commits into
sezanzeb:mainfrom
Odraude-2:fix-stable-group-keys
Jul 16, 2026
Merged

fix(groups): sort groups by unique_key to ensure stable group naming#1325
sezanzeb merged 6 commits into
sezanzeb:mainfrom
Odraude-2:fix-stable-group-keys

Conversation

@Odraude-2

@Odraude-2 Odraude-2 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Context

When multiple identical or similar input devices (e.g., identical wireless USB receivers, mice, or keyboards with empty/generic device.uniq serial numbers) are connected to a system:

  1. input-remapper generates human-readable keys dynamically during device scanning (like "Corsair Receiver", "Corsair Receiver 2", etc.) in the order they are populated in the grouped dictionary.
  2. This dictionary's order depends entirely on the event node numbers (/dev/input/eventXX) returned by evdev.list_devices().
  3. Because event numbers are assigned dynamically by the Linux kernel upon connection order, these readable keys shift or swap when devices are unplugged/reconnected, or when their initialization order changes after a reboot.
  4. Consequently, configurations (presets) in config.json get swapped between identical devices, causing the wrong profile to be loaded.

Changes

  • Instead of iterating the grouped dictionary in insertion order (which is non-deterministic and depends on event numbers), we now sort the groups alphabetically by their stable unique keys (unique_key in grouped.keys()) before generating the human-readable group keys.
  • The unique_key depends on the immutable hardware attributes and the physical USB port topology path, making it completely stable across reboots, port swaps, and device reconnections.

Verification / Testing

  • Mocked a scenario with two identical USB receivers on different ports.
  • Swapping their initialization order (event10 and event11 swapped) originally caused their configurations to swap.
  • With this patch applied, the naming and configuration mappings remain stable and deterministic.

This PR addresses the duplicate USB receiver confusion described in #1300.

@sezanzeb

sezanzeb commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Thank you

At the beginning of the run method there is a large comment explaining the reasoning for the current way of sorting things. Does your code break what is described there?

Sorting a list using 14 lines of code is a bit much, why is it important to use the shortest_name as a sorting key, can't it just use the name for the same result? Would something like

sorted(
    grouped,
    key=lambda group: group[0][0]
)

suffice? This should sort them by their name.

@sezanzeb

sezanzeb commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Ah, no, you are sorting it by device.phys, not the name, sorry.

Anyhow, I'd like you to try to find a less complex solution for this. I don't think sorting a list by device.phys should require so much code. The old run method is already not exactly a short and easy method, making a less complex solution even more important.

This code might overwrite the sorting above for path in sorted(evdev.list_devices()):, so I'd suggest to either remove that old sorting, or replace it.

@sezanzeb

Copy link
Copy Markdown
Owner

And please check if the issue in the code-comment there is still fixed when sorting by device.phys

@Odraude-2

Odraude-2 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

And please check if the issue in the code-comment there is still fixed when sorting by device.phys

simplified the sorting down to a clean concise dictionary grouping and sorting implementation:

         by_name = {}
         for unique_key, group in grouped.items():
             name = min([entry[0] for entry in group], key=len)
             by_name.setdefault(name, []).append((unique_key, group))

         ordered_groups = [
             group
             for name in by_name
             for _, group in sorted(by_name[name], key=lambda x: x[0])
         ]

How it works:

  1. We group the (unique_key, group) pairs by their base name category ( by_name ).
  2. Inside each name category, we sort identical devices alphabetically by their stable unique_key .

Why shortest_name is necessary: A single physical device group (like a keyboard) can expose multiple sub-devices with different names (e.g., "Corsair
Keyboard" and "Corsair Keyboard Consumer Control" ). Using min(..., key=len) (the shortest name) ensures we group and sort them by the clean base name of the device, which is also the name input-remapper uses to display the group in the GUI.

deterministic listing order for different devices, and stable naming/preset association for identical devices.

@Odraude-2

Copy link
Copy Markdown
Contributor Author

by using black the change have 10 logic lines but it could go smt like this

        by_name = {}
        for unique_key, group in grouped.items():
            name = min([entry[0] for entry in group], key=len)
            by_name.setdefault(name, []).append((unique_key, group))

        ordered_groups = [group for name in by_name for _, group in sorted(by_name[name], key=lambda x: x[0])]     

@Odraude-2

Copy link
Copy Markdown
Contributor Author

And please check if the issue in the code-comment there is still fixed when sorting by device.phys

Previously, the naming order depended entirely on the /dev/input/eventXX paths, which are assigned randomly by the kernel on reboot or reconnection. This caused the receivers to swap names and presets all the time.

To fix this, I grouped the devices by their base name first and then sorted them alphabetically by their unique_key (which includes the physical USB port topology path, device.phys).

This keeps different devices in their original discovery order (preserving test compatibility), but identical devices are sorted stably based on their USB port.

@sezanzeb

sezanzeb commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Why shortest_name is necessary: A single physical device group (like a keyboard) can expose multiple sub-devices with different names (e.g., "Corsair
Keyboard" and "Corsair Keyboard Consumer Control" ). Using min(..., key=len) (the shortest name) ensures we group and sort them by the clean base name of the device, which is also the name input-remapper uses to display the group in the GUI.

This is what get_unique_key is already taking care of. It gives "Corsair Keyboard" and "Corsair Keyboard Consumer Control" the same key. So lets use that instead of the name. Using ids and such (like device.info.vendor and device.info.product, which is part of get_unique_key) is always preferable to names, because names are generally meant to be human-readable and are therefore not reliable sources of information for code/algorithms/determinism/etc.

case 1: one corsair keyboard (device1, device2) and some other device (device3):

grouped:

  1. key_corsair_9384: [device1, device2]
  2. key_generic: [device3]

ordered_groups:

  1. "Corsair Keyboard": [device1, device2]
  2. "Generic Button": [device 3]

case 2: two corsair keyboards (device1 + device2 and device4 + device5) and some other device (device3):

grouped:

  1. key_corsair_9384: [device1, device2]
  2. key_generic: [device3]
  3. key_corsair_2836: [device4, device5]

ordered_groups:

  1. "Corsair Keyboard": [device1, device2, ...]
  2. "Corsair Keyboard Consumer Control": [device3, device4, ...]
  3. "Generic Button": [device3]

Did I get that right?

If we sorted the device using get_unique_key right from the start, it would probably work too with less code and complexity. The order in which we iterate over the devices will also affect the order in grouped, because dicts in python retain their insertion order. Something like

paths = evdev.list_devices()
turn `paths` into a list of evdev.InputDevice objects (keep the try/except)
sort that list by get_unique_key
then continue with the old code, doing the `if device.name == "Power Button":` checks and such

@sezanzeb

Copy link
Copy Markdown
Owner

You could even modify get_unique_key to affect the sorting order. Change the order in which the string is constructed and it changes how they are sorted. Add f"{classify(device)}" to the front and you can probably get all keyboards of all manufacturers sorted next to each other and such.

@Odraude-2

Copy link
Copy Markdown
Contributor Author

You could even modify get_unique_key to affect the sorting order. Change the order in which the string is constructed and it changes how they are sorted. Add f"{classify(device)}" to the front and you can probably get all keyboards of all manufacturers sorted next to each other and such.

Regarding the idea of prepending f"{classify(device)}" to get_unique_key :

I considered doing this to group similar devices together (like all keyboards next to each other), but there is a catch. A single physical hardware device often exposes multiple virtual sub-devices with different classifications
(for instance, a physical keyboard will have one sub-device classified as KEYBOARD and another classified as UNKNOWN for its consumer control/media keys).

If we prepended classify(device) to the unique key, these sub-devices would receive different unique keys and get split into separate groups in the GUI, instead of staying grouped under the same physical device. So keeping get_unique_key strictly hardware-based is necessary to ensure all virtual sub-devices of the same physical device are grouped together correctly.

@Odraude-2
Odraude-2 marked this pull request as draft July 16, 2026 00:22
@sezanzeb

Copy link
Copy Markdown
Owner

ah, good catch.

Thanks. It looks much cleaner now.

Once you mark it as ready, I'll merge it

@Odraude-2
Odraude-2 marked this pull request as ready for review July 16, 2026 12:54
@Odraude-2

Copy link
Copy Markdown
Contributor Author

thx you rly make me think a bit to get it better to be fair

@sezanzeb
sezanzeb merged commit c630041 into sezanzeb:main Jul 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants