diff --git a/core/logi_device_catalog.py b/core/logi_device_catalog.py index 09f1f5d3..180d7471 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 8da965fe..d060f699 100644 --- a/core/logi_devices.py +++ b/core/logi_devices.py @@ -12,7 +12,7 @@ import re from typing import Iterable -from core.logi_device_catalog import LOGI_DEVICE_SPECS +from core.logi_device_catalog import LOGI_DEVICE_SPECS, MX_MASTER_4_BUTTONS DEFAULT_GESTURE_CIDS = (0x00C3, 0x00D7) @@ -119,6 +119,16 @@ class LogiDeviceSpec: supported_buttons: tuple[str, ...] = DEFAULT_BUTTON_LAYOUT dpi_min: int = DEFAULT_DPI_MIN dpi_max: int = DEFAULT_DPI_MAX + # Catalog hints; runtime HID++ discovery overrides these via ConnectedDeviceInfo. + has_hires_wheel: bool = False + has_thumbwheel: bool = False + # True when the device exposes a haptic Sense Panel that should drive + # gestures (MX Master 4 family). Enables an OS-level btn=6 / BTN_TASK + # fallback path when HID++ divert of the panel is unavailable. + gesture_via_sense_panel: bool = False + # Optional second thumb-area control to divert as a button-only extra + # (no rawXY) alongside the active gesture CID. + thumb_button_cid: int | None = None def matches(self, product_id=None, product_name=None) -> bool: if product_id is not None and int(product_id) in self.product_ids: @@ -367,6 +377,19 @@ class ConnectedDeviceInfo: dpi_min: int = DEFAULT_DPI_MIN dpi_max: int = DEFAULT_DPI_MAX capability_inventory: DeviceCapabilityInventory = DeviceCapabilityInventory() + # has_* mirrors HID++ feature presence; *_active reflects whether the + # listener currently holds the firmware-invert lease. Engine and mouse + # hooks read these directly instead of re-walking the inventory. + has_hires_wheel: bool = False + has_thumbwheel: bool = False + hires_wheel_active: bool = False + thumbwheel_active: bool = False + gesture_via_sense_panel: bool = False + thumb_button_cid: int | None = None + # CID the listener actually diverted as the gesture role; None until divert succeeds. + active_gesture_cid: int | None = None + # True when thumb_button events arrive over HID++ rather than via the OS button path. + thumb_button_via_hid: bool = False # Seeded from Mouser's own device catalog first, then extended with broader @@ -419,9 +442,32 @@ def iter_known_devices() -> Iterable[LogiDeviceSpec]: def clamp_dpi(value, device=None) -> int: + """Clamp ``value`` into the device's DPI range, defaulting to the safe + floor on malformed input. + + ``value`` may arrive from a JSON config file, a QML binding, or a HID + report -- so the raw ``int(value)`` cast that was here before could + raise on a stringified hex value, on ``None``, or on a partial HID + read. Crashing the engine's DPI path because the user edited + ``config.json`` by hand is the wrong failure mode; we coerce + defensively and return the device minimum so the cursor never freezes. + """ dpi_min = getattr(device, "dpi_min", DEFAULT_DPI_MIN) or DEFAULT_DPI_MIN dpi_max = getattr(device, "dpi_max", DEFAULT_DPI_MAX) or DEFAULT_DPI_MAX - dpi = int(value) + if isinstance(value, bool): + # ``bool`` is a subclass of ``int`` -- reject it explicitly so a + # leaked truthy flag never silently resolves to 0 or 1 DPI. + return dpi_min + if isinstance(value, str): + try: + dpi = int(value, 0) + except (TypeError, ValueError): + return dpi_min + else: + try: + dpi = int(value) + except (TypeError, ValueError): + return dpi_min return max(dpi_min, min(dpi_max, dpi)) @@ -432,18 +478,27 @@ def resolve_device(product_id=None, product_name=None) -> LogiDeviceSpec | None: return None -def _control_cid(control) -> int | None: - if not isinstance(control, dict): - return None - cid = control.get("cid") - if cid in (None, ""): +def _coerce_cid(value) -> int | None: + """Normalize a CID value (int, ``"0x01A0"`` hex string, or ``None``) to ``int | None``. + + Returns ``None`` for falsy / unparseable inputs so callers never have to + distinguish between "absent" and "malformed" -- the contract is + intentionally fail-closed. + """ + if value in (None, ""): return None try: - return int(cid, 0) if isinstance(cid, str) else int(cid) + return int(value, 0) if isinstance(value, str) else int(value) except (TypeError, ValueError): return None +def _control_cid(control) -> int | None: + if not isinstance(control, dict): + return None + return _coerce_cid(control.get("cid")) + + def _control_int(control, field) -> int | None: if not isinstance(control, dict): return None @@ -763,6 +818,7 @@ def derive_supported_buttons_from_reprog_controls( # resolve buttons even when individual devices use per-device ui_layout keys. _LAYOUT_BUTTONS = { "mx_master": MX_MASTER_BUTTONS, + "mx_master_4": MX_MASTER_4_BUTTONS, "mx_anywhere": MX_ANYWHERE_BUTTONS, "mx_vertical": MX_VERTICAL_BUTTONS, "generic_mouse": GENERIC_BUTTONS, @@ -779,6 +835,19 @@ def get_buttons_for_layout(ui_layout_key: str) -> tuple[str, ...] | None: return None +def _resolve_capability(runtime: bool | None, catalog: bool) -> bool: + """Tristate capability resolver: ``None`` defers to catalog; non-None wins. + + Callers signal "I haven't probed this yet" with ``None`` and "I probed and + here's what I saw" with ``True``/``False``. Without the tristate, a runtime + probe that returned ``False`` (definitive absence) would be silently + overridden by an optimistic catalog hint. + """ + if runtime is None: + return bool(catalog) + return bool(runtime) + + def build_connected_device_info( *, product_id=None, @@ -792,6 +861,11 @@ def build_connected_device_info( discovered_features=None, device_identity=None, diagnostics=None, + has_hires_wheel: bool | None = None, + has_thumbwheel: bool | None = None, + hires_wheel_active: bool = False, + thumbwheel_active: bool = False, + thumb_button_via_hid: bool = False, ) -> ConnectedDeviceInfo: spec = resolve_device(product_id=product_id, product_name=product_name) pid = int(product_id) if product_id not in (None, "") else None @@ -802,17 +876,27 @@ def build_connected_device_info( "source": source, **dict(device_identity or {}), } + # Empty tuple is a legitimate "no gesture CIDs detected" signal from the + # runtime; only fall back to spec/defaults when the caller passes ``None``. + spec_gesture_cids = spec.gesture_cids if spec is not None else None inventory = build_device_capability_inventory( reprog_controls, device_identity=identity, - gesture_cids=gesture_cids or getattr(spec, "gesture_cids", None), + gesture_cids=spec_gesture_cids if gesture_cids is None else gesture_cids, active_gesture_cid=active_gesture_cid, gesture_rawxy_enabled=gesture_rawxy_enabled, discovered_features=discovered_features, diagnostics=diagnostics, ) + spec_has_hires = bool(spec.has_hires_wheel) if spec is not None else False + spec_has_thumb = bool(spec.has_thumbwheel) if spec is not None else False + eff_has_hires = _resolve_capability(has_hires_wheel, spec_has_hires) + eff_has_thumb = _resolve_capability(has_thumbwheel, spec_has_thumb) + normalized_active_cid = _coerce_cid(active_gesture_cid) if spec: - resolved_gesture_cids = tuple(gesture_cids or spec.gesture_cids) + resolved_gesture_cids = ( + tuple(gesture_cids) if gesture_cids is not None else tuple(spec.gesture_cids) + ) return ConnectedDeviceInfo( key=spec.key, display_name=spec.display_name, @@ -827,6 +911,14 @@ def build_connected_device_info( dpi_min=spec.dpi_min, dpi_max=spec.dpi_max, capability_inventory=inventory, + has_hires_wheel=eff_has_hires, + has_thumbwheel=eff_has_thumb, + hires_wheel_active=bool(hires_wheel_active), + thumbwheel_active=bool(thumbwheel_active), + gesture_via_sense_panel=bool(spec.gesture_via_sense_panel), + thumb_button_cid=spec.thumb_button_cid, + active_gesture_cid=normalized_active_cid, + thumb_button_via_hid=bool(thumb_button_via_hid), ) # Fallback for unrecognized devices (e.g., USB Receiver PID 0xC52B which @@ -836,6 +928,9 @@ def build_connected_device_info( f"Logitech PID 0x{pid:04X}" if pid is not None else "Logitech mouse" ) key = _normalize_name(display_name).replace(" ", "_") or "logitech_mouse" + fallback_gesture_cids = ( + tuple(gesture_cids) if gesture_cids is not None else tuple(DEFAULT_GESTURE_CIDS) + ) return ConnectedDeviceInfo( key=key, display_name=display_name, @@ -846,8 +941,14 @@ def build_connected_device_info( ui_layout="generic_mouse", image_asset="icons/mouse-simple.svg", supported_buttons=GENERIC_BUTTONS, - gesture_cids=tuple(gesture_cids or DEFAULT_GESTURE_CIDS), + gesture_cids=fallback_gesture_cids, capability_inventory=inventory, + has_hires_wheel=eff_has_hires, + has_thumbwheel=eff_has_thumb, + hires_wheel_active=bool(hires_wheel_active), + thumbwheel_active=bool(thumbwheel_active), + active_gesture_cid=normalized_active_cid, + thumb_button_via_hid=bool(thumb_button_via_hid), ) diff --git a/tests/test_logi_devices.py b/tests/test_logi_devices.py index 21696813..e1ca62e1 100644 --- a/tests/test_logi_devices.py +++ b/tests/test_logi_devices.py @@ -233,6 +233,31 @@ def test_clamp_dpi_defaults_without_device(self): self.assertEqual(clamp_dpi(100, None), 200) self.assertEqual(clamp_dpi(9000, None), 8000) + def test_clamp_dpi_accepts_string_int(self): + """Hand-edited config files can persist DPI as a JSON string when + users copy values around. Coerce instead of throwing.""" + self.assertEqual(clamp_dpi("1500", None), 1500) + + def test_clamp_dpi_accepts_hex_string(self): + self.assertEqual(clamp_dpi("0x5DC", None), 1500) + + def test_clamp_dpi_falls_back_to_min_on_garbage_string(self): + self.assertEqual(clamp_dpi("not-a-number", None), 200) + + def test_clamp_dpi_falls_back_to_min_on_none(self): + self.assertEqual(clamp_dpi(None, None), 200) + + def test_clamp_dpi_rejects_bool(self): + """``bool`` is a subclass of ``int`` -- without the explicit check + ``True``/``False`` would silently clamp to ``dpi_min``/``dpi_min`` + because ``int(True) == 1`` and ``min(200, 1) == 1`` becomes 200 via + the floor, which masks the upstream bug.""" + self.assertEqual(clamp_dpi(True, None), 200) + self.assertEqual(clamp_dpi(False, None), 200) + + def test_clamp_dpi_accepts_float(self): + self.assertEqual(clamp_dpi(1500.7, None), 1500) + def test_mx_anywhere_2s_supported_buttons_include_middle_and_hscroll(self): device = resolve_device(product_id=0xB01A) @@ -584,13 +609,128 @@ def test_mx_master_4_haptic_control_does_not_create_supported_button(self): self.assertIn("mode_shift", info.supported_buttons) self.assertIn("gesture_down", info.supported_buttons) - self.assertNotIn("action_ring", info.supported_buttons) + # `thumb_button` is the relocated Mouse Gesture Button (CID 0x00C3) + # exposed as a UI mapping target via MX_MASTER_4_BUTTONS. The Sense + # Panel (CID 0x01A0) drives gestures via rawXY and does not get a + # button entry of its own. + self.assertIn("thumb_button", info.supported_buttons) self.assertNotIn("haptic", info.supported_buttons) self.assertEqual( info.capability_inventory.to_dict()["known_unsupported_controls"], [{"cid": "0x01A0", "name": "haptic"}], ) + def test_mx_master_4_spec_metadata_mirrored_onto_connected_info(self): + info = build_connected_device_info(product_id=0xB042) + + self.assertEqual(info.key, "mx_master_4") + self.assertEqual(info.gesture_cids, (0x01A0, 0x00C3, 0x00D7)) + self.assertEqual(info.thumb_button_cid, 0x00C3) + self.assertTrue(info.gesture_via_sense_panel) + self.assertTrue(info.has_hires_wheel) + self.assertTrue(info.has_thumbwheel) + self.assertFalse(info.hires_wheel_active) + self.assertFalse(info.thumbwheel_active) + self.assertFalse(info.thumb_button_via_hid) + self.assertIsNone(info.active_gesture_cid) + + def test_active_gesture_cid_is_normalized_to_int(self): + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid=0x01A0, + ) + + self.assertEqual(info.active_gesture_cid, 0x01A0) + self.assertIsInstance(info.active_gesture_cid, int) + + def test_active_gesture_cid_accepts_hex_string(self): + """Runtime callers occasionally hand back CIDs as hex strings; the + normalizer must coerce or downstream mouse hooks compare wrong types. + """ + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid="0x01A0", + ) + + self.assertEqual(info.active_gesture_cid, 0x01A0) + self.assertIsInstance(info.active_gesture_cid, int) + + def test_active_gesture_cid_malformed_value_resolves_to_none(self): + """Garbage in, ``None`` out -- never a stale numeric coercion.""" + info = build_connected_device_info( + product_id=0xB042, + active_gesture_cid="not-a-hex", + ) + + self.assertIsNone(info.active_gesture_cid) + + def test_unknown_pid_falls_back_to_generic_layout(self): + """Unrecognized devices must never inherit MX-family controls or layouts.""" + info = build_connected_device_info( + product_id=0xDEAD, + product_name="Mystery Mouse", + ) + + self.assertEqual(info.ui_layout, "generic_mouse") + self.assertEqual(info.image_asset, "icons/mouse-simple.svg") + self.assertEqual(info.supported_buttons, GENERIC_BUTTONS) + self.assertNotIn("thumb_button", info.supported_buttons) + self.assertNotIn("hscroll_left", info.supported_buttons) + self.assertNotIn("mode_shift", info.supported_buttons) + self.assertFalse(info.gesture_via_sense_panel) + self.assertIsNone(info.thumb_button_cid) + + def test_unknown_pid_defaults_capabilities_to_false(self): + info = build_connected_device_info(product_id=0xDEAD) + + self.assertFalse(info.has_hires_wheel) + self.assertFalse(info.has_thumbwheel) + + def test_explicit_runtime_false_overrides_catalog_true(self): + """The tristate capability resolver is the FANG-critical contract: + a runtime probe that definitively saw no HiResWheel must defeat a + catalog hint, otherwise the listener tries to divert a feature the + device does not advertise and the device returns hard errors. + """ + info = build_connected_device_info( + product_id=0xB042, + has_hires_wheel=False, + has_thumbwheel=False, + ) + + self.assertFalse(info.has_hires_wheel) + self.assertFalse(info.has_thumbwheel) + + def test_explicit_runtime_true_on_uncatalogued_device(self): + """Devices the catalog does not know about can still be upgraded to + having a HiResWheel by a runtime probe.""" + info = build_connected_device_info( + product_id=0xDEAD, + has_hires_wheel=True, + ) + + self.assertTrue(info.has_hires_wheel) + self.assertEqual(info.ui_layout, "generic_mouse") + + def test_caller_supplied_empty_gesture_cids_is_respected(self): + """``gesture_cids=()`` is the runtime signal "I probed and saw none"; + it must not silently fall back to ``spec.gesture_cids``. + """ + info = build_connected_device_info( + product_id=0xB042, + gesture_cids=(), + ) + + self.assertEqual(info.gesture_cids, ()) + + def test_caller_supplied_empty_gesture_cids_on_unknown_device(self): + info = build_connected_device_info( + product_id=0xDEAD, + gesture_cids=(), + ) + + self.assertEqual(info.gesture_cids, ()) + if __name__ == "__main__": unittest.main()