Skip to content
Open
59 changes: 58 additions & 1 deletion CONTRIBUTING_DEVICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,41 @@ Look at the `reprog_controls` array. Each entry has a `cid` (Control ID) and
| `0x0052` | Middle click |
| `0x0053` | Back (side button) |
| `0x0056` | Forward (side button) |
| `0x00C3` | Gesture button (physical) |
| `0x00C3` | Gesture button (physical, "Thumb button" on MX Master 4) |
| `0x00C4` | Smart Shift / Mode Shift |
| `0x00D7` | Virtual gesture button |
| `0x01A0` | MX Master 4 Sense Panel (see role-swap notes below) |

MX Master 4 has two thumb-area buttons that both surface as divertable
HID++ controls and need an explicit role swap. The Sense Panel
(`0x01A0`, Solaar's `Haptic` feature -- the touch surface Logitech
markets as "Haptic Sense" and Logi Options+ exposes under the "Action
Ring" overlay) drives directional gestures because it's far more
comfortable for swipes than the small side button. The small Thumb
button (`0x00C3`, the legacy gesture CID on older MX Master variants;
Solaar's `Mouse_Gesture_Button` with the `Thumb_Button` alias on MX
Master 4) is the single-press trigger. Wire it like this:

```python
"gesture_cids": (0x01A0, 0x00C3, 0x00D7), # Sense Panel CID first
"thumb_button_cid": 0x00C3, # small button as button-only extra
"gesture_via_sense_panel": True, # enables OS-level fallback swap
```

`0x01A0` lives in `gesture_cids` so the listener prefers diverting it
with rawXY. `thumb_button_cid` is diverted as button-only (no rawXY),
so the firmware doesn't suppress normal OS mouse motion while the
small button is held -- that was the root cause of the cursor-freeze
on stock MX Master 4. `gesture_via_sense_panel` enables an OS-level
`btn=6` / `BTN_TASK` swap fallback for cases where firmware rejects
the `0x01A0` divert; the platform mouse hooks consult
`active_gesture_cid` (set to `0x01A0` on success, anything else on
fallback) and `thumb_button_via_hid` (true when the extra divert is
installed) on `ConnectedDeviceInfo` to pick the right path.

Older MX Master mice (3S, 3, 2S, classic) keep `gesture_via_sense_panel
= False` (the default) so their HID++ gesture button continues to
drive swipes and the global `Gesture button` label is shown.

Not all CIDs are divertable. Check the `flags` field -- if bit `0x0020` is
set, the control can be intercepted by Mouser.
Expand Down Expand Up @@ -75,12 +107,37 @@ Add a new dict to `LOGI_DEVICE_SPECS`:
"gesture_cids": (0x00C3,), # from gesture_candidates in your dump
"dpi_min": 200,
"dpi_max": 4000, # from discovered DPI range, or vendor specs
"has_hires_wheel": False, # set True if device exposes 0x2121
"has_thumbwheel": False, # set True if device exposes 0x2150
},
```

#### `has_hires_wheel` and `has_thumbwheel`

These flags tell Mouser the device exposes the corresponding HID++ feature so it
can divert the wheel and apply scroll inversion at the source (matching Logitech
Options+ behavior). They're catalog hints only — runtime feature discovery in
`HidGestureListener` always overrides them, so a wrong catalog flag won't cause
a divert attempt against a non-existent feature.

Set them on every device you can confirm exposes the feature. Quick way to find
out: connect the device, run Mouser with debug logs enabled, and look for
`[HidGesture] Found wheel feature 0x2121` (HiResWheel) or `Found wheel feature
0x2150` (Thumbwheel) in the output.

| Flag | True when device has |
|---|---|
| `has_hires_wheel` | A vertical scroll wheel that supports HID++ feature `0x2121` (HiResWheel). Most modern Logitech mice. |
| `has_thumbwheel` | A horizontal thumbwheel that supports HID++ feature `0x2150` (Thumbwheel). MX Master family only. |

Pick the right button tuple for `supported_buttons`:

- `MX_MASTER_BUTTONS` -- middle, gesture (with swipes), back, forward, hscroll, mode_shift
- `MX_MASTER_4_BUTTONS` -- everything in `MX_MASTER_BUTTONS` plus
`thumb_button`, the slot fed by the small Thumb button (CID `0x00C3`
via HID++) and -- on fallback paths where the Sense Panel divert was
rejected -- by the Sense Panel itself (button 6 / `BTN_TASK` at the
OS layer)
- `MX_ANYWHERE_BUTTONS` -- middle, gesture (with swipes), back, forward
- `MX_VERTICAL_BUTTONS` -- middle, back, forward
- `GENERIC_BUTTONS` -- middle, back, forward (safe default)
Expand Down
17 changes: 15 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ Logitech gesture / thumb buttons do not always appear as standard mouse events.

The same module owns the SmartShift integration. It prefers the enhanced feature `0x2111` (`FEAT_SMART_SHIFT_ENHANCED`) when available and falls back to `0x2110`, exposing both an enable flag and a sensitivity threshold; pending settings are re-applied on every reconnect (including wake-from-sleep).

#### HID++ wheel native-invert

On MX Master devices `HidGestureListener` discovers two HID++ wheel features and asks the firmware to invert the scroll sign at the source. The OS receives native HID scroll reports with the direction already flipped, so inversion survives Synergy / DeskFlow / KVM forwarding without Mouser becoming the scroll producer.

| Feature | ID | Functions Mouser uses | Wire format |
|---|---|---|---|
| **HiResWheel** (vertical) | `0x2121` | fn 0 `getWheelCapability` (multiplier discovery), fn 1 `getWheelMode` (read-before-write), fn 2 `setWheelMode` | `setWheelMode(mode)` byte: bit0 = target (Mouser writes 0 = OS), bit1 = hi-res (Mouser writes 0 = low-res so the firmware-default cadence survives), bit2 = invert (Mouser flips per `invert_vscroll`). The device continues emitting native HID scroll; only the sign is reversed. |
| **Thumbwheel** (horizontal) | `0x2150` | fn 0 `getThumbwheelInfo`, fn 2 `setThumbwheelReporting` | `setThumbwheelReporting(reporting, invertDirection)`: Mouser writes `[0x00, invertByte]` to keep the wheel non-diverted but flip the firmware's horizontal sign for `invert_hscroll`. |

The engine drives this via `_apply_wheel_invert_setting` on every profile / device change. When the device acknowledges, `wheel_native_invert_active` is set on both the engine and the platform mouse hook, and the hook's OS-layer inversion path is suppressed so a second flip doesn't net out to no inversion. On unsupported devices, devices that reject the request, or when the kill-switch is off, the hook's existing OS-layer inversion handles `invert_vscroll` / `invert_hscroll` via the fallback path (in-place CGEvent negation on macOS; uinput delta sign-flip on Linux; LL hook delta sign-flip on Windows).

The kill-switch is `settings.wheel_divert` in `config.json`: `"auto"` (default) enables divert on capable devices; `"off"` forces the OS-layer fallback even on MX Master.

### App detector

[`core/app_detector.py`](core/app_detector.py) polls the foreground window every 300ms.
Expand All @@ -173,9 +186,9 @@ All settings live in `config.json` under the platform config dir (`%APPDATA%\Mou

- Multiple named profiles with per-profile button mappings, including gesture tap + swipe actions
- Per-profile app associations (list of `.exe` / bundle / process names)
- Global settings: DPI, scroll inversion, macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift mode + sensitivity, language, and startup preferences (`start_at_login`, `start_minimized`)
- Global settings: DPI, scroll inversion (`invert_vscroll`, `invert_hscroll`), HID++ wheel-divert kill-switch (`wheel_divert: "auto" | "off"`), macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift mode + sensitivity, language, and startup preferences (`start_at_login`, `start_minimized`)
- Per-device layout override selections for unsupported devices
- Automatic migration from older config versions (current version `9`)
- Automatic migration from older config versions (current version `11`)

Logs are written via [`core/log_setup.py`](core/log_setup.py) to a 5 × 5 MB rotating file in `~/Library/Logs/Mouser`, `%APPDATA%\Mouser\logs`, or `$XDG_STATE_HOME/Mouser/logs`. The setup is idempotent and safe to call multiple times — `main_qml.py` invokes it before any Qt or core import so startup output is captured from the very first line.

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ That's it. The app opens, drops a tray / menu-bar icon, and starts remapping imm
- **Logitech Options+ must not be running.** Both apps fight over HID++ access; quit Options+ before launching Mouser.
- **macOS** asks for **Accessibility** permission so the event tap can intercept mouse events. See [readme_mac_osx.md](readme_mac_osx.md) for the full setup walkthrough.
- **Linux** needs read access to `/dev/hidraw*`, `/dev/input/event*`, and write access to `/dev/uinput`. Run the bundled helper once after extracting:

```bash
cd /path/to/extracted/Mouser
./install-linux-permissions.sh
```

Reconnect the mouse, then relaunch.
- Config is saved automatically to:
- `%APPDATA%\Mouser\config.json` (Windows)
Expand Down Expand Up @@ -113,7 +115,7 @@ That's it. The app opens, drops a tray / menu-bar icon, and starts remapping imm
- **DPI / pointer speed** — slider from 200 to the device max (8000 on MX Master) with quick presets, plus a `Cycle DPI Presets` action you can map to a button.
- **Smart Shift** — toggle Logitech's ratchet ↔ free-spin scroll mode (HID++ `0x2111`), with a sensitivity threshold and a mappable `Toggle SmartShift` action.
- **Switch scroll mode** — bind a button to flip ratchet / free-spin without opening the UI; defaults to mode-shift.
- **Scroll direction inversion** — independent toggles for vertical and horizontal scroll.
- **Scroll direction inversion** — independent toggles for vertical and horizontal scroll. On MX Master devices, inversion is applied at the device via HID++ (`0x2121` HiResWheel + `0x2150` Thumbwheel) and survives Synergy / DeskFlow / KVM forwarding.
- **Gesture button + swipe actions** — tap for one action, swipe up/down/left/right for four others.

### Cross-platform
Expand Down Expand Up @@ -314,7 +316,7 @@ For project layout, the architecture diagram, the HID++ gesture detector, the En

- **Per-device mappings aren't fully separated yet** — layout overrides are stored per detected device, but profile mappings are still global.
- **Conflicts with Logitech Options+** — both apps fight over HID++ access. Quit Options+ before running Mouser.
- **Scroll inversion** uses coalesced post-injection on Windows to avoid LL-hook deadlocks; it's stable in mainstream apps but may misbehave in some games or low-level drivers.
- **Scroll inversion** — on MX Master devices, Mouser asks the firmware to flip the wheel and thumbwheel sign at the source (HID++ `0x2121` / `0x2150`); the device keeps emitting native HID scroll, so inversion survives Synergy / DeskFlow / KVM forwarding. On other devices it falls back to OS-layer event-tap / LL-hook injection (stable in mainstream apps but may misbehave in some games or low-level drivers). Set `"wheel_divert": "off"` in `config.json` `settings` to force the OS-layer fallback even on capable devices.
- **Admin not required** — but injected keystrokes may not reach elevated windows or some games. Run Mouser elevated if you need that path.
- **Linux app detection is partial** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, GNOME / other Wayland compositors still fall back to the default profile.
- **Linux device permissions** — Mouser needs access to `/dev/hidraw*`, `/dev/input/event*`, and `/dev/uinput`. Use [`install-linux-permissions.sh`](packaging/linux/install-linux-permissions.sh) once instead of running as root.
Expand Down
80 changes: 78 additions & 2 deletions core/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Configuration manager loads/saves button mappings to a JSON file.
Configuration manager -- loads/saves button mappings to a JSON file.
Supports per-application profiles (for future use).
"""

Expand Down Expand Up @@ -29,6 +29,7 @@
"gesture": "Gesture button",
"xbutton1": "Back button",
"xbutton2": "Forward button",
"thumb_button": "Thumb button",
"hscroll_left": "Horizontal scroll left",
"hscroll_right": "Horizontal scroll right",
"mode_shift": "Mode shift button",
Expand All @@ -50,6 +51,53 @@
"gesture_down": "Gesture swipe down",
}

# Sealed set of legal values for the ``settings.wheel_divert`` kill-switch.
# Treat this like a stringly-named enum: every engine read goes through
# :func:`coerce_wheel_divert_setting` so typos in the user's config file
# never silently flip into one of the live branches.
WHEEL_DIVERT_AUTO = "auto"
WHEEL_DIVERT_OFF = "off"
WHEEL_DIVERT_VALID_VALUES: frozenset[str] = frozenset(
(WHEEL_DIVERT_AUTO, WHEEL_DIVERT_OFF)
)
WHEEL_DIVERT_DEFAULT = WHEEL_DIVERT_AUTO

_WHEEL_DIVERT_WARNED_VALUES: set[str] = set()


def coerce_wheel_divert_setting(value: object) -> str:
"""Normalize an arbitrary value into the sealed ``wheel_divert`` set.

Unknown values resolve to :data:`WHEEL_DIVERT_DEFAULT` and log a one-shot
warning per distinct typo so the user gets a single, actionable nudge
instead of a per-event flood. Type-narrows to ``str`` so call sites can
compare against the named constants without `is None` or `isinstance`
guards.
"""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in WHEEL_DIVERT_VALID_VALUES:
return normalized
key = normalized or "<empty>"
if key not in _WHEEL_DIVERT_WARNED_VALUES:
_WHEEL_DIVERT_WARNED_VALUES.add(key)
print(
f"[Config] Unknown settings.wheel_divert={value!r}; "
f"falling back to {WHEEL_DIVERT_DEFAULT!r}. "
f"Valid values: {sorted(WHEEL_DIVERT_VALID_VALUES)}."
)
return WHEEL_DIVERT_DEFAULT
if value is not None:
marker = type(value).__name__
if marker not in _WHEEL_DIVERT_WARNED_VALUES:
_WHEEL_DIVERT_WARNED_VALUES.add(marker)
print(
f"[Config] settings.wheel_divert is not a string "
f"(got {type(value).__name__}); falling back to "
f"{WHEEL_DIVERT_DEFAULT!r}."
)
return WHEEL_DIVERT_DEFAULT

# Maps config button keys to the MouseEvent types they correspond to
BUTTON_TO_EVENTS = {
"middle": ("middle_down", "middle_up"),
Expand All @@ -60,14 +108,15 @@
"gesture_down": ("gesture_swipe_down",),
"xbutton1": ("xbutton1_down", "xbutton1_up"),
"xbutton2": ("xbutton2_down", "xbutton2_up"),
"thumb_button": ("thumb_button_down", "thumb_button_up"),
"hscroll_left": ("hscroll_left",),
"hscroll_right": ("hscroll_right",),
"mode_shift": ("mode_shift_down", "mode_shift_up"),
"dpi_switch": ("dpi_switch_down", "dpi_switch_up"),
}

DEFAULT_CONFIG = {
"version": 9,
"version": 11,
"active_profile": "default",
"profiles": {
"default": {
Expand All @@ -82,6 +131,7 @@
"gesture_down": "none",
"xbutton1": "alt_tab",
"xbutton2": "alt_tab",
"thumb_button": "none",
"hscroll_left": "browser_back",
"hscroll_right": "browser_forward",
"mode_shift": "switch_scroll_mode",
Expand Down Expand Up @@ -109,6 +159,11 @@
"ignore_trackpad": True,
"check_for_updates": True,
"update_check_state": {},
# HID++ wheel divert kill-switch:
# "auto" → enable on capable devices (MX Master family).
# "off" → never divert; force OS-layer inversion fallback.
# Sealed set; ``coerce_wheel_divert_setting`` normalizes user typos.
"wheel_divert": WHEEL_DIVERT_DEFAULT,
},
}

Expand Down Expand Up @@ -329,6 +384,24 @@ def _migrate(cfg):
settings.setdefault("ignore_trackpad", True)
cfg["version"] = 9

if version < 10:
# v10: HID++ wheel divert kill-switch. Default "auto" enables divert
# on capable devices (MX Master family). Existing installs keep
# working unchanged when the device exposes 0x2121 / 0x2150.
settings = cfg.setdefault("settings", {})
settings.setdefault("wheel_divert", WHEEL_DIVERT_DEFAULT)
cfg["version"] = 10

if version < 11:
# v11: MX Master 4 Thumb button (the small button on the front face,
# CID 0x00c3 in HID++). Existing profiles get "thumb_button": "none"
# so users opt in by mapping it in the UI. Devices without this
# button simply ignore the entry.
for pdata in cfg.get("profiles", {}).values():
mappings = pdata.setdefault("mappings", {})
mappings.setdefault("thumb_button", "none")
cfg["version"] = 11

cfg.setdefault("settings", {})
cfg["settings"].setdefault("appearance_mode", "system")
cfg["settings"].setdefault("debug_mode", False)
Expand All @@ -337,6 +410,9 @@ def _migrate(cfg):
cfg["settings"].setdefault("ignore_trackpad", True)
cfg["settings"].setdefault("check_for_updates", True)
cfg["settings"].setdefault("update_check_state", {})
cfg["settings"]["wheel_divert"] = coerce_wheel_divert_setting(
cfg["settings"].get("wheel_divert", WHEEL_DIVERT_DEFAULT)
)

# Always migrate old wmplayer.exe → Microsoft.Media.Player.exe in profile apps
for pdata in cfg.get("profiles", {}).values():
Expand Down
Loading
Loading