From a0585a4d8effda413208bb65023c0feef67fb046 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Wed, 13 May 2026 18:48:33 -0400 Subject: [PATCH 01/10] feat(devices): MX Master 4 firmware-first layout metadata Extend the per-device catalog with the metadata follow-up PRs need to drive the MX Master 4's two thumb-area buttons. None of this changes runtime behavior yet; consumers land in the HID listener, engine, and mouse-hook PRs. MX_MASTER_4_BUTTONS adds `thumb_button` to the standard MX Master button set so the relocated Mouse Gesture Button (CID 0x00C3) has its own UI mapping target. LogiDeviceSpec gains `has_hires_wheel`, `has_thumbwheel`, `gesture_via_sense_panel`, and `thumb_button_cid` so device entries declare HID++ feature presence and CID roles up front. ConnectedDeviceInfo mirrors those four plus runtime-state siblings (`hires_wheel_active`, `thumbwheel_active`, `active_gesture_cid`, `thumb_button_via_hid`) so platform mouse hooks read state without re-walking the capability inventory inline. `build_connected_device_info` accepts the new state kwargs, folds in spec hints only when the caller did not provide a value, and normalizes `active_gesture_cid` to int. Naming follows Logitech's firmware/marketing terminology (verified against Solaar `special_keys.py`, the MX Master 4 reprog-controls dump in pwr-Solaar/Solaar#2964, and Logitech's own MX Master 4 marketing): 0x01A0 is the haptic Sense Panel, 0x00C3 is the relocated Mouse Gesture Button. "Action Ring" is a Logi Options+ software overlay invoked by pressing the Sense Panel, not a hardware button, so internal symbols use `thumb_button` / `gesture_via_sense_panel` instead. The MX Master 4 catalog entry lists `gesture_cids` with 0x01A0 first so the listener prefers diverting the larger Sense Panel with rawXY, points `thumb_button_cid` at 0x00C3 so the relocated Mouse Gesture Button is wired as a button-only extra, and sets `gesture_via_sense_panel = True` so the device is eligible for an OS-level fallback swap when firmware rejects the panel divert. Both CIDs are confirmed divertable + rawXY- capable on real MX Master 4 firmware (Solaar issue #2964). `tests/test_logi_devices.py` updates the haptic-control test to assert `thumb_button` is in `supported_buttons` while the Sense Panel CID still produces no UI button entry, and adds tests for spec-to-runtime mirroring and `active_gesture_cid` int normalization. --- core/logi_device_catalog.py | 66 ++++++++++++++++++++++++++++++++++--- core/logi_devices.py | 52 +++++++++++++++++++++++++++-- tests/test_logi_devices.py | 29 +++++++++++++++- 3 files changed, 140 insertions(+), 7 deletions(-) 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..5e23c30 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 @@ -763,6 +786,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, @@ -787,11 +811,16 @@ def build_connected_device_info( source=None, gesture_cids=None, reprog_controls=None, - active_gesture_cid=None, + active_gesture_cid: int | None = None, gesture_rawxy_enabled=None, discovered_features=None, device_identity=None, diagnostics=None, + has_hires_wheel=False, + has_thumbwheel=False, + hires_wheel_active=False, + thumbwheel_active=False, + thumb_button_via_hid=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 @@ -811,6 +840,11 @@ def build_connected_device_info( discovered_features=discovered_features, diagnostics=diagnostics, ) + # Caller-supplied runtime evidence wins over catalog hints; otherwise + # fall back to whatever the spec declared. + eff_has_hires = bool(has_hires_wheel) or bool(getattr(spec, "has_hires_wheel", False)) + eff_has_thumb = bool(has_thumbwheel) or bool(getattr(spec, "has_thumbwheel", False)) + normalized_active_cid = int(active_gesture_cid) if active_gesture_cid is not None else None if spec: resolved_gesture_cids = tuple(gesture_cids or spec.gesture_cids) return ConnectedDeviceInfo( @@ -827,6 +861,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 @@ -848,6 +890,12 @@ def build_connected_device_info( supported_buttons=GENERIC_BUTTONS, gesture_cids=tuple(gesture_cids or DEFAULT_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/tests/test_logi_devices.py b/tests/test_logi_devices.py index 2169681..71dd5e7 100644 --- a/tests/test_logi_devices.py +++ b/tests/test_logi_devices.py @@ -584,13 +584,40 @@ 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) + if __name__ == "__main__": unittest.main() From ced566b87df9c098d2e41807396e878da5fbc066 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 09:34:01 -0400 Subject: [PATCH 02/10] fix(devices): tristate capability resolver + safe CID coercion + fallback tests - Replace ``bool`` capability defaults in ``build_connected_device_info`` with ``bool | None``. ``None`` means "no probe performed" and defers to the catalog hint; explicit ``True``/``False`` is definitive runtime evidence. Prior behavior silently dropped a definitive ``False`` runtime probe under an optimistic catalog ``True``, which would let the listener try to divert a feature the firmware did not actually expose. - Extract ``_coerce_cid`` so caller-supplied CIDs (int, ``"0x01A0"`` hex string, ``None``) all funnel through one normalizer; downstream consumers no longer compare ``int`` against ``str`` and silently miss matches. - Stop letting an empty ``gesture_cids=()`` collapse into the spec default via short-circuit ``or``. An empty tuple is the runtime's explicit "I saw none"; treat it as truth. New tests cover: hex-string CID normalization, malformed CID resolves to None, unknown PID falls back to generic + no MX-family buttons, explicit runtime ``False`` overrides catalog ``True``, runtime ``True`` upgrades an unknown device, and empty ``gesture_cids`` is respected on both spec and fallback paths. --- core/logi_devices.py | 70 +++++++++++++++++++++--------- tests/test_logi_devices.py | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/core/logi_devices.py b/core/logi_devices.py index 5e23c30..6824ab1 100644 --- a/core/logi_devices.py +++ b/core/logi_devices.py @@ -455,18 +455,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 @@ -803,6 +812,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, @@ -811,16 +833,16 @@ def build_connected_device_info( source=None, gesture_cids=None, reprog_controls=None, - active_gesture_cid: int | None = None, + active_gesture_cid=None, gesture_rawxy_enabled=None, discovered_features=None, device_identity=None, diagnostics=None, - has_hires_wheel=False, - has_thumbwheel=False, - hires_wheel_active=False, - thumbwheel_active=False, - thumb_button_via_hid=False, + 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 @@ -831,22 +853,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, ) - # Caller-supplied runtime evidence wins over catalog hints; otherwise - # fall back to whatever the spec declared. - eff_has_hires = bool(has_hires_wheel) or bool(getattr(spec, "has_hires_wheel", False)) - eff_has_thumb = bool(has_thumbwheel) or bool(getattr(spec, "has_thumbwheel", False)) - normalized_active_cid = int(active_gesture_cid) if active_gesture_cid is not None else None + 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, @@ -878,6 +905,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, @@ -888,7 +918,7 @@ 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, diff --git a/tests/test_logi_devices.py b/tests/test_logi_devices.py index 71dd5e7..86edd01 100644 --- a/tests/test_logi_devices.py +++ b/tests/test_logi_devices.py @@ -618,6 +618,94 @@ def test_active_gesture_cid_is_normalized_to_int(self): 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() From b5d1805d12c0ff5407e7d3cea72f737c9cc95e2d Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 10:14:04 -0400 Subject: [PATCH 03/10] fix(devices): clamp_dpi coerces hand-edited config values defensively clamp_dpi used a bare int(value) cast against whatever flowed in from config.json, QML bindings, or HID reads. A user-edited config that quoted the DPI as a string, a stale None from a partial HID report, or a leaked bool flag would raise ValueError/TypeError and abort the engine's DPI path -- the cursor freezes until restart. Coerce defensively instead. Accept ints, floats, decimal/hex strings, and fall back to the device's dpi_min on anything else. Reject bool explicitly so a truthy flag never silently clamps to 0 or 1 DPI via int(True). The clamp range itself is unchanged. --- core/logi_devices.py | 25 ++++++++++++++++++++++++- tests/test_logi_devices.py | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core/logi_devices.py b/core/logi_devices.py index 6824ab1..d060f69 100644 --- a/core/logi_devices.py +++ b/core/logi_devices.py @@ -442,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)) diff --git a/tests/test_logi_devices.py b/tests/test_logi_devices.py index 86edd01..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) From a9be5952bad756e3f5597ba2f58c7f09c026e938 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Wed, 13 May 2026 18:59:32 -0400 Subject: [PATCH 04/10] feat(devices): MX Master 4 firmware-first HID++ runtime Two MX Master 4 hardware affordances need firmware-level wiring to work without freezing the cursor or fighting external KVM forwarding. This change wires both through the HID++ vendor channel, with an OS-level fallback path for cases where the firmware divert is rejected. The first is the small thumb button (CID 0x00c3, Solaar's `Mouse_Gesture_Button` with the `Thumb_Button` alias on MX Master 4). MX Master 4 also ships the Sense Panel (CID 0x01a0, Solaar's `Haptic`), which is what Logitech markets as "Haptic Sense" and what Logi Options+ binds the "Action Ring" software overlay to. The Sense Panel also surfaces as OS-level mouse btn=6 (BTN_TASK on Linux evdev) when not diverted, and without divert that report stays high for the entire press-and-drag, which freezes the cursor in many apps. HidGestureListener lists 0x01a0 first in gesture_cids so it diverts the Sense Panel with rawXY; gesture press / release / motion flow over the HID++ vendor channel and the OS never sees btn=6. The small button (0x00c3) is diverted as a button-only extra in the same setCidReporting round so its press / release fire on_thumb_button_down/up. Adds an OS-level fallback swap for cases where firmware rejects the Sense Panel divert: the platform mouse hook treats btn=6 / BTN_TASK as the gesture button and the small HID++ button becomes the thumb_button trigger. The second is wheel scroll inversion. OS-layer post-injection is unreliable when Synergy / DeskFlow / KVM forwards events to a remote machine because the inversion only applies on the host. Drive inversion at the device instead. HidGestureListener discovers FEAT_HIRES_WHEEL_ENHANCED (0x2121) and FEAT_THUMB_WHEEL (0x2150) and exposes them as has_hires_wheel / has_thumbwheel on ConnectedDeviceInfo. request_wheel_native_invert is a cross-thread API: the engine calls it from the Qt thread, the listener loop applies the device write on the HID thread via a pending-request slot, and the caller blocks on a result event with a 3 s timeout. _abort_pending_wheel_divert wakes the caller with result=False if the connection drops mid-request so callers never strand. The engine drives this via _apply_wheel_invert_setting on every profile or device change. When the device acknowledges, the engine and the platform mouse hook both flip wheel_native_invert_active and the hook suppresses its OS-layer inversion path. Cross-cutting changes: core/config.py adds a thumb_button entry to BUTTON_NAMES and BUTTON_TO_EVENTS, bumps DEFAULT_CONFIG version 9 to 11, and adds migrations v10 (wheel_divert default) and v11 (thumb_button mapping default). core/mouse_hook_contract.py adds wheel_native_invert_active to the Protocol so the engine reads it without an isinstance check. core/mouse_hook_macos.py routes btn=6 OtherMouseDown / Up to THUMB_BUTTON events and to _begin_gesture_capture / _end_gesture_capture (HID-first swallow, OS-fallback gesture capture). State mutations protected by a new _gesture_lock that covers both the CGEventTap main-thread callback and the HID listener background thread. core/mouse_hook_linux.py adds a BTN_TASK branch to _handle_button with the same routing, and core/mouse_hook_windows.py guards WM_MOUSEWHEEL / WM_MOUSEHWHEEL on wheel_native_invert_active. tests cover MX4 thumb-button dispatch, the wheel native-invert request / timeout / replay machinery, and the new config migrations. --- core/config.py | 30 +- core/engine.py | 125 +++++- core/hid_gesture.py | 665 +++++++++++++++++++++++++++++-- core/mouse_hook_base.py | 56 +++ core/mouse_hook_contract.py | 4 + core/mouse_hook_linux.py | 120 ++++-- core/mouse_hook_macos.py | 175 +++++---- core/mouse_hook_types.py | 4 + core/mouse_hook_windows.py | 13 +- tests/test_config.py | 19 +- tests/test_engine.py | 9 + tests/test_hid_gesture.py | 327 +++++++++++++++- tests/test_mouse_hook.py | 370 +++++++++++++++++- tests/test_smart_shift.py | 26 +- tests/test_wheel_divert.py | 754 ++++++++++++++++++++++++++++++++++++ ui/locale_manager.py | 2 + 16 files changed, 2519 insertions(+), 180 deletions(-) create mode 100644 tests/test_wheel_divert.py diff --git a/core/config.py b/core/config.py index b8afa87..e117a67 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", @@ -60,6 +61,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 +69,7 @@ } DEFAULT_CONFIG = { - "version": 9, + "version": 11, "active_profile": "default", "profiles": { "default": { @@ -82,6 +84,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 +112,10 @@ "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. + "wheel_divert": "auto", }, } @@ -329,6 +336,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", "auto") + 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 +362,7 @@ def _migrate(cfg): cfg["settings"].setdefault("ignore_trackpad", True) cfg["settings"].setdefault("check_for_updates", True) cfg["settings"].setdefault("update_check_state", {}) + cfg["settings"].setdefault("wheel_divert", "auto") # 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..a38fdbe 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. """ @@ -67,6 +67,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 +146,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 +258,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 +285,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 +341,102 @@ 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 = settings.get("wheel_divert", "auto") == "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 +620,17 @@ 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 = ( + self.cfg.get("settings", {}).get("wheel_divert", "auto") != "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: @@ -725,7 +840,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 +859,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 diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 67498fa..7e5463d 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,156 @@ _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.""" + for listener in list(_ATEXIT_LISTENERS): + try: + listener.stop() + except Exception: + pass + + +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,10 +762,10 @@ 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) @@ -637,6 +790,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 = ( @@ -710,16 +866,33 @@ 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 self._dev = None # hid.device() self._thread = None self._running = False @@ -733,6 +906,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 +928,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 +964,25 @@ 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. + if self._dev is not None and self._wheel_divert_state: + try: + self._set_native_wheel_invert_vertical(False) + except Exception: + pass + try: + self._set_native_wheel_invert_horizontal(False) + except Exception: + pass + self._wheel_divert_state = False self._running = False d = self._dev if d: @@ -860,6 +1066,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 +1279,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,26 +1448,92 @@ 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, 0x33) + 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) 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): + """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. 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 = 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 + self._thumb_button_cid = int(cid) + self._extra_diverts[int(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).""" + return self._thumb_button_cid is not None + def _divert_extras(self): """Divert additional CIDs (e.g. mode shift) without raw XY.""" if self._feat_idx is None: @@ -1277,11 +1567,22 @@ def _undivert(self): except Exception: pass self._rawxy_enabled = False + # Best-effort revert; mid-disconnect failures are harmless because + # firmware auto-reverts on power cycle anyway. + try: + self._set_native_wheel_invert_vertical(False) + except Exception: + pass + try: + self._set_native_wheel_invert_horizontal(False) + except Exception: + pass + 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 +1601,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 +1621,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 +1665,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 +1708,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 +1801,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 +1819,144 @@ 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.""" + target = (bool(invert_vertical), bool(invert_horizontal)) + self._wheel_divert_target = target + with self._wheel_divert_call_lock: + 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 +2016,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 +2132,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 +2194,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 +2207,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 +2285,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 +2327,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 +2346,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 +2383,38 @@ 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. + self._install_thumb_button_extra(device_spec) self._divert_extras() if idx == BT_DEV_IDX: actual_transport = "Bluetooth" @@ -1881,9 +2440,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 +2481,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 +2526,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 +2536,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 +2571,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/mouse_hook_base.py b/core/mouse_hook_base.py index 60e5623..63eb91a 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,33 @@ 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 + + @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"): @@ -273,12 +305,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..d50e90b 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,9 @@ 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: + # Skip the OS-layer rewrite when firmware is already flipping + # the sign at the device, otherwise the two flips cancel out. + if self.invert_vscroll and not self.wheel_native_invert_active: self._uinput.write(_ecodes.EV_REL, code, -value) else: self._uinput.write_event(event) @@ -871,7 +921,7 @@ def _handle_rel(self, event): if should_block: return - if self.invert_hscroll: + if self.invert_hscroll and not self.wheel_native_invert_active: self._uinput.write(_ecodes.EV_REL, code, -value) else: self._uinput.write_event(event) @@ -910,7 +960,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..ea9adf4 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,7 +42,11 @@ 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 _kCGEventTapDisabledByTimeout = 0xFFFFFFFE _kCGEventTapDisabledByUserInput = 0xFFFFFFFF @@ -66,8 +70,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 +95,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 @@ -344,6 +307,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 +340,30 @@ 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_field = 88 + is_continuous = bool( + Quartz.CGEventGetIntegerValueField(cg_event, is_continuous_field) + ) + if self.ignore_trackpad and is_continuous: + return cg_event h_delta = Quartz.CGEventGetIntegerValueField( cg_event, Quartz.kCGScrollWheelEventFixedPtDeltaAxis2 ) @@ -404,9 +392,15 @@ 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. Skipped when + # firmware is already inverting at the source, otherwise + # the two flips cancel out. + if not self.wheel_native_invert_active: + if self.invert_vscroll: + self._negate_scroll_axis(cg_event, 1) + if self.invert_hscroll: + self._negate_scroll_axis(cg_event, 2) if mouse_event: self._enqueue_dispatch_event(mouse_event) @@ -419,11 +413,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 +430,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 +450,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 +487,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 +516,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 +573,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 +660,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..1edcf36 100644 --- a/core/mouse_hook_windows.py +++ b/core/mouse_hook_windows.py @@ -427,7 +427,12 @@ 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: + # `wheel_native_invert_active` means the firmware has + # already flipped the sign at the device level. Skip the + # OS-layer inversion path so we don't flip a second time. + if self.wheel_native_invert_active: + pass + elif self.invert_vscroll: delta = hiword(mouse_data) if delta != 0 and self._ri_hwnd: self._pending_vscroll += -delta @@ -451,7 +456,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.invert_hscroll and not self.wheel_native_invert_active: if delta != 0 and self._ri_hwnd and not should_block: self._pending_hscroll += -delta if self._hscroll_posted: @@ -595,7 +600,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 +688,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() diff --git a/tests/test_config.py b/tests/test_config.py index 54cb498..ead821a 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 = { 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..1d1d776 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,315 @@ 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.""" + 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) + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec) + + self.assertEqual(listener._thumb_button_cid, 0x00C3) + self.assertIn(0x00C3, listener._extra_diverts) + 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_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) + + with patch("builtins.print"): + listener._install_thumb_button_extra(device_spec) + + 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 + with patch("builtins.print"): + listener._install_thumb_button_extra( + SimpleNamespace(thumb_button_cid=0x00C3) + ) + 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 +979,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 +1026,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 +1068,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_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..71015dd --- /dev/null +++ b/tests/test_wheel_divert.py @@ -0,0 +1,754 @@ +"""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 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 + 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 + 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 + 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_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", From 13532704972b8abcdbb54dd14420e4e971ab0baf Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 09:44:22 -0400 Subject: [PATCH 05/10] fix(hooks): gate OS-layer scroll inversion on a connected Logitech device The wheel-invert toggle was designed to flip Logitech scroll across KVM / Synergy by asking the firmware to invert at the source. The OS-layer fallback (event-tap negate on macOS, uinput sign flip on Linux, raw-input inject on Windows) was a safety net for devices that cannot do the firmware path -- but it ran any time ``wheel_native_invert_active`` was False, including when no Logitech was connected at all. The practical bug: turning the toggle on while only a trackpad or generic USB mouse is attached inverted every scroll event flowing through Mouser, even though the toggle was never meant to apply to those devices. The fix centralises the gate as ``BaseMouseHook._apply_v/hscroll_invert_fallback``, which now requires ``self._connected_device is not None`` (a Logitech is currently bound) in addition to ``not wheel_native_invert_active``. All three platform hooks route through the new helpers so future ports inherit the same contract. Also fixes a stack of FANG-level defects in the firmware-runtime layer: core/hid_gesture.py - Gate ``_install_thumb_button_extra`` on the live REPROG_V4 controls list. We never queue a divert for a CID the firmware does not actually advertise on the current connection -- previously the static catalog was the sole source of truth, which let setCidReporting hammer the device for controls that did not exist. - Track per-CID divert acknowledgments in ``_extra_divert_acks`` and recompute ``thumb_button_via_hid`` against that set. Previously the property flipped to True immediately on install, before the setCidReporting call ran; the hook layer trusted that to suppress the OS BTN_TASK fallback, eating presses whenever the divert was rejected. - Drop failed CIDs from ``_extra_diverts`` so the OS-fallback path stays in charge of buttons the firmware refused. - Replace bare ``except Exception: pass`` in ``_atexit_stop_listeners`` and ``stop()`` with narrowed handlers that log the failure. A stuck divert state is a user-visible bug; the next session needs the breadcrumb to diagnose it. - Move ``self._wheel_divert_target = target`` inside ``_wheel_divert_call_lock`` in ``request_wheel_native_invert`` so two concurrent callers cannot interleave updates to the reconnect-replay cache. core/mouse_hook_macos.py - Resolve ``kCGScrollWheelEventIsContinuous`` through the Quartz symbol when available; fall back to the integer literal so the event-tap path no longer carries a naked magic number and picks up future SDK renumbering automatically. Tests - New: ``test_install_thumb_button_extra_skipped_when_cid_absent_from_reprog`` and ``test_divert_extras_clears_acks_and_drops_failed_cids`` lock the capability-gating + ack-tracking invariants. - New: ``test_os_inversion_skipped_when_no_logitech_connected`` and ``test_os_inversion_resumes_when_logitech_reconnects`` pin the connected-device gate on the macOS path. - Updated: existing ``MacOSSuppressionTests`` cases now set ``hook._connected_device`` explicitly so they exercise the fallback path the gate intends. --- core/hid_gesture.py | 139 ++++++++++++++++++++++++++++++------- core/mouse_hook_base.py | 27 +++++++ core/mouse_hook_linux.py | 10 +-- core/mouse_hook_macos.py | 32 ++++++--- core/mouse_hook_windows.py | 13 ++-- tests/test_hid_gesture.py | 81 +++++++++++++++++++-- tests/test_wheel_divert.py | 71 +++++++++++++++++++ 7 files changed, 321 insertions(+), 52 deletions(-) diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 7e5463d..6697cdd 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -198,12 +198,18 @@ def _candidate_matches_cache(info, cached_candidate) -> bool: 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.""" + 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: - pass + 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): @@ -860,6 +866,36 @@ 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: @@ -893,6 +929,12 @@ def __init__(self, on_down=None, on_up=None, on_move=None, 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 @@ -971,17 +1013,21 @@ def start(self): 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. + # 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: - pass + 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: - pass + 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 @@ -1482,19 +1528,24 @@ def _divert(self): self._gesture_cid = DEFAULT_GESTURE_CID return False - def _install_thumb_button_extra(self, device_spec): + 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. Skipped when the - CID is already the active gesture CID (fallback path).""" + + 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 = getattr(device_spec, "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: @@ -1503,8 +1554,14 @@ def _install_thumb_button_extra(self, device_spec): f"-- it's already the active gesture CID (fallback path)" ) return - self._thumb_button_cid = int(cid) - self._extra_diverts[int(cid)] = { + 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, @@ -1530,19 +1587,43 @@ def _fire_thumb_button_up(self): @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).""" - return self._thumb_button_cid is not None + """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(): + self._extra_divert_acks.clear() + failed: list[int] = [] + for cid in list(self._extra_diverts.keys()): resp = self._set_cid_reporting(cid, 0x03) 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).""" @@ -1938,10 +2019,16 @@ def request_wheel_native_invert( """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.""" + 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)) - self._wheel_divert_target = target 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 @@ -2413,8 +2500,10 @@ def _priority(info): ) if self._divert(): # Install BEFORE _divert_extras so it lands in the - # same setCidReporting round. - self._install_thumb_button_extra(device_spec) + # 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" diff --git a/core/mouse_hook_base.py b/core/mouse_hook_base.py index 63eb91a..46350ec 100644 --- a/core/mouse_hook_base.py +++ b/core/mouse_hook_base.py @@ -132,6 +132,33 @@ def hid_runtime_state(self): # (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 diff --git a/core/mouse_hook_linux.py b/core/mouse_hook_linux.py index d50e90b..4832e2b 100644 --- a/core/mouse_hook_linux.py +++ b/core/mouse_hook_linux.py @@ -897,9 +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: - # Skip the OS-layer rewrite when firmware is already flipping - # the sign at the device, otherwise the two flips cancel out. - if self.invert_vscroll and not self.wheel_native_invert_active: + # 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) @@ -921,7 +923,7 @@ def _handle_rel(self, event): if should_block: return - if self.invert_hscroll and not self.wheel_native_invert_active: + if self._apply_hscroll_invert_fallback(): self._uinput.write(_ecodes.EV_REL, code, -value) else: self._uinput.write_event(event) diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index ea9adf4..68fb5fb 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -48,6 +48,16 @@ def wrapper(*args, **kwargs): # 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 @@ -358,9 +368,10 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): == _INJECTED_EVENT_MARKER ): return cg_event - is_continuous_field = 88 is_continuous = bool( - Quartz.CGEventGetIntegerValueField(cg_event, is_continuous_field) + Quartz.CGEventGetIntegerValueField( + cg_event, _CG_SCROLL_FIELD_IS_CONTINUOUS + ) ) if self.ignore_trackpad and is_continuous: return cg_event @@ -393,14 +404,15 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): if should_block: return None # In-place sign flip on the original event so downstream - # consumers see unit type / phase preserved. Skipped when - # firmware is already inverting at the source, otherwise - # the two flips cancel out. - if not self.wheel_native_invert_active: - if self.invert_vscroll: - self._negate_scroll_axis(cg_event, 1) - if self.invert_hscroll: - self._negate_scroll_axis(cg_event, 2) + # 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) diff --git a/core/mouse_hook_windows.py b/core/mouse_hook_windows.py index 1edcf36..8f43376 100644 --- a/core/mouse_hook_windows.py +++ b/core/mouse_hook_windows.py @@ -427,12 +427,11 @@ def _low_level_handler_inner(self, nCode, wParam, lParam): should_block = MouseEvent.MIDDLE_UP in self._blocked_events elif wParam == WM_MOUSEWHEEL: - # `wheel_native_invert_active` means the firmware has - # already flipped the sign at the device level. Skip the - # OS-layer inversion path so we don't flip a second time. - if self.wheel_native_invert_active: - pass - elif 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 @@ -456,7 +455,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 and not self.wheel_native_invert_active: + 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: diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index 1d1d776..bba5714 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -799,7 +799,8 @@ def fake_set_cid_reporting(cid, flags): 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 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( @@ -808,12 +809,19 @@ def test_install_thumb_button_extra_adds_cid_when_distinct_from_gesture(self): ) 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) + 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. @@ -822,6 +830,59 @@ def test_install_thumb_button_extra_adds_cid_when_distinct_from_gesture(self): 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 @@ -832,9 +893,12 @@ def test_install_thumb_button_extra_skipped_when_same_as_gesture_cid(self): ) 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) + listener._install_thumb_button_extra(device_spec, controls) self.assertIsNone(listener._thumb_button_cid) self.assertNotIn(0x00C3, listener._extra_diverts) @@ -846,7 +910,7 @@ def test_install_thumb_button_extra_no_op_when_cid_unset(self): device_spec = SimpleNamespace(thumb_button_cid=None) with patch("builtins.print"): - listener._install_thumb_button_extra(device_spec) + listener._install_thumb_button_extra(device_spec, []) self.assertIsNone(listener._thumb_button_cid) self.assertEqual(listener._extra_diverts, {}) @@ -945,16 +1009,21 @@ def test_install_thumb_button_extra_clears_stale_entry_on_reconnect(self): 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) + 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) + SimpleNamespace(thumb_button_cid=None), + [], ) self.assertNotIn(0x00C3, listener._extra_diverts) diff --git a/tests/test_wheel_divert.py b/tests/test_wheel_divert.py index 71015dd..5a4b477 100644 --- a/tests/test_wheel_divert.py +++ b/tests/test_wheel_divert.py @@ -390,12 +390,28 @@ def test_os_inversion_skipped_when_native_active(self): # 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) @@ -416,6 +432,7 @@ def test_horizontal_inversion_negates_axis_2_in_place(self): 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) @@ -434,6 +451,7 @@ def test_both_axes_inverted_in_single_pass(self): 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) @@ -447,6 +465,59 @@ def test_both_axes_inverted_in_single_pass(self): 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, From cc0c77d44766bfaf9a44f8a6c8cd9ed03788f338 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 10:00:05 -0400 Subject: [PATCH 06/10] fix(config,hid): seal wheel_divert + name HID++ divert flag bytes Two FANG-grade cleanups that turn stringly-typed config + magic protocol constants into named, validated surfaces: core/config.py - Introduce ``WHEEL_DIVERT_AUTO`` / ``WHEEL_DIVERT_OFF`` / ``WHEEL_DIVERT_VALID_VALUES`` / ``WHEEL_DIVERT_DEFAULT`` so call sites compare against named constants instead of bare strings. - ``coerce_wheel_divert_setting`` normalizes case + whitespace, rejects unknowns into the safe default, and logs one warning per distinct typo so a misconfigured ``config.json`` produces one actionable message instead of a per-event flood. - ``load_config`` now routes ``settings.wheel_divert`` through the coercer so the on-disk value is the sealed value before any engine branch sees it. core/engine.py - ``_apply_wheel_invert_setting`` and ``_run_saved_settings_replay`` both pull the kill-switch decision through ``coerce_wheel_divert_setting``, removing the two direct string comparisons that previously trusted the config file's value. core/hid_gesture.py - Replace the bare ``0x33`` / ``0x03`` / ``0x22`` / ``0x02`` REPROG_V4 ``setCidReporting`` mode bytes with named constants ``_DIVERT_RAW_XY``, ``_DIVERT_BUTTON_ONLY``, ``_UNDIVERT_RAW_XY``, ``_UNDIVERT_BUTTON``. Drop a single block-comment explaining the bit layout so the next reader does not have to spelunk the HID++ spec to understand what ``0x33`` means. - ``_undivert`` now logs each teardown failure rather than swallowing the exception; teardown still completes regardless because callers (disconnect, atexit) depend on it never raising. tests/test_config.py - New ``WheelDivertCoercionTests`` (10 cases) pinning: canonical values round-trip, case + whitespace normalize, unknown strings fall back to the default, warnings are deduped per distinct value, ``None`` resolves silently, non-string types warn once per type, end-to-end ``load_config`` normalizes case on disk, and an unknown typo persisted to disk surfaces as the sealed default. --- core/config.py | 56 ++++++++++++++++++++++++++-- core/engine.py | 9 ++++- core/hid_gesture.py | 62 +++++++++++++++++++++--------- tests/test_config.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 22 deletions(-) diff --git a/core/config.py b/core/config.py index e117a67..7c16dc7 100644 --- a/core/config.py +++ b/core/config.py @@ -51,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"), @@ -115,7 +162,8 @@ # HID++ wheel divert kill-switch: # "auto" → enable on capable devices (MX Master family). # "off" → never divert; force OS-layer inversion fallback. - "wheel_divert": "auto", + # Sealed set; ``coerce_wheel_divert_setting`` normalizes user typos. + "wheel_divert": WHEEL_DIVERT_DEFAULT, }, } @@ -341,7 +389,7 @@ def _migrate(cfg): # 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", "auto") + settings.setdefault("wheel_divert", WHEEL_DIVERT_DEFAULT) cfg["version"] = 10 if version < 11: @@ -362,7 +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"].setdefault("wheel_divert", "auto") + 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 a38fdbe..17e6af3 100644 --- a/core/engine.py +++ b/core/engine.py @@ -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 @@ -350,7 +351,9 @@ def _apply_wheel_invert_setting(self, *, force: bool = False) -> None: OS-layer inversion path is suppressed; on failure the OS-layer path handles inversion.""" settings = self.cfg.get("settings", {}) - kill_switch_off = settings.get("wheel_divert", "auto") == "off" + 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 @@ -624,7 +627,9 @@ def _run_saved_settings_replay(self): # firmware that forgot invert state after sleep is realigned. self._apply_wheel_invert_setting(force=True) native_invert_target = ( - self.cfg.get("settings", {}).get("wheel_divert", "auto") != "off" + 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)) ) diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 6697cdd..b89cbf8 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -777,6 +777,22 @@ def _linux_logitech_hidraw_nodes(base="/sys/class/hidraw"): 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 = { @@ -1512,13 +1528,13 @@ def _divert(self): self._gesture_cid = cid button_only = cid in self._button_only_cids if not button_only: - resp = self._set_cid_reporting(cid, 0x33) + 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 mode = "button-only (catalog hint)" if button_only else "button-only fallback" print(f"[HidGesture] Divert {_format_cid(cid)} ({mode}): " @@ -1612,7 +1628,7 @@ def _divert_extras(self): self._extra_divert_acks.clear() failed: list[int] = [] for cid in list(self._extra_diverts.keys()): - resp = self._set_cid_reporting(cid, 0x03) + 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'}") @@ -1626,38 +1642,50 @@ def _divert_extras(self): 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. + # 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: - pass + 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: - pass + 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 ─────────────────────────────────────────────── diff --git a/tests/test_config.py b/tests/test_config.py index ead821a..aea9d57 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -250,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 = [ From c4d30867781e2daa8553c56f2252c12a18d07d84 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 10:18:08 -0400 Subject: [PATCH 07/10] fix(hooks,engine): plug remaining no-silent-exception and teardown gaps A handful of adjacent FANG-grade tightenings against the same code paths the HID++ runtime exercises. Each one stands alone but they share the same theme: never swallow a callback failure, never mutate state after teardown, never read shared state without the lock that protects its writes. core/mouse_hook_base.py - ``_set_device_connected`` and the three ``_emit_*`` helpers used to swallow callback exceptions with ``except Exception: pass``. Replace with narrow handlers that log the offending callback by name; nothing propagates because the call sites are inside hot paths and must continue, but the breadcrumb tells you which integration is broken. core/mouse_hook_windows.py - Add ``_gesture_lock`` and serialize the begin / end transitions of ``_gesture_active`` / ``_gesture_triggered`` across the LL hook thread (XBUTTON path), the Raw Input window-proc thread, and the HID listener thread. macOS and Linux already had this lock; Windows did not, so a side-button press racing with a HID gesture-down could leave the state machine half-flipped. - Move ``_dispatch`` and ``_emit_*`` out of the lock so an engine callback that re-enters the hook can never deadlock. core/mouse_hook_macos.py - Drop the event-tap callback early when ``_running`` is False. The CGEventTap keeps firing briefly after ``stop()`` -- macOS does not drain in-flight callbacks before disabling the tap, so without the guard we still enqueue events into a torn-down dispatch worker and apply scroll inversion after the connection has been released. - Log the previously silent Quartz user-data probe failure so a borked binding cannot make the injected-event marker silently misfire for the rest of the session. core/engine.py - ``stop()`` now cancels every outstanding entry in ``_mouse_release_timers``. The safety auto-release timer scheduled by ``execute_action`` could otherwise fire after the hook had already been torn down and call ``inject_mouse_up`` against nothing. - ``_battery_poll_loop`` reads ``_replay_inflight`` under ``_replay_lock``. Without it the Smart Shift poll can sneak in partway through a replay round-trip and the firmware queues conflicting HID++ writes. --- core/engine.py | 21 +++++++++++- core/mouse_hook_base.py | 21 +++++++----- core/mouse_hook_macos.py | 17 ++++++++-- core/mouse_hook_windows.py | 65 ++++++++++++++++++++++++-------------- 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/core/engine.py b/core/engine.py index 17e6af3..63cbcf6 100644 --- a/core/engine.py +++ b/core/engine.py @@ -778,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 ): @@ -928,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/mouse_hook_base.py b/core/mouse_hook_base.py index 46350ec..7f15bc7 100644 --- a/core/mouse_hook_base.py +++ b/core/mouse_hook_base.py @@ -197,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 @@ -213,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, []) diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index 68fb5fb..3010d21 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -229,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, @@ -254,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 diff --git a/core/mouse_hook_windows.py b/core/mouse_hook_windows.py index 8f43376..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 @@ -562,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) @@ -709,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") From 9fd00d76d7957742c5d9d7b21cf6f89cfc67bcd1 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Wed, 13 May 2026 19:02:50 -0400 Subject: [PATCH 08/10] feat(ui): wheel-divert state badge on Scroll page When the engine diverts scroll inversion to the device firmware via HID++ (0x2121 / 0x2150), the OS-level inversion path is suppressed. Without any UI feedback this made "scroll is inverted but KVM forwarding isn't" awkward to debug, so the Scroll page now shows a small badge next to the invert toggles indicating which path is live. The backend exposes wheelDivertActive as a Property and routes the engine-thread change callback through a queued slot before emitting the notification on the Qt main thread. ScrollPage.qml renders the badge with a tooltip explaining the Synergy / DeskFlow / KVM forwarding implication, and three locale keys cover English, Simplified Chinese, and Traditional Chinese. --- ui/backend.py | 27 ++++++++++++++++++++ ui/locale_manager.py | 12 +++++++++ ui/qml/ScrollPage.qml | 59 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/ui/backend.py b/ui/backend.py index db48f74..4045fd2 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -215,6 +215,7 @@ class Backend(QObject): knownAppsChanged = Signal() updateAvailable = Signal(str, str) updateInstallChanged = Signal() + wheelDivertActiveChanged = Signal(bool) # Internal cross-thread signals _profileSwitchRequest = Signal(str) @@ -229,6 +230,7 @@ class Backend(QObject): _updateCheckFinishedRequest = Signal(bool, bool, object) _updateInstallStateRequest = Signal(str, str, bool) _updateInstallProgressRequest = Signal(int) + _wheelDivertChangeRequest = Signal(bool) def __init__(self, engine=None, parent=None, root_dir=None): super().__init__(parent) @@ -280,6 +282,7 @@ def __init__(self, engine=None, parent=None, root_dir=None): self._update_timer = QTimer(self) self._update_timer.setInterval(DEFAULT_AUTO_CHECK_INTERVAL_SECONDS * 1000) self._update_timer.timeout.connect(lambda: self._startUpdateCheck(manual=False)) + self._wheel_divert_active = False # Cross-thread signal connections self._profileSwitchRequest.connect( @@ -306,6 +309,8 @@ def __init__(self, engine=None, parent=None, root_dir=None): self._handleUpdateInstallState, Qt.QueuedConnection) self._updateInstallProgressRequest.connect( self._handleUpdateInstallProgress, Qt.QueuedConnection) + self._wheelDivertChangeRequest.connect( + self._handleWheelDivertChange, Qt.QueuedConnection) # Wire engine callbacks if engine: @@ -322,6 +327,10 @@ def __init__(self, engine=None, parent=None, root_dir=None): engine.set_smart_shift_read_callback(self._onEngineSmartShiftRead) if hasattr(engine, "set_status_callback"): engine.set_status_callback(self._onEngineStatusMessage) + if hasattr(engine, "set_wheel_divert_change_callback"): + engine.set_wheel_divert_change_callback( + self._onEngineWheelDivertChange + ) if hasattr(engine, "set_debug_enabled"): engine.set_debug_enabled(self.debugMode) self._mouse_connected = bool(getattr(engine, "device_connected", False)) @@ -511,6 +520,12 @@ def invertVScroll(self): def invertHScroll(self): return self._cfg.get("settings", {}).get("invert_hscroll", False) + @Property(bool, notify=wheelDivertActiveChanged) + def wheelDivertActive(self): + """True when scroll inversion is being applied by the device firmware + via HID++ (0x2121 / 0x2150) rather than by Mouser at the OS layer.""" + return bool(self._wheel_divert_active) + @Property(bool, notify=settingsChanged) def ignoreTrackpad(self): return self._cfg.get("settings", {}).get("ignore_trackpad", True) @@ -1629,6 +1644,18 @@ def _handleStatusMessage(self, message): if message: self.statusMessage.emit(message) + def _onEngineWheelDivertChange(self, active): + """Called from the engine thread; hops to the Qt main thread.""" + self._wheelDivertChangeRequest.emit(bool(active)) + + @Slot(bool) + def _handleWheelDivertChange(self, active): + """Runs on Qt main thread.""" + active = bool(active) + if active != self._wheel_divert_active: + self._wheel_divert_active = active + self.wheelDivertActiveChanged.emit(active) + @Slot(str) def _handleProfileSwitch(self, profile_name): """Runs on Qt main thread.""" diff --git a/ui/locale_manager.py b/ui/locale_manager.py index 0b56717..a659917 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -173,6 +173,10 @@ "scroll.scroll_direction_desc": "Invert the scroll direction (natural scrolling)", "scroll.invert_vertical": "Invert vertical scroll", "scroll.invert_horizontal": "Invert horizontal scroll", + "scroll.wheel_invert_native": "Applied on device (HID++)", + "scroll.wheel_invert_os": "Applied by Mouser", + "scroll.wheel_invert_native_tooltip": "Inversion is flipped at the device firmware (HID++ 0x2121 / 0x2150). The scroll wheel reports the inverted direction natively, so any remote machine receiving these events over Synergy / DeskFlow / KVM / Barrier sees them already inverted.", + "scroll.wheel_invert_os_tooltip": "Inversion is applied locally by Mouser before the OS dispatches the event. A remote machine receiving these scroll events over Synergy / DeskFlow / KVM / Barrier will see the original (un-inverted) direction.", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI changes require HID++ communication with the device and will take effect after a short delay.", @@ -387,6 +391,10 @@ "scroll.scroll_direction_desc": "\u53cd\u8f6c\u6eda\u52a8\u65b9\u5411\uff08\u81ea\u7136\u6eda\u52a8\uff09", "scroll.invert_vertical": "\u53cd\u8f6c\u5782\u76f4\u6eda\u52a8", "scroll.invert_horizontal": "\u53cd\u8f6c\u6c34\u5e73\u6eda\u52a8", + "scroll.wheel_invert_native": "\u8bbe\u5907\u7aef\u53cd\u8f6c\uff08HID++\uff09", + "scroll.wheel_invert_os": "Mouser \u672c\u5730\u53cd\u8f6c", + "scroll.wheel_invert_native_tooltip": "\u7531\u8bbe\u5907\u56fa\u4ef6\uff08HID++ 0x2121 / 0x2150\uff09\u5728\u6eda\u8f6e\u4e0a\u76f4\u63a5\u53cd\u8f6c\u3002\u901a\u8fc7 Synergy / DeskFlow / KVM / Barrier \u8f6c\u53d1\u540e\uff0c\u8fdc\u7aef\u673a\u5668\u4ecd\u5c06\u770b\u5230\u5df2\u53cd\u8f6c\u7684\u65b9\u5411\u3002", + "scroll.wheel_invert_os_tooltip": "\u53cd\u8f6c\u7531 Mouser \u5728\u672c\u673a\u8f93\u5165\u5c42\u5b8c\u6210\u3002\u901a\u8fc7 Synergy / DeskFlow / KVM / Barrier \u8f6c\u53d1\u540e\uff0c\u8fdc\u7aef\u673a\u5668\u4f1a\u770b\u5230\u672a\u53cd\u8f6c\u7684\u539f\u59cb\u65b9\u5411\u3002", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI \u66f4\u6539\u9700\u8981\u901a\u8fc7 HID++ \u4e0e\u8bbe\u5907\u901a\u4fe1\uff0c\u5c06\u5728\u77ed\u6682\u5ef6\u8fdf\u540e\u751f\u6548\u3002", @@ -595,6 +603,10 @@ "scroll.scroll_direction_desc": "\u53cd\u8f49\u6372\u52d5\u65b9\u5411\uff08\u81ea\u7136\u6372\u52d5\uff09", "scroll.invert_vertical": "\u53cd\u8f49\u5782\u76f4\u6372\u52d5", "scroll.invert_horizontal": "\u53cd\u8f49\u6c34\u5e73\u6372\u52d5", + "scroll.wheel_invert_native": "\u88dd\u7f6e\u7aef\u53cd\u8f49\uff08HID++\uff09", + "scroll.wheel_invert_os": "Mouser \u672c\u6a5f\u53cd\u8f49", + "scroll.wheel_invert_native_tooltip": "\u7531\u88dd\u7f6e\u97cc\u9ad4\uff08HID++ 0x2121 / 0x2150\uff09\u5728\u6efe\u8f2a\u4e0a\u76f4\u63a5\u53cd\u8f49\u3002\u900f\u904e Synergy / DeskFlow / KVM / Barrier \u8f49\u767c\u5f8c\uff0c\u9060\u7aef\u6a5f\u5668\u4ecd\u5c07\u770b\u5230\u5df2\u53cd\u8f49\u7684\u65b9\u5411\u3002", + "scroll.wheel_invert_os_tooltip": "\u53cd\u8f49\u7531 Mouser \u5728\u672c\u6a5f\u8f38\u5165\u5c64\u5b8c\u6210\u3002\u900f\u904e Synergy / DeskFlow / KVM / Barrier \u8f49\u767c\u5f8c\uff0c\u9060\u7aef\u6a5f\u5668\u6703\u770b\u5230\u672a\u53cd\u8f49\u7684\u539f\u59cb\u65b9\u5411\u3002", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI \u66f4\u6539\u9700\u8981\u900f\u904e HID++ \u8207\u88dd\u7f6e\u901a\u8a0a\uff0c\u5c07\u5728\u77ed\u66ab\u5ef6\u9072\u5f8c\u751f\u6548\u3002", diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index 160aa4d..fe69f95 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -962,6 +962,55 @@ Item { color: scrollPage.theme.textSecondary } + component InvertScopeBadge: Rectangle { + // Inline indicator placed inside an invert toggle row. + // Visible only when the toggle is on (so it always + // describes a currently-applied inversion). Colour + // tells the user whether that inversion will survive + // Synergy / DeskFlow / KVM forwarding (HID++ does, + // OS-layer injection does not). + property bool active: backend.wheelDivertActive + property bool toggleOn: false + visible: toggleOn + width: labelText.implicitWidth + 16 + height: 22 + radius: 11 + color: active + ? scrollPage.theme.accent + : scrollPage.theme.bgSubtle + border.width: active ? 0 : 1 + border.color: scrollPage.theme.textSecondary + + ToolTip.text: active + ? s["scroll.wheel_invert_native_tooltip"] + : s["scroll.wheel_invert_os_tooltip"] + ToolTip.delay: 400 + ToolTip.visible: hoverArea.containsMouse + + Text { + id: labelText + anchors.centerIn: parent + text: parent.active + ? s["scroll.wheel_invert_native"] + : s["scroll.wheel_invert_os"] + font { + family: uiState.fontFamily + pixelSize: 11 + bold: parent.active + } + color: parent.active + ? scrollPage.theme.bgSidebar + : scrollPage.theme.textSecondary + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + Rectangle { width: parent.width height: 52 @@ -974,6 +1023,7 @@ Item { leftMargin: 16 rightMargin: 16 } + spacing: 10 Text { text: s["scroll.invert_vertical"] @@ -985,6 +1035,10 @@ Item { Layout.fillWidth: true } + InvertScopeBadge { + toggleOn: vscrollSwitch.checked + } + Switch { id: vscrollSwitch checked: backend.invertVScroll @@ -1008,6 +1062,7 @@ Item { leftMargin: 16 rightMargin: 16 } + spacing: 10 Text { text: s["scroll.invert_horizontal"] @@ -1019,6 +1074,10 @@ Item { Layout.fillWidth: true } + InvertScopeBadge { + toggleOn: hscrollSwitch.checked + } + Switch { id: hscrollSwitch checked: backend.invertHScroll From f34bec2ec2e1feaf88c93510e1a21463a9e7db80 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 09:48:20 -0400 Subject: [PATCH 09/10] fix(ui): make wheel-divert badge tristate so it reflects the new gate The badge previously had two states ("Applied on device (HID++)" / "Applied by Mouser"). With the platform-hook gate from #171, the toggle also has a third runtime state: on but inactive because no Logitech is currently connected. Without the third state, the badge would say "Applied by Mouser" while the gate actually suppresses inversion -- the exact UX failure mode the gate is meant to eliminate. * InvertScopeBadge resolves its scope from (mouseConnected, wheelDivertActive): "device" when firmware owns it, "mouser" when the OS-layer fallback owns it, "inactive" when neither is in effect. * Add ``Accessible.role`` / ``name`` / ``description`` so screen readers surface the same information as the hover tooltip. * Add ``scroll.wheel_invert_inactive`` + ``..._inactive_tooltip`` strings in English, Simplified Chinese, and Traditional Chinese. Cover the cross-thread Backend plumbing in ``BackendWheelDivertSignalTests``: * engine callback registration (gated on attribute presence), * default ``wheelDivertActive`` state is False, * callback flip + signal emission via the Qt event loop, * redundant callbacks do not churn the signal, * non-bool truthy values coerce to strict bool so QML's ``Property(bool, ...)`` binding never sees an int. --- tests/test_backend.py | 89 +++++++++++++++++++++++++++++++++++++++++++ ui/locale_manager.py | 6 +++ ui/qml/ScrollPage.qml | 65 ++++++++++++++++++++++--------- 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index 1310276..f2badf6 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -46,6 +46,7 @@ def __init__( self.debug_callback = None self.gesture_callback = None self.status_callback = None + self.wheel_divert_callback = None self.debug_enabled = None self.start_count = 0 self.stop_count = 0 @@ -72,6 +73,9 @@ def set_gesture_event_callback(self, cb): def set_status_callback(self, cb): self.status_callback = cb + def set_wheel_divert_change_callback(self, cb): + self.wheel_divert_callback = cb + def set_debug_enabled(self, enabled): self.debug_enabled = enabled @@ -1273,5 +1277,90 @@ def test_set_start_minimized_does_not_call_apply_login_startup(self): self.assertFalse(backend.startMinimized) +@unittest.skipIf(Backend is None, "PySide6 not installed in test environment") +class BackendWheelDivertSignalTests(unittest.TestCase): + """Pin the contract that exposes HID++ wheel-divert state to QML. + + The Scroll page's invert-scope badge derives its tristate ("device", + "mouser", "inactive") from ``backend.wheelDivertActive`` and + ``backend.mouseConnected``. If either side of that contract slips, the + badge silently misrepresents which inversion path the device is on -- + which is the precise UX failure mode that the new platform-hook gate is + supposed to eliminate. + """ + + def _make_backend(self, engine=None): + loaded_config = copy.deepcopy(DEFAULT_CONFIG) + with ( + patch("ui.backend.load_config", return_value=loaded_config), + patch("ui.backend.save_config"), + patch("ui.backend.supports_login_startup", return_value=False), + ): + return Backend(engine=engine) + + def test_engine_callback_is_registered_when_available(self): + engine = _FakeEngine() + backend = self._make_backend(engine=engine) + self.addCleanup(backend.deleteLater) + self.assertIsNotNone(engine.wheel_divert_callback) + + def test_default_state_is_inactive(self): + backend = self._make_backend(engine=_FakeEngine()) + self.addCleanup(backend.deleteLater) + self.assertFalse(backend.wheelDivertActive) + + def test_callback_flips_property_and_emits_signal(self): + _ensure_qapp() + engine = _FakeEngine() + backend = self._make_backend(engine=engine) + self.addCleanup(backend.deleteLater) + + events: list[bool] = [] + backend.wheelDivertActiveChanged.connect(events.append) + + engine.wheel_divert_callback(True) + QCoreApplication.processEvents() + self.assertTrue(backend.wheelDivertActive) + self.assertEqual(events, [True]) + + engine.wheel_divert_callback(False) + QCoreApplication.processEvents() + self.assertFalse(backend.wheelDivertActive) + self.assertEqual(events, [True, False]) + + def test_redundant_callback_does_not_emit_signal(self): + """No-op transitions must not churn the QML binding -- otherwise the + badge animation would re-trigger on every reconnect even when the + scope stayed the same. + """ + _ensure_qapp() + engine = _FakeEngine() + backend = self._make_backend(engine=engine) + self.addCleanup(backend.deleteLater) + + events: list[bool] = [] + backend.wheelDivertActiveChanged.connect(events.append) + + engine.wheel_divert_callback(False) + engine.wheel_divert_callback(False) + QCoreApplication.processEvents() + self.assertEqual(events, []) + + def test_truthy_non_bool_value_is_coerced(self): + """Engines that pass ``1`` / ``0`` instead of strict booleans must not + leak the raw int into the property. QML bindings type-narrow against + the ``Property(bool, ...)`` declaration, so the property accessor has + to coerce defensively. + """ + _ensure_qapp() + engine = _FakeEngine() + backend = self._make_backend(engine=engine) + self.addCleanup(backend.deleteLater) + + engine.wheel_divert_callback(1) + QCoreApplication.processEvents() + self.assertIs(backend.wheelDivertActive, True) + + if __name__ == "__main__": unittest.main() diff --git a/ui/locale_manager.py b/ui/locale_manager.py index a659917..f1fce54 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -175,8 +175,10 @@ "scroll.invert_horizontal": "Invert horizontal scroll", "scroll.wheel_invert_native": "Applied on device (HID++)", "scroll.wheel_invert_os": "Applied by Mouser", + "scroll.wheel_invert_inactive": "Inactive \u2014 connect a Logitech mouse", "scroll.wheel_invert_native_tooltip": "Inversion is flipped at the device firmware (HID++ 0x2121 / 0x2150). The scroll wheel reports the inverted direction natively, so any remote machine receiving these events over Synergy / DeskFlow / KVM / Barrier sees them already inverted.", "scroll.wheel_invert_os_tooltip": "Inversion is applied locally by Mouser before the OS dispatches the event. A remote machine receiving these scroll events over Synergy / DeskFlow / KVM / Barrier will see the original (un-inverted) direction.", + "scroll.wheel_invert_inactive_tooltip": "The wheel-invert toggle is on but no Logitech mouse is currently connected. Mouser only inverts scroll events from Logitech devices, so this toggle has no effect right now -- connect a supported Logitech mouse and the badge will update.", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI changes require HID++ communication with the device and will take effect after a short delay.", @@ -393,8 +395,10 @@ "scroll.invert_horizontal": "\u53cd\u8f6c\u6c34\u5e73\u6eda\u52a8", "scroll.wheel_invert_native": "\u8bbe\u5907\u7aef\u53cd\u8f6c\uff08HID++\uff09", "scroll.wheel_invert_os": "Mouser \u672c\u5730\u53cd\u8f6c", + "scroll.wheel_invert_inactive": "\u672a\u751f\u6548 \u2014 \u8bf7\u8fde\u63a5\u7f57\u6280\u9f20\u6807", "scroll.wheel_invert_native_tooltip": "\u7531\u8bbe\u5907\u56fa\u4ef6\uff08HID++ 0x2121 / 0x2150\uff09\u5728\u6eda\u8f6e\u4e0a\u76f4\u63a5\u53cd\u8f6c\u3002\u901a\u8fc7 Synergy / DeskFlow / KVM / Barrier \u8f6c\u53d1\u540e\uff0c\u8fdc\u7aef\u673a\u5668\u4ecd\u5c06\u770b\u5230\u5df2\u53cd\u8f6c\u7684\u65b9\u5411\u3002", "scroll.wheel_invert_os_tooltip": "\u53cd\u8f6c\u7531 Mouser \u5728\u672c\u673a\u8f93\u5165\u5c42\u5b8c\u6210\u3002\u901a\u8fc7 Synergy / DeskFlow / KVM / Barrier \u8f6c\u53d1\u540e\uff0c\u8fdc\u7aef\u673a\u5668\u4f1a\u770b\u5230\u672a\u53cd\u8f6c\u7684\u539f\u59cb\u65b9\u5411\u3002", + "scroll.wheel_invert_inactive_tooltip": "\u53cd\u8f6c\u5f00\u5173\u5df2\u542f\u7528\uff0c\u4f46\u672a\u8fde\u63a5\u4efb\u4f55\u7f57\u6280\u9f20\u6807\u3002Mouser \u4ec5\u53cd\u8f6c\u6765\u81ea\u7f57\u6280\u8bbe\u5907\u7684\u6eda\u52a8\u4e8b\u4ef6\uff0c\u56e0\u6b64\u5f53\u524d\u6b64\u5f00\u5173\u4e0d\u4f1a\u751f\u6548\u3002\u8fde\u63a5\u53d7\u652f\u6301\u7684\u7f57\u6280\u9f20\u6807\u540e\uff0c\u5fbd\u6807\u5c06\u66f4\u65b0\u3002", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI \u66f4\u6539\u9700\u8981\u901a\u8fc7 HID++ \u4e0e\u8bbe\u5907\u901a\u4fe1\uff0c\u5c06\u5728\u77ed\u6682\u5ef6\u8fdf\u540e\u751f\u6548\u3002", @@ -605,8 +609,10 @@ "scroll.invert_horizontal": "\u53cd\u8f49\u6c34\u5e73\u6372\u52d5", "scroll.wheel_invert_native": "\u88dd\u7f6e\u7aef\u53cd\u8f49\uff08HID++\uff09", "scroll.wheel_invert_os": "Mouser \u672c\u6a5f\u53cd\u8f49", + "scroll.wheel_invert_inactive": "\u672a\u751f\u6548 \u2014 \u8acb\u9023\u63a5\u7f85\u6280\u9f20\u6a19", "scroll.wheel_invert_native_tooltip": "\u7531\u88dd\u7f6e\u97cc\u9ad4\uff08HID++ 0x2121 / 0x2150\uff09\u5728\u6efe\u8f2a\u4e0a\u76f4\u63a5\u53cd\u8f49\u3002\u900f\u904e Synergy / DeskFlow / KVM / Barrier \u8f49\u767c\u5f8c\uff0c\u9060\u7aef\u6a5f\u5668\u4ecd\u5c07\u770b\u5230\u5df2\u53cd\u8f49\u7684\u65b9\u5411\u3002", "scroll.wheel_invert_os_tooltip": "\u53cd\u8f49\u7531 Mouser \u5728\u672c\u6a5f\u8f38\u5165\u5c64\u5b8c\u6210\u3002\u900f\u904e Synergy / DeskFlow / KVM / Barrier \u8f49\u767c\u5f8c\uff0c\u9060\u7aef\u6a5f\u5668\u6703\u770b\u5230\u672a\u53cd\u8f49\u7684\u539f\u59cb\u65b9\u5411\u3002", + "scroll.wheel_invert_inactive_tooltip": "\u53cd\u8f49\u958b\u95dc\u5df2\u555f\u7528\uff0c\u4f46\u76ee\u524d\u672a\u9023\u63a5\u4efb\u4f55\u7f85\u6280\u9f20\u6a19\u3002Mouser \u50c5\u53cd\u8f49\u4f86\u81ea\u7f85\u6280\u88dd\u7f6e\u7684\u6efe\u52d5\u4e8b\u4ef6\uff0c\u56e0\u6b64\u73fe\u5728\u6b64\u958b\u95dc\u4e0d\u6703\u751f\u6548\u3002\u9023\u63a5\u53d7\u652f\u63f4\u7684\u7f85\u6280\u9f20\u6a19\u5f8c\uff0c\u6a19\u8a18\u5c07\u66f4\u65b0\u3002", "scroll.ignore_trackpad": "Ignore trackpad", "scroll.ignore_trackpad_desc": "Only respond to mouse events, not trackpad or Magic Mouse", "scroll.dpi_note": "DPI \u66f4\u6539\u9700\u8981\u900f\u904e HID++ \u8207\u88dd\u7f6e\u901a\u8a0a\uff0c\u5c07\u5728\u77ed\u66ab\u5ef6\u9072\u5f8c\u751f\u6548\u3002", diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index fe69f95..bcfc723 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -964,43 +964,74 @@ Item { component InvertScopeBadge: Rectangle { // Inline indicator placed inside an invert toggle row. - // Visible only when the toggle is on (so it always - // describes a currently-applied inversion). Colour - // tells the user whether that inversion will survive - // Synergy / DeskFlow / KVM forwarding (HID++ does, - // OS-layer injection does not). + // Tristate so the user always sees the truth about + // whether their toggle is doing anything: + // * "device" -- inverted at device firmware (HID++). + // Survives Synergy / DeskFlow / KVM / + // Barrier forwarding because the + // wheel reports the inverted sign + // natively. + // * "mouser" -- inverted locally by Mouser before + // the OS dispatches the event. Does + // NOT survive KVM forwarding (the + // remote sees the original sign). + // * "inactive" -- the toggle is on but no Logitech + // is currently connected, so the + // platform hook gate suppresses + // inversion entirely. property bool active: backend.wheelDivertActive + property bool mouseConnected: backend.mouseConnected property bool toggleOn: false + readonly property string scope: + !mouseConnected + ? "inactive" + : active + ? "device" + : "mouser" visible: toggleOn width: labelText.implicitWidth + 16 height: 22 radius: 11 - color: active + color: scope === "device" ? scrollPage.theme.accent - : scrollPage.theme.bgSubtle - border.width: active ? 0 : 1 - border.color: scrollPage.theme.textSecondary - - ToolTip.text: active + : scope === "inactive" + ? "transparent" + : scrollPage.theme.bgSubtle + border.width: scope === "device" ? 0 : 1 + border.color: scope === "inactive" + ? scrollPage.theme.warning + : scrollPage.theme.textSecondary + + ToolTip.text: scope === "device" ? s["scroll.wheel_invert_native_tooltip"] - : s["scroll.wheel_invert_os_tooltip"] + : scope === "inactive" + ? s["scroll.wheel_invert_inactive_tooltip"] + : s["scroll.wheel_invert_os_tooltip"] ToolTip.delay: 400 ToolTip.visible: hoverArea.containsMouse + Accessible.role: Accessible.StaticText + Accessible.name: labelText.text + Accessible.description: ToolTip.text + Text { id: labelText anchors.centerIn: parent - text: parent.active + text: parent.scope === "device" ? s["scroll.wheel_invert_native"] - : s["scroll.wheel_invert_os"] + : parent.scope === "inactive" + ? s["scroll.wheel_invert_inactive"] + : s["scroll.wheel_invert_os"] font { family: uiState.fontFamily pixelSize: 11 - bold: parent.active + bold: parent.scope === "device" } - color: parent.active + color: parent.scope === "device" ? scrollPage.theme.bgSidebar - : scrollPage.theme.textSecondary + : parent.scope === "inactive" + ? scrollPage.theme.warning + : scrollPage.theme.textSecondary } MouseArea { From d7decdba3ab0df80aac88e68a8e27e9e4ce8ed02 Mon Sep 17 00:00:00 2001 From: hughesyadaddy Date: Mon, 18 May 2026 10:18:44 -0400 Subject: [PATCH 10/10] fix(ui): remove duplicate _handleStatusMessage slot The Backend class defined ``_handleStatusMessage`` twice with identical bodies (~lines 1641 and 1854 before this change). Python keeps the second definition, so the earlier copy was dead code -- but any future edit to the upper block would silently no-op. Delete the first copy; the second one already carries the @Slot(str) decorator and the same body, and it is the one the cross-thread connection in __init__ already binds against (the bound-method ``self._handleStatusMessage`` resolves to the second definition at connect time regardless). --- ui/backend.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ui/backend.py b/ui/backend.py index 4045fd2..d744ed0 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -1638,12 +1638,6 @@ def _onEngineStatusMessage(self, message): """Called from engine thread — posts to Qt main thread.""" self._statusMessageRequest.emit(str(message or "")) - @Slot(str) - def _handleStatusMessage(self, message): - """Runs on Qt main thread.""" - if message: - self.statusMessage.emit(message) - def _onEngineWheelDivertChange(self, active): """Called from the engine thread; hops to the Qt main thread.""" self._wheelDivertChangeRequest.emit(bool(active))