diff --git a/CONTRIBUTING_DEVICES.md b/CONTRIBUTING_DEVICES.md index e9ad253..77e4a0c 100644 --- a/CONTRIBUTING_DEVICES.md +++ b/CONTRIBUTING_DEVICES.md @@ -48,28 +48,34 @@ Look at the `reprog_controls` array. Each entry has a `cid` (Control ID) and Not all CIDs are divertable. Check the `flags` field -- if bit `0x0020` is set, the control can be intercepted by Mouser. +Directional gesture mappings also require RawXY support (`0x0100` or +`0x0200`) and a successful RawXY divert during connection. --- ## 3. Add the device definition -### a) Edit `core/logi_devices.py` +### a) Add a device catalog entry -Add a new `LogiDeviceSpec` entry to the `KNOWN_LOGI_DEVICES` tuple: +For exact device support, edit `core/logi_device_catalog.py` first. This file +holds Mouser's community-maintained per-device Logitech entries, including the +device image and hotspot coordinates used by the UI. + +Add a new dict to `LOGI_DEVICE_SPECS`: ```python -LogiDeviceSpec( - key="mx_ergo", # unique snake_case key - display_name="MX Ergo", # human-readable name - product_ids=(0xB0XX,), # from your dump's product_id - aliases=("Logitech MX Ergo",), # alternative names the device may report - ui_layout="generic_mouse", # or a custom layout key (see step 4) - image_asset="icons/mouse-simple.svg", # or a custom image (see step 4) - supported_buttons=GENERIC_BUTTONS, # adjust to match your mouse - gesture_cids=(0x00C3,), # from gesture_candidates in your dump - dpi_min=200, - dpi_max=4000, # from discovered DPI range, or Logitech specs -), +{ + "key": "mx_ergo", # unique snake_case key + "display_name": "MX Ergo", # human-readable name + "product_ids": (0xB0XX,), # from your dump's product_id + "aliases": ("Logitech MX Ergo",), # alternative names the device may report + "ui_layout": "mx_ergo", # exact layout key + "image_asset": "logitech-mice/mx_ergo/mouse.png", + "supported_buttons": GENERIC_BUTTONS, # adjust to match your mouse + "gesture_cids": (0x00C3,), # from gesture_candidates in your dump + "dpi_min": 200, + "dpi_max": 4000, # from discovered DPI range, or vendor specs +}, ``` Pick the right button tuple for `supported_buttons`: @@ -80,43 +86,55 @@ Pick the right button tuple for `supported_buttons`: - `GENERIC_BUTTONS` -- middle, back, forward (safe default) - Or define a new tuple if your mouse has a unique button set. -### b) (Optional) Add an interactive layout +`supported_buttons` is a static fallback. When Mouser connects through HID++ +and discovers `REPROG_V4` controls, it may narrow HID++-gated buttons such as +gesture, Smart Shift / mode shift, and DPI switch based on the runtime control +table. Unknown CIDs are intentionally not exposed until Mouser has code that +knows how to handle them. Horizontal scroll remains catalog-driven because +some devices implement it as OS events or side-button + wheel behavior instead +of a standalone reprogrammable control. + +Use `core/logi_devices.py` only when you are adding a broader family fallback +without exact art yet. + +### b) Add an exact interactive layout If you want the mouse page to show an interactive diagram with clickable -hotspot dots: +hotspot dots, add a layout dict in `core/logi_device_catalog.py` instead of +growing `core/device_layouts.py`: -1. Create an image of your mouse (top-down PNG or SVG, ~400x350 px). - Place it in `images/`. -2. Add a layout dict in `core/device_layouts.py`: +1. Create a small image set for your mouse and place it in + `images/logitech-mice//`. +2. Add a layout dict to `LOGI_DEVICE_LAYOUTS`: ```python -MY_DEVICE_LAYOUT = { - "key": "my_device", - "label": "My Device family", - "image_asset": "mouse_my_device.svg", - "image_width": 400, - "image_height": 350, +"mx_ergo": { + "key": "mx_ergo", + "label": "MX Ergo", + "image_asset": "logitech-mice/mx_ergo/mouse.png", + "image_width": 260, + "image_height": 400, "interactive": True, - "manual_selectable": True, + "manual_selectable": False, "note": "", "hotspots": [ { - "buttonKey": "middle", # must match a supported_buttons entry + "buttonKey": "middle", # must match a supported_buttons entry "label": "Middle button", - "summaryType": "mapping", # "mapping", "gesture", or "hscroll" + "summaryType": "mapping", # "mapping", "gesture", or "hscroll" "normX": 0.50, # 0-1, fraction of image width "normY": 0.30, # 0-1, fraction of image height "labelSide": "right", # "left" or "right" "labelOffX": 150, # pixel offset for the annotation line "labelOffY": -60, }, - # ... one entry per visible button ], -} +}, ``` -3. Register it in the `DEVICE_LAYOUTS` dict at the bottom of the file. -4. Set `ui_layout` in your `LogiDeviceSpec` to match the layout key. +`core/device_layouts.py` still owns shared manual family layouts such as +`mx_master`, `mx_anywhere`, and `mx_vertical`. Keep those family entries +manual-selectable; keep exact per-device layouts auto-detected only. ### Estimating hotspot coordinates @@ -125,6 +143,14 @@ cursor X by image width and cursor Y by image height to get `normX`/`normY`. The label offset values control where the annotation text appears relative to the dot -- experiment with positive/negative values until it looks right. +### Keep it small + +- Prefer focused, reviewable device entries over large multi-device changes. +- Keep image assets and hotspot data close to what the UI actually uses. +- Prefer exact per-device entries for hardware that has been checked in-app. +- If the device is only partially understood, add a family fallback first and + leave the exact layout for a follow-up contribution. + --- ## 4. Test your changes diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ac5ec7c..a46c847 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -132,8 +132,9 @@ Intercepted events are either **blocked** (hook returns `1`) and replaced with a ### Device catalog & layout registry -- [`core/logi_devices.py`](core/logi_devices.py) resolves known product IDs and model aliases into a `ConnectedDeviceInfo` record with display name, DPI range, preferred gesture CIDs, and default UI layout key. -- [`core/device_layouts.py`](core/device_layouts.py) stores image assets, hotspot coordinates, layout notes, and whether a layout is interactive or only a generic fallback. `_FAMILY_FALLBACKS` maps per-model keys (`mx_master_4`, `mx_anywhere_3s`, …) to family layout keys until a dedicated overlay exists. +- [`core/logi_device_catalog.py`](core/logi_device_catalog.py) holds Mouser's curated per-device Logitech specs, image assets, and hotspot coordinates for dedicated control surfaces. +- [`core/logi_devices.py`](core/logi_devices.py) resolves known product IDs and model aliases into a `ConnectedDeviceInfo` record with display name, DPI range, preferred gesture CIDs, supported buttons, and default UI layout key. +- [`core/device_layouts.py`](core/device_layouts.py) stores built-in family layouts plus catalog layouts, layout notes, and whether a layout is interactive or only a generic fallback. `_FAMILY_FALLBACKS` maps per-model keys to family layout keys until a dedicated overlay exists. - [`ui/backend.py`](ui/backend.py) combines auto-detected device info with any persisted per-device layout override and exposes the effective layout to QML. ### Gesture button detection @@ -185,7 +186,7 @@ Two pages accessible from a slim sidebar in [`ui/qml/Main.qml`](ui/qml/Main.qml) ### Mouse & profiles - **Left panel** — list of profiles. The "Default (All Apps)" profile is always present. Per-app profiles show the app icon and name. Selecting a profile binds it as the active editing target. -- **Right panel** — device-aware mouse view. MX Master family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker. +- **Right panel** — device-aware mouse view. MX Master and MX Anywhere family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker. - **Add profile** — combo box at the bottom lists known apps (Chrome, Edge, VS Code, VLC, etc.). Click `+` to create a per-app profile. ### Point & scroll @@ -225,6 +226,7 @@ mouser/ │ ├── key_simulator.py # Platform-specific action simulator │ ├── linux_permissions.py # hidraw / event / uinput permission report │ ├── log_setup.py # Rotating file log + stdout redirection +│ ├── logi_device_catalog.py # Curated Logitech specs, assets, and hotspots │ ├── logi_devices.py # Known Logitech device catalog + connected-device metadata │ ├── mouse_hook.py # Platform dispatcher façade │ ├── mouse_hook_base.py # Shared base class diff --git a/README.md b/README.md index 342451e..3d3b138 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ English | [中文文档](README_CN.md) A lightweight, open-source, fully local alternative to **Logitech Options+** for remapping Logitech HID++ mice. The current best experience is on the **MX Master** -family, with detection and fallback UI support for additional Logitech models. +and **MX Anywhere** families, with detection and fallback UI support for additional +Logitech models. **No telemetry. No cloud. No Logitech account required.** @@ -128,7 +129,7 @@ That's it. The app opens, drops a tray / menu-bar icon, and starts remapping imm - **Bluetooth and Logi Bolt** — both transports are supported on all three platforms; the UI labels the live connection (`Logi Bolt` only when the receiver PID is positively identified). - **Auto-reconnection** — Mouser watches for power-off / on cycles and rebinds HID++ + the OS mouse hook without a restart; SmartShift settings are replayed on every reconnect (including wake-from-sleep). - **Live connection status** — real-time Connected / Not Connected badge, model name, and active layout in the UI. -- **Device-aware UI** — interactive MX Master diagram with clickable hotspots; generic fallback card for other models, with an experimental layout-override picker. +- **Device-aware UI** — interactive MX Master and MX Anywhere diagrams with clickable hotspots; generic fallback card for other models, with an experimental layout-override picker. ### Multi-language UI @@ -148,12 +149,12 @@ That's it. The app opens, drops a tray / menu-bar icon, and starts remapping imm | Family / model | Detection + HID++ probing | UI support | |---|---|---| -| MX Master 4 / 3S / 3 / 2S / MX Master | Yes | Dedicated interactive `mx_master` layout | -| MX Anywhere 3S / 3 / 2S | Yes | Generic fallback card, experimental manual override | +| MX Master 4 / 3S / 3 / 2S / MX Master | Yes | Dedicated interactive per-model layouts | +| MX Anywhere 3S / 3 / 2S | Yes | Dedicated interactive per-model layouts | | MX Vertical | Yes | Generic fallback card (with DPI switch button support) | | Unknown Logitech HID++ mice | Best effort by PID/name | Generic fallback card | -> Only the MX Master family currently has a dedicated visual overlay. Other devices are still detected, show their model name, and can opt into an experimental layout override — button positions just may not line up until a real overlay lands. See [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md) to add yours. +> MX Master and MX Anywhere devices have dedicated visual overlays. Other devices are still detected, show their model name, and can opt into an experimental layout override — button positions just may not line up until a real overlay lands. See [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md) to add yours. --- @@ -300,7 +301,7 @@ For project layout, the architecture diagram, the HID++ gesture detector, the En ## Roadmap -- [ ] **Dedicated overlays for more devices** — real hotspot maps and artwork for MX Anywhere, MX Vertical, and other Logitech families +- [ ] **Dedicated overlays for more devices** — real hotspot maps and artwork for MX Vertical and other Logitech families - [ ] **True per-device config** — separate mappings cleanly when multiple Logitech mice are used on the same machine - [ ] **Dynamic button inventory** — build button lists from discovered `REPROG_CONTROLS_V4` controls instead of the current fixed sets - [ ] **Improved scroll inversion** — explore driver-level or interception-driver approaches diff --git a/README_CN.md b/README_CN.md index 744338e..3b7fc75 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,7 +6,7 @@ 中文文档|[English README](README.md) -一个轻量、开源、**完全本地运行** 的 **Logitech Options+** 替代品,用于对罗技 HID++ 鼠标进行按键 / 手势重映射。当前对 **MX Master** 系列体验最佳,并对更多罗技型号提供识别与通用回退 UI。 +一个轻量、开源、**完全本地运行** 的 **Logitech Options+** 替代品,用于对罗技 HID++ 鼠标进行按键 / 手势重映射。当前对 **MX Master** 与 **MX Anywhere** 系列体验最佳,并对更多罗技型号提供识别与通用回退 UI。 **零遥测,零云端,无需罗技账号。** @@ -126,7 +126,7 @@ - **蓝牙与 Logi Bolt** — 三个平台都支持两种连接方式;UI 实时显示当前连接类型(仅在确认接收器 PID 时才显示 `Logi Bolt`)。 - **自动重连** — Mouser 监听断电 / 上电循环,无需重启即可重新绑定 HID++ 与系统鼠标 hook;每次重连(包括从睡眠唤醒)都会回放 SmartShift 设置。 - **实时连接状态** — UI 显示 Connected / Not Connected 徽标、设备型号和当前布局。 -- **设备感知 UI** — MX Master 系列提供带可点击热区的交互示意图;其他型号使用通用回退卡片,并支持实验性的布局覆盖选择器。 +- **设备感知 UI** — MX Master 与 MX Anywhere 系列提供带可点击热区的交互示意图;其他型号使用通用回退卡片,并支持实验性的布局覆盖选择器。 ### 多语言 UI @@ -146,12 +146,12 @@ | 系列 / 型号 | 识别 + HID++ 探测 | UI 支持 | |---|---|---| -| MX Master 4 / 3S / 3 / 2S / MX Master | 是 | 专用交互布局 `mx_master` | -| MX Anywhere 3S / 3 / 2S | 是 | 通用回退卡片,支持实验性手动覆盖 | +| MX Master 4 / 3S / 3 / 2S / MX Master | 是 | 专用的逐型号交互布局 | +| MX Anywhere 3S / 3 / 2S | 是 | 专用的逐型号交互布局 | | MX Vertical | 是 | 通用回退卡片(含 DPI 切换按键支持) | | 其他罗技 HID++ 鼠标 | 按 PID / 名称尽力识别 | 通用回退卡片 | -> 目前只有 MX Master 系列拥有专用的可视化覆盖层。其他设备同样可被识别、显示型号名,并可启用实验性布局覆盖;但在专用覆盖层加入前,按键热区位置可能不够精确。要为你的设备添加支持,请见 [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md)。 +> MX Master 与 MX Anywhere 系列拥有专用的可视化覆盖层。其他设备同样可被识别、显示型号名,并可启用实验性布局覆盖;但在专用覆盖层加入前,按键热区位置可能不够精确。要为你的设备添加支持,请见 [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md)。 --- @@ -298,7 +298,7 @@ pyinstaller Mouser-linux.spec --noconfirm ## 路线图 -- [ ] **更多设备的专用覆盖层** — 为 MX Anywhere、MX Vertical 及其他罗技系列添加真实热区图与示意图素材 +- [ ] **更多设备的专用覆盖层** — 为 MX Vertical 及其他罗技系列添加真实热区图与示意图素材 - [ ] **真正的每设备配置** — 当一台机器接入多只罗技鼠标时,干净地分离各自的映射 - [ ] **动态按键清单** — 基于发现的 `REPROG_CONTROLS_V4` 控件构建按键列表,而不是依赖当前的固定按键集合 - [ ] **更好的滚动反转** — 探索驱动级或拦截驱动方案 diff --git a/core/device_layouts.py b/core/device_layouts.py index ce355aa..22f582c 100644 --- a/core/device_layouts.py +++ b/core/device_layouts.py @@ -9,6 +9,8 @@ from copy import deepcopy +from core.logi_device_catalog import LOGI_DEVICE_LAYOUTS + MX_MASTER_LAYOUT = { "key": "mx_master", @@ -211,6 +213,7 @@ "mx_anywhere": MX_ANYWHERE_LAYOUT, "mx_vertical": MX_VERTICAL_LAYOUT, "generic_mouse": GENERIC_MOUSE_LAYOUT, + **LOGI_DEVICE_LAYOUTS, } # Maps a device-specific key like "mx_master_3s" to its family layout key. diff --git a/core/hid_gesture.py b/core/hid_gesture.py index bbd66d9..9a9f1ea 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -1801,6 +1801,9 @@ def _direct_device_first(info): transport=actual_transport, source=source, gesture_cids=self._gesture_candidates, + reprog_controls=controls, + active_gesture_cid=self._gesture_cid, + gesture_rawxy_enabled=self._rawxy_enabled, ) return True continue # divert failed — try next receiver slot diff --git a/core/logi_device_catalog.py b/core/logi_device_catalog.py new file mode 100644 index 0000000..010243b --- /dev/null +++ b/core/logi_device_catalog.py @@ -0,0 +1,704 @@ +""" +Small Logitech device catalog entries. + +These records are maintained device by device after the Mouser UI has been +checked locally. We keep the catalog small so supported devices stay easy to +review and maintain. +""" + +from __future__ import annotations + + +MX_ANYWHERE_BUTTONS = ( + "middle", + "gesture", + "gesture_left", + "gesture_right", + "gesture_up", + "gesture_down", + "xbutton1", + "xbutton2", + "hscroll_left", + "hscroll_right", +) + +MX_ANYWHERE_SMARTSHIFT_BUTTONS = ( + *MX_ANYWHERE_BUTTONS, + "mode_shift", +) + + +def _hotspot( + button_key: str, + label: str, + summary_type: str, + norm_x: float, + norm_y: float, + *, + label_side: str, + label_off_x: int, + label_off_y: int, + is_hscroll: bool = False, +) -> dict[str, object]: + return { + "buttonKey": button_key, + "label": label, + "summaryType": summary_type, + "normX": norm_x, + "normY": norm_y, + "labelSide": label_side, + "labelOffX": label_off_x, + "labelOffY": label_off_y, + "isHScroll": is_hscroll, + } + + +def _layout( + key: str, + label: str, + image_asset: str, + image_width: int, + image_height: int, + hotspots: list[dict[str, object]], +) -> dict[str, object]: + return { + "key": key, + "label": label, + "image_asset": image_asset, + "image_width": image_width, + "image_height": image_height, + "interactive": True, + "manual_selectable": False, + "note": "", + "hotspots": hotspots, + } + + +LOGI_DEVICE_SPECS = ( + { + "key": "mx_master_4", + "display_name": "MX Master 4", + "product_ids": (0xB042, 0xB048), + "aliases": ( + "Logitech MX Master 4", + "Wireless Mouse MX Master 4", + "MX Master 4 for Mac", + "MX Master 4 for Business", + "MX_Master_4", + ), + "ui_layout": "mx_master_4", + "image_asset": "logitech-mice/mx_master_4/mouse.png", + }, + { + "key": "mx_master_3s", + "display_name": "MX Master 3S", + "product_ids": (0xB034, 0xB043), + "aliases": ( + "Logitech MX Master 3S", + "MX Master 3S for Mac", + "MX Master 3S for Business", + ), + "ui_layout": "mx_master_3s", + "image_asset": "logitech-mice/mx_master_3s/mouse.png", + }, + { + "key": "mx_master_3", + "display_name": "MX Master 3", + "product_ids": (0xB023, 0xB028), + "aliases": ( + "Wireless Mouse MX Master 3", + "MX Master 3 for Mac", + "MX Master 3 Mac", + "MX Master 3 for Business", + ), + "ui_layout": "mx_master_3", + "image_asset": "logitech-mice/mx_master_3/mouse.png", + }, + { + "key": "mx_master_2s", + "display_name": "MX Master 2S", + "product_ids": (0xB019,), + "aliases": ( + "Wireless Mouse MX Master 2S", + "MX Master 2S", + ), + "ui_layout": "mx_master_2s", + "image_asset": "logitech-mice/mx_master_2s/mouse.png", + "dpi_max": 4000, + }, + { + "key": "mx_master", + "display_name": "MX Master", + "product_ids": (0xB012,), + "aliases": ( + "Wireless Mouse MX Master", + "MX Master", + ), + "ui_layout": "mx_master_classic", + "image_asset": "logitech-mice/mx_master/mouse.png", + "dpi_max": 4000, + }, + { + "key": "mx_anywhere_3s", + "display_name": "MX Anywhere 3S", + "product_ids": (0xB037,), + "aliases": ( + "Logitech MX Anywhere 3S", + "MX Anywhere 3S for Mac", + ), + "ui_layout": "mx_anywhere_3s", + "image_asset": "logitech-mice/mx_anywhere_3s/mouse.png", + "supported_buttons": MX_ANYWHERE_SMARTSHIFT_BUTTONS, + "dpi_max": 8000, + }, + { + "key": "mx_anywhere_3", + "display_name": "MX Anywhere 3", + "product_ids": (0xB025, 0xB02D), + "aliases": ( + "MX Anywhere 3 for Mac", + "MX Anywhere 3 for Business", + ), + "ui_layout": "mx_anywhere_3", + "image_asset": "logitech-mice/mx_anywhere_3/mouse.png", + "supported_buttons": MX_ANYWHERE_SMARTSHIFT_BUTTONS, + "dpi_max": 4000, + }, + { + "key": "mx_anywhere_2s", + "display_name": "MX Anywhere 2S", + "product_ids": (0xB01A,), + "aliases": ( + "Wireless Mobile Mouse MX Anywhere 2S", + "MX Anywhere 2S", + ), + "ui_layout": "mx_anywhere_2s", + "image_asset": "logitech-mice/mx_anywhere_2s/mouse.png", + "supported_buttons": MX_ANYWHERE_BUTTONS, + "dpi_max": 4000, + }, +) + + +LOGI_DEVICE_LAYOUTS = { + "mx_master_4": _layout( + "mx_master_4", + "MX Master 4", + "logitech-mice/mx_master_4/mouse.png", + 256, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.755, + 0.19, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.473, + 0.60, + label_side="right", + label_off_x=160, + label_off_y=20, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.425, + 0.47, + label_side="right", + label_off_x=160, + label_off_y=-30, + ), + _hotspot( + "gesture", + "Gesture button", + "gesture", + 0.386, + 0.361, + label_side="left", + label_off_x=-260, + label_off_y=20, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll left", + "hscroll", + 0.565, + 0.564, + label_side="right", + label_off_x=160, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.805, + 0.395, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_master_3s": _layout( + "mx_master_3s", + "MX Master 3S", + "logitech-mice/mx_master_3s/mouse.png", + 248, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.71, + 0.15, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.45, + 0.60, + label_side="right", + label_off_x=160, + label_off_y=20, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.35, + 0.43, + label_side="left", + label_off_x=-260, + label_off_y=-10, + ), + _hotspot( + "gesture", + "Gesture button", + "gesture", + 0.08, + 0.58, + label_side="left", + label_off_x=-260, + label_off_y=40, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll left", + "hscroll", + 0.55, + 0.515, + label_side="right", + label_off_x=160, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.81, + 0.34, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_master_3": _layout( + "mx_master_3", + "MX Master 3", + "logitech-mice/mx_master_3/mouse.png", + 248, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.71, + 0.15, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.45, + 0.60, + label_side="right", + label_off_x=160, + label_off_y=20, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.35, + 0.43, + label_side="left", + label_off_x=-260, + label_off_y=-10, + ), + _hotspot( + "gesture", + "Gesture button", + "gesture", + 0.08, + 0.58, + label_side="left", + label_off_x=-260, + label_off_y=40, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll left", + "hscroll", + 0.55, + 0.515, + label_side="right", + label_off_x=160, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.81, + 0.34, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_master_2s": _layout( + "mx_master_2s", + "MX Master 2S", + "logitech-mice/mx_master_2s/mouse.png", + 261, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.73, + 0.18, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.49, + 0.70, + label_side="right", + label_off_x=160, + label_off_y=20, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.47, + 0.58, + label_side="right", + label_off_x=160, + label_off_y=-30, + ), + _hotspot( + "gesture", + "Gesture button", + "gesture", + 0.13, + 0.69, + label_side="left", + label_off_x=-260, + label_off_y=40, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll left", + "hscroll", + 0.40, + 0.46, + label_side="left", + label_off_x=-240, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.79, + 0.36, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_master_classic": _layout( + "mx_master_classic", + "MX Master", + "logitech-mice/mx_master/mouse.png", + 262, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.73, + 0.18, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.49, + 0.70, + label_side="right", + label_off_x=160, + label_off_y=20, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.47, + 0.58, + label_side="right", + label_off_x=160, + label_off_y=-30, + ), + _hotspot( + "gesture", + "Gesture button", + "gesture", + 0.13, + 0.69, + label_side="left", + label_off_x=-260, + label_off_y=40, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll left", + "hscroll", + 0.40, + 0.46, + label_side="left", + label_off_x=-240, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.79, + 0.36, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_anywhere_2s": _layout( + "mx_anywhere_2s", + "MX Anywhere 2S", + "logitech-mice/mx_anywhere_2s/mouse.png", + 253, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.52, + 0.385, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.02, + 0.58, + label_side="left", + label_off_x=-240, + label_off_y=10, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.02, + 0.44, + label_side="left", + label_off_x=-260, + label_off_y=-10, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll", + "hscroll", + 0.38, + 0.195, + label_side="left", + label_off_x=-240, + label_off_y=-70, + is_hscroll=True, + ), + ], + ), + "mx_anywhere_3": _layout( + "mx_anywhere_3", + "MX Anywhere 3", + "logitech-mice/mx_anywhere_3/mouse.png", + 239, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.72, + 0.17, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.28, + 0.61, + label_side="left", + label_off_x=-240, + label_off_y=10, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.22, + 0.43, + label_side="left", + label_off_x=-260, + label_off_y=-10, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll", + "hscroll", + 0.70, + 0.19, + label_side="right", + label_off_x=160, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.75, + 0.34, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), + "mx_anywhere_3s": _layout( + "mx_anywhere_3s", + "MX Anywhere 3S", + "logitech-mice/mx_anywhere_3s/mouse.png", + 239, + 400, + [ + _hotspot( + "middle", + "Middle button", + "mapping", + 0.71, + 0.16, + label_side="right", + label_off_x=120, + label_off_y=-120, + ), + _hotspot( + "xbutton1", + "Back button", + "mapping", + 0.28, + 0.60, + label_side="left", + label_off_x=-240, + label_off_y=10, + ), + _hotspot( + "xbutton2", + "Forward button", + "mapping", + 0.22, + 0.41, + label_side="left", + label_off_x=-260, + label_off_y=-10, + ), + _hotspot( + "hscroll_left", + "Horizontal scroll", + "hscroll", + 0.37, + 0.24, + label_side="left", + label_off_x=-240, + label_off_y=-70, + is_hscroll=True, + ), + _hotspot( + "mode_shift", + "Mode shift button", + "mapping", + 0.75, + 0.34, + label_side="right", + label_off_x=160, + label_off_y=0, + ), + ], + ), +} diff --git a/core/logi_devices.py b/core/logi_devices.py index 18e3674..d97cf30 100644 --- a/core/logi_devices.py +++ b/core/logi_devices.py @@ -11,6 +11,8 @@ from dataclasses import dataclass from typing import Iterable +from core.logi_device_catalog import LOGI_DEVICE_SPECS + DEFAULT_GESTURE_CIDS = (0x00C3, 0x00D7) DEFAULT_DPI_MIN = 200 @@ -33,9 +35,8 @@ "mode_shift", ) -# MX Anywhere has a gesture button but no horizontal scroll tilt and no -# dedicated mode-shift button. Gesture support is a best guess -- needs -# validation by an owner. +# Conservative fallback for generic MX Anywhere-family overrides. Exact +# cataloged MX Anywhere devices provide their own button sets. MX_ANYWHERE_BUTTONS = ( "middle", "gesture", @@ -66,6 +67,23 @@ # Backward-compat alias used by config.py and other modules. DEFAULT_BUTTON_LAYOUT = MX_MASTER_BUTTONS +_GESTURE_BUTTON_KEYS = ( + "gesture", + "gesture_left", + "gesture_right", + "gesture_up", + "gesture_down", +) +_CID_GATED_BUTTONS = { + "mode_shift": 0x00C4, + "dpi_switch": 0x00FD, +} +_KEY_FLAG_DIVERTABLE = 0x0020 +_KEY_FLAG_RAW_XY = 0x0100 +_KEY_FLAG_FORCE_RAW_XY = 0x0200 +_MAPPING_FLAG_RAW_XY_DIVERTED = 0x0010 +_MAPPING_FLAG_FORCE_RAW_XY_DIVERTED = 0x0040 + @dataclass(frozen=True) class LogiDeviceSpec: @@ -106,51 +124,11 @@ class ConnectedDeviceInfo: dpi_max: int = DEFAULT_DPI_MAX -# Seeded from Mouser's existing support plus upstream identifiers seen in -# Solaar/logiops for the major MX-family mice we want to grow into first. -KNOWN_LOGI_DEVICES = ( - LogiDeviceSpec( - key="mx_master_4", - display_name="MX Master 4", - product_ids=(0xB042,), - aliases=( - "Logitech MX Master 4", - "MX Master 4 for Mac", - "MX_Master_4", - "MX Master 4 for Business", - ), - ui_layout="mx_master_4", - ), - LogiDeviceSpec( - key="mx_master_3s", - display_name="MX Master 3S", - product_ids=(0xB034,), - aliases=("Logitech MX Master 3S", "MX Master 3S for Mac"), - ui_layout="mx_master_3s", - ), - LogiDeviceSpec( - key="mx_master_3", - display_name="MX Master 3", - product_ids=(0xB023,), - aliases=("Wireless Mouse MX Master 3", "MX Master 3 for Mac", "MX Master 3 Mac"), - ui_layout="mx_master_3", - ), - LogiDeviceSpec( - key="mx_master_2s", - display_name="MX Master 2S", - product_ids=(0xB019,), - aliases=("Wireless Mouse MX Master 2S",), - ui_layout="mx_master_2s", - dpi_max=4000, - ), - LogiDeviceSpec( - key="mx_master", - display_name="MX Master", - product_ids=(0xB012,), - aliases=("Wireless Mouse MX Master",), - ui_layout="mx_master", - dpi_max=4000, - ), +# Seeded from Mouser's own device catalog first, then extended with broader +# family support for devices that still use a shared layout. +KNOWN_LOGI_DEVICES = tuple( + LogiDeviceSpec(**spec) for spec in LOGI_DEVICE_SPECS +) + ( LogiDeviceSpec( key="mx_vertical", display_name="MX Vertical", @@ -161,36 +139,6 @@ class ConnectedDeviceInfo: supported_buttons=MX_VERTICAL_BUTTONS, dpi_max=4000, ), - LogiDeviceSpec( - key="mx_anywhere_3s", - display_name="MX Anywhere 3S", - product_ids=(0xB037,), - aliases=("MX Anywhere 3S for Mac",), - ui_layout="mouse_mx_anywhere_3s.png", - image_asset="mouse_mx_anywhere_3s.png", - supported_buttons=MX_ANYWHERE_BUTTONS, - dpi_max=8000, - ), - LogiDeviceSpec( - key="mx_anywhere_3", - display_name="MX Anywhere 3", - product_ids=(0xB025,), - aliases=("MX Anywhere 3 for Mac",), - ui_layout="mx_anywhere_3", - image_asset="mouse_mx_anywhere_3s.png", - supported_buttons=MX_ANYWHERE_BUTTONS, - dpi_max=4000, - ), - LogiDeviceSpec( - key="mx_anywhere_2s", - display_name="MX Anywhere 2S", - product_ids=(0xB01A,), - aliases=("Wireless Mobile Mouse MX Anywhere 2S",), - ui_layout="mx_anywhere_2s", - image_asset="mouse_mx_anywhere_3s.png", - supported_buttons=MX_ANYWHERE_BUTTONS, - dpi_max=4000, - ), ) @@ -218,6 +166,112 @@ 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, ""): + return None + try: + return int(cid, 0) if isinstance(cid, str) else int(cid) + except (TypeError, ValueError): + return None + + +def _control_int(control, field) -> int | None: + if not isinstance(control, dict): + return None + value = control.get(field) + 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_by_cid(controls) -> dict[int, dict]: + by_cid = {} + for control in controls: + cid = _control_cid(control) + if cid is not None and isinstance(control, dict): + by_cid[cid] = control + return by_cid + + +def _control_is_divertable(control) -> bool: + flags = _control_int(control, "flags") + if flags is None: + # Older tests and manually supplied dumps may only include CIDs. Do not + # narrow those more aggressively than the previous CID-only behavior. + return True + return bool(flags & _KEY_FLAG_DIVERTABLE) + + +def _control_has_raw_xy(control) -> bool: + flags = _control_int(control, "flags") + mapping_flags = _control_int(control, "mapping_flags") + if flags is None and mapping_flags is None: + return True + flags = flags or 0 + mapping_flags = mapping_flags or 0 + return bool( + flags & (_KEY_FLAG_RAW_XY | _KEY_FLAG_FORCE_RAW_XY) + or mapping_flags + & (_MAPPING_FLAG_RAW_XY_DIVERTED | _MAPPING_FLAG_FORCE_RAW_XY_DIVERTED) + ) + + +def derive_supported_buttons_from_reprog_controls( + static_buttons: tuple[str, ...], + controls, + gesture_cids=None, + active_gesture_cid=None, + gesture_rawxy_enabled=None, +) -> tuple[str, ...]: + """Narrow HID++-gated buttons using discovered REPROG_V4 controls. + + OS-level buttons and horizontal scroll remain catalog-driven because they + are not always represented as divertable HID++ controls. + """ + if not controls: + return static_buttons + + controls_by_cid = _control_by_cid(controls) + if not controls_by_cid: + return static_buttons + + allowed = set(static_buttons) + gesture_candidates = tuple(gesture_cids or DEFAULT_GESTURE_CIDS) + active_cid = _control_cid({"cid": active_gesture_cid}) + if active_cid is None: + active_cid = next( + ( + cid + for cid in gesture_candidates + if cid in controls_by_cid and _control_is_divertable(controls_by_cid[cid]) + ), + None, + ) + gesture_control = controls_by_cid.get(active_cid) + if not gesture_control or not _control_is_divertable(gesture_control): + allowed.difference_update(_GESTURE_BUTTON_KEYS) + elif not ( + (gesture_rawxy_enabled is not False) + and _control_has_raw_xy(gesture_control) + ): + allowed.difference_update( + ("gesture_left", "gesture_right", "gesture_up", "gesture_down") + ) + + for button_key, cid in _CID_GATED_BUTTONS.items(): + control = controls_by_cid.get(cid) + if not control or not _control_is_divertable(control): + allowed.discard(button_key) + + return tuple(button for button in static_buttons if button in allowed) + + # Maps family layout keys to their button sets so the override picker can # resolve buttons even when individual devices use per-device ui_layout keys. _LAYOUT_BUTTONS = { @@ -245,10 +299,14 @@ def build_connected_device_info( transport=None, source=None, gesture_cids=None, + reprog_controls=None, + active_gesture_cid=None, + gesture_rawxy_enabled=None, ) -> ConnectedDeviceInfo: spec = resolve_device(product_id=product_id, product_name=product_name) pid = int(product_id) if product_id not in (None, "") else None if spec: + resolved_gesture_cids = tuple(gesture_cids or spec.gesture_cids) return ConnectedDeviceInfo( key=spec.key, display_name=spec.display_name, @@ -258,8 +316,14 @@ def build_connected_device_info( source=source, ui_layout=spec.ui_layout, image_asset=spec.image_asset, - supported_buttons=spec.supported_buttons, - gesture_cids=tuple(gesture_cids or spec.gesture_cids), + supported_buttons=derive_supported_buttons_from_reprog_controls( + spec.supported_buttons, + reprog_controls, + gesture_cids=resolved_gesture_cids, + active_gesture_cid=active_gesture_cid, + gesture_rawxy_enabled=gesture_rawxy_enabled, + ), + gesture_cids=resolved_gesture_cids, dpi_min=spec.dpi_min, dpi_max=spec.dpi_max, ) @@ -278,7 +342,7 @@ def build_connected_device_info( transport=transport, source=source, ui_layout="mx_master_3s", - image_asset="mouse.png", + image_asset="logitech-mice/mx_master_3s/mouse.png", supported_buttons=MX_MASTER_BUTTONS, gesture_cids=tuple(gesture_cids or DEFAULT_GESTURE_CIDS), ) diff --git a/images/logitech-mice/mx_anywhere_2s/mouse.png b/images/logitech-mice/mx_anywhere_2s/mouse.png new file mode 100644 index 0000000..ae7073f Binary files /dev/null and b/images/logitech-mice/mx_anywhere_2s/mouse.png differ diff --git a/images/logitech-mice/mx_anywhere_3/mouse.png b/images/logitech-mice/mx_anywhere_3/mouse.png new file mode 100644 index 0000000..b438566 Binary files /dev/null and b/images/logitech-mice/mx_anywhere_3/mouse.png differ diff --git a/images/logitech-mice/mx_anywhere_3s/mouse.png b/images/logitech-mice/mx_anywhere_3s/mouse.png new file mode 100644 index 0000000..b438566 Binary files /dev/null and b/images/logitech-mice/mx_anywhere_3s/mouse.png differ diff --git a/images/logitech-mice/mx_master/mouse.png b/images/logitech-mice/mx_master/mouse.png new file mode 100644 index 0000000..b04739b Binary files /dev/null and b/images/logitech-mice/mx_master/mouse.png differ diff --git a/images/logitech-mice/mx_master_2s/mouse.png b/images/logitech-mice/mx_master_2s/mouse.png new file mode 100644 index 0000000..55503bd Binary files /dev/null and b/images/logitech-mice/mx_master_2s/mouse.png differ diff --git a/images/logitech-mice/mx_master_3/mouse.png b/images/logitech-mice/mx_master_3/mouse.png new file mode 100644 index 0000000..61a9584 Binary files /dev/null and b/images/logitech-mice/mx_master_3/mouse.png differ diff --git a/images/logitech-mice/mx_master_3s/mouse.png b/images/logitech-mice/mx_master_3s/mouse.png new file mode 100644 index 0000000..397cf96 Binary files /dev/null and b/images/logitech-mice/mx_master_3s/mouse.png differ diff --git a/images/logitech-mice/mx_master_4/mouse.png b/images/logitech-mice/mx_master_4/mouse.png new file mode 100644 index 0000000..4e64de6 Binary files /dev/null and b/images/logitech-mice/mx_master_4/mouse.png differ diff --git a/tests/test_backend.py b/tests/test_backend.py index 1023a52..8b97fd6 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -111,13 +111,58 @@ def test_disconnected_override_request_does_not_persist(self): overrides = backend._cfg.get("settings", {}).get("device_layout_overrides", {}) self.assertEqual(overrides, {}) + def test_connected_device_can_override_exact_layout_with_family_layout(self): + device = SimpleNamespace( + key="mx_master_4", + display_name="MX Master 4", + dpi_min=200, + dpi_max=8000, + ui_layout="mx_master_4", + supported_buttons=("middle", "xbutton1", "xbutton2"), + ) + + with ( + patch("ui.backend.load_config", return_value=copy.deepcopy(DEFAULT_CONFIG)), + patch("ui.backend.save_config"), + patch("ui.backend.supports_login_startup", return_value=False), + ): + backend = Backend(engine=_FakeEngine(device_connected=True, connected_device=device)) + backend.setDeviceLayoutOverride("mx_master") + + overrides = backend._cfg.get("settings", {}).get("device_layout_overrides", {}) + self.assertEqual(overrides, {"mx_master_4": "mx_master"}) + self.assertEqual(backend.effectiveDeviceLayoutKey, "mx_master") + + def test_connected_device_supported_buttons_filter_mapping_list(self): + device = SimpleNamespace( + key="mx_master_3s", + display_name="MX Master 3S", + dpi_min=200, + dpi_max=8000, + ui_layout="mx_master_3s", + supported_buttons=("middle", "xbutton1"), + ) + + with ( + patch("ui.backend.load_config", return_value=copy.deepcopy(DEFAULT_CONFIG)), + patch("ui.backend.save_config"), + patch("ui.backend.supports_login_startup", return_value=False), + ): + backend = Backend(engine=_FakeEngine(device_connected=True, connected_device=device)) + + button_keys = [button["key"] for button in backend.buttons] + self.assertIn("middle", button_keys) + self.assertIn("xbutton1", button_keys) + self.assertNotIn("gesture", button_keys) + self.assertNotIn("mode_shift", button_keys) + def test_disconnect_clears_stale_linux_device_identity_and_layout(self): device = SimpleNamespace( key="mx_master_3", display_name="MX Master 3S", dpi_min=200, dpi_max=8000, - ui_layout="mx_master", + ui_layout="mx_master_3", ) def fake_layout(key): @@ -132,7 +177,7 @@ def fake_layout(key): backend = Backend(engine=_FakeEngine(device_connected=True, connected_device=device)) self.assertTrue(backend.mouseConnected) self.assertEqual(backend.connectedDeviceKey, "mx_master_3") - self.assertEqual(backend.effectiveDeviceLayoutKey, "mx_master") + self.assertEqual(backend.effectiveDeviceLayoutKey, "mx_master_3") backend._battery_level = 42 backend._handleConnectionChange(False) @@ -148,7 +193,7 @@ def test_refresh_updates_hid_features_without_reemitting_connection_edge(self): display_name="MX Master 3S", dpi_min=200, dpi_max=8000, - ui_layout="mx_master", + ui_layout="mx_master_3", ) def fake_layout(key): diff --git a/tests/test_config.py b/tests/test_config.py index d65942d..4ef9055 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -54,7 +54,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): migrated["profiles"]["default"]["mappings"][key], "none" ) # v7→v8 migration promotes the physical SmartShift button from "none" to - # "switch_scroll_mode" (ratchet ↔ free-spin, matching Logi Options+ default). + # "switch_scroll_mode" (ratchet ↔ free-spin). self.assertEqual( migrated["profiles"]["default"]["mappings"]["mode_shift"], "switch_scroll_mode", diff --git a/tests/test_device_layouts.py b/tests/test_device_layouts.py index 64d48c5..d3411d0 100644 --- a/tests/test_device_layouts.py +++ b/tests/test_device_layouts.py @@ -1,9 +1,29 @@ import unittest +from pathlib import Path from core.device_layouts import get_device_layout, get_manual_layout_choices +from core.logi_devices import KNOWN_LOGI_DEVICES class DeviceLayoutTests(unittest.TestCase): + def test_known_devices_have_interactive_layouts_and_assets(self): + image_root = Path(__file__).resolve().parents[1] / "images" + for device in KNOWN_LOGI_DEVICES: + with self.subTest(device=device.key, ui_layout=device.ui_layout): + layout = get_device_layout(device.ui_layout) + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], device.ui_layout) + self.assertTrue((image_root / layout["image_asset"]).is_file()) + + def test_known_device_hotspots_are_supported_buttons(self): + for device in KNOWN_LOGI_DEVICES: + layout = get_device_layout(device.ui_layout) + supported_buttons = set(device.supported_buttons) + for hotspot in layout["hotspots"]: + with self.subTest(device=device.key, button=hotspot["buttonKey"]): + self.assertIn(hotspot["buttonKey"], supported_buttons) + def test_master_layout_is_interactive(self): layout = get_device_layout("mx_master") @@ -40,6 +60,67 @@ def test_mx_vertical_layout_is_interactive(self): self.assertEqual(layout["image_asset"], "mx_vertical.png") self.assertGreater(len(layout["hotspots"]), 0) + def test_exact_mx_master_3s_layout_uses_catalog_asset(self): + layout = get_device_layout("mx_master_3s") + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], "mx_master_3s") + self.assertEqual( + layout["image_asset"], + "logitech-mice/mx_master_3s/mouse.png", + ) + self.assertGreater(len(layout["hotspots"]), 0) + + def test_exact_mx_master_4_layout_uses_catalog_asset(self): + layout = get_device_layout("mx_master_4") + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], "mx_master_4") + self.assertEqual( + layout["image_asset"], + "logitech-mice/mx_master_4/mouse.png", + ) + self.assertGreater(len(layout["hotspots"]), 0) + + def test_exact_mx_anywhere_2s_layout_uses_catalog_asset(self): + layout = get_device_layout("mx_anywhere_2s") + hotspot_keys = {hotspot["buttonKey"] for hotspot in layout["hotspots"]} + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], "mx_anywhere_2s") + self.assertEqual( + layout["image_asset"], + "logitech-mice/mx_anywhere_2s/mouse.png", + ) + self.assertIn("hscroll_left", hotspot_keys) + self.assertNotIn("mode_shift", hotspot_keys) + + def test_exact_mx_anywhere_3_layout_uses_catalog_asset(self): + layout = get_device_layout("mx_anywhere_3") + hotspot_keys = {hotspot["buttonKey"] for hotspot in layout["hotspots"]} + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], "mx_anywhere_3") + self.assertEqual( + layout["image_asset"], + "logitech-mice/mx_anywhere_3/mouse.png", + ) + self.assertIn("hscroll_left", hotspot_keys) + self.assertIn("mode_shift", hotspot_keys) + + def test_exact_mx_anywhere_3s_layout_uses_catalog_asset(self): + layout = get_device_layout("mx_anywhere_3s") + hotspot_keys = {hotspot["buttonKey"] for hotspot in layout["hotspots"]} + + self.assertTrue(layout["interactive"]) + self.assertEqual(layout["key"], "mx_anywhere_3s") + self.assertEqual( + layout["image_asset"], + "logitech-mice/mx_anywhere_3s/mouse.png", + ) + self.assertIn("hscroll_left", hotspot_keys) + self.assertIn("mode_shift", hotspot_keys) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index a2aeb1a..88a1086 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -556,6 +556,104 @@ def fake_find_feature(feature_id): # devIdx 0xFF (first tried) = Bluetooth self.assertEqual(listener.connected_device.transport, "Bluetooth") + def test_try_connect_applies_runtime_supported_buttons(self): + listener = hid_gesture.HidGestureListener() + info = { + "product_id": 0xB034, + "usage_page": 0xFF00, + "usage": 0x0001, + "source": "hidapi-enumerate", + "product_string": "MX Master 3S", + "path": b"/dev/hidraw-test", + } + controls = [ + {"cid": 0x0052, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x0053, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x0056, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x00C3, "flags": 0x0130, "mapping_flags": 0x0011}, + ] + fake_dev = _FakeHidDevice() + + def fake_find_feature(feature_id): + 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, "_divert", return_value=True), + patch.object(listener, "_divert_extras"), + 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("gesture", listener.connected_device.supported_buttons) + self.assertNotIn("gesture_up", listener.connected_device.supported_buttons) + self.assertNotIn("mode_shift", listener.connected_device.supported_buttons) + + def test_try_connect_preserves_directional_gestures_after_rawxy_divert(self): + listener = hid_gesture.HidGestureListener() + info = { + "product_id": 0xB034, + "usage_page": 0xFF00, + "usage": 0x0001, + "source": "hidapi-enumerate", + "product_string": "MX Master 3S", + "path": b"/dev/hidraw-test", + } + controls = [ + {"cid": 0x0052, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x0053, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x0056, "flags": 0x0030, "mapping_flags": 0x0001}, + {"cid": 0x00C3, "flags": 0x0130, "mapping_flags": 0x0011}, + {"cid": 0x00C4, "flags": 0x0130, "mapping_flags": 0x0001}, + ] + fake_dev = _FakeHidDevice() + + def fake_find_feature(feature_id): + if feature_id == hid_gesture.FEAT_REPROG_V4: + return 0x09 + return None + + def fake_divert(): + listener._gesture_cid = 0x00C3 + listener._rawxy_enabled = True + return True + + 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, "_divert", side_effect=fake_divert), + patch.object(listener, "_divert_extras"), + 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("gesture", listener.connected_device.supported_buttons) + self.assertIn("gesture_up", listener.connected_device.supported_buttons) + self.assertIn("mode_shift", listener.connected_device.supported_buttons) + def test_transport_label_logi_bolt_for_bolt_receiver(self): """devIdx 1-6 with Bolt PID 0xC548 should produce 'Logi Bolt'.""" listener = hid_gesture.HidGestureListener() diff --git a/tests/test_logi_devices.py b/tests/test_logi_devices.py index a3e0671..1724386 100644 --- a/tests/test_logi_devices.py +++ b/tests/test_logi_devices.py @@ -2,8 +2,13 @@ from core.logi_devices import ( DEFAULT_GESTURE_CIDS, + KNOWN_LOGI_DEVICES, + MX_MASTER_BUTTONS, + MX_VERTICAL_BUTTONS, build_connected_device_info, clamp_dpi, + derive_supported_buttons_from_reprog_controls, + get_buttons_for_layout, resolve_device, ) @@ -22,6 +27,13 @@ def test_resolve_mx_master_4_by_hid_product_string(self): self.assertIsNotNone(device) self.assertEqual(device.key, "mx_master_4") + def test_resolve_mx_master_4_business_pid_to_same_layout(self): + device = resolve_device(product_id=0xB048) + + self.assertIsNotNone(device) + self.assertEqual(device.key, "mx_master_4") + self.assertEqual(device.ui_layout, "mx_master_4") + def test_resolve_device_by_product_id(self): device = resolve_device(product_id=0xB034) @@ -29,6 +41,12 @@ def test_resolve_device_by_product_id(self): self.assertEqual(device.key, "mx_master_3s") self.assertEqual(device.display_name, "MX Master 3S") + def test_resolve_mx_master_3s_business_pid(self): + device = resolve_device(product_id=0xB043) + + self.assertIsNotNone(device) + self.assertEqual(device.key, "mx_master_3s") + def test_resolve_device_by_alias(self): device = resolve_device(product_name="MX Master 3 for Mac") @@ -36,6 +54,73 @@ def test_resolve_device_by_alias(self): self.assertEqual(device.key, "mx_master_3") self.assertIn(0xB023, device.product_ids) + def test_resolve_mx_master_3_business_pid(self): + device = resolve_device(product_id=0xB028) + + self.assertIsNotNone(device) + self.assertEqual(device.key, "mx_master_3") + + def test_resolve_mx_anywhere_3_promoted_pids(self): + for product_id in (0xB025, 0xB02D): + with self.subTest(product_id=product_id): + device = resolve_device(product_id=product_id) + + self.assertIsNotNone(device) + self.assertEqual(device.key, "mx_anywhere_3") + self.assertEqual(device.ui_layout, "mx_anywhere_3") + self.assertEqual( + device.image_asset, + "logitech-mice/mx_anywhere_3/mouse.png", + ) + + def test_mx_anywhere_3s_uses_exact_catalog_layout(self): + info = build_connected_device_info(product_id=0xB037) + + self.assertEqual(info.display_name, "MX Anywhere 3S") + self.assertEqual(info.ui_layout, "mx_anywhere_3s") + self.assertEqual( + info.image_asset, + "logitech-mice/mx_anywhere_3s/mouse.png", + ) + + def test_exact_mx_anywhere_button_sets_include_expected_controls(self): + anywhere_2s = get_buttons_for_layout("mx_anywhere_2s") + anywhere_3 = get_buttons_for_layout("mx_anywhere_3") + anywhere_3s = get_buttons_for_layout("mx_anywhere_3s") + + for buttons in (anywhere_2s, anywhere_3, anywhere_3s): + with self.subTest(buttons=buttons): + self.assertIn("hscroll_left", buttons) + self.assertIn("hscroll_right", buttons) + self.assertIn("gesture_left", buttons) + self.assertIn("gesture_right", buttons) + + self.assertNotIn("mode_shift", anywhere_2s) + self.assertIn("mode_shift", anywhere_3) + self.assertIn("mode_shift", anywhere_3s) + + def test_known_product_ids_are_unique(self): + product_ids = {} + for device in KNOWN_LOGI_DEVICES: + for product_id in device.product_ids: + with self.subTest(product_id=f"0x{product_id:04X}"): + self.assertNotIn(product_id, product_ids) + product_ids[product_id] = device.key + + def test_all_known_product_ids_resolve_to_their_device(self): + for device in KNOWN_LOGI_DEVICES: + for product_id in device.product_ids: + with self.subTest(device=device.key, product_id=f"0x{product_id:04X}"): + self.assertEqual(resolve_device(product_id=product_id), device) + + def test_all_exact_layout_keys_resolve_to_button_sets(self): + for device in KNOWN_LOGI_DEVICES: + with self.subTest(device=device.key, ui_layout=device.ui_layout): + self.assertEqual( + get_buttons_for_layout(device.ui_layout), + device.supported_buttons, + ) + def test_build_connected_device_info_uses_registry_defaults(self): info = build_connected_device_info( product_id=0xB023, @@ -49,11 +134,31 @@ def test_build_connected_device_info_uses_registry_defaults(self): self.assertEqual(info.transport, "Bluetooth Low Energy") self.assertEqual(info.gesture_cids, DEFAULT_GESTURE_CIDS) self.assertEqual(info.ui_layout, "mx_master_3") + self.assertIn("mode_shift", info.supported_buttons) + + def test_build_connected_device_info_filters_runtime_hid_buttons(self): + info = build_connected_device_info( + product_id=0xB023, + reprog_controls=[ + {"cid": 0x0052}, + {"cid": 0x0053}, + {"cid": 0x0056}, + {"cid": 0x00C3}, + ], + gesture_cids=(0x00C3,), + ) + + self.assertIn("gesture", info.supported_buttons) + self.assertNotIn("mode_shift", info.supported_buttons) + self.assertIn("hscroll_left", info.supported_buttons) def test_build_connected_device_info_falls_back_to_runtime_name(self): info = build_connected_device_info( product_id=0xB999, product_name="Mystery Logitech Mouse", + reprog_controls=[ + {"cid": 0x00C3}, + ], gesture_cids=(0x00F1,), ) @@ -61,6 +166,7 @@ def test_build_connected_device_info_falls_back_to_runtime_name(self): self.assertEqual(info.key, "mystery_logitech_mouse") self.assertEqual(info.gesture_cids, (0x00F1,)) self.assertEqual(info.ui_layout, "mx_master_3s") + self.assertEqual(info.image_asset, "logitech-mice/mx_master_3s/mouse.png") def test_clamp_dpi_uses_known_device_bounds(self): info = build_connected_device_info(product_id=0xB019) @@ -73,5 +179,210 @@ def test_clamp_dpi_defaults_without_device(self): self.assertEqual(clamp_dpi(9000, None), 8000) +class RuntimeSupportedButtonTests(unittest.TestCase): + @staticmethod + def _control(cid, flags=None, mapping_flags=None): + control = {"cid": cid} + if flags is not None: + control["flags"] = flags + if mapping_flags is not None: + control["mapping_flags"] = mapping_flags + return control + + def test_reprog_control_filter_keeps_static_buttons_without_controls(self): + self.assertEqual( + derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [], + gesture_cids=(0x00C3,), + ), + MX_MASTER_BUTTONS, + ) + + def test_reprog_control_filter_removes_missing_gesture_group(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x0052), + self._control(0x0053), + self._control(0x0056), + self._control(0x00C4), + ], + gesture_cids=(0x00C3,), + ) + + self.assertNotIn("gesture", buttons) + self.assertNotIn("gesture_left", buttons) + self.assertNotIn("gesture_right", buttons) + + def test_reprog_control_filter_keeps_selected_gesture_cid(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x00D7, flags=0x03B0), + self._control(0x00C4, flags=0x0130), + ], + gesture_cids=(0x00D7,), + active_gesture_cid=0x00D7, + gesture_rawxy_enabled=True, + ) + + self.assertIn("gesture", buttons) + self.assertIn("gesture_up", buttons) + + def test_reprog_control_filter_removes_directional_gestures_without_rawxy(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x00C3, flags=0x0030), + self._control(0x00C4, flags=0x0130), + ], + gesture_cids=(0x00C3,), + active_gesture_cid=0x00C3, + gesture_rawxy_enabled=False, + ) + + self.assertIn("gesture", buttons) + self.assertNotIn("gesture_left", buttons) + self.assertNotIn("gesture_right", buttons) + self.assertNotIn("gesture_up", buttons) + self.assertNotIn("gesture_down", buttons) + + def test_reprog_control_filter_removes_missing_mode_shift(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x00C3, flags=0x0130), + self._control(0x0052), + ], + gesture_cids=(0x00C3,), + active_gesture_cid=0x00C3, + gesture_rawxy_enabled=True, + ) + + self.assertNotIn("mode_shift", buttons) + + def test_reprog_control_filter_removes_non_divertable_mode_shift(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x00C3, flags=0x0130), + self._control(0x00C4, flags=0x0110), + ], + gesture_cids=(0x00C3,), + active_gesture_cid=0x00C3, + gesture_rawxy_enabled=True, + ) + + self.assertNotIn("mode_shift", buttons) + + def test_reprog_control_filter_removes_missing_dpi_switch(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_VERTICAL_BUTTONS, + [ + self._control(0x0052), + self._control(0x0053), + self._control(0x0056), + ], + ) + + self.assertNotIn("dpi_switch", buttons) + + def test_reprog_control_filter_preserves_hscroll_without_hscroll_cids(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control(0x00C3, flags=0x0130), + self._control(0x00C4, flags=0x0130), + ], + gesture_cids=(0x00C3,), + active_gesture_cid=0x00C3, + gesture_rawxy_enabled=True, + ) + + self.assertIn("hscroll_left", buttons) + self.assertIn("hscroll_right", buttons) + + def test_reprog_control_filter_ignores_unknown_cids_and_preserves_order(self): + buttons = derive_supported_buttons_from_reprog_controls( + MX_MASTER_BUTTONS, + [ + self._control("0x01A0", flags="0x0130"), + self._control("0x00C3", flags="0x0130"), + self._control("0x00C4", flags="0x0130"), + ], + gesture_cids=(0x00C3,), + active_gesture_cid="0x00C3", + gesture_rawxy_enabled=True, + ) + + self.assertNotIn("0x01A0", buttons) + self.assertEqual(buttons, tuple(button for button in MX_MASTER_BUTTONS)) + + def test_mx_anywhere_2s_solaar_controls_keep_tilt_hscroll_without_mode_shift(self): + info = build_connected_device_info( + product_id=0xB01A, + reprog_controls=[ + self._control(0x0052, flags=0x0130), + self._control(0x0053, flags=0x0130), + self._control(0x0056, flags=0x0130), + self._control(0x005B, flags=0x0130), + self._control(0x005D, flags=0x0130), + self._control(0x00D7, flags=0x03A0), + ], + gesture_cids=(0x00D7,), + active_gesture_cid=0x00D7, + gesture_rawxy_enabled=True, + ) + + self.assertIn("gesture_left", info.supported_buttons) + self.assertIn("gesture_right", info.supported_buttons) + self.assertIn("hscroll_left", info.supported_buttons) + self.assertIn("hscroll_right", info.supported_buttons) + self.assertNotIn("mode_shift", info.supported_buttons) + + def test_mx_anywhere_3s_solaar_controls_keep_mode_shift_and_catalog_hscroll(self): + info = build_connected_device_info( + product_id=0xB037, + reprog_controls=[ + self._control(0x0052, flags=0x0130), + self._control(0x0053, flags=0x0130), + self._control(0x0056, flags=0x0130), + self._control(0x00C4, flags=0x0130), + self._control(0x00D7, flags=0x03A0), + ], + gesture_cids=(0x00D7,), + active_gesture_cid=0x00D7, + gesture_rawxy_enabled=True, + ) + + self.assertIn("mode_shift", info.supported_buttons) + self.assertIn("gesture_up", info.supported_buttons) + self.assertIn("hscroll_left", info.supported_buttons) + self.assertIn("hscroll_right", info.supported_buttons) + + def test_mx_master_4_haptic_control_does_not_create_supported_button(self): + info = build_connected_device_info( + product_id=0xB042, + reprog_controls=[ + self._control(0x0052, flags=0x0130), + self._control(0x0053, flags=0x0130), + self._control(0x0056, flags=0x0130), + self._control(0x00C3, flags=0x0130), + self._control(0x00C4, flags=0x0130), + self._control(0x01A0, flags=0x0130), + self._control(0x00D7, flags=0x03A0), + ], + gesture_cids=(0x00C3, 0x00D7), + active_gesture_cid=0x00C3, + gesture_rawxy_enabled=True, + ) + + self.assertIn("mode_shift", info.supported_buttons) + self.assertIn("gesture_down", info.supported_buttons) + self.assertNotIn("action_ring", info.supported_buttons) + self.assertNotIn("haptic", info.supported_buttons) + + if __name__ == "__main__": unittest.main() diff --git a/ui/locale_manager.py b/ui/locale_manager.py index 72d54a4..f0e0ec2 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -133,7 +133,7 @@ "scroll.scroll_speed_desc": "Adjust how fast the page scrolls per wheel click. 1.0\u00d7 is the system default.", "scroll.scroll_speed_presets": "Presets:", "scroll.smooth_scroll": "Smooth Scrolling", - "scroll.smooth_scroll_desc": "Add inertia so the page coasts to a stop after each wheel tick, similar to Logi Options.", + "scroll.smooth_scroll_desc": "Add inertia so the page coasts to a stop after each wheel tick for a smoother feel.", "scroll.scroll_direction": "Scroll Direction", "scroll.scroll_direction_desc": "Invert the scroll direction (natural scrolling)", "scroll.invert_vertical": "Invert vertical scroll", @@ -301,7 +301,7 @@ "scroll.scroll_speed_desc": "\u8c03\u6574\u6bcf\u6b21\u6eda\u8f6e\u6eda\u52a8\u7684\u9875\u9762\u79fb\u52a8\u901f\u5ea6\u30021.0\u00d7 \u4e3a\u7cfb\u7edf\u9ed8\u8ba4\u3002", "scroll.scroll_speed_presets": "\u9884\u8bbe\uff1a", "scroll.smooth_scroll": "\u5e73\u6ed1\u6eda\u52a8", - "scroll.smooth_scroll_desc": "\u6eda\u8f6e\u6eda\u52a8\u540e\u6dfb\u52a0\u60ef\u6027\u6ed1\u884c\u6548\u679c\uff0c\u7c7b\u4f3c Logi Options \u7684\u5e73\u6ed1\u6eda\u52a8\u4f53\u9a8c\u3002", + "scroll.smooth_scroll_desc": "\u6eda\u8f6e\u6eda\u52a8\u540e\u6dfb\u52a0\u60ef\u6027\u6ed1\u884c\u6548\u679c\uff0c\u8ba9\u6eda\u52a8\u4f53\u9a8c\u66f4\u987a\u6ed1\u3002", "scroll.scroll_direction": "\u6eda\u52a8\u65b9\u5411", "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", @@ -463,7 +463,7 @@ "scroll.scroll_speed_desc": "\u8abf\u6574\u6bcf\u6b21\u6eda\u8f2a\u6eda\u52d5\u7684\u9801\u9762\u79fb\u52d5\u901f\u5ea6\u30021.0\u00d7 \u70ba\u7cfb\u7d71\u9810\u8a2d\u3002", "scroll.scroll_speed_presets": "\u9810\u8a2d\uff1a", "scroll.smooth_scroll": "\u5e73\u6ed1\u6372\u52d5", - "scroll.smooth_scroll_desc": "\u6eda\u8f2a\u6eda\u52d5\u5f8c\u6dfb\u52a0\u6163\u6027\u6ed1\u884c\u6548\u679c\uff0c\u985e\u4f3c Logi Options \u7684\u5e73\u6ed1\u6372\u52d5\u9ad4\u9a57\u3002", + "scroll.smooth_scroll_desc": "\u6eda\u8f2a\u6eda\u52d5\u5f8c\u6dfb\u52a0\u6163\u6027\u6ed1\u884c\u6548\u679c\uff0c\u8b93\u6372\u52d5\u9ad4\u9a57\u66f4\u9806\u66a2\u3002", "scroll.scroll_direction": "\u6372\u52d5\u65b9\u5411", "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",