diff --git a/core/config.py b/core/config.py index b8afa87..7c16dc7 100644 --- a/core/config.py +++ b/core/config.py @@ -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). """ @@ -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", @@ -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 "" + 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"), @@ -60,6 +108,7 @@ "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"), @@ -67,7 +116,7 @@ } DEFAULT_CONFIG = { - "version": 9, + "version": 11, "active_profile": "default", "profiles": { "default": { @@ -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", @@ -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, }, } @@ -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) @@ -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(): diff --git a/core/engine.py b/core/engine.py index 6ab4156..63cbcf6 100644 --- a/core/engine.py +++ b/core/engine.py @@ -1,5 +1,5 @@ """ -Engine — wires the mouse hook to the key simulator using the +Engine -- wires the mouse hook to the key simulator using the current configuration. Sits between the hook layer and the UI. Supports per-application auto-switching of profiles. """ @@ -14,6 +14,7 @@ from core.config import ( load_config, get_active_mappings, get_profile_for_app, BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config, + WHEEL_DIVERT_OFF, coerce_wheel_divert_setting, ) from core.app_detector import AppDetector from core.mouse_hook_types import HidRuntimeState @@ -67,6 +68,12 @@ def __init__(self): self._replay_lock = threading.Lock() self._mouse_release_timers = {} # action_id → Timer for safety auto-release self._lock = threading.Lock() + # HID++ native-invert tracking. `_last_native_invert_target` is + # the most recently acknowledged (invert_v, invert_h) so the + # fast-path can skip redundant device round-trips on profile changes. + self._wheel_divert_change_cb = None + self._wheel_divert_active_local = False + self._last_native_invert_target = (False, False) self.hook.set_debug_callback(self._emit_debug) self.hook.set_gesture_callback(self._emit_gesture_event) self.hook.set_status_callback(self._emit_status) @@ -140,6 +147,8 @@ def _setup_hooks(self): ) self._emit_mapping_snapshot("Hook mappings refreshed", mappings) + # Drive HID++ firmware wheel-invert from settings + device capability. + self._apply_wheel_invert_setting() for btn_key, action_id in mappings.items(): events = list(BUTTON_TO_EVENTS.get(btn_key, ())) @@ -250,7 +259,7 @@ def _toggle_smart_shift(self): IMPORTANT: this is called from a HID event callback which runs on the HID loop thread. Calling hg.set_smart_shift() directly would block waiting for - the same loop to process the pending request — a deadlock that causes the + the same loop to process the pending request -- a deadlock that causes the 3-second timeout seen in the logs. Config and UI are updated synchronously; the device write is dispatched to a separate thread. """ @@ -277,7 +286,7 @@ def _switch_scroll_mode(self): """Switch between ratchet and free-spin (Logi Options+ physical button behaviour). SmartShift auto-switching is disabled so the chosen fixed mode takes effect. - Same deadlock caveat as _toggle_smart_shift — device write runs off-thread. + Same deadlock caveat as _toggle_smart_shift -- device write runs off-thread. """ settings = self.cfg.get("settings", {}) current_mode = settings.get("smart_shift_mode", "ratchet") @@ -333,6 +342,104 @@ def _write(): hg.set_dpi(new_dpi) threading.Thread(target=_write, daemon=True, name="CycleDPI").start() + def _apply_wheel_invert_setting(self, *, force: bool = False) -> None: + """Drive HID++ firmware wheel-invert from settings + device + capability. ``force=True`` re-issues writes even when cached + state matches the target -- used by ``_run_saved_settings_replay`` + to realign firmware that forgot state after sleep. On success the + engine + platform hook flip ``wheel_native_invert_active`` so the + OS-layer inversion path is suppressed; on failure the OS-layer + path handles inversion.""" + settings = self.cfg.get("settings", {}) + kill_switch_off = ( + coerce_wheel_divert_setting(settings.get("wheel_divert")) == WHEEL_DIVERT_OFF + ) + invert_v = bool(settings.get("invert_vscroll", False)) + invert_h = bool(settings.get("invert_hscroll", False)) + device = self.connected_device + capable = bool(device and ( + getattr(device, "has_hires_wheel", False) + or getattr(device, "has_thumbwheel", False) + )) + # Stay True even when both invert flags are False so we own the + # wheel-mode write and a stale invert lease from a crashed Mouser + # session is reset to native on reconnect. + target_active = bool(capable and not kill_switch_off) + hg = self.hook._hid_gesture + if ( + not force + and target_active == self._wheel_divert_active_local + and target_active == bool(getattr(self.hook, "wheel_native_invert_active", False)) + and (not target_active or self._last_native_invert_target == (invert_v, invert_h)) + ): + return + ack = False + if target_active and hg is not None and hasattr(hg, "request_wheel_native_invert"): + try: + ack = bool(hg.request_wheel_native_invert(invert_v, invert_h)) + except Exception as exc: + print(f"[Engine] wheel native-invert request failed: {exc}") + ack = False + elif not target_active and hg is not None and hasattr(hg, "request_wheel_native_invert"): + try: + hg.request_wheel_native_invert(False, False) + except Exception as exc: + print(f"[Engine] wheel native-invert release failed: {exc}") + new_active = bool(target_active and ack) + prev_active = self._wheel_divert_active_local + self._wheel_divert_active_local = new_active + self.hook.wheel_native_invert_active = new_active + self._last_native_invert_target = (invert_v, invert_h) if new_active else (False, False) + if hg is not None and hasattr(hg, "set_wheel_divert_active_flags"): + try: + hg.set_wheel_divert_active_flags( + bool(new_active and invert_v + and getattr(hg, "_hires_wheel_idx", None) is not None), + bool(new_active and invert_h + and getattr(hg, "_thumbwheel_idx", None) is not None), + ) + except Exception as exc: + print(f"[Engine] set_wheel_divert_active_flags failed: {exc}") + if new_active != prev_active: + print( + f"[Engine] wheel native-invert -> " + f"{'ON (HID++)' if new_active else 'OFF (OS fallback)'} " + f"capable={capable} kill_switch_off={kill_switch_off} " + f"invert_v={invert_v} invert_h={invert_h} ack={ack}" + ) + if not new_active and target_active: + self._emit_status( + "Firmware wheel invert FAILED on a capable device -- " + "falling back to OS-level inversion." + ) + self._notify_wheel_divert_change(new_active) + + def _notify_wheel_divert_change(self, active: bool) -> None: + if self._wheel_divert_change_cb is None: + return + try: + self._wheel_divert_change_cb(bool(active)) + except Exception as exc: + print(f"[Engine] wheel divert change callback raised: {exc}") + + def set_wheel_divert_change_callback(self, cb) -> None: + """Register ``cb(active: bool)`` invoked whenever the HID++ wheel + divert lease toggles. Fires once immediately with the current + state. Pass ``None`` to detach the currently registered callback.""" + self._wheel_divert_change_cb = cb + if cb is None: + return + try: + cb(bool(self._wheel_divert_active_local)) + except Exception as exc: + print(f"[Engine] wheel divert change callback (initial) raised: {exc}") + + @property + def wheel_native_invert_active(self) -> bool: + """True iff the connected device is performing scroll inversion at + the firmware level (so the OS-layer inversion path is suppressed).""" + return bool(self._wheel_divert_active_local) + def _make_hscroll_handler(self, action_id): def handler(event): if not self._enabled: @@ -516,6 +623,19 @@ def _run_saved_settings_replay(self): except Exception: pass + # Phase A.5: re-apply HID++ native wheel invert with force=True so + # firmware that forgot invert state after sleep is realigned. + self._apply_wheel_invert_setting(force=True) + native_invert_target = ( + coerce_wheel_divert_setting( + self.cfg.get("settings", {}).get("wheel_divert") + ) != WHEEL_DIVERT_OFF + and bool(getattr(self.connected_device, "has_hires_wheel", False) + or getattr(self.connected_device, "has_thumbwheel", False)) + ) + if native_invert_target and not self._wheel_divert_active_local: + replay_ok = False + time.sleep(3) hg = self.hook._hid_gesture if hg is None or getattr(hg, "connected_device", None) is None: @@ -658,8 +778,15 @@ def _battery_poll_loop(self, stop_event): except Exception: pass + # Read ``_replay_inflight`` under the same lock that the + # replay thread uses to flip it, otherwise the battery + # loop can issue a Smart Shift poll partway through a + # replay round-trip and the firmware queues conflicting + # HID++ writes. + with self._replay_lock: + replay_inflight = self._replay_inflight if ( - not self._replay_inflight + not replay_inflight and now - _last_ss >= _ss_poll_interval and hg.smart_shift_supported ): @@ -725,7 +852,7 @@ def set_dpi(self, dpi_value): hg = self.hook._hid_gesture if hg: return hg.set_dpi(dpi) - print("[Engine] No HID++ connection — DPI not applied") + print("[Engine] No HID++ connection -- DPI not applied") return False def set_smart_shift(self, mode, smart_shift_enabled=False, threshold=25): @@ -744,7 +871,7 @@ def set_smart_shift(self, mode, smart_shift_enabled=False, threshold=25): result = hg.set_smart_shift(mode, smart_shift_enabled, threshold) print(f"[Engine] set_smart_shift -> {'OK' if result else 'FAILED'}") return result - print("[Engine] set_smart_shift: No HID++ connection — not applied") + print("[Engine] set_smart_shift: No HID++ connection -- not applied") return False @property @@ -808,3 +935,15 @@ def stop(self): self._battery_poll_thread = None self._app_detector.stop() self.hook.stop() + # Cancel any pending safety auto-release timers. Without this the + # threading.Timer scheduled by execute_action can still fire after + # ``stop()`` returns and call ``inject_mouse_up`` against a hook + # that has already been torn down -- one of the timers fires a + # phantom release every time Mouser quits during a long press. + timers = list(self._mouse_release_timers.values()) + self._mouse_release_timers.clear() + for timer in timers: + try: + timer.cancel() + except Exception as exc: # noqa: BLE001 - shutdown must complete + print(f"[Engine] stop: failed to cancel release timer: {exc!r}") diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 67498fa..b89cbf8 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -1,5 +1,5 @@ """ -hid_gesture.py — Detect Logitech HID++ gesture controls and device features. +hid_gesture.py -- Detect Logitech HID++ gesture controls and device features. Many Logitech mice expose their gesture button and DPI/battery controls only through the HID++ vendor channel instead of standard OS mouse events. This @@ -11,12 +11,15 @@ Falls back gracefully if the package or device are unavailable. """ +import atexit import os import stat import sys import queue import threading import time +import weakref +from dataclasses import replace as _dataclass_replace from core.logi_devices import ( DEFAULT_GESTURE_CIDS, @@ -60,6 +63,162 @@ _LOG_ONCE_KEYS = set() +_ATEXIT_LISTENERS = weakref.WeakSet() +_ATEXIT_REGISTERED = False +_ATEXIT_LOCK = threading.Lock() + + +# Last-known-good (transport, PID, dev_idx, ...) cache for sub-second +# warm-start device detection. Schema is additive: unknown keys ignored. + +_CACHE_SCHEMA_VERSION = 1 +_CACHE_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 days + + +def _cache_dir() -> str: + if sys.platform == "darwin": + base = os.path.expanduser("~/Library/Application Support/Mouser") + elif sys.platform.startswith("linux"): + base = os.environ.get( + "XDG_CONFIG_HOME", + os.path.expanduser("~/.config"), + ) + base = os.path.join(base, "Mouser") + else: + base = os.path.join( + os.environ.get("APPDATA", os.path.expanduser("~")), + "Mouser", + ) + return base + + +def _cache_path() -> str: + return os.path.join(_cache_dir(), "last_device.json") + + +def _load_last_device_cache() -> dict | None: + """Read the last-known-good device cache. Returns None on missing, + malformed, expired, or version-mismatched cache (all treated as + cache-miss). Never raises.""" + path = _cache_path() + try: + import json + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + except (FileNotFoundError, OSError, ValueError): + return None + if not isinstance(payload, dict): + return None + if int(payload.get("version", 0)) != _CACHE_SCHEMA_VERSION: + return None + saved_at = float(payload.get("saved_at", 0) or 0) + if saved_at and (time.time() - saved_at) > _CACHE_TTL_SECONDS: + return None + candidate = payload.get("candidate") + device = payload.get("device") + if not isinstance(candidate, dict) or not isinstance(device, dict): + return None + return payload + + +def _save_last_device_cache(*, candidate: dict, device: dict) -> None: + """Atomically persist the last-known-good device tuple. Failures are + logged but never raised -- caching is a pure speedup.""" + path = _cache_path() + try: + import json, tempfile + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + payload = { + "version": _CACHE_SCHEMA_VERSION, + "saved_at": time.time(), + "candidate": candidate, + "device": device, + } + fd, tmp = tempfile.mkstemp( + prefix=".last_device.", suffix=".tmp", + dir=os.path.dirname(path), + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.flush() + try: + os.fsync(fh.fileno()) + except OSError: + pass + os.replace(tmp, path) + finally: + try: + if os.path.exists(tmp): + os.remove(tmp) + except OSError: + pass + except Exception as exc: + print(f"[HidGesture] Could not save device cache: {exc}") + + +def _candidate_signature(info) -> dict: + """Persistent identity fields for cache matching. Path is a hint + only -- USB ports and IOKit paths can shift between sessions.""" + pid = int(info.get("product_id", 0) or 0) + return { + "pid": pid, + "usage_page": int(info.get("usage_page", 0) or 0), + "usage": int(info.get("usage", 0) or 0), + "transport": info.get("transport") or "", + "source": info.get("source", "unknown"), + "path": _device_path_display(info.get("path")) or "", + } + + +def _candidate_match_score(info, cached_candidate) -> int: + """Ranking score for a candidate against the cached identity tuple. + 0 = no match, 1 = PID+usage match, 2 = +same source backend, + 3 = +same OS path. Higher score sorts first in the connect order.""" + sig = _candidate_signature(info) + if sig["pid"] != int(cached_candidate.get("pid", 0) or 0): + return 0 + if sig["usage_page"] != int(cached_candidate.get("usage_page", 0) or 0): + return 0 + if sig["usage"] != int(cached_candidate.get("usage", 0) or 0): + return 0 + score = 1 + cached_source = cached_candidate.get("source") or "" + if cached_source and sig["source"] == cached_source: + score += 1 + cached_path = cached_candidate.get("path") or "" + if cached_path and sig["path"] == cached_path: + score += 1 + return score + + +def _candidate_matches_cache(info, cached_candidate) -> bool: + return _candidate_match_score(info, cached_candidate) > 0 + + +def _atexit_stop_listeners(): + """Best-effort undivert before interpreter exit so a Mouser crash or + SIGTERM does not leave the device stuck in HID++ divert mode. + + Failures are logged but otherwise tolerated -- atexit runs during + interpreter teardown when the HID stack may already be partially gone, + so we never propagate. We do *not* silently swallow: a stuck divert is a + user-visible bug that the next session needs to diagnose. + """ + for listener in list(_ATEXIT_LISTENERS): + try: + listener.stop() + except Exception as exc: # noqa: BLE001 - atexit must never propagate + print(f"[HidGesture] atexit stop failed for {listener!r}: {exc}") + + +def _register_atexit_listener(listener): + global _ATEXIT_REGISTERED + with _ATEXIT_LOCK: + _ATEXIT_LISTENERS.add(listener) + if not _ATEXIT_REGISTERED: + atexit.register(_atexit_stop_listeners) + _ATEXIT_REGISTERED = True def _log_once(key, message): @@ -609,15 +768,31 @@ def _linux_logitech_hidraw_nodes(base="/sys/class/hidraw"): FEAT_ADJ_DPI = 0x2201 # Adjustable DPI FEAT_SMART_SHIFT = 0x2110 # Smart Shift basic FEAT_SMART_SHIFT_ENHANCED = 0x2111 # Smart Shift Enhanced (MX Master 3/3S, MX Master 4) -FEAT_HIRES_WHEEL = 0x2120 -FEAT_HIRES_WHEEL_ENHANCED = 0x2121 +FEAT_HIRES_WHEEL = 0x2120 # Hi-Res Wheel (basic / older devices) +FEAT_HIRES_WHEEL_ENHANCED = 0x2121 # Hi-Res Wheel Enhanced (MX Master 3/3S/4 -- divert + hi-res deltas) FEAT_LOWRES_WHEEL = 0x2130 -FEAT_THUMB_WHEEL = 0x2150 +FEAT_THUMB_WHEEL = 0x2150 # Thumbwheel (horizontal thumbwheel divert) FEAT_UNIFIED_BATT = 0x1004 # Unified Battery (preferred) FEAT_DEVICE_NAME = 0x0005 # Device Name & Type FEAT_BATTERY_STATUS = 0x1000 # Battery Status (fallback) DEFAULT_GESTURE_CID = DEFAULT_GESTURE_CIDS[0] +# REPROG_V4 ``setCidReporting`` control flags (fn 3 byte 2). The +# protocol packs four bits we ever toggle from this module: +# bit 0 (0x01) = temporary divert -- volatile, cleared on disconnect. +# bit 1 (0x02) = persistent divert -- survives sleep/wake. +# bit 4 (0x10) = temporary rawXY -- forward raw cursor deltas instead +# of the synthesized button click. +# bit 5 (0x20) = persistent rawXY -- survives sleep/wake. +# We always set both the volatile and persistent bits so the firmware +# replays our divert across power-saving wake events without needing a +# re-driver round trip. The constants below name the four combinations +# this module emits so call sites no longer read like bare magic bytes. +_DIVERT_BUTTON_ONLY = 0x03 # 0x01 | 0x02 -- divert as button click +_DIVERT_RAW_XY = 0x33 # 0x01 | 0x02 | 0x10 | 0x20 -- divert + rawXY +_UNDIVERT_BUTTON = 0x02 # 0x02 only -- revert button-only divert +_UNDIVERT_RAW_XY = 0x22 # 0x02 | 0x20 -- revert rawXY divert + MY_SW = 0x0A # arbitrary software-id used in our requests HIDPP_ERROR_NAMES = { @@ -637,6 +812,9 @@ def _linux_logitech_hidraw_nodes(base="/sys/class/hidraw"): 0x00C4: "Smart Shift", 0x00D7: "Virtual Gesture Button", 0x00FD: "DPI Switch", + # MX Master 4 Sense Panel; divertable rawXY-capable control + # used as the primary gesture source on the big pad. + 0x01A0: "Sense Panel", } KEY_FLAG_BITS = ( @@ -704,22 +882,75 @@ def _format_cid(cid): return f"0x{cid:04X} ({name})" if name else f"0x{cid:04X}" +def _coerce_int_cid(value) -> int | None: + """Normalize a CID value (``int``, ``"0x01A0"`` hex string, ``None``) to + ``int | None``. Fail-closed: malformed inputs resolve to ``None`` so the + caller never falls into divert paths with garbage values. + """ + if value in (None, ""): + return None + try: + return int(value, 0) if isinstance(value, str) else int(value) + except (TypeError, ValueError): + return None + + +def _control_present(controls, cid: int) -> bool: + """True when ``cid`` appears in the live REPROG_V4 ``controls`` dump. + + Centralizes the catalog-vs-runtime gating invariant: we never attempt to + divert (or otherwise act on) a CID the firmware does not advertise on the + current connection. + """ + if not controls: + return False + for control in controls: + if not isinstance(control, dict): + continue + if _coerce_int_cid(control.get("cid")) == cid: + return True + return False + + # ── Listener class ──────────────────────────────────────────────── class HidGestureListener: """Background thread: diverts the gesture button and listens via HID++.""" def __init__(self, on_down=None, on_up=None, on_move=None, - on_connect=None, on_disconnect=None, extra_diverts=None): + on_connect=None, on_disconnect=None, extra_diverts=None, + on_wheel=None, on_thumbwheel=None, + on_thumb_button_down=None, on_thumb_button_up=None): self._on_down = on_down self._on_up = on_up self._on_move = on_move self._on_connect = on_connect self._on_disconnect = on_disconnect + # Accepted for divert+inject-era callers; native-invert never + # sees wheelMovement / thumbwheelEvent notifications. + self._on_wheel = on_wheel + self._on_thumbwheel = on_thumbwheel + # Optional callbacks for devices with a dedicated thumb_button CID + # (currently MX Master 4's small HID++ button). Wired after the + # gesture divert succeeds, only when `LogiDeviceSpec.thumb_button_cid` + # is set AND it is NOT the active gesture CID. + self._on_thumb_button_down = on_thumb_button_down + self._on_thumb_button_up = on_thumb_button_up + # Static extras (mode_shift, dpi_switch). Action ring extras are + # added DYNAMICALLY at connect time via `_install_thumb_button_extra` + # because the choice depends on which CID `_divert` settled on. + self._static_extra_diverts = dict(extra_diverts or {}) self._extra_diverts = { cid: {**info, "held": False} - for cid, info in (extra_diverts or {}).items() + for cid, info in self._static_extra_diverts.items() } + self._thumb_button_cid: int | None = None + # Per-CID divert acknowledgment: a CID lives in ``_extra_diverts`` from + # the moment it is installed, but it only joins this set after the + # firmware acknowledges the ``setCidReporting`` call. ``thumb_button_via_hid`` + # reads off this set so callers never suppress the OS-level BTN_TASK + # fallback while the device is still emitting it. + self._extra_divert_acks: set[int] = set() self._dev = None # hid.device() self._thread = None self._running = False @@ -733,6 +964,11 @@ def __init__(self, on_down=None, on_up=None, on_move=None, self._held = False self._connected = False # True while HID++ device is open self._rawxy_enabled = False + # CIDs requiring button-only divert (0x03) instead of the default + # rawXY-enabled divert (0x33); currently the device's thumb_button + # CID so it stays usable on the fallback path without freezing + # the cursor. + self._button_only_cids: set[int] = set() self._pending_dpi = None # set by set_dpi(), applied in loop self._dpi_result = None # True/False after apply self._smart_shift_idx = None # feature index of SMART_SHIFT / SMART_SHIFT_ENHANCED @@ -750,6 +986,21 @@ def __init__(self, on_down=None, on_up=None, on_move=None, self._connected_device_info = None self._last_controls = [] # REPROG_V4 controls from last connection self._consecutive_request_timeouts = 0 + # 0x2121 Hi-Res Wheel + 0x2150 Thumbwheel native-invert state. + # Lock ordering: outer `_wheel_divert_call_lock` serializes + # cross-thread callers, inner `_wheel_divert_lock` protects the + # pending/result slot. The event signals listener-loop completion. + self._hires_wheel_idx = None + self._hires_wheel_multiplier = None + self._thumbwheel_idx = None + self._thumbwheel_multiplier = None + self._wheel_divert_target = (False, False) + self._wheel_divert_state = False + self._pending_wheel_divert = None + self._wheel_divert_event = threading.Event() + self._wheel_divert_lock = threading.Lock() + self._wheel_divert_call_lock = threading.Lock() + self._wheel_divert_result = None # ── public API ──────────────────────────────────────────────── @@ -771,12 +1022,29 @@ def start(self): "Logitech HID++ devices may not enumerate" ) self._running = True + _register_atexit_listener(self) self._thread = threading.Thread( target=self._main_loop, daemon=True, name="HidGesture") self._thread.start() return True def stop(self): + # Best-effort revert to native non-inverted before tearing down, so a + # graceful exit leaves the device in firmware default state. We log + # failures rather than swallow them: a failed revert means the next + # session will see an unexpected divert state and the user needs the + # breadcrumb to debug it. We do not propagate -- ``stop`` must always + # complete the rest of teardown (close device, join thread). + if self._dev is not None and self._wheel_divert_state: + try: + self._set_native_wheel_invert_vertical(False) + except Exception as exc: # noqa: BLE001 - teardown must complete + print(f"[HidGesture] stop: vertical invert revert failed: {exc}") + try: + self._set_native_wheel_invert_horizontal(False) + except Exception as exc: # noqa: BLE001 - teardown must complete + print(f"[HidGesture] stop: horizontal invert revert failed: {exc}") + self._wheel_divert_state = False self._running = False d = self._dev if d: @@ -860,6 +1128,16 @@ def dump_device_info(self): features[f"BATTERY ({feat_name})"] = f"index 0x{self._battery_idx:02X}" for feature_id, index in sorted(self._wheel_feature_indexes.items()): features[f"WHEEL (0x{feature_id:04X})"] = f"index 0x{index:02X}" + if self._hires_wheel_idx is not None: + features["HIRES_WHEEL_ENHANCED (0x2121) [divert]"] = ( + f"index 0x{self._hires_wheel_idx:02X} " + f"mul={self._hires_wheel_multiplier}" + ) + if self._thumbwheel_idx is not None: + features["THUMB_WHEEL (0x2150) [divert]"] = ( + f"index 0x{self._thumbwheel_idx:02X} " + f"divertedRes={self._thumbwheel_multiplier}" + ) controls = [] for c in self._last_controls: @@ -1063,11 +1341,19 @@ def _request(self, feat, func, params, timeout_ms=2000): # ── feature helpers ─────────────────────────────────────────── - def _find_feature(self, feature_id): - """Use IRoot (feature 0x0000) to discover a feature index.""" + def _find_feature(self, feature_id, timeout_ms=2000): + """Use IRoot (feature 0x0000) to discover a feature index. + + `timeout_ms` controls how long to wait for the IRoot response. The + default of 2000 ms is the safe value for active sessions; during + the REPROG_V4 discovery probe in `_try_connect` we use a much + tighter timeout (≈400 ms) because a live HID++ device responds in + <50 ms and waiting longer just stalls us across non-matching + receiver slots and candidate interfaces. + """ hi = (feature_id >> 8) & 0xFF lo = feature_id & 0xFF - resp = self._request(0x00, 0, [hi, lo, 0x00]) + resp = self._request(0x00, 0, [hi, lo, 0x00], timeout_ms=timeout_ms) if resp: _, _, _, _, p = resp if p and p[0] != 0: @@ -1224,64 +1510,188 @@ def add_candidate(cid): return ordered or list(preferred) def _divert(self): - """Divert the selected gesture control and enable raw XY when supported.""" + """Divert the selected gesture control. RawXY is requested for any + CID not flagged as button-only in `_button_only_cids`. + + Why per-CID? On MX Master 4 the sense panel (0x01A0) is the + primary gesture CID and benefits from rawXY (the firmware then + delivers swipe motion over the vendor channel and pins the cursor + on its own). The small button (0x00C3) is the thumb_button CID and + gets diverted button-only as an extra elsewhere -- but if 0x01A0 + divert is rejected we fall back to 0x00C3 as the gesture CID, and + then we want it button-only so the cursor doesn't freeze every + time the user clicks the small button. A single button-only flag + for the whole listener can't express that.""" if self._feat_idx is None: return False for cid in self._gesture_candidates: self._gesture_cid = cid - resp = self._set_cid_reporting(cid, 0x33) - if resp is not None: - self._rawxy_enabled = True - print(f"[HidGesture] Divert {_format_cid(cid)} with RawXY: OK") - return True + button_only = cid in self._button_only_cids + if not button_only: + resp = self._set_cid_reporting(cid, _DIVERT_RAW_XY) + if resp is not None: + self._rawxy_enabled = True + print(f"[HidGesture] Divert {_format_cid(cid)} with RawXY: OK") + return True self._rawxy_enabled = False - resp = self._set_cid_reporting(cid, 0x03) + resp = self._set_cid_reporting(cid, _DIVERT_BUTTON_ONLY) ok = resp is not None - print(f"[HidGesture] Divert {_format_cid(cid)}: " + mode = "button-only (catalog hint)" if button_only else "button-only fallback" + print(f"[HidGesture] Divert {_format_cid(cid)} ({mode}): " f"{'OK' if ok else 'FAILED'}") if ok: return True self._gesture_cid = DEFAULT_GESTURE_CID return False + def _install_thumb_button_extra(self, device_spec, controls): + """Wire ``device_spec.thumb_button_cid`` (when set) as a button-only + extra divert so its press/release fires ``_on_thumb_button_down/up``. + + Must run between ``_divert()`` and ``_divert_extras()`` so the entry + lands in the same setCidReporting round. Gated against the live + REPROG_V4 ``controls`` list -- we never install an extra divert for a + CID the firmware has not advertised, otherwise ``setCidReporting`` + hammers the device for a control that does not exist. Skipped when the + CID is already the active gesture CID (fallback path). + """ + if ( + self._thumb_button_cid is not None + and self._thumb_button_cid not in self._static_extra_diverts + ): + self._extra_diverts.pop(self._thumb_button_cid, None) + self._thumb_button_cid = None + cid = _coerce_int_cid(getattr(device_spec, "thumb_button_cid", None)) + if cid is None: + return + if cid == self._gesture_cid: + print( + f"[HidGesture] Skip thumb_button extra {_format_cid(cid)} " + f"-- it's already the active gesture CID (fallback path)" + ) + return + if not _control_present(controls, cid): + print( + f"[HidGesture] Skip thumb_button extra {_format_cid(cid)} " + f"-- firmware does not advertise this CID in REPROG_V4" + ) + return + self._thumb_button_cid = cid + self._extra_diverts[cid] = { + "on_down": self._fire_thumb_button_down, + "on_up": self._fire_thumb_button_up, + "held": False, + } + + def _fire_thumb_button_down(self): + cb = self._on_thumb_button_down + if cb is None: + return + try: + cb() + except Exception as exc: + print(f"[HidGesture] thumb_button down callback error: {exc}") + + def _fire_thumb_button_up(self): + cb = self._on_thumb_button_up + if cb is None: + return + try: + cb() + except Exception as exc: + print(f"[HidGesture] thumb_button up callback error: {exc}") + + @property + def thumb_button_via_hid(self) -> bool: + """True when the listener is delivering thumb_button events from a + HID++ extra divert (rather than the OS-level btn=6 fallback). + + Reads off ``_extra_divert_acks``, not ``_thumb_button_cid``, so the + property only flips after ``setCidReporting`` is acknowledged. Without + this gate the hook layer would suppress the OS BTN_TASK path while the + device is still emitting it, eating the press entirely. + """ + cid = self._thumb_button_cid + return cid is not None and cid in self._extra_divert_acks + def _divert_extras(self): - """Divert additional CIDs (e.g. mode shift) without raw XY.""" + """Divert additional CIDs (e.g. mode shift) without raw XY. + + Tracks per-CID acknowledgment in ``_extra_divert_acks`` so callers + know which extras the firmware actually took. CIDs that fail the + setCidReporting call are removed from ``_extra_diverts`` so the loop + does not later route OS events through a divert handler the firmware + never installed. + """ if self._feat_idx is None: return - for cid, info in self._extra_diverts.items(): - resp = self._set_cid_reporting(cid, 0x03) + self._extra_divert_acks.clear() + failed: list[int] = [] + for cid in list(self._extra_diverts.keys()): + resp = self._set_cid_reporting(cid, _DIVERT_BUTTON_ONLY) ok = resp is not None print(f"[HidGesture] Extra divert {_format_cid(cid)}: " f"{'OK' if ok else 'FAILED'}") + if ok: + self._extra_divert_acks.add(cid) + else: + failed.append(cid) + for cid in failed: + if cid == self._thumb_button_cid: + self._thumb_button_cid = None + self._extra_diverts.pop(cid, None) def _undivert(self): - """Restore default button behaviour (best-effort).""" + """Restore default button behaviour (best-effort). + + Failures during teardown are logged at debug level rather than + silently swallowed -- a stuck divert state is a user-visible bug + and the next session needs the breadcrumb to diagnose it. We + intentionally never raise here because callers (disconnect path, + atexit) must complete the rest of teardown regardless. + """ if self._feat_idx is None or self._dev is None: return - # Undivert extra CIDs for cid in self._extra_diverts: hi = (cid >> 8) & 0xFF lo = cid & 0xFF try: self._tx(LONG_ID, self._feat_idx, 3, - [hi, lo, 0x02, 0x00, 0x00]) - except Exception: - pass - # Undivert gesture CID + [hi, lo, _UNDIVERT_BUTTON, 0x00, 0x00]) + except Exception as exc: # noqa: BLE001 - teardown must complete + print( + f"[HidGesture] _undivert: extra {_format_cid(cid)} " + f"revert failed: {exc}" + ) hi = (self._gesture_cid >> 8) & 0xFF lo = self._gesture_cid & 0xFF - flags = 0x22 if self._rawxy_enabled else 0x02 + flags = _UNDIVERT_RAW_XY if self._rawxy_enabled else _UNDIVERT_BUTTON try: self._tx(LONG_ID, self._feat_idx, 3, [hi, lo, flags, 0x00, 0x00]) - except Exception: - pass + except Exception as exc: # noqa: BLE001 - teardown must complete + print( + f"[HidGesture] _undivert: gesture {_format_cid(self._gesture_cid)} " + f"revert failed: {exc}" + ) self._rawxy_enabled = False + # Best-effort revert; mid-disconnect failures are harmless because + # firmware auto-reverts on power cycle anyway, but we still log so + # a stuck divert across a normal disconnect is observable. + try: + self._set_native_wheel_invert_vertical(False) + except Exception as exc: # noqa: BLE001 - teardown must complete + print(f"[HidGesture] _undivert: vertical invert revert failed: {exc}") + try: + self._set_native_wheel_invert_horizontal(False) + except Exception as exc: # noqa: BLE001 - teardown must complete + print(f"[HidGesture] _undivert: horizontal invert revert failed: {exc}") + self._wheel_divert_state = False # ── DPI control ─────────────────────────────────────────────── def set_dpi(self, dpi_value): - """Queue a DPI change — will be applied on the listener thread. + """Queue a DPI change -- will be applied on the listener thread. Can be called from any thread. Returns True on success.""" dpi = clamp_dpi(dpi_value, self._connected_device_info) self._dpi_result = None @@ -1300,7 +1710,7 @@ def _apply_pending_dpi(self): if dpi is None: return if self._dpi_idx is None or self._dev is None: - print("[HidGesture] Cannot set DPI — not connected") + print("[HidGesture] Cannot set DPI -- not connected") self._dpi_result = False self._pending_dpi = None return @@ -1320,7 +1730,7 @@ def _apply_pending_dpi(self): self._pending_dpi = None def read_dpi(self): - """Queue a DPI read — will be applied on the listener thread. + """Queue a DPI read -- will be applied on the listener thread. Can be called from any thread. Returns the DPI value or None.""" self._dpi_result = None self._pending_dpi = "read" # special sentinel @@ -1364,6 +1774,20 @@ def _apply_pending_read_dpi(self): def smart_shift_supported(self): return self._smart_shift_idx is not None + @property + def hires_wheel_supported(self): + return self._hires_wheel_idx is not None + + @property + def thumbwheel_supported(self): + return self._thumbwheel_idx is not None + + @property + def wheel_divert_active(self): + """True iff the device acknowledged the divert ON request on the + current connection. Mirrors the listener-thread internal state.""" + return bool(self._wheel_divert_state) + def set_smart_shift(self, mode, smart_shift_enabled=False, threshold=25): """Queue a Smart Shift settings change. mode: 'ratchet' or 'freespin' (fixed mode when smart_shift_enabled=False) @@ -1393,7 +1817,7 @@ def _apply_pending_smart_shift(self): if pending is None: return if self._smart_shift_idx is None or self._dev is None: - print("[HidGesture] Cannot set Smart Shift — not connected") + print("[HidGesture] Cannot set Smart Shift -- not connected") self._finish_pending_smart_shift(None if pending == "read" else False) return if pending == "read": @@ -1486,7 +1910,7 @@ def _apply_pending_read_smart_shift(self): mode_byte = p[0] if p else 0 auto_disengage = p[1] if len(p) > 1 else 0 print(f"[HidGesture] Smart Shift raw: mode=0x{mode_byte:02X} auto_disengage=0x{auto_disengage:02X}") - # Freespin mode means fixed free-spin — SmartShift auto-switching is always OFF. + # Freespin mode means fixed free-spin -- SmartShift auto-switching is always OFF. # The device preserves the auto_disengage byte in freespin state, so we must # not use it to infer enabled=True; only ratchet mode can have SmartShift active. # For ratchet: auto_disengage 1-50 → SmartShift active; 0 or ≥51 → disabled. @@ -1504,6 +1928,150 @@ def _apply_pending_read_smart_shift(self): print("[HidGesture] Smart Shift read FAILED") self._finish_pending_smart_shift(None) + # 0x2121 setWheelMode (fn 2) bitfield: bit0=target (0=HID, 1=divert), + # bit1=resolution (0=low, 1=hi-res), bit2=invert. Mouser keeps target + # and resolution at 0 and only drives bit2 -- hi-res emits fractional + # events per detent which renders as jumpy scroll on apps without + # trackpad-class smoothing. + _WHEEL_MODE_BIT_TARGET = 0x01 + _WHEEL_MODE_BIT_RESOLUTION = 0x02 + _WHEEL_MODE_BIT_INVERT = 0x04 + # 0x2150 setThumbwheelReporting (fn 2): [reportingMode, invertDirection]. + _THUMBWHEEL_SET_REPORTING_FN = 2 + + def _set_native_wheel_invert_vertical(self, invert: bool) -> bool: + """Read-modify-write the 0x2121 wheel mode to native low-res with + the invert bit reflecting `invert`. Listener-thread only. Returns + True when the feature is absent (no-op success) or the device + acknowledges the write.""" + if self._hires_wheel_idx is None: + return True + if self._dev is None: + return False + target_mode = self._WHEEL_MODE_BIT_INVERT if invert else 0x00 + current_resp = self._request(self._hires_wheel_idx, 1, []) + if current_resp is not None: + _, _, _, _, params = current_resp + current_mode = int(params[0]) & 0xFF if params else None + if current_mode == target_mode: + return True + resp = self._request(self._hires_wheel_idx, 2, [target_mode]) + return resp is not None + + def _set_native_wheel_invert_horizontal(self, invert: bool) -> bool: + """Set firmware invert on the thumbwheel (0x2150 fn 2) without + diverting. Listener-thread only.""" + if self._thumbwheel_idx is None: + return True + if self._dev is None: + return False + invert_byte = 0x01 if invert else 0x00 + resp = self._request( + self._thumbwheel_idx, + self._THUMBWHEEL_SET_REPORTING_FN, + [0x00, invert_byte], + ) + return resp is not None + + def _apply_pending_native_wheel_invert(self) -> None: + """Drain the pending native-invert slot on the listener thread. + Mirrors the Smart-Shift pattern: on IOError the main loop's + cleanup calls ``_abort_pending_wheel_divert`` which wakes the + waiter, so callers never strand.""" + with self._wheel_divert_lock: + target = self._pending_wheel_divert + if target is None: + return + invert_v, invert_h = target + no_features = ( + self._hires_wheel_idx is None and self._thumbwheel_idx is None + ) + if no_features: + ok_v = ok_h = False + success = False + else: + ok_v = self._set_native_wheel_invert_vertical(invert_v) + ok_h = self._set_native_wheel_invert_horizontal(invert_h) + success = bool(ok_v and ok_h) + self._wheel_divert_state = bool(success) + with self._wheel_divert_lock: + self._wheel_divert_result = success + self._pending_wheel_divert = None + self._wheel_divert_event.set() + if no_features: + print( + "[HidGesture] Wheel native invert skipped -- " + "neither 0x2121 nor 0x2150 available" + ) + else: + print( + f"[HidGesture] Wheel native invert v={invert_v} h={invert_h} " + f"vertical={'OK' if ok_v else 'FAIL'} " + f"thumb={'OK' if ok_h else 'FAIL'}" + ) + + def _abort_pending_wheel_divert(self) -> None: + """Wake any waiter with result=False when the connection drops + mid-request, and clear stale post-success state so the next + request starts cleanly. Mirrors `_abort_pending_smart_shift`.""" + with self._wheel_divert_lock: + had_waiter = self._pending_wheel_divert is not None + self._wheel_divert_result = False if had_waiter else None + self._pending_wheel_divert = None + if had_waiter: + self._wheel_divert_event.set() + + def set_wheel_divert_active_flags(self, vertical: bool, thumb: bool) -> None: + """Update the published ConnectedDeviceInfo so external readers + see which axes are currently inverted at the firmware level. + ConnectedDeviceInfo is frozen, so this swaps the reference via + ``dataclasses.replace`` for thread safety.""" + info = self._connected_device_info + if info is None: + return + try: + self._connected_device_info = _dataclass_replace( + info, + hires_wheel_active=bool(vertical), + thumbwheel_active=bool(thumb), + ) + except Exception as exc: + print(f"[HidGesture] set_wheel_divert_active_flags error: {exc}") + + def request_wheel_native_invert( + self, + invert_vertical: bool, + invert_horizontal: bool, + timeout_s: float = 3.0, + ) -> bool: + """Cross-thread API. Ask the device to flip the wheel sign at + the firmware level (no divert). Blocks until the listener + applies the write or ``timeout_s`` elapses. ``_wheel_divert_target`` + is cached so reconnect replays the same intent. + + The reconnect-replay cache (``_wheel_divert_target``) is mutated + *inside* ``_wheel_divert_call_lock`` so two concurrent callers cannot + interleave updates: whichever caller wins the lock is the one whose + intent persists across reconnect. + """ + target = (bool(invert_vertical), bool(invert_horizontal)) + with self._wheel_divert_call_lock: + self._wheel_divert_target = target + with self._wheel_divert_lock: + self._wheel_divert_result = None + self._pending_wheel_divert = target + self._wheel_divert_event.clear() + if not self._wheel_divert_event.wait(timeout_s): + with self._wheel_divert_lock: + if self._pending_wheel_divert is not None: + self._wheel_divert_result = False + self._pending_wheel_divert = None + self._wheel_divert_event.set() + print("[HidGesture] Wheel native-invert request timed out") + return False + with self._wheel_divert_lock: + return bool(self._wheel_divert_result) + def read_battery(self): """Queue a battery read and wait for the listener thread result.""" self._battery_result = None @@ -1563,6 +2131,14 @@ def _decode_s16(hi, lo): value -= 0x10000 return value + @staticmethod + def _decode_s16_be(hi, lo): + """Big-endian signed-16 decode for 0x2121 / 0x2150 notifications.""" + value = ((hi & 0xFF) << 8) | (lo & 0xFF) + if value & 0x8000: + value -= 0x10000 + return value + def _force_release_stale_holds(self): """Synthesize UP events for any buttons stuck in the held state. @@ -1671,21 +2247,50 @@ def _on_report(self, raw): # ── connect / main loop ─────────────────────────────────────── def _try_connect(self): - """Open the vendor HID collection, discover features, divert.""" + """Open the vendor HID collection, discover features, divert. + + Warm path: a cached ``last_device.json`` biases candidate and + dev_idx ordering so the previously-working interface is probed + first with a tight 400 ms REPROG_V4 timeout. Cold path falls + back to the default direct-then-receiver scan with the same + per-slot timeout. On divert success the working tuple is + persisted so the next launch hits the warm path.""" infos = self._vendor_hid_infos() if not infos: return False - # Try direct devices (Bluetooth) before USB receivers, which - # require scanning multiple slots with slow timeouts. - def _direct_device_first(info): + cached = _load_last_device_cache() + cached_candidate = ( + cached.get("candidate") if isinstance(cached, dict) else None + ) + cached_device = ( + cached.get("device") if isinstance(cached, dict) else None + ) + + def _default_priority(info): name = (info.get("product_string") or "").lower() return (1 if "receiver" in name else 0, name) - infos.sort(key=_direct_device_first) + # Negate the score so higher match (cached interface) sorts first. + def _priority(info): + score = ( + _candidate_match_score(info, cached_candidate) + if cached_candidate is not None else 0 + ) + return (-score,) + _default_priority(info) + + infos.sort(key=_priority) print(f"[HidGesture] Backend preference: {_BACKEND_PREFERENCE}") print(f"[HidGesture] Candidate HID interfaces: {len(infos)}") + if cached_candidate: + print( + f"[HidGesture] Cached last-known device: " + f"PID=0x{int(cached_candidate.get('pid', 0)):04X} " + f"devIdx=0x{int((cached_device or {}).get('dev_idx', 0)):02X} " + f"name='{(cached_device or {}).get('name', '?')}' " + f"(warm-path probe will run first)" + ) for info in infos: pid = int(info.get("product_id", 0) or 0) up = int(info.get("usage_page", 0) or 0) @@ -1704,6 +2309,8 @@ def _direct_device_first(info): usage = info.get("usage", 0) product = info.get("product_string") source = info.get("source", "unknown") + # Snapshot before inner branches rebind `info` to HID++ responses. + candidate_signature = _candidate_signature(info) device_spec = resolve_device(product_id=pid, product_name=product) self._feat_idx = None self._dpi_idx = None @@ -1715,7 +2322,18 @@ def _direct_device_first(info): self._gesture_candidates = list( getattr(device_spec, "gesture_cids", ()) or DEFAULT_GESTURE_CIDS ) + # thumb_button CID must be diverted button-only (no rawXY) so + # firmware doesn't suppress OS cursor motion while it's held. + self._button_only_cids = set() + ar_cid = getattr(device_spec, "thumb_button_cid", None) + if ar_cid is not None: + self._button_only_cids.add(int(ar_cid)) self._rawxy_enabled = False + self._hires_wheel_idx = None + self._hires_wheel_multiplier = None + self._thumbwheel_idx = None + self._thumbwheel_multiplier = None + self._wheel_divert_state = False opened_transport = None opened_up = int(up or 0) opened_usage = int(usage or 0) @@ -1782,12 +2400,31 @@ def _direct_device_first(info): if self._dev is None: continue - # Try Bluetooth direct (0xFF) first, then Bolt receiver slots + # Cached dev_idx first, then Bluetooth direct (0xFF), then + # receiver slots 1..6. 400 ms per-slot discovery timeout -- + # a live HID++ device replies in <50 ms. + default_idx_order = (BT_DEV_IDX, 1, 2, 3, 4, 5, 6) + cached_dev_idx = None + if ( + cached_candidate is not None + and cached_device is not None + and _candidate_matches_cache(info, cached_candidate) + ): + try: + cached_dev_idx = int(cached_device.get("dev_idx")) + except (TypeError, ValueError): + cached_dev_idx = None + if cached_dev_idx is not None: + idx_order = (cached_dev_idx,) + tuple( + i for i in default_idx_order if i != cached_dev_idx + ) + else: + idx_order = default_idx_order reprog_found = False hidpp_name = None - for idx in (0xFF, 1, 2, 3, 4, 5, 6): + for idx in idx_order: self._dev_idx = idx - fi = self._find_feature(FEAT_REPROG_V4) + fi = self._find_feature(FEAT_REPROG_V4, timeout_ms=400) if fi is not None: reprog_found = True self._feat_idx = fi @@ -1805,6 +2442,12 @@ def _direct_device_first(info): getattr(device_spec, "gesture_cids", ()) or DEFAULT_GESTURE_CIDS ) + # Re-evaluate hints when HID++ name resolves a more + # specific spec than the receiver PID alone. + self._button_only_cids = set() + ar_cid = getattr(device_spec, "thumb_button_cid", None) + if ar_cid is not None: + self._button_only_cids.add(int(ar_cid)) controls = self._discover_reprog_controls() self._last_controls = controls self._gesture_candidates = self._choose_gesture_candidates( @@ -1818,7 +2461,7 @@ def _direct_device_first(info): if dpi_fi: self._dpi_idx = dpi_fi print(f"[HidGesture] Found ADJUSTABLE_DPI @0x{dpi_fi:02X}") - # Prefer 0x2111 (Enhanced) — used by MX Master 3/3S/4 and Logi Options+. + # Prefer 0x2111 (Enhanced) -- used by MX Master 3/3S/4 and Logi Options+. # Fall back to 0x2110 (basic) for older devices. ss_fi = self._find_feature(FEAT_SMART_SHIFT_ENHANCED) if ss_fi: @@ -1855,7 +2498,40 @@ def _direct_device_first(info): self._battery_idx = batt_fi self._battery_feature_id = FEAT_BATTERY_STATUS print(f"[HidGesture] Found BATTERY_STATUS @0x{batt_fi:02X}") + hw_fi = self._find_feature(FEAT_HIRES_WHEEL_ENHANCED) + if hw_fi: + self._hires_wheel_idx = hw_fi + cap = self._request(hw_fi, 0, []) + if cap: + _, _, _, _, p = cap + mul = p[0] if p else None + self._hires_wheel_multiplier = ( + int(mul) if mul not in (None, 0) else None + ) + print( + f"[HidGesture] Found HIRES_WHEEL_ENHANCED @0x{hw_fi:02X} " + f"mul={self._hires_wheel_multiplier}" + ) + tw_fi = self._find_feature(FEAT_THUMB_WHEEL) + if tw_fi: + self._thumbwheel_idx = tw_fi + info = self._request(tw_fi, 0, []) + if info: + _, _, _, _, p = info + if len(p) >= 4: + self._thumbwheel_multiplier = ( + (p[2] << 8) | p[3] + ) or None + print( + f"[HidGesture] Found THUMB_WHEEL @0x{tw_fi:02X} " + f"divertedRes={self._thumbwheel_multiplier}" + ) if self._divert(): + # Install BEFORE _divert_extras so it lands in the + # same setCidReporting round. Pass the live REPROG_V4 + # controls so the helper can refuse to divert CIDs the + # firmware does not advertise. + self._install_thumb_button_extra(device_spec, controls) self._divert_extras() if idx == BT_DEV_IDX: actual_transport = "Bluetooth" @@ -1881,9 +2557,39 @@ def _direct_device_first(info): "hid_module": _HID_MODULE_NAME or "", "device_path": opened_path, }, + has_hires_wheel=bool(self._hires_wheel_idx is not None), + has_thumbwheel=bool(self._thumbwheel_idx is not None), + hires_wheel_active=False, + thumbwheel_active=False, + thumb_button_via_hid=self.thumb_button_via_hid, ) + # Replay the last desired native-invert state on + # reconnect; listener loop drains this next iter. + if ( + any(self._wheel_divert_target) + and ( + self._hires_wheel_idx is not None + or self._thumbwheel_idx is not None + ) + ): + with self._wheel_divert_lock: + self._wheel_divert_result = None + self._pending_wheel_divert = self._wheel_divert_target + self._wheel_divert_event.clear() + try: + _save_last_device_cache( + candidate=candidate_signature, + device={ + "name": hidpp_name or product, + "dev_idx": int(idx), + "transport": actual_transport, + "feat_idx_reprog": int(fi), + }, + ) + except Exception as exc: + print(f"[HidGesture] Cache write skipped: {exc}") return True - continue # divert failed — try next receiver slot + continue # divert failed -- try next receiver slot if not reprog_found: print( "[HidGesture] Opened candidate but REPROG_V4 was not found " @@ -1892,7 +2598,7 @@ def _direct_device_first(info): f"transport={opened_transport or '-'} source={source}" ) - # Couldn't use this interface — close and try next + # Couldn't use this interface -- close and try next try: self._dev.close() except Exception: @@ -1937,8 +2643,8 @@ def _main_loop(self): # full reconnect so button diverts are re-applied. if self._consecutive_request_timeouts >= _CONSECUTIVE_TIMEOUT_RECONNECT: print(f"[HidGesture] {self._consecutive_request_timeouts} consecutive " - f"request timeouts — forcing reconnect") - raise IOError("consecutive request timeouts — device likely asleep") + f"request timeouts -- forcing reconnect") + raise IOError("consecutive request timeouts -- device likely asleep") # Apply any queued DPI command if self._pending_dpi is not None: if self._pending_dpi == "read": @@ -1947,6 +2653,8 @@ def _main_loop(self): self._apply_pending_dpi() if self._pending_smart_shift is not None: self._apply_pending_smart_shift() + if self._pending_wheel_divert is not None: + self._apply_pending_native_wheel_invert() if self._pending_battery is not None: self._apply_pending_read_battery() raw = self._rx(1000) @@ -1980,6 +2688,12 @@ def _main_loop(self): self._pending_dpi = None self._dpi_result = None self._abort_pending_smart_shift() + self._abort_pending_wheel_divert() + self._hires_wheel_idx = None + self._hires_wheel_multiplier = None + self._thumbwheel_idx = None + self._thumbwheel_multiplier = None + self._wheel_divert_state = False self._last_logged_battery = None self._consecutive_request_timeouts = 0 if self._held: diff --git a/core/logi_device_catalog.py b/core/logi_device_catalog.py index 09f1f5d..180d747 100644 --- a/core/logi_device_catalog.py +++ b/core/logi_device_catalog.py @@ -27,6 +27,25 @@ "mode_shift", ) +# MX Master 4 layout: the standard MX Master set plus a third thumb-area +# button. The MX Master 4 relocates the legacy gesture button (CID 0x00C3) +# next to back/forward and adds a haptic Sense Panel (CID 0x01A0); the +# "thumb_button" key gives the relocated button its own UI mapping target. +MX_MASTER_4_BUTTONS = ( + "middle", + "gesture", + "gesture_left", + "gesture_right", + "gesture_up", + "gesture_down", + "xbutton1", + "xbutton2", + "thumb_button", + "hscroll_left", + "hscroll_right", + "mode_shift", +) + def _hotspot( button_key: str, @@ -88,6 +107,22 @@ def _layout( ), "ui_layout": "mx_master_4", "image_asset": "logitech-mice/mx_master_4/mouse.png", + "supported_buttons": MX_MASTER_4_BUTTONS, + "has_hires_wheel": True, + "has_thumbwheel": True, + # The Sense Panel (CID 0x01A0) and the relocated Mouse Gesture + # Button (0x00C3) are both divertable + rawXY-capable on the + # MX Master 4 firmware. List 0x01A0 first so the listener prefers + # the larger Sense Panel as the swipe surface; fall back to + # 0x00C3 then the virtual gesture control 0x00D7. + "gesture_cids": (0x01A0, 0x00C3, 0x00D7), + # Divert the relocated Mouse Gesture Button as a button-only extra + # alongside the active gesture CID. Skipped when the listener has + # to fall back to 0x00C3 as the gesture CID. + "thumb_button_cid": 0x00C3, + # Enables the OS-level btn=6 / BTN_TASK gesture swap as a fallback + # for clients that cannot divert the Sense Panel via HID++. + "gesture_via_sense_panel": True, }, { "key": "mx_master_3s", @@ -100,6 +135,8 @@ def _layout( ), "ui_layout": "mx_master_3s", "image_asset": "logitech-mice/mx_master_3s/mouse.png", + "has_hires_wheel": True, + "has_thumbwheel": True, }, { "key": "mx_master_3", @@ -113,6 +150,8 @@ def _layout( ), "ui_layout": "mx_master_3", "image_asset": "logitech-mice/mx_master_3/mouse.png", + "has_hires_wheel": True, + "has_thumbwheel": True, }, { "key": "mx_master_2s", @@ -125,6 +164,8 @@ def _layout( "ui_layout": "mx_master_2s", "image_asset": "logitech-mice/mx_master_2s/mouse.png", "dpi_max": 4000, + "has_hires_wheel": True, + "has_thumbwheel": True, }, { "key": "mx_master", @@ -137,6 +178,8 @@ def _layout( "ui_layout": "mx_master_classic", "image_asset": "logitech-mice/mx_master/mouse.png", "dpi_max": 4000, + "has_hires_wheel": True, + "has_thumbwheel": True, }, { "key": "mx_anywhere_3s", @@ -219,14 +262,29 @@ def _layout( label_off_y=-90, ), _hotspot( + # Sense Panel (CID 0x01A0) drives directional gestures via + # rawXY divert. Labeled by physical identity so remapping + # in the UI does not lie about where the press lands. "gesture", - "Gesture button", + "Sense Panel", "gesture", - 0.386, - 0.361, + 0.18, + 0.69, + label_side="left", + label_off_x=-240, + label_off_y=40, + ), + _hotspot( + # Relocated Mouse Gesture Button (CID 0x00C3) at the top of + # the side row, exposed as a single-press mapping target. + "thumb_button", + "Top thumb button", + "mapping", + 0.392, + 0.405, label_side="left", label_off_x=-260, - label_off_y=20, + label_off_y=-60, ), _hotspot( "hscroll_left", diff --git a/core/logi_devices.py b/core/logi_devices.py index 8da965f..d060f69 100644 --- a/core/logi_devices.py +++ b/core/logi_devices.py @@ -12,7 +12,7 @@ import re from typing import Iterable -from core.logi_device_catalog import LOGI_DEVICE_SPECS +from core.logi_device_catalog import LOGI_DEVICE_SPECS, MX_MASTER_4_BUTTONS DEFAULT_GESTURE_CIDS = (0x00C3, 0x00D7) @@ -119,6 +119,16 @@ class LogiDeviceSpec: supported_buttons: tuple[str, ...] = DEFAULT_BUTTON_LAYOUT dpi_min: int = DEFAULT_DPI_MIN dpi_max: int = DEFAULT_DPI_MAX + # Catalog hints; runtime HID++ discovery overrides these via ConnectedDeviceInfo. + has_hires_wheel: bool = False + has_thumbwheel: bool = False + # True when the device exposes a haptic Sense Panel that should drive + # gestures (MX Master 4 family). Enables an OS-level btn=6 / BTN_TASK + # fallback path when HID++ divert of the panel is unavailable. + gesture_via_sense_panel: bool = False + # Optional second thumb-area control to divert as a button-only extra + # (no rawXY) alongside the active gesture CID. + thumb_button_cid: int | None = None def matches(self, product_id=None, product_name=None) -> bool: if product_id is not None and int(product_id) in self.product_ids: @@ -367,6 +377,19 @@ class ConnectedDeviceInfo: dpi_min: int = DEFAULT_DPI_MIN dpi_max: int = DEFAULT_DPI_MAX capability_inventory: DeviceCapabilityInventory = DeviceCapabilityInventory() + # has_* mirrors HID++ feature presence; *_active reflects whether the + # listener currently holds the firmware-invert lease. Engine and mouse + # hooks read these directly instead of re-walking the inventory. + has_hires_wheel: bool = False + has_thumbwheel: bool = False + hires_wheel_active: bool = False + thumbwheel_active: bool = False + gesture_via_sense_panel: bool = False + thumb_button_cid: int | None = None + # CID the listener actually diverted as the gesture role; None until divert succeeds. + active_gesture_cid: int | None = None + # True when thumb_button events arrive over HID++ rather than via the OS button path. + thumb_button_via_hid: bool = False # Seeded from Mouser's own device catalog first, then extended with broader @@ -419,9 +442,32 @@ def iter_known_devices() -> Iterable[LogiDeviceSpec]: def clamp_dpi(value, device=None) -> int: + """Clamp ``value`` into the device's DPI range, defaulting to the safe + floor on malformed input. + + ``value`` may arrive from a JSON config file, a QML binding, or a HID + report -- so the raw ``int(value)`` cast that was here before could + raise on a stringified hex value, on ``None``, or on a partial HID + read. Crashing the engine's DPI path because the user edited + ``config.json`` by hand is the wrong failure mode; we coerce + defensively and return the device minimum so the cursor never freezes. + """ dpi_min = getattr(device, "dpi_min", DEFAULT_DPI_MIN) or DEFAULT_DPI_MIN dpi_max = getattr(device, "dpi_max", DEFAULT_DPI_MAX) or DEFAULT_DPI_MAX - dpi = int(value) + if isinstance(value, bool): + # ``bool`` is a subclass of ``int`` -- reject it explicitly so a + # leaked truthy flag never silently resolves to 0 or 1 DPI. + return dpi_min + if isinstance(value, str): + try: + dpi = int(value, 0) + except (TypeError, ValueError): + return dpi_min + else: + try: + dpi = int(value) + except (TypeError, ValueError): + return dpi_min return max(dpi_min, min(dpi_max, dpi)) @@ -432,18 +478,27 @@ def resolve_device(product_id=None, product_name=None) -> LogiDeviceSpec | None: return None -def _control_cid(control) -> int | None: - if not isinstance(control, dict): - return None - cid = control.get("cid") - if cid in (None, ""): +def _coerce_cid(value) -> int | None: + """Normalize a CID value (int, ``"0x01A0"`` hex string, or ``None``) to ``int | None``. + + Returns ``None`` for falsy / unparseable inputs so callers never have to + distinguish between "absent" and "malformed" -- the contract is + intentionally fail-closed. + """ + if value in (None, ""): return None try: - return int(cid, 0) if isinstance(cid, str) else int(cid) + return int(value, 0) if isinstance(value, str) else int(value) except (TypeError, ValueError): return None +def _control_cid(control) -> int | None: + if not isinstance(control, dict): + return None + return _coerce_cid(control.get("cid")) + + def _control_int(control, field) -> int | None: if not isinstance(control, dict): return None @@ -763,6 +818,7 @@ def derive_supported_buttons_from_reprog_controls( # resolve buttons even when individual devices use per-device ui_layout keys. _LAYOUT_BUTTONS = { "mx_master": MX_MASTER_BUTTONS, + "mx_master_4": MX_MASTER_4_BUTTONS, "mx_anywhere": MX_ANYWHERE_BUTTONS, "mx_vertical": MX_VERTICAL_BUTTONS, "generic_mouse": GENERIC_BUTTONS, @@ -779,6 +835,19 @@ def get_buttons_for_layout(ui_layout_key: str) -> tuple[str, ...] | None: return None +def _resolve_capability(runtime: bool | None, catalog: bool) -> bool: + """Tristate capability resolver: ``None`` defers to catalog; non-None wins. + + Callers signal "I haven't probed this yet" with ``None`` and "I probed and + here's what I saw" with ``True``/``False``. Without the tristate, a runtime + probe that returned ``False`` (definitive absence) would be silently + overridden by an optimistic catalog hint. + """ + if runtime is None: + return bool(catalog) + return bool(runtime) + + def build_connected_device_info( *, product_id=None, @@ -792,6 +861,11 @@ def build_connected_device_info( discovered_features=None, device_identity=None, diagnostics=None, + has_hires_wheel: bool | None = None, + has_thumbwheel: bool | None = None, + hires_wheel_active: bool = False, + thumbwheel_active: bool = False, + thumb_button_via_hid: bool = False, ) -> ConnectedDeviceInfo: spec = resolve_device(product_id=product_id, product_name=product_name) pid = int(product_id) if product_id not in (None, "") else None @@ -802,17 +876,27 @@ def build_connected_device_info( "source": source, **dict(device_identity or {}), } + # Empty tuple is a legitimate "no gesture CIDs detected" signal from the + # runtime; only fall back to spec/defaults when the caller passes ``None``. + spec_gesture_cids = spec.gesture_cids if spec is not None else None inventory = build_device_capability_inventory( reprog_controls, device_identity=identity, - gesture_cids=gesture_cids or getattr(spec, "gesture_cids", None), + gesture_cids=spec_gesture_cids if gesture_cids is None else gesture_cids, active_gesture_cid=active_gesture_cid, gesture_rawxy_enabled=gesture_rawxy_enabled, discovered_features=discovered_features, diagnostics=diagnostics, ) + spec_has_hires = bool(spec.has_hires_wheel) if spec is not None else False + spec_has_thumb = bool(spec.has_thumbwheel) if spec is not None else False + eff_has_hires = _resolve_capability(has_hires_wheel, spec_has_hires) + eff_has_thumb = _resolve_capability(has_thumbwheel, spec_has_thumb) + normalized_active_cid = _coerce_cid(active_gesture_cid) if spec: - resolved_gesture_cids = tuple(gesture_cids or spec.gesture_cids) + resolved_gesture_cids = ( + tuple(gesture_cids) if gesture_cids is not None else tuple(spec.gesture_cids) + ) return ConnectedDeviceInfo( key=spec.key, display_name=spec.display_name, @@ -827,6 +911,14 @@ def build_connected_device_info( dpi_min=spec.dpi_min, dpi_max=spec.dpi_max, capability_inventory=inventory, + has_hires_wheel=eff_has_hires, + has_thumbwheel=eff_has_thumb, + hires_wheel_active=bool(hires_wheel_active), + thumbwheel_active=bool(thumbwheel_active), + gesture_via_sense_panel=bool(spec.gesture_via_sense_panel), + thumb_button_cid=spec.thumb_button_cid, + active_gesture_cid=normalized_active_cid, + thumb_button_via_hid=bool(thumb_button_via_hid), ) # Fallback for unrecognized devices (e.g., USB Receiver PID 0xC52B which @@ -836,6 +928,9 @@ def build_connected_device_info( f"Logitech PID 0x{pid:04X}" if pid is not None else "Logitech mouse" ) key = _normalize_name(display_name).replace(" ", "_") or "logitech_mouse" + fallback_gesture_cids = ( + tuple(gesture_cids) if gesture_cids is not None else tuple(DEFAULT_GESTURE_CIDS) + ) return ConnectedDeviceInfo( key=key, display_name=display_name, @@ -846,8 +941,14 @@ def build_connected_device_info( ui_layout="generic_mouse", image_asset="icons/mouse-simple.svg", supported_buttons=GENERIC_BUTTONS, - gesture_cids=tuple(gesture_cids or DEFAULT_GESTURE_CIDS), + gesture_cids=fallback_gesture_cids, capability_inventory=inventory, + has_hires_wheel=eff_has_hires, + has_thumbwheel=eff_has_thumb, + hires_wheel_active=bool(hires_wheel_active), + thumbwheel_active=bool(thumbwheel_active), + active_gesture_cid=normalized_active_cid, + thumb_button_via_hid=bool(thumb_button_via_hid), ) diff --git a/core/mouse_hook_base.py b/core/mouse_hook_base.py index 60e5623..7f15bc7 100644 --- a/core/mouse_hook_base.py +++ b/core/mouse_hook_base.py @@ -13,6 +13,7 @@ from core.mouse_hook_types import HidRuntimeState, MouseEvent, format_debug_details + class BaseMouseHook: def __init__(self): self._callbacks = {} @@ -44,6 +45,10 @@ def __init__(self): self._gesture_input_source = None self._connected_device = None self._dispatch_queue = None + # True when the device is inverting at the firmware level + # (HID++ 0x2121 / 0x2150); the OS-layer path must skip its own + # flip to avoid cancelling out. Engine flips after request acks. + self.wheel_native_invert_active = False def _init_dispatch_queue(self, maxsize=0): """Initialize dispatch queue storage for subclasses with event threads.""" @@ -123,6 +128,60 @@ def hid_runtime_state(self): connected_device=self._connected_device, ) + # MX Master 4's Sense Panel exposes itself as CID 0x01A0 + # (raw-XY divertable) AND as OS btn=6 / BTN_TASK. + SENSE_PANEL_CID = 0x01A0 + + def _apply_vscroll_invert_fallback(self) -> bool: + """True only when the OS-layer vertical-scroll inversion fallback + should fire on the current event. + + The user's wheel-invert toggle is meant to flip *Logitech* scroll -- + firmware-first on HID++-capable devices, OS-layer event-tap on the + rest. When no Logitech is currently connected we have no source-of- + truth that the event came from a device the toggle applies to, so the + fallback must stand down rather than invert every trackpad / generic + USB mouse scroll the OS forwards through us. The three platform + guards live in one place so future hooks (X11, BSD, etc.) inherit + the same contract. + """ + if not self.invert_vscroll: + return False + if self.wheel_native_invert_active: + return False + return self._connected_device is not None + + def _apply_hscroll_invert_fallback(self) -> bool: + """Horizontal twin of :meth:`_apply_vscroll_invert_fallback`.""" + if not self.invert_hscroll: + return False + if self.wheel_native_invert_active: + return False + return self._connected_device is not None + + @property + def _thumb_button_via_hid(self) -> bool: + """True when thumb_button presses arrive over the HID++ vendor + channel (via the thumb_button extra divert); platform hooks + swallow any leaked btn=6 / BTN_TASK instead of double-dispatching.""" + device = self._connected_device + return bool(device is not None and getattr( + device, "thumb_button_via_hid", False + )) + + @property + def _gesture_via_sense_panel(self) -> bool: + """True only on the OS-level fallback path: catalog declares + ``gesture_via_sense_panel=True`` AND the listener diverted + something other than 0x01A0 as the gesture CID.""" + device = self._connected_device + if device is None or not getattr( + device, "gesture_via_sense_panel", False + ): + return False + active = getattr(device, "active_gesture_cid", None) + return active != self.SENSE_PANEL_CID + def dump_device_info(self): hg = getattr(self, "_hid_gesture", None) if hg and hasattr(hg, "dump_device_info"): @@ -138,8 +197,11 @@ def _set_device_connected(self, connected): if self._connection_change_cb: try: self._connection_change_cb(connected) - except Exception: - pass + except Exception as exc: # noqa: BLE001 - callback boundary + print( + f"[MouseHook] connection_change_cb raised on " + f"{state.lower()}: {exc!r}" + ) def set_debug_callback(self, callback): self._debug_callback = callback @@ -154,22 +216,24 @@ def _emit_debug(self, message): if self.debug_mode and self._debug_callback: try: self._debug_callback(message) - except Exception: - pass + except Exception as exc: # noqa: BLE001 - callback boundary + # ``_emit_debug`` is itself the diagnostic channel, so the + # failure goes straight to print() rather than recursing. + print(f"[MouseHook] debug_callback raised: {exc!r}") def _emit_status(self, message): if self._status_callback: try: self._status_callback(message) - except Exception: - pass + except Exception as exc: # noqa: BLE001 - callback boundary + print(f"[MouseHook] status_callback raised: {exc!r}") def _emit_gesture_event(self, event): if self.debug_mode and self._gesture_callback: try: self._gesture_callback(event) - except Exception: - pass + except Exception as exc: # noqa: BLE001 - callback boundary + print(f"[MouseHook] gesture_callback raised: {exc!r}") def _dispatch(self, event): callbacks = self._callbacks.get(event.event_type, []) @@ -273,12 +337,36 @@ def _start_hid_listener(self): on_connect=self._on_hid_connect, on_disconnect=self._on_hid_disconnect, extra_diverts=self._build_extra_diverts(), + on_thumb_button_down=self._on_hid_thumb_button_down, + on_thumb_button_up=self._on_hid_thumb_button_up, ) self._hid_gesture = listener if not listener.start(): self._hid_gesture = None return self._hid_gesture + def _on_hid_thumb_button_down(self): + """Dispatch THUMB_BUTTON_DOWN from a HID++ extra divert.""" + self._emit_debug("HID thumb_button button down") + try: + from core.mouse_hook_types import MouseEvent + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_DOWN)) + except Exception as exc: + print(f"[MouseHook] thumb_button down dispatch error: {exc}") + + def _on_hid_thumb_button_up(self): + self._emit_debug("HID thumb_button button up") + try: + from core.mouse_hook_types import MouseEvent + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_UP)) + except Exception as exc: + print(f"[MouseHook] thumb_button up dispatch error: {exc}") + + def configure_wheel_multipliers(self, vertical: int, horizontal: int) -> None: + """No-op kept for divert+inject-era callers; native invert never + injects scroll so multipliers are unused.""" + del vertical, horizontal + def _stop_hid_listener(self): if self._hid_gesture: self._hid_gesture.stop() diff --git a/core/mouse_hook_contract.py b/core/mouse_hook_contract.py index c5e6397..b732ed5 100644 --- a/core/mouse_hook_contract.py +++ b/core/mouse_hook_contract.py @@ -14,6 +14,10 @@ class MouseHookLike(Protocol): invert_hscroll: bool divert_mode_shift: bool divert_dpi_switch: bool + # When True, the connected Logitech device is performing scroll + # inversion at the firmware level (HID++ 0x2121 / 0x2150). The OS-layer + # inversion path must be skipped to avoid double flipping. + wheel_native_invert_active: bool _hid_gesture: Any def register(self, event_type: str, callback: Callable[[Any], None]) -> None: ... diff --git a/core/mouse_hook_linux.py b/core/mouse_hook_linux.py index ef7e9c9..4832e2b 100644 --- a/core/mouse_hook_linux.py +++ b/core/mouse_hook_linux.py @@ -19,7 +19,7 @@ _EVDEV_OK = True except ImportError: _EVDEV_OK = False - print("[MouseHook] python-evdev not installed — pip install evdev") + print("[MouseHook] python-evdev not installed -- pip install evdev") from core.logi_devices import ( build_evdev_connected_device_info, @@ -239,7 +239,7 @@ def _enable_evdev_remapping(self): ) except PermissionError: print( - "[MouseHook] Permission denied — add user to 'input' group " + "[MouseHook] Permission denied -- add user to 'input' group " "and ensure /dev/uinput is writable" ) self._set_evdev_remap_ready(False, _REMAP_REASON_UINPUT_FAILED) @@ -421,44 +421,65 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source): if dispatch_event: self._dispatch(dispatch_event) - def _on_hid_gesture_down(self): - if self._ui_passthrough: - return + def _begin_gesture_capture(self, source_label): + """Activate gesture tracking from any source (HID++ gesture button + or BTN_TASK haptic-panel fallback).""" with self._gesture_lock: - if not self._gesture_active: - self._gesture_active = True + if self._gesture_active: + return + self._gesture_active = True + self._gesture_triggered = False + self._emit_debug(f"{source_label} button down") + self._emit_gesture_event({"type": "button_down"}) + if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + self._start_gesture_tracking() + else: + self._gesture_tracking = False self._gesture_triggered = False - self._emit_debug("HID gesture button down") - self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): - self._start_gesture_tracking() - else: - self._gesture_tracking = False - self._gesture_triggered = False - def _on_hid_gesture_up(self): - if self._ui_passthrough: - return + def _end_gesture_capture(self, source_label): dispatch_click = False with self._gesture_lock: - if self._gesture_active: - should_click = not self._gesture_triggered - self._gesture_active = False - self._finish_gesture_tracking() - self._gesture_triggered = False - self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" - ) - self._emit_gesture_event( - { - "type": "button_up", - "click_candidate": should_click, - } - ) - dispatch_click = should_click + if not self._gesture_active: + return + should_click = not self._gesture_triggered + self._gesture_active = False + self._finish_gesture_tracking() + self._gesture_triggered = False + self._emit_debug( + f"{source_label} button up click_candidate={str(should_click).lower()}" + ) + self._emit_gesture_event( + { + "type": "button_up", + "click_candidate": should_click, + } + ) + dispatch_click = should_click if dispatch_click: self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + def _on_hid_gesture_down(self): + if self._ui_passthrough: + return + # MX4 routing: when the Sense Panel is the gesture source for this + # device, the small HID++ "gesture" button (CID 0x00c3) is the + # Thumb button, not the gesture trigger. + if self._gesture_via_sense_panel: + self._emit_debug("HID thumb button down") + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_DOWN)) + return + self._begin_gesture_capture("HID gesture") + + def _on_hid_gesture_up(self): + if self._ui_passthrough: + return + if self._gesture_via_sense_panel: + self._emit_debug("HID thumb button up") + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_UP)) + return + self._end_gesture_capture("HID gesture") + def _on_hid_mode_shift_down(self): if self._ui_passthrough: return @@ -486,6 +507,9 @@ def _on_hid_dpi_switch_up(self): def _on_hid_gesture_move(self, delta_x, delta_y): if self._ui_passthrough: return + # MX4 fallback: drop rawXY from the small HID++ button. + if self._gesture_via_sense_panel: + return self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") self._emit_gesture_event( { @@ -824,6 +848,30 @@ def _handle_button(self, event): mouse_event = MouseEvent(MouseEvent.MIDDLE_UP) should_block = MouseEvent.MIDDLE_UP in self._blocked_events + # MX Master 4 Sense Panel ("Action Ring" in Logi Options+) arrives + # as BTN_TASK in evdev (button 6 on X11). + elif event.code == getattr(_ecodes, "BTN_TASK", 0x117): + if self._gesture_via_sense_panel: + # Fallback path: 0x01a0 divert was rejected, so BTN_TASK + # drives the gesture trigger. REL events while held feed + # the swipe detector via `_handle_rel`. + if event.value == 1: + self._begin_gesture_capture("Sense panel gesture") + elif event.value == 0: + self._end_gesture_capture("Sense panel gesture") + return + if self._thumb_button_via_hid: + # The small Thumb button (CID 0x00c3) is being diverted + # over HID++ on this device, so any BTN_TASK leaking + # through is the Sense Panel; suppress it. + return + if event.value == 1: + mouse_event = MouseEvent(MouseEvent.THUMB_BUTTON_DOWN) + should_block = MouseEvent.THUMB_BUTTON_DOWN in self._blocked_events + elif event.value == 0: + mouse_event = MouseEvent(MouseEvent.THUMB_BUTTON_UP) + should_block = MouseEvent.THUMB_BUTTON_UP in self._blocked_events + if mouse_event: self._dispatch(mouse_event) @@ -849,7 +897,11 @@ def _handle_rel(self, event): rel_wheel_hi_res = getattr(_ecodes, "REL_WHEEL_HI_RES", 0x0B) if code == _ecodes.REL_WHEEL or code == rel_wheel_hi_res: - if self.invert_vscroll: + # The OS-layer sign flip only fires when a Logitech device is + # currently connected (otherwise we would invert every uinput + # scroll from a generic mouse or trackball routed through us) + # and the firmware is not already inverting at the source. + if self._apply_vscroll_invert_fallback(): self._uinput.write(_ecodes.EV_REL, code, -value) else: self._uinput.write_event(event) @@ -871,7 +923,7 @@ def _handle_rel(self, event): if should_block: return - if self.invert_hscroll: + if self._apply_hscroll_invert_fallback(): self._uinput.write(_ecodes.EV_REL, code, -value) else: self._uinput.write_event(event) @@ -910,7 +962,7 @@ def start(self): ) self._evdev_thread.start() else: - print("[MouseHook] evdev not available — button remapping disabled") + print("[MouseHook] evdev not available -- button remapping disabled") return True diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index 2e971b7..3010d21 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -26,7 +26,7 @@ except ImportError: _QUARTZ_OK = False print( - "[MouseHook] pyobjc-framework-Quartz not installed — " + "[MouseHook] pyobjc-framework-Quartz not installed -- " "pip install pyobjc-framework-Quartz" ) @@ -42,8 +42,22 @@ def wrapper(*args, **kwargs): _BTN_MIDDLE = 2 _BTN_BACK = 3 _BTN_FORWARD = 4 -_SCROLL_INVERT_MARKER = 0x4D4F5553 +# MX Master 4 Sense Panel ("Action Ring" in Logi Options+) arrives as +# btn=6 at the OS level (kCGEventOtherMouseDown). With HID++ divert +# disabled, the gesture path falls back to the event-tap source, so +# btn=6 drives _begin_gesture_capture / _end_gesture_capture below. +_BTN_OS_EXTRA = 6 _INJECTED_EVENT_MARKER = 0x4D4F5554 +# CGEvent integer-value-field id for kCGScrollWheelEventIsContinuous. Some +# Quartz versions surface the symbolic constant (``Quartz.kCGScrollWheelEventIsContinuous``), +# others do not -- we cache the integer here so the event-tap path does not +# carry a naked magic number, and we still fall back to the symbol when the +# binding is available so future SDK renumbering picks up automatically. +_CG_SCROLL_FIELD_IS_CONTINUOUS = getattr( + Quartz if _QUARTZ_OK else object(), + "kCGScrollWheelEventIsContinuous", + 88, +) _kCGEventTapDisabledByTimeout = 0xFFFFFFFE _kCGEventTapDisabledByUserInput = 0xFFFFFFFF @@ -66,8 +80,19 @@ def __init__(self): self._init_dispatch_queue(maxsize=512) self._dispatch_thread = None self._first_event_logged = False - - def _negate_scroll_axis(self, cg_event, axis): + # Serializes `_gesture_active` transitions across the CGEventTap + # main-thread callback (btn=6 fallback) and the HID++ listener + # background thread, so a leaked btn=6 racing a HID press cannot + # leave the flag inconsistent. + self._gesture_lock = threading.Lock() + + def _negate_scroll_axis(self, cg_event, axis: int) -> None: + """In-place flip of Delta/FixedPtDelta/PointDelta on ``axis`` + (1 = vertical, 2 = horizontal). Modifying the original event + preserves unit type, phase, and source identity for downstream + consumers (VMs, remote desktops, games).""" + if axis not in (1, 2): + raise ValueError(f"axis must be 1 (vertical) or 2 (horizontal), got {axis!r}") for field_name in ( f"kCGScrollWheelEventDeltaAxis{axis}", f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", @@ -80,58 +105,6 @@ def _negate_scroll_axis(self, cg_event, axis): if value: Quartz.CGEventSetIntegerValueField(cg_event, field, -value) - def _post_inverted_scroll_event(self, cg_event): - v_point = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis1 - ) - h_point = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis2 - ) - if self.invert_vscroll: - v_point = -v_point - if self.invert_hscroll: - h_point = -h_point - - inverted = Quartz.CGEventCreateScrollWheelEvent( - None, - Quartz.kCGScrollEventUnitPixel, - 2, - v_point, - h_point, - ) - if not inverted: - return False - Quartz.CGEventSetFlags(inverted, Quartz.CGEventGetFlags(cg_event)) - Quartz.CGEventSetIntegerValueField( - inverted, Quartz.kCGEventSourceUserData, _SCROLL_INVERT_MARKER - ) - for axis in (1, 2): - sign = -1 if ( - (axis == 1 and self.invert_vscroll) - or (axis == 2 and self.invert_hscroll) - ) else 1 - for field_name in ( - f"kCGScrollWheelEventDeltaAxis{axis}", - f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", - f"kCGScrollWheelEventPointDeltaAxis{axis}", - ): - field = getattr(Quartz, field_name, None) - if field is None: - continue - value = Quartz.CGEventGetIntegerValueField(cg_event, field) - Quartz.CGEventSetIntegerValueField(inverted, field, sign * value) - for field_name in ( - "kCGScrollWheelEventScrollPhase", - "kCGScrollWheelEventMomentumPhase", - ): - field = getattr(Quartz, field_name, None) - if field is None: - continue - value = Quartz.CGEventGetIntegerValueField(cg_event, field) - Quartz.CGEventSetIntegerValueField(inverted, field, value) - Quartz.CGEventPost(Quartz.kCGHIDEventTap, inverted) - return True - def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not (self._gesture_direction_enabled and self._gesture_active): return @@ -256,6 +229,14 @@ def _dispatch_worker(self): @_autoreleased def _event_tap_callback(self, proxy, event_type, cg_event, refcon): + # The CGEventTap continues to fire briefly after ``stop()`` sets + # ``_running = False`` -- macOS does not synchronously drain + # in-flight callbacks before disabling the tap. Drop the event + # untouched so we never enqueue into a torn-down dispatch worker, + # mutate shared state, or apply scroll inversion after the device + # connection has already been released. + if not self._running: + return cg_event try: if event_type in ( _kCGEventTapDisabledByTimeout, @@ -281,8 +262,13 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): == _INJECTED_EVENT_MARKER ): return cg_event - except Exception: - pass + except Exception as exc: # noqa: BLE001 - Quartz boundary + # Surface failures so a borked Quartz binding cannot make + # the injected-event marker silently misfire on every + # event for the rest of the session. + self._emit_debug( + f"CGEventGetIntegerValueField(kCGEventSourceUserData) failed: {exc!r}" + ) mouse_event = None should_block = False @@ -344,6 +330,20 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): elif btn == _BTN_FORWARD: mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events + elif btn == _BTN_OS_EXTRA: + if self._gesture_via_sense_panel: + # Fallback path: 0x01a0 divert was rejected, so + # btn=6 (Sense Panel) drives swipe detection via + # the event_tap source. Swallow the click. + self._begin_gesture_capture("Sense panel gesture") + return None + if self._thumb_button_via_hid: + # The small Thumb button (CID 0x00c3) is being + # diverted over HID++ on this device, so any btn=6 + # leaking through is the Sense Panel; suppress it. + return None + mouse_event = MouseEvent(MouseEvent.THUMB_BUTTON_DOWN) + should_block = MouseEvent.THUMB_BUTTON_DOWN in self._blocked_events elif event_type == Quartz.kCGEventOtherMouseUp: btn = Quartz.CGEventGetIntegerValueField( @@ -363,19 +363,31 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): elif btn == _BTN_FORWARD: mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) should_block = MouseEvent.XBUTTON2_UP in self._blocked_events + elif btn == _BTN_OS_EXTRA: + if self._gesture_via_sense_panel: + self._end_gesture_capture("Sense panel gesture") + return None + if self._thumb_button_via_hid: + return None + mouse_event = MouseEvent(MouseEvent.THUMB_BUTTON_UP) + should_block = MouseEvent.THUMB_BUTTON_UP in self._blocked_events elif event_type == Quartz.kCGEventScrollWheel: + # Allow Mouser's own injected scroll events through untouched. if ( Quartz.CGEventGetIntegerValueField( cg_event, Quartz.kCGEventSourceUserData ) - == _SCROLL_INVERT_MARKER + == _INJECTED_EVENT_MARKER ): return cg_event - if self.ignore_trackpad: - is_continuous_field = 88 - if Quartz.CGEventGetIntegerValueField(cg_event, is_continuous_field): - return cg_event + is_continuous = bool( + Quartz.CGEventGetIntegerValueField( + cg_event, _CG_SCROLL_FIELD_IS_CONTINUOUS + ) + ) + if self.ignore_trackpad and is_continuous: + return cg_event h_delta = Quartz.CGEventGetIntegerValueField( cg_event, Quartz.kCGScrollWheelEventFixedPtDeltaAxis2 ) @@ -404,9 +416,16 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): mouse_event = None if should_block: return None - if self.invert_vscroll or self.invert_hscroll: - if self._post_inverted_scroll_event(cg_event): - return None + # In-place sign flip on the original event so downstream + # consumers see unit type / phase preserved. Gated on a + # Logitech device being connected: the toggle is meant for + # Logitech scroll, not for inverting every trackpad and + # generic USB mouse the OS hands us. Also skipped when the + # firmware already inverted at the source. + if self._apply_vscroll_invert_fallback(): + self._negate_scroll_axis(cg_event, 1) + if self._apply_hscroll_invert_fallback(): + self._negate_scroll_axis(cg_event, 2) if mouse_event: self._enqueue_dispatch_event(mouse_event) @@ -419,11 +438,16 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): print(f"[MouseHook] event tap callback error: {exc}") return cg_event - def _on_hid_gesture_down(self): - if not self._gesture_active: + def _begin_gesture_capture(self, source_label: str) -> None: + """Activate gesture tracking from any source (HID++ gesture + button or btn=6 haptic-panel fallback). Lock-guarded against + cross-thread mutation of ``_gesture_active``.""" + with self._gesture_lock: + if self._gesture_active: + return self._gesture_active = True self._gesture_triggered = False - self._emit_debug("HID gesture button down") + self._emit_debug(f"{source_label} button down") self._emit_gesture_event({"type": "button_down"}) if self._gesture_direction_enabled and not self._gesture_cooldown_active(): self._start_gesture_tracking() @@ -431,14 +455,19 @@ def _on_hid_gesture_down(self): self._gesture_tracking = False self._gesture_triggered = False - def _on_hid_gesture_up(self): - if self._gesture_active: + def _end_gesture_capture(self, source_label: str) -> None: + """Resolve a capture: dispatch GESTURE_CLICK when no swipe fired, + otherwise no-op. Click dispatch runs outside the lock.""" + should_click = False + with self._gesture_lock: + if not self._gesture_active: + return should_click = not self._gesture_triggered self._gesture_active = False self._finish_gesture_tracking() self._gesture_triggered = False self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" + f"{source_label} button up click_candidate={str(should_click).lower()}" ) self._emit_gesture_event( { @@ -446,8 +475,25 @@ def _on_hid_gesture_up(self): "click_candidate": should_click, } ) - if should_click: - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + if should_click: + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + + def _on_hid_gesture_down(self): + # MX4 routing: when the Sense Panel is the gesture source for this + # device, the small HID++ "gesture" button (CID 0x00c3) is the + # Thumb button, not the gesture trigger. + if self._gesture_via_sense_panel: + self._emit_debug("HID thumb button down") + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_DOWN)) + return + self._begin_gesture_capture("HID gesture") + + def _on_hid_gesture_up(self): + if self._gesture_via_sense_panel: + self._emit_debug("HID thumb button up") + self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_UP)) + return + self._end_gesture_capture("HID gesture") def _on_hid_mode_shift_down(self): self._emit_debug("HID mode shift button down") @@ -466,6 +512,10 @@ def _on_hid_dpi_switch_up(self): self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) def _on_hid_gesture_move(self, delta_x, delta_y): + # MX4 fallback: drop rawXY from the small HID++ button so it + # cannot pollute an in-flight haptic-panel gesture. + if self._gesture_via_sense_panel: + return self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") self._emit_gesture_event( { @@ -491,7 +541,7 @@ def _re_enable_tap_and_reconnect(reason): ok = Quartz.CGEventTapIsEnabled(self._tap) print( f"[MouseHook] Event tap re-enabled ({reason}): " - f"{'OK' if ok else 'FAILED — may need restart'}", + f"{'OK' if ok else 'FAILED -- may need restart'}", flush=True, ) if hg: @@ -548,7 +598,7 @@ def _unregister_wake_observer(self): def start(self): if not _QUARTZ_OK: - print("[MouseHook] Quartz not available — hook not installed") + print("[MouseHook] Quartz not available -- hook not installed") return False if self._running: return True @@ -635,7 +685,7 @@ def stop(self): "_BTN_MIDDLE", "_BTN_BACK", "_BTN_FORWARD", - "_SCROLL_INVERT_MARKER", + "_BTN_OS_EXTRA", "_INJECTED_EVENT_MARKER", "_kCGEventTapDisabledByTimeout", "_kCGEventTapDisabledByUserInput", diff --git a/core/mouse_hook_types.py b/core/mouse_hook_types.py index da0de2d..ac5abc7 100644 --- a/core/mouse_hook_types.py +++ b/core/mouse_hook_types.py @@ -23,6 +23,10 @@ class MouseEvent: XBUTTON1_UP = "xbutton1_up" XBUTTON2_DOWN = "xbutton2_down" XBUTTON2_UP = "xbutton2_up" + # MX Master 4 Thumb button (small front-face button, HID++ CID 0x00c3). + # Distinct from the Sense Panel (CID 0x01a0), which is the gesture button. + THUMB_BUTTON_DOWN = "thumb_button_down" + THUMB_BUTTON_UP = "thumb_button_up" MIDDLE_DOWN = "middle_down" MIDDLE_UP = "middle_up" GESTURE_DOWN = "gesture_down" diff --git a/core/mouse_hook_windows.py b/core/mouse_hook_windows.py index efcee54..ab94d0f 100644 --- a/core/mouse_hook_windows.py +++ b/core/mouse_hook_windows.py @@ -241,6 +241,12 @@ def __init__(self): self._startup_ok = False self._prev_raw_buttons = {} self._last_rehook_time = 0 + # Serializes gesture begin/end transitions across the LL hook + # thread (XBUTTON), the Raw Input window proc thread, and the HID + # listener thread. Held only around the `_gesture_active` / + # `_gesture_triggered` flips -- dispatch happens outside so an + # engine callback that re-enters the hook never deadlocks. + self._gesture_lock = threading.Lock() self._init_dispatch_queue(maxsize=512) self._dispatch_worker_thread = None @@ -427,7 +433,11 @@ def _low_level_handler_inner(self, nCode, wParam, lParam): should_block = MouseEvent.MIDDLE_UP in self._blocked_events elif wParam == WM_MOUSEWHEEL: - if self.invert_vscroll: + # The OS-layer inversion path only runs when a Logitech is + # currently connected (the toggle is meant for Logitech + # scroll, not generic / trackball / virtual mouse events) and + # the firmware is not already inverting at the source. + if self._apply_vscroll_invert_fallback(): delta = hiword(mouse_data) if delta != 0 and self._ri_hwnd: self._pending_vscroll += -delta @@ -451,7 +461,7 @@ def _low_level_handler_inner(self, nCode, wParam, lParam): event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(delta)) should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events - if self.invert_hscroll: + if self._apply_hscroll_invert_fallback(): if delta != 0 and self._ri_hwnd and not should_block: self._pending_hscroll += -delta if self._hscroll_posted: @@ -558,15 +568,20 @@ def _check_raw_mouse_gesture(self, hDevice, buffer): if extra_now == extra_prev: return if extra_now and not extra_prev: - if not self._gesture_active: + with self._gesture_lock: + if self._gesture_active: + return self._gesture_active = True self._gesture_triggered = False - print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})") - elif not extra_now and extra_prev: - if self._gesture_active: + print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})") + return + if not extra_now and extra_prev: + with self._gesture_lock: + if not self._gesture_active: + return self._gesture_active = False - print("[MouseHook] Gesture UP") - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + print("[MouseHook] Gesture UP") + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) def _setup_raw_input(self): instance = GetModuleHandleW(None) @@ -595,7 +610,7 @@ def _setup_raw_input(self): None, ) if not self._ri_hwnd: - print("[MouseHook] CreateWindowExW failed — gesture detection unavailable") + print("[MouseHook] CreateWindowExW failed -- gesture detection unavailable") return False ShowWindow(self._ri_hwnd, SW_HIDE) @@ -683,7 +698,7 @@ def _on_device_change(self): if now - self._last_rehook_time < 2.0: return self._last_rehook_time = now - print("[MouseHook] Device change detected — refreshing hook") + print("[MouseHook] Device change detected -- refreshing hook") self._device_name_cache.clear() self._prev_raw_buttons.clear() self._reinstall_hook() @@ -705,34 +720,42 @@ def _reinstall_hook(self): print("[MouseHook] Failed to reinstall hook!") def _on_hid_gesture_down(self): - if not self._gesture_active: + with self._gesture_lock: + if self._gesture_active: + return self._gesture_active = True self._gesture_triggered = False - self._emit_debug("HID gesture button down") - self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + tracking = ( + self._gesture_direction_enabled + and not self._gesture_cooldown_active() + ) + if tracking: self._start_gesture_tracking() else: self._gesture_tracking = False - self._gesture_triggered = False + self._emit_debug("HID gesture button down") + self._emit_gesture_event({"type": "button_down"}) def _on_hid_gesture_up(self): - if self._gesture_active: + should_click = False + with self._gesture_lock: + if not self._gesture_active: + return should_click = not self._gesture_triggered self._gesture_active = False self._finish_gesture_tracking() self._gesture_triggered = False - self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" - ) - self._emit_gesture_event( - { - "type": "button_up", - "click_candidate": should_click, - } - ) - if should_click: - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + self._emit_debug( + f"HID gesture button up click_candidate={str(should_click).lower()}" + ) + self._emit_gesture_event( + { + "type": "button_up", + "click_candidate": should_click, + } + ) + if should_click: + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) def _on_hid_mode_shift_down(self): self._emit_debug("HID mode shift button down") diff --git a/tests/test_config.py b/tests/test_config.py index 54cb498..aea9d57 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,7 +43,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): migrated = config._migrate(legacy) - self.assertEqual(migrated["version"], 9) + self.assertEqual(migrated["version"], 11) self.assertEqual(migrated["profiles"]["default"]["apps"], []) self.assertFalse(migrated["settings"]["invert_hscroll"]) self.assertFalse(migrated["settings"]["invert_vscroll"]) @@ -59,6 +59,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): self.assertTrue(migrated["settings"]["check_for_updates"]) self.assertEqual(migrated["settings"]["update_check_state"], {}) self.assertFalse(migrated["settings"]["start_at_login"]) + self.assertEqual(migrated["settings"]["wheel_divert"], "auto") self.assertNotIn("start_with_windows", migrated["settings"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["gesture"], "none" @@ -88,7 +89,7 @@ def test_migrate_updates_media_player_profile_apps(self): migrated = config._migrate(cfg) - self.assertEqual(migrated["version"], 9) + self.assertEqual(migrated["version"], 11) self.assertEqual( migrated["profiles"]["media"]["apps"], ["Microsoft.Media.Player.exe", "VLC.exe"], @@ -100,6 +101,7 @@ def test_migrate_updates_media_player_profile_apps(self): self.assertTrue(migrated["settings"]["check_for_updates"]) self.assertEqual(migrated["settings"]["update_check_state"], {}) self.assertFalse(migrated["settings"]["start_at_login"]) + self.assertEqual(migrated["settings"]["wheel_divert"], "auto") self.assertNotIn("start_with_windows", migrated["settings"]) def test_load_config_merges_missing_defaults_from_disk(self): @@ -130,7 +132,8 @@ def test_load_config_merges_missing_defaults_from_disk(self): ): loaded = config.load_config() - self.assertEqual(loaded["version"], 9) + self.assertEqual(loaded["version"], 11) + self.assertEqual(loaded["settings"]["wheel_divert"], "auto") self.assertEqual(loaded["settings"]["dpi"], 800) self.assertFalse(loaded["settings"]["start_at_login"]) self.assertEqual(loaded["settings"]["gesture_threshold"], 50) @@ -147,6 +150,9 @@ def test_load_config_merges_missing_defaults_from_disk(self): self.assertEqual( loaded["profiles"]["default"]["mappings"]["gesture_left"], "none" ) + self.assertEqual( + loaded["profiles"]["default"]["mappings"]["thumb_button"], "none" + ) def test_migrate_renames_start_with_windows_to_start_at_login(self): legacy = { @@ -157,12 +163,17 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self): migrated = config._migrate(legacy) - self.assertEqual(migrated["version"], 9) + self.assertEqual(migrated["version"], 11) self.assertTrue(migrated["settings"]["start_at_login"]) + self.assertEqual(migrated["settings"]["wheel_divert"], "auto") self.assertEqual( migrated["profiles"]["default"]["mappings"]["mode_shift"], "switch_scroll_mode", ) + self.assertEqual( + migrated["profiles"]["default"]["mappings"]["thumb_button"], + "none", + ) def test_get_profile_for_app_matches_aliases(self): cfg = { @@ -239,6 +250,95 @@ def test_get_profile_for_app_matches_linux_legacy_launcher_path(self): ) +class WheelDivertCoercionTests(unittest.TestCase): + """``coerce_wheel_divert_setting`` is the only validator standing between + the user's editable JSON and the engine's branchy live paths. Typos must + resolve to a known-safe value rather than silently flipping into a third + behavior the engine does not test.""" + + def setUp(self) -> None: + # The coercer warns once per distinct typo for the lifetime of the + # process. Reset between tests so warning-count assertions stay + # deterministic regardless of test ordering. + config._WHEEL_DIVERT_WARNED_VALUES.clear() + + def test_accepts_canonical_auto(self) -> None: + self.assertEqual(config.coerce_wheel_divert_setting("auto"), "auto") + + def test_accepts_canonical_off(self) -> None: + self.assertEqual(config.coerce_wheel_divert_setting("off"), "off") + + def test_normalizes_case_and_whitespace(self) -> None: + self.assertEqual(config.coerce_wheel_divert_setting(" AUTO "), "auto") + self.assertEqual(config.coerce_wheel_divert_setting("OFF"), "off") + + def test_unknown_string_falls_back_to_auto(self) -> None: + with patch("builtins.print") as plog: + self.assertEqual( + config.coerce_wheel_divert_setting("enabled"), + config.WHEEL_DIVERT_DEFAULT, + ) + plog.assert_called_once() + + def test_unknown_string_warns_only_once_per_value(self) -> None: + with patch("builtins.print") as plog: + config.coerce_wheel_divert_setting("typo") + config.coerce_wheel_divert_setting("typo") + config.coerce_wheel_divert_setting("typo") + self.assertEqual(plog.call_count, 1) + + def test_distinct_typos_each_warn_once(self) -> None: + with patch("builtins.print") as plog: + config.coerce_wheel_divert_setting("a") + config.coerce_wheel_divert_setting("b") + config.coerce_wheel_divert_setting("a") + self.assertEqual(plog.call_count, 2) + + def test_none_resolves_silently_to_auto(self) -> None: + with patch("builtins.print") as plog: + self.assertEqual( + config.coerce_wheel_divert_setting(None), + config.WHEEL_DIVERT_DEFAULT, + ) + plog.assert_not_called() + + def test_non_string_warns_once_per_type(self) -> None: + with patch("builtins.print") as plog: + config.coerce_wheel_divert_setting(42) + config.coerce_wheel_divert_setting(43) + self.assertEqual(plog.call_count, 1) + + def test_empty_string_resolves_to_auto(self) -> None: + with patch("builtins.print"): + self.assertEqual( + config.coerce_wheel_divert_setting(""), + config.WHEEL_DIVERT_DEFAULT, + ) + + def test_load_config_normalizes_case_on_disk(self) -> None: + """End-to-end: a canonical value persisted as uppercase / mixed case + must round-trip into the lowercase sealed value after ``load_config``. + """ + with tempfile.TemporaryDirectory() as tmp: + cfg_path = Path(tmp) / "config.json" + cfg_path.write_text( + json.dumps({"version": 11, "settings": {"wheel_divert": "OFF"}}) + ) + with patch("core.config.CONFIG_FILE", str(cfg_path)): + loaded = config.load_config() + self.assertEqual(loaded["settings"]["wheel_divert"], "off") + + def test_load_config_canonicalizes_unknown_typo_to_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg_path = Path(tmp) / "config.json" + cfg_path.write_text( + json.dumps({"version": 11, "settings": {"wheel_divert": "yes"}}) + ) + with patch("core.config.CONFIG_FILE", str(cfg_path)), patch("builtins.print"): + loaded = config.load_config() + self.assertEqual(loaded["settings"]["wheel_divert"], config.WHEEL_DIVERT_DEFAULT) + + class AppCatalogTests(unittest.TestCase): def test_resolve_app_spec_uses_catalog_alias(self): fake_catalog = [ diff --git a/tests/test_engine.py b/tests/test_engine.py index dfbe00c..dcadef2 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -18,6 +18,10 @@ def __init__(self): self._hid_gesture = None self.start_called = False self.stop_called = False + self.wheel_native_invert_active = False + # Back-compat alias mirrored on the real BaseMouseHook for callers + # from the divert+inject build of the test fixtures. + self.wheel_divert_active = False def set_debug_callback(self, cb): self._debug_callback = cb @@ -34,6 +38,11 @@ def set_connection_change_callback(self, cb): def configure_gestures(self, **kwargs): self._gesture_config = kwargs + def configure_wheel_multipliers(self, vertical, horizontal): + # Retained for shape compatibility; real BaseMouseHook accepts but + # no-ops the call in native-invert mode. + return None + def block(self, event_type): pass diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index 91c08a5..bba5714 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -202,7 +202,7 @@ def test_try_connect_accepts_known_device_without_usage_metadata(self): } fake_dev = _FakeHidDevice() - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x10 return None @@ -364,7 +364,7 @@ def test_try_connect_success_path_keeps_existing_reprog_discovery_diagnostics(se listener, info = self._make_listener() fake_dev = _FakeHidDevice() - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x10 return None @@ -417,7 +417,7 @@ def test_try_connect_rearms_extra_diverts_on_reconnect(self): } fake_devs = [_FakeHidDevice(), _FakeHidDevice()] - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x10 return None @@ -512,7 +512,7 @@ def test_divert_failure_continues_to_next_receiver_slot(self): fake_dev = _FakeHidDevice() divert_call_count = [0] - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x09 return None @@ -578,7 +578,7 @@ def test_transport_label_bluetooth_for_direct_connection(self): } fake_dev = _FakeHidDevice() - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x09 return None @@ -623,7 +623,7 @@ def test_try_connect_applies_runtime_supported_buttons(self): ] fake_dev = _FakeHidDevice() - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x09 return None @@ -651,6 +651,384 @@ def fake_find_feature(feature_id): self.assertNotIn("gesture_up", listener.connected_device.supported_buttons) self.assertNotIn("mode_shift", listener.connected_device.supported_buttons) + def test_try_connect_marks_thumb_button_cid_button_only_for_mx_master_4(self): + """MX Master 4 must mark its thumb_button CID (the small HID++ + button at 0x00C3) as button-only so the rawXY-enabled divert is + skipped if it ever ends up as the active gesture CID. Without + that the firmware suppresses OS mouse motion while the user + holds the button, freezing the cursor for nothing -- its rawXY + data is irrelevant when gestures are routed through the haptic + panel.""" + listener = hid_gesture.HidGestureListener() + info = { + "product_id": 0xB042, # MX Master 4 + "usage_page": 0xFF00, + "usage": 0x0001, + "source": "hidapi-enumerate", + "product_string": "MX Master 4", + "path": b"/dev/hidraw-test", + } + fake_dev = _FakeHidDevice() + + def fake_find_feature(feature_id, *, timeout_ms=None): + if feature_id == hid_gesture.FEAT_REPROG_V4: + return 0x09 + return None + + with ( + patch.object(listener, "_vendor_hid_infos", return_value=[info]), + patch.object(listener, "_find_feature", side_effect=fake_find_feature), + patch.object(listener, "_discover_reprog_controls", return_value=[]), + patch.object(listener, "_divert", return_value=True), + patch.object(listener, "_divert_extras"), + patch.object(listener, "_install_thumb_button_extra"), + patch.object(listener, "_query_device_name", return_value=None), + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object(hid_gesture, "_HID_API_STYLE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace(device=lambda: fake_dev), + create=True, + ), + patch("builtins.print"), + ): + self.assertTrue(listener._try_connect()) + + self.assertIn( + 0x00C3, listener._button_only_cids, + "MX Master 4's small HID++ button (thumb_button_cid) must be " + "added to _button_only_cids so the rawXY divert is skipped -- " + "rawXY would freeze the cursor while held.", + ) + + def test_try_connect_leaves_button_only_cids_empty_for_mx_master_3s(self): + """Older MX Masters have no thumb_button_cid, so no CID needs to be + forced into button-only mode. The default rawXY-enabled divert on + the gesture CID is what drives directional swipes on those mice.""" + listener = hid_gesture.HidGestureListener() + info = { + "product_id": 0xB034, # MX Master 3S + "usage_page": 0xFF00, + "usage": 0x0001, + "source": "hidapi-enumerate", + "product_string": "MX Master 3S", + "path": b"/dev/hidraw-test", + } + fake_dev = _FakeHidDevice() + + def fake_find_feature(feature_id, *, timeout_ms=None): + if feature_id == hid_gesture.FEAT_REPROG_V4: + return 0x09 + return None + + with ( + patch.object(listener, "_vendor_hid_infos", return_value=[info]), + patch.object(listener, "_find_feature", side_effect=fake_find_feature), + patch.object(listener, "_discover_reprog_controls", return_value=[]), + patch.object(listener, "_divert", return_value=True), + patch.object(listener, "_divert_extras"), + patch.object(listener, "_install_thumb_button_extra"), + patch.object(listener, "_query_device_name", return_value=None), + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object(hid_gesture, "_HID_API_STYLE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace(device=lambda: fake_dev), + create=True, + ), + patch("builtins.print"), + ): + self.assertTrue(listener._try_connect()) + + self.assertEqual(listener._button_only_cids, set()) + + def test_divert_skips_rawxy_attempt_for_cids_in_button_only_set(self): + """`_divert` must request 0x03 (button-only) directly for any CID + flagged in `_button_only_cids`, rather than first trying 0x33 + (rawXY-enabled) and falling back. This is the per-CID equivalent + of the old global button-only flag.""" + listener = hid_gesture.HidGestureListener() + listener._feat_idx = 0x09 + listener._gesture_candidates = [0x00C3] + listener._button_only_cids = {0x00C3} + + recorded = [] + + def fake_set_cid_reporting(cid, flags): + recorded.append((cid, flags)) + return True + + with ( + patch.object(listener, "_set_cid_reporting", side_effect=fake_set_cid_reporting), + patch("builtins.print"), + ): + self.assertTrue(listener._divert()) + + self.assertEqual(recorded, [(0x00C3, 0x03)]) + self.assertFalse(listener._rawxy_enabled) + + def test_divert_tries_rawxy_first_for_cids_not_in_button_only_set(self): + """Default behavior -- including for the new haptic CID 0x01A0 on + MX Master 4 -- is to request rawXY-enabled divert (0x33) so the + firmware delivers swipe motion over the vendor channel and pins + the cursor on its own.""" + listener = hid_gesture.HidGestureListener() + listener._feat_idx = 0x09 + listener._gesture_candidates = [0x01A0] + listener._button_only_cids = {0x00C3} # only small button is button-only + + recorded = [] + + def fake_set_cid_reporting(cid, flags): + recorded.append((cid, flags)) + return True + + with ( + patch.object(listener, "_set_cid_reporting", side_effect=fake_set_cid_reporting), + patch("builtins.print"), + ): + self.assertTrue(listener._divert()) + + self.assertEqual(recorded, [(0x01A0, 0x33)]) + self.assertTrue(listener._rawxy_enabled) + + def test_install_thumb_button_extra_adds_cid_when_distinct_from_gesture(self): + """Happy path: 0x01A0 is the active gesture CID on MX Master 4, + so 0x00C3 (thumb_button_cid) is added as a button-only extra with + thumb_button callbacks wired in. ``thumb_button_via_hid`` stays + False until ``_divert_extras`` acknowledges the setCidReporting.""" + on_down = Mock() + on_up = Mock() + listener = hid_gesture.HidGestureListener( + on_thumb_button_down=on_down, + on_thumb_button_up=on_up, + ) + listener._gesture_cid = 0x01A0 + device_spec = SimpleNamespace(thumb_button_cid=0x00C3) + controls = [ + {"cid": 0x00C3, "flags": 0x0030, "mapping_flags": 0x0001}, + ] + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec, controls) + + self.assertEqual(listener._thumb_button_cid, 0x00C3) + self.assertIn(0x00C3, listener._extra_diverts) + # No ack yet -- divert_extras has not run. + self.assertFalse(listener.thumb_button_via_hid) + + listener._extra_divert_acks.add(0x00C3) + self.assertTrue(listener.thumb_button_via_hid) + + # Trigger the wired callbacks via the extras dispatch path. + listener._extra_diverts[0x00C3]["on_down"]() + on_down.assert_called_once() + listener._extra_diverts[0x00C3]["on_up"]() + on_up.assert_called_once() + + def test_install_thumb_button_extra_skipped_when_cid_absent_from_reprog(self): + """Catalog declares a thumb_button CID, but the firmware does not + advertise it on this connection. The helper must refuse to queue the + divert -- queueing would hammer setCidReporting with a CID the + firmware never exposed. + """ + listener = hid_gesture.HidGestureListener( + on_thumb_button_down=Mock(), + on_thumb_button_up=Mock(), + ) + listener._gesture_cid = 0x01A0 + device_spec = SimpleNamespace(thumb_button_cid=0x00C3) + controls = [ + {"cid": 0x0052, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x01A0, "flags": 0x0130, "mapping_flags": 0x0011}, + ] + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec, controls) + + self.assertIsNone(listener._thumb_button_cid) + self.assertNotIn(0x00C3, listener._extra_diverts) + self.assertFalse(listener.thumb_button_via_hid) + + def test_divert_extras_clears_acks_and_drops_failed_cids(self): + """When setCidReporting fails for a CID, the listener must drop it + from ``_extra_diverts`` so the OS-fallback path stays in charge of + that button. ``thumb_button_via_hid`` then resolves to False. + """ + listener = hid_gesture.HidGestureListener() + listener._feat_idx = 0x09 + listener._thumb_button_cid = 0x00C3 + listener._extra_diverts = { + 0x00C4: {"on_down": Mock(), "on_up": Mock(), "held": False}, + 0x00C3: {"on_down": Mock(), "on_up": Mock(), "held": False}, + } + responses = {0x00C4: True, 0x00C3: None} + + def fake_set_cid_reporting(cid, flags): + return responses.get(cid) + + with ( + patch.object(listener, "_set_cid_reporting", side_effect=fake_set_cid_reporting), + patch("builtins.print"), + ): + listener._divert_extras() + + self.assertEqual(listener._extra_divert_acks, {0x00C4}) + self.assertIn(0x00C4, listener._extra_diverts) + self.assertNotIn(0x00C3, listener._extra_diverts) + self.assertIsNone(listener._thumb_button_cid) + self.assertFalse(listener.thumb_button_via_hid) + + def test_install_thumb_button_extra_skipped_when_same_as_gesture_cid(self): + """Fallback path: 0x01A0 divert was rejected, so 0x00C3 became + the gesture CID. The thumb_button extra must NOT be added -- that + would re-divert the same CID and stomp on the gesture flags.""" + listener = hid_gesture.HidGestureListener( + on_thumb_button_down=Mock(), + on_thumb_button_up=Mock(), + ) + listener._gesture_cid = 0x00C3 + device_spec = SimpleNamespace(thumb_button_cid=0x00C3) + controls = [ + {"cid": 0x00C3, "flags": 0x0030, "mapping_flags": 0x0001}, + ] + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec, controls) + + self.assertIsNone(listener._thumb_button_cid) + self.assertNotIn(0x00C3, listener._extra_diverts) + self.assertFalse(listener.thumb_button_via_hid) + + def test_install_thumb_button_extra_no_op_when_cid_unset(self): + listener = hid_gesture.HidGestureListener() + listener._gesture_cid = 0x00C3 + device_spec = SimpleNamespace(thumb_button_cid=None) + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec, []) + + self.assertIsNone(listener._thumb_button_cid) + self.assertEqual(listener._extra_diverts, {}) + self.assertFalse(listener.thumb_button_via_hid) + + def test_mx_master_4_full_connect_wires_haptic_gesture_and_thumb_button_extra(self): + """End-to-end happy path: when MX Master 4 connects and the + haptic CID 0x01A0 is divertable with rawXY, the listener picks + it as the active gesture CID and ALSO installs 0x00C3 as the + thumb_button extra. The resulting ConnectedDeviceInfo carries + both flags so platform mouse hooks can drop their OS-level + fallback paths and let HID++ own both buttons end-to-end.""" + on_action_down = Mock() + on_action_up = Mock() + listener = hid_gesture.HidGestureListener( + on_thumb_button_down=on_action_down, + on_thumb_button_up=on_action_up, + ) + info = { + "product_id": 0xB042, # MX Master 4 + "usage_page": 0xFF00, + "usage": 0x0001, + "source": "hidapi-enumerate", + "product_string": "MX Master 4", + "path": b"/dev/hidraw-test", + } + # Simulate the device exposing both the haptic CID (0x01A0 with + # rawXY) and the small button (0x00C3, also rawXY-capable) plus + # back/forward. + controls = [ + {"cid": 0x0053, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x0056, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x00C3, "flags": 0x0130, "mapping_flags": 0x0011}, + {"cid": 0x01A0, "flags": 0x0130, "mapping_flags": 0x0011}, + ] + fake_dev = _FakeHidDevice() + cid_calls: list[tuple[int, int]] = [] + + def fake_set_cid_reporting(cid, flags): + cid_calls.append((cid, flags)) + return True # firmware accepts every divert + + def fake_find_feature(feature_id, *, timeout_ms=None): + if feature_id == hid_gesture.FEAT_REPROG_V4: + return 0x09 + return None + + with ( + patch.object(listener, "_vendor_hid_infos", return_value=[info]), + patch.object(listener, "_find_feature", side_effect=fake_find_feature), + patch.object(listener, "_discover_reprog_controls", return_value=controls), + patch.object(listener, "_set_cid_reporting", side_effect=fake_set_cid_reporting), + patch.object(listener, "_query_device_name", return_value="MX Master 4"), + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object(hid_gesture, "_HID_API_STYLE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace(device=lambda: fake_dev), + create=True, + ), + patch("builtins.print"), + ): + self.assertTrue(listener._try_connect()) + + # Active gesture CID should be the sense panel. + self.assertEqual(listener._gesture_cid, 0x01A0) + # And it should have been diverted with rawXY (0x33), not 0x03. + self.assertIn( + (0x01A0, 0x33), cid_calls, + "haptic CID must be diverted with rawXY so swipe data flows " + f"over HID++; setCidReporting calls were {cid_calls}", + ) + # Action ring extra installed for 0x00C3, button-only. + self.assertEqual(listener._thumb_button_cid, 0x00C3) + self.assertIn(0x00C3, listener._extra_diverts) + self.assertIn( + (0x00C3, 0x03), cid_calls, + "thumb_button CID must be diverted button-only (no rawXY) so " + "the firmware doesn't suppress cursor motion while the " + f"small button is held; setCidReporting calls were {cid_calls}", + ) + # ConnectedDeviceInfo reflects the wiring. + self.assertEqual( + listener.connected_device.active_gesture_cid, 0x01A0 + ) + self.assertTrue(listener.connected_device.thumb_button_via_hid) + + def test_install_thumb_button_extra_clears_stale_entry_on_reconnect(self): + """Reconnect to a different device whose spec has no thumb_button + CID -- the previously-installed entry must be removed so it doesn't + leak across devices.""" + listener = hid_gesture.HidGestureListener( + on_thumb_button_down=Mock(), + on_thumb_button_up=Mock(), + ) + listener._gesture_cid = 0x01A0 + first_controls = [ + {"cid": 0x00C3, "flags": 0x0030, "mapping_flags": 0x0001}, + ] + with patch("builtins.print"): + listener._install_thumb_button_extra( + SimpleNamespace(thumb_button_cid=0x00C3), + first_controls, + ) + self.assertIn(0x00C3, listener._extra_diverts) + + listener._gesture_cid = 0x00C3 # different device + with patch("builtins.print"): + listener._install_thumb_button_extra( + SimpleNamespace(thumb_button_cid=None), + [], + ) + + self.assertNotIn(0x00C3, listener._extra_diverts) + self.assertIsNone(listener._thumb_button_cid) + def test_try_connect_preserves_directional_gestures_after_rawxy_divert(self): listener = hid_gesture.HidGestureListener() info = { @@ -670,7 +1048,7 @@ def test_try_connect_preserves_directional_gestures_after_rawxy_divert(self): ] fake_dev = _FakeHidDevice() - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id == hid_gesture.FEAT_REPROG_V4: return 0x09 return None @@ -717,7 +1095,7 @@ def test_transport_label_logi_bolt_for_bolt_receiver(self): fake_dev = _FakeHidDevice() call_count = [0] - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id != hid_gesture.FEAT_REPROG_V4: return None call_count[0] += 1 @@ -759,7 +1137,7 @@ def test_transport_label_usb_receiver_for_non_bolt(self): fake_dev = _FakeHidDevice() call_count = [0] - def fake_find_feature(feature_id): + def fake_find_feature(feature_id, *, timeout_ms=None): if feature_id != hid_gesture.FEAT_REPROG_V4: return None call_count[0] += 1 diff --git a/tests/test_logi_devices.py b/tests/test_logi_devices.py index 2169681..e1ca62e 100644 --- a/tests/test_logi_devices.py +++ b/tests/test_logi_devices.py @@ -233,6 +233,31 @@ def test_clamp_dpi_defaults_without_device(self): self.assertEqual(clamp_dpi(100, None), 200) self.assertEqual(clamp_dpi(9000, None), 8000) + def test_clamp_dpi_accepts_string_int(self): + """Hand-edited config files can persist DPI as a JSON string when + users copy values around. Coerce instead of throwing.""" + self.assertEqual(clamp_dpi("1500", None), 1500) + + def test_clamp_dpi_accepts_hex_string(self): + self.assertEqual(clamp_dpi("0x5DC", None), 1500) + + def test_clamp_dpi_falls_back_to_min_on_garbage_string(self): + self.assertEqual(clamp_dpi("not-a-number", None), 200) + + def test_clamp_dpi_falls_back_to_min_on_none(self): + self.assertEqual(clamp_dpi(None, None), 200) + + def test_clamp_dpi_rejects_bool(self): + """``bool`` is a subclass of ``int`` -- without the explicit check + ``True``/``False`` would silently clamp to ``dpi_min``/``dpi_min`` + because ``int(True) == 1`` and ``min(200, 1) == 1`` becomes 200 via + the floor, which masks the upstream bug.""" + self.assertEqual(clamp_dpi(True, None), 200) + self.assertEqual(clamp_dpi(False, None), 200) + + def test_clamp_dpi_accepts_float(self): + self.assertEqual(clamp_dpi(1500.7, None), 1500) + def test_mx_anywhere_2s_supported_buttons_include_middle_and_hscroll(self): device = resolve_device(product_id=0xB01A) @@ -584,13 +609,128 @@ def test_mx_master_4_haptic_control_does_not_create_supported_button(self): self.assertIn("mode_shift", info.supported_buttons) self.assertIn("gesture_down", info.supported_buttons) - self.assertNotIn("action_ring", info.supported_buttons) + # `thumb_button` is the relocated Mouse Gesture Button (CID 0x00C3) + # exposed as a UI mapping target via MX_MASTER_4_BUTTONS. The Sense + # Panel (CID 0x01A0) drives gestures via rawXY and does not get a + # button entry of its own. + self.assertIn("thumb_button", info.supported_buttons) self.assertNotIn("haptic", info.supported_buttons) self.assertEqual( info.capability_inventory.to_dict()["known_unsupported_controls"], [{"cid": "0x01A0", "name": "haptic"}], ) + def test_mx_master_4_spec_metadata_mirrored_onto_connected_info(self): + info = build_connected_device_info(product_id=0xB042) + + self.assertEqual(info.key, "mx_master_4") + self.assertEqual(info.gesture_cids, (0x01A0, 0x00C3, 0x00D7)) + self.assertEqual(info.thumb_button_cid, 0x00C3) + self.assertTrue(info.gesture_via_sense_panel) + self.assertTrue(info.has_hires_wheel) + self.assertTrue(info.has_thumbwheel) + self.assertFalse(info.hires_wheel_active) + self.assertFalse(info.thumbwheel_active) + self.assertFalse(info.thumb_button_via_hid) + self.assertIsNone(info.active_gesture_cid) + + def test_active_gesture_cid_is_normalized_to_int(self): + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid=0x01A0, + ) + + self.assertEqual(info.active_gesture_cid, 0x01A0) + self.assertIsInstance(info.active_gesture_cid, int) + + def test_active_gesture_cid_accepts_hex_string(self): + """Runtime callers occasionally hand back CIDs as hex strings; the + normalizer must coerce or downstream mouse hooks compare wrong types. + """ + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid="0x01A0", + ) + + self.assertEqual(info.active_gesture_cid, 0x01A0) + self.assertIsInstance(info.active_gesture_cid, int) + + def test_active_gesture_cid_malformed_value_resolves_to_none(self): + """Garbage in, ``None`` out -- never a stale numeric coercion.""" + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid="not-a-hex", + ) + + self.assertIsNone(info.active_gesture_cid) + + def test_unknown_pid_falls_back_to_generic_layout(self): + """Unrecognized devices must never inherit MX-family controls or layouts.""" + info = build_connected_device_info( + product_id=0xDEAD, + product_name="Mystery Mouse", + ) + + self.assertEqual(info.ui_layout, "generic_mouse") + self.assertEqual(info.image_asset, "icons/mouse-simple.svg") + self.assertEqual(info.supported_buttons, GENERIC_BUTTONS) + self.assertNotIn("thumb_button", info.supported_buttons) + self.assertNotIn("hscroll_left", info.supported_buttons) + self.assertNotIn("mode_shift", info.supported_buttons) + self.assertFalse(info.gesture_via_sense_panel) + self.assertIsNone(info.thumb_button_cid) + + def test_unknown_pid_defaults_capabilities_to_false(self): + info = build_connected_device_info(product_id=0xDEAD) + + self.assertFalse(info.has_hires_wheel) + self.assertFalse(info.has_thumbwheel) + + def test_explicit_runtime_false_overrides_catalog_true(self): + """The tristate capability resolver is the FANG-critical contract: + a runtime probe that definitively saw no HiResWheel must defeat a + catalog hint, otherwise the listener tries to divert a feature the + device does not advertise and the device returns hard errors. + """ + info = build_connected_device_info( + product_id=0xB042, + has_hires_wheel=False, + has_thumbwheel=False, + ) + + self.assertFalse(info.has_hires_wheel) + self.assertFalse(info.has_thumbwheel) + + def test_explicit_runtime_true_on_uncatalogued_device(self): + """Devices the catalog does not know about can still be upgraded to + having a HiResWheel by a runtime probe.""" + info = build_connected_device_info( + product_id=0xDEAD, + has_hires_wheel=True, + ) + + self.assertTrue(info.has_hires_wheel) + self.assertEqual(info.ui_layout, "generic_mouse") + + def test_caller_supplied_empty_gesture_cids_is_respected(self): + """``gesture_cids=()`` is the runtime signal "I probed and saw none"; + it must not silently fall back to ``spec.gesture_cids``. + """ + info = build_connected_device_info( + product_id=0xB042, + gesture_cids=(), + ) + + self.assertEqual(info.gesture_cids, ()) + + def test_caller_supplied_empty_gesture_cids_on_unknown_device(self): + info = build_connected_device_info( + product_id=0xDEAD, + gesture_cids=(), + ) + + self.assertEqual(info.gesture_cids, ()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mouse_hook.py b/tests/test_mouse_hook.py index d34208a..055117b 100644 --- a/tests/test_mouse_hook.py +++ b/tests/test_mouse_hook.py @@ -34,13 +34,19 @@ def capabilities(self, absinfo=False): class _CapturingListener: def __init__(self, on_down=None, on_up=None, on_move=None, - on_connect=None, on_disconnect=None, extra_diverts=None): + on_connect=None, on_disconnect=None, extra_diverts=None, + on_wheel=None, on_thumbwheel=None, + on_thumb_button_down=None, on_thumb_button_up=None): self.on_down = on_down self.on_up = on_up self.on_move = on_move self.on_connect = on_connect self.on_disconnect = on_disconnect self.extra_diverts = extra_diverts or {} + self.on_wheel = on_wheel + self.on_thumbwheel = on_thumbwheel + self.on_thumb_button_down = on_thumb_button_down + self.on_thumb_button_up = on_thumb_button_up self.connected_device = None self.started = False self.stopped = False @@ -875,6 +881,368 @@ def test_normal_event_does_not_reenable(self): self.mock_quartz.CGEventTapEnable.assert_not_called() +@unittest.skipUnless(sys.platform == "darwin", "macOS-only tests") +class MacOSThumbButtonTests(unittest.TestCase): + """Verify the MX Master 4 Sense Panel (button 6 at the OS layer) + is dispatched as THUMB_BUTTON_DOWN/UP MouseEvents.""" + + _kCGEventOtherMouseDown = 25 + _kCGEventOtherMouseUp = 26 + _kCGMouseEventButtonNumber = 0x21 + _kCGEventSourceUserData = 0x2A + + def setUp(self): + self.mock_quartz = MagicMock(name="Quartz") + self.mock_quartz.kCGEventOtherMouseDown = self._kCGEventOtherMouseDown + self.mock_quartz.kCGEventOtherMouseUp = self._kCGEventOtherMouseUp + self.mock_quartz.kCGMouseEventButtonNumber = self._kCGMouseEventButtonNumber + self.mock_quartz.kCGEventSourceUserData = self._kCGEventSourceUserData + mouse_hook.Quartz = self.mock_quartz + + def tearDown(self): + if hasattr(mouse_hook, "Quartz") and isinstance( + mouse_hook.Quartz, MagicMock): + del mouse_hook.Quartz + + def _make_hook(self): + hook = mouse_hook.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook._enqueue_dispatch_event = MagicMock(name="enqueue") + return hook + + def _mock_field(self, button_number): + def _get(event, field): + if field == self._kCGMouseEventButtonNumber: + return button_number + if field == self._kCGEventSourceUserData: + return 0 + return 0 + return _get + + def test_btn6_down_dispatches_thumb_button_down(self): + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + hook._event_tap_callback( + None, self._kCGEventOtherMouseDown, cg_event, None + ) + + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + self.assertTrue( + any(e.event_type == mouse_hook.MouseEvent.THUMB_BUTTON_DOWN for e in events), + f"expected THUMB_BUTTON_DOWN in {[e.event_type for e in events]}", + ) + + def test_btn6_up_dispatches_thumb_button_up(self): + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + hook._event_tap_callback( + None, self._kCGEventOtherMouseUp, cg_event, None + ) + + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + self.assertTrue( + any(e.event_type == mouse_hook.MouseEvent.THUMB_BUTTON_UP for e in events), + f"expected THUMB_BUTTON_UP in {[e.event_type for e in events]}", + ) + + def test_btn3_back_still_dispatches_xbutton1_not_thumb_button(self): + # Regression guard: don't accidentally route every OtherMouse event to + # the new ACTION_RING dispatch. + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(3) + + hook._event_tap_callback( + None, self._kCGEventOtherMouseDown, cg_event, None + ) + + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + types = [e.event_type for e in events] + self.assertIn(mouse_hook.MouseEvent.XBUTTON1_DOWN, types) + self.assertNotIn(mouse_hook.MouseEvent.THUMB_BUTTON_DOWN, types) + + +@unittest.skipUnless(sys.platform == "darwin", "macOS-only tests") +class MacOSMxMaster4SensePanelGestureTests(unittest.TestCase): + """MX Master 4 routes the Sense Panel (btn=6 / "Action Ring" overlay) + as the gesture source, with directional swipes driven from OS deltas + while held, and routes the small HID++ button (CID 0x00c3) as the + single-press Thumb button trigger.""" + + _kCGEventOtherMouseDown = 25 + _kCGEventOtherMouseUp = 26 + _kCGMouseEventButtonNumber = 0x21 + _kCGEventSourceUserData = 0x2A + + def setUp(self): + self.mock_quartz = MagicMock(name="Quartz") + self.mock_quartz.kCGEventOtherMouseDown = self._kCGEventOtherMouseDown + self.mock_quartz.kCGEventOtherMouseUp = self._kCGEventOtherMouseUp + self.mock_quartz.kCGMouseEventButtonNumber = self._kCGMouseEventButtonNumber + self.mock_quartz.kCGEventSourceUserData = self._kCGEventSourceUserData + mouse_hook.Quartz = self.mock_quartz + + def tearDown(self): + if hasattr(mouse_hook, "Quartz") and isinstance( + mouse_hook.Quartz, MagicMock): + del mouse_hook.Quartz + + def _make_hook(self): + """MX Master 4 in OS-level FALLBACK mode (HID++ haptic-panel + divert was rejected, so 0x01A0 is NOT the active gesture CID and + btn=6 still leaks through -- the swap takes over here).""" + hook = mouse_hook.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook._enqueue_dispatch_event = MagicMock(name="enqueue") + hook._connected_device = SimpleNamespace( + key="mx_master_4", + gesture_via_sense_panel=True, + active_gesture_cid=0x00C3, # fallback: small button is gesture + thumb_button_via_hid=False, + ) + return hook + + def _mock_field(self, button_number): + def _get(event, field): + if field == self._kCGMouseEventButtonNumber: + return button_number + if field == self._kCGEventSourceUserData: + return 0 + return 0 + return _get + + def test_btn6_down_starts_gesture_capture_no_thumb_button(self): + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + result = hook._event_tap_callback( + None, self._kCGEventOtherMouseDown, cg_event, None + ) + + self.assertIsNone(result, "btn=6 down should be swallowed in haptic-panel mode") + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + types = [e.event_type for e in events] + self.assertNotIn( + mouse_hook.MouseEvent.THUMB_BUTTON_DOWN, types, + "MX4 must not dispatch thumb_button on btn=6 -- that role is " + "taken by the small HID++ button.", + ) + self.assertTrue(hook._gesture_active, "gesture capture must activate") + + def test_btn6_up_without_swipe_dispatches_gesture_click(self): + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + # Down then up with no rawxy / drag in between → click candidate. + hook._event_tap_callback( + None, self._kCGEventOtherMouseDown, cg_event, None + ) + # Patch _dispatch directly so we capture the click that fires from + # the listener-side dispatch path (skips _dispatch_queue). + hook._dispatch = MagicMock() + hook._event_tap_callback( + None, self._kCGEventOtherMouseUp, cg_event, None + ) + + dispatched_types = [ + call_args.args[0].event_type + for call_args in hook._dispatch.call_args_list + ] + self.assertIn( + mouse_hook.MouseEvent.GESTURE_CLICK, dispatched_types, + f"expected GESTURE_CLICK in {dispatched_types}", + ) + self.assertFalse(hook._gesture_active) + + def test_hid_gesture_button_dispatches_thumb_button_not_gesture(self): + hook = self._make_hook() + hook._dispatch = MagicMock() + + hook._on_hid_gesture_down() + hook._on_hid_gesture_up() + + dispatched_types = [ + call_args.args[0].event_type + for call_args in hook._dispatch.call_args_list + ] + self.assertIn(mouse_hook.MouseEvent.THUMB_BUTTON_DOWN, dispatched_types) + self.assertIn(mouse_hook.MouseEvent.THUMB_BUTTON_UP, dispatched_types) + self.assertNotIn( + mouse_hook.MouseEvent.GESTURE_CLICK, dispatched_types, + "small HID++ button must NOT fire gesture events on MX4", + ) + self.assertFalse(hook._gesture_active) + + def test_hid_rawxy_move_ignored_in_sense_panel_mode(self): + hook = self._make_hook() + # Manually activate haptic-panel gesture so accumulator could fire. + hook._gesture_direction_enabled = True + hook._begin_gesture_capture("Sense panel gesture") + before_x = hook._gesture_delta_x + + hook._on_hid_gesture_move(-200, 0) + + self.assertEqual( + hook._gesture_delta_x, before_x, + "rawXY from the small button must not contaminate a haptic-panel gesture", + ) + + +@unittest.skipUnless(sys.platform == "darwin", "macOS-only tests") +class MacOSMxMaster4HidPlusPlusGestureTests(unittest.TestCase): + """Happy path: the HID++ listener successfully diverted the haptic + CID (0x01A0) so the firmware delivers gesture press / release / rawXY + over the vendor channel, AND it diverted 0x00C3 as the thumb_button + extra. The macOS event tap must NOT do its own swap or dispatch in + this mode -- the HID listener owns both buttons end-to-end.""" + + _kCGEventOtherMouseDown = 25 + _kCGEventOtherMouseUp = 26 + _kCGMouseEventButtonNumber = 0x21 + _kCGEventSourceUserData = 0x2A + + def setUp(self): + self.mock_quartz = MagicMock(name="Quartz") + self.mock_quartz.kCGEventOtherMouseDown = self._kCGEventOtherMouseDown + self.mock_quartz.kCGEventOtherMouseUp = self._kCGEventOtherMouseUp + self.mock_quartz.kCGMouseEventButtonNumber = self._kCGMouseEventButtonNumber + self.mock_quartz.kCGEventSourceUserData = self._kCGEventSourceUserData + mouse_hook.Quartz = self.mock_quartz + + def tearDown(self): + if hasattr(mouse_hook, "Quartz") and isinstance( + mouse_hook.Quartz, MagicMock): + del mouse_hook.Quartz + + def _make_hook(self): + hook = mouse_hook.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook._enqueue_dispatch_event = MagicMock(name="enqueue") + # MX Master 4 in HID++ success mode: 0x01A0 is the active gesture + # CID, and 0x00C3 was wired as the thumb_button extra. + hook._connected_device = SimpleNamespace( + key="mx_master_4", + gesture_via_sense_panel=True, + active_gesture_cid=0x01A0, + thumb_button_via_hid=True, + ) + return hook + + def _mock_field(self, button_number): + def _get(event, field): + if field == self._kCGMouseEventButtonNumber: + return button_number + if field == self._kCGEventSourceUserData: + return 0 + return 0 + return _get + + def test_btn6_down_swallowed_when_thumb_button_via_hid(self): + """If the firmware leaks btn=6 anyway (firmware quirk), the OS + path must NOT dispatch THUMB_BUTTON_DOWN -- the HID listener has + already done so via the 0x00C3 extra divert.""" + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + result = hook._event_tap_callback( + None, self._kCGEventOtherMouseDown, cg_event, None + ) + + self.assertIsNone(result, "leaked btn=6 must be blocked, not passed through") + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + types = [e.event_type for e in events] + self.assertNotIn( + mouse_hook.MouseEvent.THUMB_BUTTON_DOWN, types, + "macOS hook must not dispatch thumb_button when the HID++ " + "listener is already delivering it", + ) + self.assertFalse( + hook._gesture_active, + "btn=6 must not start an OS-level gesture when 0x01A0 is " + "the active HID++ gesture CID", + ) + + def test_btn6_up_swallowed_when_thumb_button_via_hid(self): + hook = self._make_hook() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = self._mock_field(6) + + result = hook._event_tap_callback( + None, self._kCGEventOtherMouseUp, cg_event, None + ) + + self.assertIsNone(result) + events = [ + call_args.args[0] + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + types = [e.event_type for e in events] + self.assertNotIn(mouse_hook.MouseEvent.THUMB_BUTTON_UP, types) + + def test_hid_gesture_button_drives_normal_gesture_path(self): + """When 0x01A0 is the active gesture CID, the HID gesture + callbacks come from the sense panel and must run the normal + gesture-capture flow (NOT the thumb_button swap that the fallback + mode uses).""" + hook = self._make_hook() + + hook._on_hid_gesture_down() + + self.assertTrue( + hook._gesture_active, + "HID gesture down on 0x01A0 must activate gesture capture", + ) + + def test_hid_rawxy_move_accumulates_for_swipe_in_success_mode(self): + """Critical: in success mode we WANT to accumulate HID++ rawXY + deltas (those are coming from 0x01A0 = the sense panel motion). + The fallback-only short-circuit in _on_hid_gesture_move must NOT + engage. A delta past the gesture threshold should fire a swipe + event end-to-end.""" + hook = self._make_hook() + hook._gesture_direction_enabled = True + hook._begin_gesture_capture("HID gesture") + hook._gesture_input_source = "hid_rawxy" + + # Default threshold is 50; -150 is comfortably past it as a left swipe. + hook._on_hid_gesture_move(-150, 0) + + queued_types = [ + call_args.args[0].event_type + for call_args in hook._enqueue_dispatch_event.call_args_list + ] + self.assertIn( + mouse_hook.MouseEvent.GESTURE_SWIPE_LEFT, queued_types, + f"expected GESTURE_SWIPE_LEFT in {queued_types} -- HID rawXY " + "from the haptic CID must drive swipe detection in success mode", + ) + + @unittest.skipUnless(sys.platform == "darwin", "macOS-only tests") class MacOSTrackpadScrollFilterTests(unittest.TestCase): """Verify CGEventTap callback passes through trackpad events untouched.""" diff --git a/tests/test_smart_shift.py b/tests/test_smart_shift.py index 2534974..d43b34f 100644 --- a/tests/test_smart_shift.py +++ b/tests/test_smart_shift.py @@ -12,7 +12,7 @@ # ────────────────────────────────────────────────────────────────────────────── -# HidGestureListener — write path +# HidGestureListener -- write path # ────────────────────────────────────────────────────────────────────────────── class SmartShiftWriteTests(unittest.TestCase): @@ -90,7 +90,7 @@ def test_failed_request_sets_result_false(self): # ────────────────────────────────────────────────────────────────────────────── -# HidGestureListener — read path +# HidGestureListener -- read path # ────────────────────────────────────────────────────────────────────────────── class SmartShiftReadTests(unittest.TestCase): @@ -241,7 +241,7 @@ def worker(): # ────────────────────────────────────────────────────────────────────────────── -# Engine — SmartShift config persistence and startup +# Engine -- SmartShift config persistence and startup # ────────────────────────────────────────────────────────────────────────────── class _FakeMouseHook: @@ -254,12 +254,16 @@ def __init__(self): self._hid_gesture = None self.divert_mode_shift = False self.start_called = False + self.wheel_native_invert_active = False + self.wheel_divert_active = False def set_debug_callback(self, cb): pass def set_gesture_callback(self, cb): pass def set_status_callback(self, cb): pass def set_connection_change_callback(self, cb): pass def configure_gestures(self, **kwargs): pass + def configure_wheel_multipliers(self, vertical, horizontal): + return None def block(self, event_type): pass def register(self, event_type, callback): pass def reset_bindings(self): pass @@ -425,7 +429,7 @@ def test_run_saved_settings_replay_notifies_ui_with_saved_state(self): # ────────────────────────────────────────────────────────────────────────────── -# Backend — SmartShift properties, slots, and device read sync +# Backend -- SmartShift properties, slots, and device read sync # ────────────────────────────────────────────────────────────────────────────── try: @@ -500,7 +504,7 @@ def test_handle_smart_shift_read_updates_in_memory_config(self): # Simulate the two-step cross-thread call: stage state, then invoke handler backend._pending_smart_shift_state = {"mode": "freespin", "enabled": False, "threshold": 35} backend._handleSmartShiftRead() - # Hardware reads should NOT be persisted — user's explicit saves drive the file. + # Hardware reads should NOT be persisted -- user's explicit saves drive the file. save_mock.assert_not_called() self.assertEqual(backend._cfg["settings"]["smart_shift_mode"], "freespin") self.assertFalse(backend._cfg["settings"]["smart_shift_enabled"]) @@ -535,7 +539,7 @@ def test_handle_smart_shift_read_ignores_non_dict(self): # ────────────────────────────────────────────────────────────────────────────── -# Engine — _toggle_smart_shift (physical button / mapped action) +# Engine -- _toggle_smart_shift (physical button / mapped action) # ────────────────────────────────────────────────────────────────────────────── class EngineToggleSmartShiftTests(unittest.TestCase): @@ -622,7 +626,7 @@ def test_make_handler_calls_switch_for_switch_action(self): # ────────────────────────────────────────────────────────────────────────────── -# Engine — _switch_scroll_mode (ratchet ↔ freespin, disables SmartShift auto) +# Engine -- _switch_scroll_mode (ratchet ↔ freespin, disables SmartShift auto) # ────────────────────────────────────────────────────────────────────────────── class EngineSwitchScrollModeTests(unittest.TestCase): @@ -689,7 +693,7 @@ def test_switch_preserves_threshold(self): # ────────────────────────────────────────────────────────────────────────────── -# Config v7 migration — mode_shift "none" → "toggle_smart_shift" +# Config v7 migration -- mode_shift "none" → "toggle_smart_shift" # (v7 runs as an intermediate step; v8 then upgrades toggle → switch) # ────────────────────────────────────────────────────────────────────────────── @@ -749,11 +753,11 @@ def test_multiple_profiles_all_migrated(self): def test_version_bumped_to_current(self): from core.config import _migrate migrated = _migrate(self._v6_config()) - self.assertEqual(migrated["version"], 9) + self.assertEqual(migrated["version"], 11) # ────────────────────────────────────────────────────────────────────────────── -# Config v8 migration — mode_shift "toggle_smart_shift" → "switch_scroll_mode" +# Config v8 migration -- mode_shift "toggle_smart_shift" → "switch_scroll_mode" # ────────────────────────────────────────────────────────────────────────────── class ConfigV8MigrationTests(unittest.TestCase): @@ -811,7 +815,7 @@ def test_multiple_profiles_all_migrated(self): def test_version_bumped_to_current(self): from core.config import _migrate migrated = _migrate(self._v7_config()) - self.assertEqual(migrated["version"], 9) + self.assertEqual(migrated["version"], 11) class HidForceReconnectTests(unittest.TestCase): diff --git a/tests/test_wheel_divert.py b/tests/test_wheel_divert.py new file mode 100644 index 0000000..5a4b477 --- /dev/null +++ b/tests/test_wheel_divert.py @@ -0,0 +1,825 @@ +"""Tests for the HID++ native wheel-invert path. + +Native invert = Mouser writes the firmware invert bit on `0x2121` / +`0x2150` *without* diverting the wheel through HID++ notifications. The OS +receives native HID scroll with the direction already flipped at the +device, so KVM forwarders see inverted scroll and the native scroll +cadence / momentum is preserved end-to-end. +""" + +from __future__ import annotations + +import copy +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +from core import hid_gesture as hg_mod +from core.config import DEFAULT_CONFIG, _migrate +from core.hid_gesture import ( + FEAT_HIRES_WHEEL_ENHANCED, + FEAT_THUMB_WHEEL, + HidGestureListener, +) +from core.logi_devices import resolve_device +from core.mouse_hook_base import BaseMouseHook +from core.mouse_hook_contract import MouseHookLike + + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── + + +def _make_listener() -> HidGestureListener: + return HidGestureListener() + + +def _resp(params): + return (0xFF, 0x12, 0x0, 0x0, list(params)) + + +# ────────────────────────────────────────────────────────────────────────────── +# Signed-int helper +# ────────────────────────────────────────────────────────────────────────────── + + +class DecodeS16BETests(unittest.TestCase): + def test_decode_s16_be(self): + decode = HidGestureListener._decode_s16_be + self.assertEqual(decode(0x80, 0x00), -32768) + self.assertEqual(decode(0x7F, 0xFF), 32767) + self.assertEqual(decode(0x00, 0x01), 1) + self.assertEqual(decode(0xFF, 0xFF), -1) + self.assertEqual(decode(0x00, 0x00), 0) + + def test_decode_s16_be_full_range(self): + decode = HidGestureListener._decode_s16_be + for hi in range(256): + for lo in range(256): + v = decode(hi, lo) + self.assertGreaterEqual(v, -32768) + self.assertLessEqual(v, 32767) + + +# ────────────────────────────────────────────────────────────────────────────── +# Capability discovery +# ────────────────────────────────────────────────────────────────────────────── + + +class CapabilityDiscoveryTests(unittest.TestCase): + def test_capability_discovery(self): + listener = _make_listener() + feature_map = {FEAT_HIRES_WHEEL_ENHANCED: 0x07, FEAT_THUMB_WHEEL: 0x08} + request_responses = { + (0x07, 0): _resp([8, 0x00, 0x10, 0x00]), # multiplier=8 + (0x08, 0): _resp([0x00, 0x10, 0x00, 0x78]), # divertedRes=120 + } + + def fake_find(feat_id): + return feature_map.get(feat_id) + + def fake_request(feat, func, params, timeout_ms=2000): + return request_responses.get((feat, func)) + + with ( + patch.object(listener, "_find_feature", side_effect=fake_find), + patch.object(listener, "_request", side_effect=fake_request), + ): + hw_fi = listener._find_feature(FEAT_HIRES_WHEEL_ENHANCED) + if hw_fi: + listener._hires_wheel_idx = hw_fi + cap = listener._request(hw_fi, 0, []) + if cap: + _, _, _, _, p = cap + listener._hires_wheel_multiplier = p[0] or None + tw_fi = listener._find_feature(FEAT_THUMB_WHEEL) + if tw_fi: + listener._thumbwheel_idx = tw_fi + info = listener._request(tw_fi, 0, []) + if info: + _, _, _, _, p = info + listener._thumbwheel_multiplier = ((p[2] << 8) | p[3]) or None + + self.assertEqual(listener._hires_wheel_idx, 0x07) + self.assertEqual(listener._hires_wheel_multiplier, 8) + self.assertEqual(listener._thumbwheel_idx, 0x08) + self.assertEqual(listener._thumbwheel_multiplier, 120) + self.assertTrue(listener.hires_wheel_supported) + self.assertTrue(listener.thumbwheel_supported) + + def test_capability_discovery_negative(self): + listener = _make_listener() + with ( + patch.object(listener, "_find_feature", return_value=None), + patch.object(listener, "_request", return_value=None), + ): + self.assertIsNone(listener._find_feature(FEAT_HIRES_WHEEL_ENHANCED)) + self.assertFalse(listener.hires_wheel_supported) + self.assertFalse(listener.thumbwheel_supported) + + +# ────────────────────────────────────────────────────────────────────────────── +# Native-invert apply +# ────────────────────────────────────────────────────────────────────────────── + + +class _FakeDevice: + def write(self, *args, **kwargs): + return len(args[0]) if args else 0 + + def read(self, *args, **kwargs): + return None + + def close(self): + pass + + +class NativeInvertApplyTests(unittest.TestCase): + def _setup_capable_listener(self): + listener = _make_listener() + listener._dev = _FakeDevice() + listener._hires_wheel_idx = 0x07 + listener._thumbwheel_idx = 0x08 + listener._hires_wheel_multiplier = 8 + listener._thumbwheel_multiplier = 120 + return listener + + @staticmethod + def _request_router(get_mode_response, write_response=None): + """Build a side_effect function for _request that returns a + getWheelMode response on fn=1 and a generic ack on fn=2 (or + whatever the test passes as write_response). Mirrors the + read-modify-write protocol the helper now uses.""" + if write_response is None: + write_response = _resp([0]) + + def _route(feat, func, params, timeout_ms=2000): + if feat == 0x07 and func == 1: + return get_mode_response + return write_response + + return _route + + def test_apply_invert_on_writes_low_res_invert(self): + # Mouser drives wheel mode to native low-res with invert ON + # regardless of the device's current state. Hi-res mode causes + # jumpy scroll on apps without trackpad-class smoothing, so we + # always clear bit 1. + listener = self._setup_capable_listener() + get_mode = _resp([0x00]) + with patch.object( + listener, "_request", + side_effect=self._request_router(get_mode), + ) as req: + with listener._wheel_divert_lock: + listener._pending_wheel_divert = (True, True) + listener._apply_pending_native_wheel_invert() + self.assertTrue(listener._wheel_divert_state) + req.assert_any_call(0x07, 1, []) # read current mode + req.assert_any_call(0x07, 2, [0x04]) # native low-res + invert + req.assert_any_call(0x08, 2, [0x00, 0x01]) + + def test_apply_invert_on_clears_existing_hires_bit(self): + # If the device is currently in hi-res mode (e.g. left over from + # Logitech Options+ or a previous Mouser build), Mouser must + # forcibly downgrade it to low-res to fix the jumpy feel. + listener = self._setup_capable_listener() + get_mode = _resp([0x02]) # hi-res, native, no invert + with patch.object( + listener, "_request", + side_effect=self._request_router(get_mode), + ) as req: + with listener._wheel_divert_lock: + listener._pending_wheel_divert = (True, False) + listener._apply_pending_native_wheel_invert() + req.assert_any_call(0x07, 2, [0x04]) # hi-res CLEARED, invert set + req.assert_any_call(0x08, 2, [0x00, 0x00]) + + def test_apply_invert_off_writes_low_res_no_invert(self): + listener = self._setup_capable_listener() + listener._wheel_divert_state = True + get_mode = _resp([0x06]) # hi-res + invert active + with patch.object( + listener, "_request", + side_effect=self._request_router(get_mode), + ) as req: + with listener._wheel_divert_lock: + listener._pending_wheel_divert = (False, False) + listener._apply_pending_native_wheel_invert() + self.assertTrue(listener._wheel_divert_state) + req.assert_any_call(0x07, 2, [0x00]) # firmware default + req.assert_any_call(0x08, 2, [0x00, 0x00]) + + def test_apply_invert_clears_divert_bit(self): + # Pathological case: device left in divert state from a crashed + # Mouser session. We must clear bit 0 (target) AND bit 1 (hi-res). + listener = self._setup_capable_listener() + get_mode = _resp([0x07]) # target + hi-res + invert + with patch.object( + listener, "_request", + side_effect=self._request_router(get_mode), + ) as req: + with listener._wheel_divert_lock: + listener._pending_wheel_divert = (True, False) + listener._apply_pending_native_wheel_invert() + req.assert_any_call(0x07, 2, [0x04]) # only invert kept + + def test_apply_invert_skips_redundant_write(self): + # Device already exactly in target state → no setWheelMode call. + listener = self._setup_capable_listener() + get_mode = _resp([0x04]) # native low-res + invert + with patch.object( + listener, "_request", + side_effect=self._request_router(get_mode), + ) as req: + with listener._wheel_divert_lock: + listener._pending_wheel_divert = (True, True) + listener._apply_pending_native_wheel_invert() + self.assertEqual( + [c for c in req.call_args_list + if c.args[:2] == (0x07, 2)], + [], + "Vertical setWheelMode must not write when current == target", + ) + + def test_request_native_invert_idempotent(self): + """Two consecutive request_wheel_native_invert calls each issue + fresh device reads/writes (firmware can forget after sleep).""" + listener = self._setup_capable_listener() + + def drain_apply(): + listener._apply_pending_native_wheel_invert() + + with patch.object( + listener, "_request", + side_effect=self._request_router(_resp([0x00])), + ) as req: + def fake_wait(timeout=None): + drain_apply() + listener._wheel_divert_event.set() + return True + + with patch.object(listener._wheel_divert_event, "wait", side_effect=fake_wait): + ok1 = listener.request_wheel_native_invert(True, False) + ok2 = listener.request_wheel_native_invert(True, False) + + self.assertTrue(ok1) + self.assertTrue(ok2) + # Each call: 1 read + 1 write (vertical) + 1 write (thumb) = 3 calls + self.assertGreaterEqual(req.call_count, 6) + + def test_undivert_on_stop(self): + """stop() restores the device to native non-inverted state when the + listener was holding firmware invert active. The read-modify-write + helper inspects the current mode first, so we simulate a device + currently inverted (bit 2 set) to force the revert write to fire.""" + listener = self._setup_capable_listener() + listener._wheel_divert_state = True + listener._connected_device_info = SimpleNamespace(key="mx_master_3s") + listener._thread = None + + with patch.object( + listener, "_request", + side_effect=self._request_router(_resp([0x04])), + ) as req: + listener.stop() + + targets = {(c.args[0], c.args[1]) for c in req.call_args_list} + self.assertIn((0x07, 1), targets) # read current mode (RMW) + self.assertIn((0x07, 2), targets) # write reverted mode + self.assertIn((0x08, 2), targets) # thumbwheel revert + self.assertFalse(listener._wheel_divert_state) + + +# ────────────────────────────────────────────────────────────────────────────── +# Catalog flags +# ────────────────────────────────────────────────────────────────────────────── + + +class CatalogFlagsTests(unittest.TestCase): + def test_catalog_flags(self): + for name in ("MX Master 3S", "MX Master 3", "MX Master 4", "MX Master 2S", "MX Master"): + spec = resolve_device(product_name=name) + self.assertIsNotNone(spec, name) + self.assertTrue(spec.has_hires_wheel, name) + self.assertTrue(spec.has_thumbwheel, name) + + spec = resolve_device(product_name="MX Vertical") + self.assertIsNotNone(spec) + self.assertFalse(spec.has_hires_wheel) + self.assertFalse(spec.has_thumbwheel) + + +# ────────────────────────────────────────────────────────────────────────────── +# Base hook native-invert flag +# ────────────────────────────────────────────────────────────────────────────── + + +class BaseHookFlagTests(unittest.TestCase): + def test_default_state(self): + hook = BaseMouseHook() + self.assertFalse(hook.wheel_native_invert_active) + + def test_configure_wheel_multipliers_is_noop(self): + # Native-invert mode does no scroll injection, so multipliers are + # unused. The method is retained only for shape compatibility. + hook = BaseMouseHook() + hook.configure_wheel_multipliers(8, 120) + # No exception, no state change beyond not having the old fields. + self.assertFalse(hasattr(hook, "_wheel_residual_v")) + + +# ────────────────────────────────────────────────────────────────────────────── +# macOS event-tap suppression of OS-layer inversion +# ────────────────────────────────────────────────────────────────────────────── + + +class MacOSSuppressionTests(unittest.TestCase): + """When `wheel_native_invert_active=True`, the macOS event-tap callback + must skip the OS-layer inversion path (`_negate_scroll_axis`) so the + firmware-level flip doesn't get double-applied. When inactive, in-place + negation runs against the original event (no block-and-reinject).""" + + _kCGScrollWheelEventIsContinuous = 88 + _kCGEventScrollWheel = 22 + + def setUp(self): + try: + from core import mouse_hook_macos + except Exception: + self.skipTest("macOS hook unavailable in this environment") + self._mouse_hook_macos = mouse_hook_macos + self._prev_quartz = getattr(mouse_hook_macos, "Quartz", None) + self.mock_quartz = MagicMock(name="Quartz") + self.mock_quartz.kCGEventScrollWheel = self._kCGEventScrollWheel + mouse_hook_macos.Quartz = self.mock_quartz + + def tearDown(self): + if self._prev_quartz is None: + if hasattr(self._mouse_hook_macos, "Quartz"): + delattr(self._mouse_hook_macos, "Quartz") + else: + self._mouse_hook_macos.Quartz = self._prev_quartz + + def _mock_get_field(self, *, is_continuous=0, source_user_data=0): + def _get(_event, field): + if field == self._kCGScrollWheelEventIsContinuous: + return is_continuous + if field == self.mock_quartz.kCGEventSourceUserData: + return source_user_data + return 0 + return _get + + def test_os_inversion_skipped_when_native_active(self): + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_vscroll = True + hook.wheel_native_invert_active = True + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + with patch.object(hook, "_negate_scroll_axis") as negate: + result = hook._event_tap_callback( + None, self._kCGEventScrollWheel, cg_event, None + ) + negate.assert_not_called() + # Original event flows through untouched -- no block, no reinject. + self.assertIs(result, cg_event) + + def _logitech_stub(self): + """Minimal stand-in for a connected Logitech ``ConnectedDeviceInfo``. + + The OS-fallback inversion path requires ``_connected_device is not + None`` as proof that scroll events are coming from a Logitech the + user's invert toggle is meant to apply to. Tests that exercise the + fallback path must pin this state explicitly. + """ + return SimpleNamespace( + key="mx_master_3s", + display_name="MX Master 3S", + thumb_button_via_hid=False, + gesture_via_sense_panel=False, + ) + + def test_os_inversion_runs_when_native_inactive(self): + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_vscroll = True + hook.wheel_native_invert_active = False + hook._connected_device = self._logitech_stub() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + with patch.object(hook, "_negate_scroll_axis") as negate: + result = hook._event_tap_callback( + None, self._kCGEventScrollWheel, cg_event, None + ) + # Vertical inversion negates axis 1 in place; the SAME event is + # returned (not None), so the caller passes it through untouched + # apart from the sign flip. + negate.assert_called_once_with(cg_event, 1) + self.assertIs(result, cg_event) + + def test_horizontal_inversion_negates_axis_2_in_place(self): + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_hscroll = True + hook.wheel_native_invert_active = False + hook._connected_device = self._logitech_stub() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + with patch.object(hook, "_negate_scroll_axis") as negate: + result = hook._event_tap_callback( + None, self._kCGEventScrollWheel, cg_event, None + ) + negate.assert_called_once_with(cg_event, 2) + self.assertIs(result, cg_event) + + def test_both_axes_inverted_in_single_pass(self): + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_vscroll = True + hook.invert_hscroll = True + hook.wheel_native_invert_active = False + hook._connected_device = self._logitech_stub() + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + with patch.object(hook, "_negate_scroll_axis") as negate: + result = hook._event_tap_callback( + None, self._kCGEventScrollWheel, cg_event, None + ) + negate.assert_any_call(cg_event, 1) + negate.assert_any_call(cg_event, 2) + self.assertEqual(negate.call_count, 2) + self.assertIs(result, cg_event) + + def test_os_inversion_skipped_when_no_logitech_connected(self): + """The wheel-invert toggle is meant for Logitech scroll. When no + Logitech is connected we have no source-of-truth that a scroll event + came from a device the toggle applies to, so the OS-layer fallback + must stand down rather than invert every trackpad / generic mouse + scroll the OS forwards through us. + """ + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_vscroll = True + hook.invert_hscroll = True + hook.wheel_native_invert_active = False + hook._connected_device = None # no Logitech detected + cg_event = MagicMock(name="cg_event") + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + with patch.object(hook, "_negate_scroll_axis") as negate: + result = hook._event_tap_callback( + None, self._kCGEventScrollWheel, cg_event, None + ) + negate.assert_not_called() + self.assertIs(result, cg_event) + + def test_os_inversion_resumes_when_logitech_reconnects(self): + """Disconnect/reconnect transitions must not require Mouser restart: + the very next event after ``_connected_device`` flips back to a + ``ConnectedDeviceInfo`` is the one we start inverting again. + """ + hook = self._mouse_hook_macos.MouseHook() + hook._running = True + hook._tap = MagicMock(name="tap") + hook.invert_vscroll = True + hook.wheel_native_invert_active = False + self.mock_quartz.CGEventGetIntegerValueField.side_effect = ( + self._mock_get_field(is_continuous=0) + ) + + hook._connected_device = None + with patch.object(hook, "_negate_scroll_axis") as negate_off: + hook._event_tap_callback( + None, self._kCGEventScrollWheel, MagicMock(name="evt-off"), None + ) + negate_off.assert_not_called() + + hook._connected_device = self._logitech_stub() + with patch.object(hook, "_negate_scroll_axis") as negate_on: + hook._event_tap_callback( + None, self._kCGEventScrollWheel, MagicMock(name="evt-on"), None + ) + negate_on.assert_called_once() + + def test_negate_scroll_axis_flips_all_three_delta_fields_in_place(self): + """Direct unit test: negate flips Delta, FixedPtDelta, and + PointDelta for the requested axis. Apps read different fields, + so all three must be consistent.""" + from unittest.mock import call + hook = self._mouse_hook_macos.MouseHook() + # Mock Quartz field-name attributes the negate loop reads. + self.mock_quartz.kCGScrollWheelEventDeltaAxis1 = 0xA + self.mock_quartz.kCGScrollWheelEventFixedPtDeltaAxis1 = 0xB + self.mock_quartz.kCGScrollWheelEventPointDeltaAxis1 = 0xC + cg_event = MagicMock(name="cg_event") + # Field-id → mocked current value lookup. + values = {0xA: 5, 0xB: 50_000, 0xC: 8} + + def _get_field(_event, field): + return values.get(field, 0) + self.mock_quartz.CGEventGetIntegerValueField.side_effect = _get_field + sets = [] + + def _set_field(_event, field, value): + sets.append((field, value)) + self.mock_quartz.CGEventSetIntegerValueField.side_effect = _set_field + + hook._negate_scroll_axis(cg_event, 1) + + self.assertIn((0xA, -5), sets) + self.assertIn((0xB, -50_000), sets) + self.assertIn((0xC, -8), sets) + + +# ────────────────────────────────────────────────────────────────────────────── +# Protocol conformance +# ────────────────────────────────────────────────────────────────────────────── + + +class ProtocolConformanceTests(unittest.TestCase): + def test_protocol_conformance(self): + modules = [] + for name in ("mouse_hook_macos", "mouse_hook_windows", "mouse_hook_linux"): + try: + mod = __import__(f"core.{name}", fromlist=["MouseHook"]) + modules.append(mod.MouseHook) + except Exception: + continue + if not modules: + self.skipTest("No platform mouse hook importable") + + for cls in modules: + try: + inst = cls() + except Exception: + inst = cls.__new__(cls) + BaseMouseHook.__init__(inst) + for attr in ( + "wheel_native_invert_active", + "invert_vscroll", + "invert_hscroll", + ): + self.assertTrue( + hasattr(inst, attr), + f"{cls.__name__} missing {attr}", + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Engine driver +# ────────────────────────────────────────────────────────────────────────────── + + +class _FakeHook: + def __init__(self): + self.invert_vscroll = False + self.invert_hscroll = False + self.debug_mode = False + self.connected_device = None + self.device_connected = False + self.divert_mode_shift = False + self.divert_dpi_switch = False + self.wheel_native_invert_active = False + self.wheel_divert_active = False # back-compat alias + self._hid_gesture = None + self._blocked_events = set() + + def set_debug_callback(self, cb): pass + def set_gesture_callback(self, cb): pass + def set_status_callback(self, cb): pass + def set_connection_change_callback(self, cb): pass + def configure_gestures(self, **kwargs): pass + def configure_wheel_multipliers(self, v, h): return None + def block(self, event_type): pass + def register(self, event_type, callback): pass + def reset_bindings(self): pass + def start(self): pass + def stop(self): pass + + +class _FakeAppDetector: + def __init__(self, callback): + self.callback = callback + def start(self): pass + def stop(self): pass + + +class _FakeHidGesture: + def __init__(self, *, ack=True, has_wheel=True, has_thumb=True): + self.ack = ack + self.requests = [] + self._hires_wheel_idx = 0x07 if has_wheel else None + self._thumbwheel_idx = 0x08 if has_thumb else None + self._hires_wheel_multiplier = 8 if has_wheel else None + self._thumbwheel_multiplier = 120 if has_thumb else None + self.connected_device = SimpleNamespace( + has_hires_wheel=has_wheel, has_thumbwheel=has_thumb, + ) + self.smart_shift_supported = False + self.flags_set_to = None + + def request_wheel_native_invert(self, invert_v, invert_h, timeout_s=3.0): + self.requests.append((bool(invert_v), bool(invert_h))) + return bool(self.ack) + + def set_wheel_divert_active_flags(self, vertical, thumb): + self.flags_set_to = (vertical, thumb) + + +class EngineNativeInvertTests(unittest.TestCase): + def _make_engine(self, *, wheel_divert="auto", invert_v=False, invert_h=False, + ack=True, has_wheel=True, has_thumb=True, capable=True): + from core.engine import Engine + + cfg = copy.deepcopy(DEFAULT_CONFIG) + cfg["settings"]["wheel_divert"] = wheel_divert + cfg["settings"]["invert_vscroll"] = invert_v + cfg["settings"]["invert_hscroll"] = invert_h + + with ( + patch("core.engine.MouseHook", _FakeHook), + patch("core.engine.AppDetector", _FakeAppDetector), + patch("core.engine.load_config", return_value=cfg), + ): + engine = Engine() + if capable: + engine.hook._hid_gesture = _FakeHidGesture( + ack=ack, has_wheel=has_wheel, has_thumb=has_thumb, + ) + engine.hook.connected_device = SimpleNamespace( + has_hires_wheel=has_wheel, + has_thumbwheel=has_thumb, + ) + return engine + + def test_capable_device_drives_native_invert(self): + engine = self._make_engine(invert_v=True, invert_h=False) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + self.assertEqual(hg.requests, [(True, False)]) + self.assertTrue(engine.wheel_native_invert_active) + self.assertTrue(engine.hook.wheel_native_invert_active) + + def test_capable_device_resets_to_native_when_invert_off(self): + # Even with both flags False, the engine still owns the wheel-mode + # write so a stale invert lease from a prior crashed Mouser session + # gets reset to non-inverted on connect. + engine = self._make_engine(invert_v=False, invert_h=False) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + self.assertEqual(hg.requests, [(False, False)]) + self.assertTrue(engine.wheel_native_invert_active) + + def test_kill_switch_skips_firmware_invert(self): + engine = self._make_engine(wheel_divert="off", invert_v=True) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + # No request issued when kill-switch is on. + self.assertEqual(hg.requests, []) + self.assertFalse(engine.wheel_native_invert_active) + + def test_incapable_device_skips_firmware_invert(self): + engine = self._make_engine(invert_v=True, has_wheel=False, has_thumb=False) + engine.hook.connected_device = SimpleNamespace( + has_hires_wheel=False, has_thumbwheel=False, + ) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + self.assertEqual(hg.requests, []) + self.assertFalse(engine.wheel_native_invert_active) + + def test_failed_ack_falls_back_to_os_layer(self): + engine = self._make_engine(invert_v=True, ack=False) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + self.assertEqual(hg.requests, [(True, False)]) + self.assertFalse(engine.wheel_native_invert_active) + + def test_fast_path_skips_redundant_apply(self): + engine = self._make_engine(invert_v=True) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + hg.requests.clear() + for _ in range(5): + engine._apply_wheel_invert_setting() + self.assertEqual(hg.requests, []) + + def test_force_replays_writes(self): + engine = self._make_engine(invert_v=True) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + hg.requests.clear() + engine._apply_wheel_invert_setting(force=True) + self.assertEqual(hg.requests, [(True, False)]) + + def test_toggle_invert_writes_new_state(self): + engine = self._make_engine(invert_v=False) + engine._apply_wheel_invert_setting() + hg = engine.hook._hid_gesture + hg.requests.clear() + engine.cfg["settings"]["invert_vscroll"] = True + engine._apply_wheel_invert_setting() + self.assertEqual(hg.requests, [(True, False)]) + + def test_change_callback_fires_on_transition(self): + engine = self._make_engine(invert_v=True) + seen = [] + engine.set_wheel_divert_change_callback(seen.append) + self.assertEqual(seen, [False]) + engine._apply_wheel_invert_setting() + self.assertEqual(seen, [False, True]) + engine.cfg["settings"]["wheel_divert"] = "off" + engine._apply_wheel_invert_setting() + self.assertEqual(seen, [False, True, False]) + + +# ────────────────────────────────────────────────────────────────────────────── +# Config migration +# ────────────────────────────────────────────────────────────────────────────── + + +class ConfigMigrationTests(unittest.TestCase): + def test_migration_adds_wheel_divert_default_auto(self): + legacy = { + "version": 1, + "settings": {"invert_vscroll": False}, + "profiles": { + "default": {"label": "Default", "apps": [], "mappings": {}}, + }, + } + migrated = _migrate(legacy) + self.assertEqual(migrated["settings"]["wheel_divert"], "auto") + + def test_migration_preserves_off_value(self): + legacy = { + "version": 9, + "settings": {"wheel_divert": "off"}, + "profiles": {}, + } + migrated = _migrate(legacy) + self.assertEqual(migrated["settings"]["wheel_divert"], "off") + + def test_v11_migration_preserves_user_thumb_button_mapping(self): + # Idempotency: a v11 config with a user-mapped thumb_button must + # NOT be clobbered by a re-run of the migration chain. + already_v11 = { + "version": 11, + "settings": {"wheel_divert": "auto"}, + "profiles": { + "default": { + "label": "Default", + "apps": [], + "mappings": {"thumb_button": "alt_tab"}, + }, + }, + } + migrated = _migrate(already_v11) + self.assertEqual( + migrated["profiles"]["default"]["mappings"]["thumb_button"], + "alt_tab", + ) + + def test_v11_migration_adds_default_when_missing(self): + # Cold-start: a sub-v11 config should be populated with the + # "none" default, not have an existing mapping overwritten. + pre_v11 = { + "version": 10, + "settings": {"wheel_divert": "auto"}, + "profiles": { + "gaming": { + "label": "Gaming", + "apps": [], + "mappings": {"xbutton1": "browser_back"}, + }, + }, + } + migrated = _migrate(pre_v11) + self.assertEqual( + migrated["profiles"]["gaming"]["mappings"]["thumb_button"], + "none", + ) + self.assertEqual( + migrated["profiles"]["gaming"]["mappings"]["xbutton1"], + "browser_back", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/locale_manager.py b/ui/locale_manager.py index d6d1f34..0b56717 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -662,6 +662,7 @@ "Gesture button": "\u624b\u52bf\u952e", "Back button": "\u540e\u9000\u952e", "Forward button": "\u524d\u8fdb\u952e", + "Thumb button": "\u62c7\u6307\u952e", "Horizontal scroll left": "\u6c34\u5e73\u5de6\u6eda", "Horizontal scroll right":"\u6c34\u5e73\u53f3\u6eda", "Horizontal Scroll": "\u6c34\u5e73\u6eda\u52a8", @@ -676,6 +677,7 @@ "Gesture button": "\u624b\u52e2\u9375", "Back button": "\u5f8c\u9000\u9375", "Forward button": "\u524d\u9032\u9375", + "Thumb button": "\u62c7\u6307\u9375", "Horizontal scroll left": "\u6c34\u5e73\u5de6\u6372", "Horizontal scroll right":"\u6c34\u5e73\u53f3\u6372", "Horizontal Scroll": "\u6c34\u5e73\u6372\u52d5",