Skip to content

fix: route device actions to the right mouse; per-mouse profile tabs - #174

Merged
mmaher88 merged 2 commits into
masterfrom
fix/per-device-routing
Nov 9, 2026
Merged

mmaher88 merged 2 commits into
masterfrom
fix/per-device-routing

Conversation

@mmaher88

@mmaher88 mmaher88 commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Problem

Two bugs with one root cause: device work was aimed at whichever mouse is selected in the carousel, not the mouse it was for.

1. Profile not re-applied after wake / KVM switch (single mouse). On disconnect, DeviceModel hides the offline mouse's card. With one mouse the selection becomes -1. On reconnect PhysicalDevice emits transportSetupComplete before stateChanged re-adds the card, so applyProfileToHardware found no activeSession() and returned silently. The mouse stayed in firmware defaults (DPI reset, buttons undiverted, gestures dead, default SmartShift). The log read device transport ready, applying profile: "default" followed by nothing. This has been present since the PhysicalDevice refactor. In July it was misattributed to a connection race, and #147 only fixed the main-wheel symptom.

2. Two mice of the same model interfere with each other.

  • Mouse B's profile was written to mouse A when B was set up and on every B reconnect.
  • B's buttons, gestures and thumb wheel ran A's mappings against A's session.
  • DPI/SmartShift changes made by B's buttons were saved to A's profile.
  • Only the selected mouse followed window focus.
  • The profile tab bar was one list shared by both mice. Each mouse's app tabs were merged in when it connected and never rebuilt, so tab clicks, adds and removes, and the highlights hit the wrong mouse's profiles.

Changes

fix(device): route device actions to the mouse they belong to

  • Profile applies: ProfileOrchestrator::applyProfileToHardware(device, profile) writes to that device's transport using its own descriptor. It logs when it can't, instead of bailing silently. setupProfileForDevice and onTransportSetupComplete pass their device.
  • Window focus: every connected mouse switches to its own app profile.
  • Input events: ButtonActionDispatcher slots take the source PhysicalDevice (AppRoot tags each device's signals). Per-device state is keyed by serial. The by-button DPI/SmartShift signals carry the serial, so changes are saved to that mouse.
  • Selection: DeviceModel keeps the same mouse selected when a row is inserted or removed in front of it.
  • Unchanged: UI edits still act on the selected mouse.

fix(profiles): show each mouse's own app profile tabs

  • ProfileModel::resetProfiles rebuilds the tab list in one reset, without emitting user-action signals.
  • The tab list is rebuilt from ProfileEngine::appBindings(serial) on every carousel selection change, and when the selected mouse is set up. Connecting another mouse no longer touches the tab bar.

Tests

  • 22 new tests, and every routing and tab test failed before the fix:
    • reconnect with a single mouse
    • reconnect of the other mouse
    • adding a second mouse
    • button / gesture / thumb-wheel routing per mouse
    • saving button-made changes to the right mouse
    • focus switching for every mouse
    • keeping the selection when rows are added or removed
    • per-mouse tabs, tab add/remove isolation and highlights
    • ProfileModel::resetProfiles unit tests
  • CarouselSwitchSwapsButtonModel was rewritten: its precondition only passed because adding mouse B wrote B's profile onto A, which refreshed A's button list as a side effect.
  • 774/774 C++, 9/9 tray, 72/72 QML; the pre-push gate is green. Each commit builds and passes on its own.

Hardware validation

Done with two MX Master 3S-family mice on two Bolt receivers.

  • Power-cycling each mouse with its receiver attached (the path that failed): each got device transport ready, applying profile … to <its serial> followed by the full writes, and DPI went from the firmware's 1000 back to 1800.
  • KVM switch that re-enumerated the receiver: the mouse was set up again with its own profile.
  • Second mouse waking: it got its own profile; the selected mouse was untouched.
  • Using both mice together: each thumb wheel used its own mouse's mode (zoom vs volume, interleaved in the log). Window focus switched only the mouse that has a profile for that app.
  • Tabs: each mouse shows only its own app tabs; adding or removing a tab affects only that mouse.

Related

Not in this PR

  • Device name read with a two-byte index and descriptor matching by receiver PID, which make "MX Master 3S For Business" (WPID 0xB035) match the MX Master 3 descriptor. That will be a separate PR.

🤖 Generated with Claude Code

…e UI selection

Profile applies, reconnect re-applies, window-focus profile switching and
diverted-button / gesture / thumb wheel input all resolved their target
through the carousel selection (ActiveDeviceResolver::activeSession()).
The selection is UI state and is wrong for device work:

- With one mouse, a disconnect hides its card and clears the selection
  (-1). On reconnect PhysicalDevice emits transportSetupComplete before
  stateChanged re-inserts the card, so applyProfileToHardware found no
  session and returned silently. The mouse stayed in firmware defaults
  after every wake / KVM switch: DPI reset, buttons undiverted, gestures
  dead. The log showed "applying profile" followed by nothing.
- With two mice, mouse B's profile was written to mouse A on setup and
  on every B reconnect, B's buttons ran A's mappings against A's
  session, button-made DPI/SmartShift changes were saved to A's profile,
  and only the selected mouse followed window focus.

Every device-originated path now names its device:

- ProfileOrchestrator::applyProfileToHardware(device, profile) writes to
  that device's transport using its own descriptor, and logs when it
  cannot instead of bailing silently. setupProfileForDevice and
  onTransportSetupComplete pass their device; neither touches the UI's
  current-descriptor pointer any more.
- onWindowFocusChanged switches every connected mouse to its own app
  profile; only the selected mouse moves the tab bar's hardware marker.
- ButtonActionDispatcher slots take the source PhysicalDevice (AppRoot
  tags each device's signals), use its descriptor, session and hardware
  profile, and key per-device state by its serial. The by-button
  signals carry the serial so applyHardwareChange saves to that mouse.
- DeviceModel keeps the same mouse selected when a row is inserted or
  removed in front of it, instead of silently re-pointing the index.

UI edits (button reassignments, point/scroll controls, tabs) still act on
the selected mouse, which is the one the user is looking at.

CarouselSwitchSwapsButtonModel relied on the bug: adding mouse B wrote
B's profile onto A's session, whose change signals refreshed A's button
list as a side effect. It now drives real carousel switches and compares
action names (both mappings surface as type "keystroke").
ProfileModel was a single tab list shared by every mouse. Each time a
mouse connected, setupProfileForDevice appended that mouse's app
bindings to it, and nothing ever removed or rebuilt them. Once a second
mouse connected, both showed the union of their app profiles:

- clicking another mouse's tab created an empty profile for the
  selected mouse that no app binding pointed at;
- adding or removing a tab changed only the selected mouse's profiles
  but the tab appeared or vanished for both;
- addProfile refused an app class already in the list, so a mouse could
  not get its own profile for an app the other mouse already had;
- the viewed-tab and "active on device" highlights were single values
  shared by both mice.

The tab bar now always shows the selected mouse's app profiles:

- ProfileModel::resetProfiles replaces the app tabs in one model reset
  and points both highlights at profiles by name, without emitting the
  user-action signals (profileSwitched / profileAdded / profileRemoved).
- ProfileEngine::appBindings(serial) exposes a mouse's bindings.
- ProfileOrchestrator::rebuildProfileTabs(serial) builds the tabs from
  them, and runs on every carousel selection change (AppRoot) and when
  the selected mouse is set up. Connecting another mouse no longer
  touches the tab bar; with no mouse selected only Defaults remains.
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.

1 participant