Skip to content

Commit 448f721

Browse files
committed
feat: add multi-device Logitech support and fallback layouts
1 parent 96bc2d3 commit 448f721

18 files changed

Lines changed: 1112 additions & 130 deletions

README.md

Lines changed: 57 additions & 38 deletions
Large diffs are not rendered by default.

core/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
}
5454

5555
DEFAULT_CONFIG = {
56-
"version": 3,
56+
"version": 4,
5757
"active_profile": "default",
5858
"profiles": {
5959
"default": {
@@ -86,6 +86,7 @@
8686
"gesture_cooldown_ms": 500,
8787
"appearance_mode": "system",
8888
"debug_mode": False,
89+
"device_layout_overrides": {},
8990
},
9091
}
9192

@@ -230,9 +231,15 @@ def _migrate(cfg):
230231
mappings.setdefault(key, "none")
231232
cfg["version"] = 3
232233

234+
if version < 4:
235+
settings = cfg.setdefault("settings", {})
236+
settings.setdefault("device_layout_overrides", {})
237+
cfg["version"] = 4
238+
233239
cfg.setdefault("settings", {})
234240
cfg["settings"].setdefault("appearance_mode", "system")
235241
cfg["settings"].setdefault("debug_mode", False)
242+
cfg["settings"].setdefault("device_layout_overrides", {})
236243

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

core/device_layouts.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
Device-layout registry for Mouser's interactive mouse view.
3+
4+
The goal is to keep device-specific visual layout data out of QML so adding a
5+
new Logitech family becomes a data change instead of a UI rewrite.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from copy import deepcopy
11+
12+
13+
MX_MASTER_LAYOUT = {
14+
"key": "mx_master",
15+
"label": "MX Master family",
16+
"image_asset": "mouse.png",
17+
"image_width": 460,
18+
"image_height": 360,
19+
"interactive": True,
20+
"manual_selectable": True,
21+
"note": "",
22+
"hotspots": [
23+
{
24+
"buttonKey": "middle",
25+
"label": "Middle button",
26+
"summaryType": "mapping",
27+
"normX": 0.35,
28+
"normY": 0.40,
29+
"labelSide": "right",
30+
"labelOffX": 100,
31+
"labelOffY": -160,
32+
},
33+
{
34+
"buttonKey": "gesture",
35+
"label": "Gesture button",
36+
"summaryType": "gesture",
37+
"normX": 0.70,
38+
"normY": 0.63,
39+
"labelSide": "left",
40+
"labelOffX": -200,
41+
"labelOffY": 60,
42+
},
43+
{
44+
"buttonKey": "xbutton2",
45+
"label": "Forward button",
46+
"summaryType": "mapping",
47+
"normX": 0.60,
48+
"normY": 0.48,
49+
"labelSide": "left",
50+
"labelOffX": -300,
51+
"labelOffY": 0,
52+
},
53+
{
54+
"buttonKey": "xbutton1",
55+
"label": "Back button",
56+
"summaryType": "mapping",
57+
"normX": 0.65,
58+
"normY": 0.40,
59+
"labelSide": "right",
60+
"labelOffX": 200,
61+
"labelOffY": 50,
62+
},
63+
{
64+
"buttonKey": "hscroll_left",
65+
"label": "Horizontal scroll",
66+
"summaryType": "hscroll",
67+
"isHScroll": True,
68+
"normX": 0.60,
69+
"normY": 0.375,
70+
"labelSide": "right",
71+
"labelOffX": 200,
72+
"labelOffY": -50,
73+
},
74+
],
75+
}
76+
77+
GENERIC_MOUSE_LAYOUT = {
78+
"key": "generic_mouse",
79+
"label": "Generic mouse",
80+
"image_asset": "icons/mouse-simple.svg",
81+
"image_width": 220,
82+
"image_height": 220,
83+
"interactive": False,
84+
"manual_selectable": False,
85+
"note": (
86+
"This device is detected and the backend can still probe HID++ features, "
87+
"but Mouser does not have a dedicated visual overlay for it yet."
88+
),
89+
"hotspots": [],
90+
}
91+
92+
MX_ANYWHERE_LAYOUT = {
93+
**GENERIC_MOUSE_LAYOUT,
94+
"key": "mx_anywhere",
95+
"label": "MX Anywhere family",
96+
"note": (
97+
"MX Anywhere support is wired for device detection and HID++ probing. "
98+
"A dedicated overlay image and hotspot map still need to be added."
99+
),
100+
}
101+
102+
MX_VERTICAL_LAYOUT = {
103+
**GENERIC_MOUSE_LAYOUT,
104+
"key": "mx_vertical",
105+
"label": "MX Vertical family",
106+
"note": (
107+
"MX Vertical uses a different physical shape, so Mouser falls back to a "
108+
"generic device card until a dedicated overlay is added."
109+
),
110+
}
111+
112+
113+
DEVICE_LAYOUTS = {
114+
"mx_master": MX_MASTER_LAYOUT,
115+
"mx_anywhere": MX_ANYWHERE_LAYOUT,
116+
"mx_vertical": MX_VERTICAL_LAYOUT,
117+
"generic_mouse": GENERIC_MOUSE_LAYOUT,
118+
}
119+
120+
121+
def get_device_layout(layout_key=None):
122+
layout = DEVICE_LAYOUTS.get(layout_key or "", DEVICE_LAYOUTS["generic_mouse"])
123+
return deepcopy(layout)
124+
125+
126+
def get_manual_layout_choices():
127+
choices = [{"key": "", "label": "Auto-detect"}]
128+
for layout in DEVICE_LAYOUTS.values():
129+
if layout.get("manual_selectable"):
130+
choices.append({
131+
"key": layout["key"],
132+
"label": layout.get("label", layout["key"]),
133+
})
134+
return choices

core/engine.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config,
1313
)
1414
from core.app_detector import AppDetector
15+
from core.logi_devices import clamp_dpi
1516

1617

1718
class Engine:
@@ -253,6 +254,10 @@ def set_connection_change_callback(self, cb):
253254
def device_connected(self):
254255
return self.hook.device_connected
255256

257+
@property
258+
def connected_device(self):
259+
return getattr(self.hook, "connected_device", None)
260+
256261
@property
257262
def enabled(self):
258263
return self._enabled
@@ -262,12 +267,13 @@ def enabled(self):
262267
# ------------------------------------------------------------------
263268
def set_dpi(self, dpi_value):
264269
"""Send DPI change to the mouse via HID++."""
265-
self.cfg.setdefault("settings", {})["dpi"] = dpi_value
270+
dpi = clamp_dpi(dpi_value, self.connected_device)
271+
self.cfg.setdefault("settings", {})["dpi"] = dpi
266272
save_config(self.cfg)
267273
# Try via the hook's HidGestureListener
268274
hg = self.hook._hid_gesture
269275
if hg:
270-
return hg.set_dpi(dpi_value)
276+
return hg.set_dpi(dpi)
271277
print("[Engine] No HID++ connection — DPI not applied")
272278
return False
273279

core/hid_gesture.py

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
"""
2-
hid_gesture.py — Detect MX Master 3S gesture button via Logitech HID++.
2+
hid_gesture.py — Detect Logitech HID++ gesture controls and device features.
33
4-
The gesture button on a Bluetooth-connected MX Master 3S (without Logi Options+)
5-
often produces NO standard OS-level mouse event. This module uses the HID++
6-
protocol (over hidapi) to:
7-
8-
1. Open the Logitech vendor-specific HID collection (UP 0xFF43).
9-
2. Discover the REPROG_CONTROLS_V4 (0x1B04) feature via IRoot.
10-
3. Divert the gesture button (CID 0x00C3) so we receive notifications.
11-
4. Fire callbacks on gesture press / release.
4+
Many Logitech mice expose their gesture button and DPI/battery controls only
5+
through the HID++ vendor channel instead of standard OS mouse events. This
6+
module opens the Logitech HID interface, discovers REPROG_CONTROLS_V4 and
7+
related features, diverts the best gesture candidate it can find, and reports
8+
press/release or RawXY movement back to Mouser.
129
1310
Requires: pip install hidapi
1411
Falls back gracefully if the package or device are unavailable.
@@ -19,6 +16,13 @@
1916
import threading
2017
import time
2118

19+
from core.logi_devices import (
20+
DEFAULT_GESTURE_CIDS,
21+
build_connected_device_info,
22+
clamp_dpi,
23+
resolve_device,
24+
)
25+
2226
try:
2327
import hid as _hid
2428
HIDAPI_OK = True
@@ -439,8 +443,7 @@ def read(self, _size, timeout_ms=0):
439443
FEAT_ADJ_DPI = 0x2201 # Adjustable DPI
440444
FEAT_UNIFIED_BATT = 0x1004 # Unified Battery (preferred)
441445
FEAT_BATTERY_STATUS = 0x1000 # Battery Status (fallback)
442-
DEFAULT_GESTURE_CID = 0x00C3 # "Mouse Gesture Button"
443-
FALLBACK_GESTURE_CIDS = (0x00D7,)
446+
DEFAULT_GESTURE_CID = DEFAULT_GESTURE_CIDS[0]
444447

445448
MY_SW = 0x0A # arbitrary software-id used in our requests
446449

@@ -548,14 +551,15 @@ def __init__(self, on_down=None, on_up=None, on_move=None,
548551
self._battery_feature_id = None
549552
self._dev_idx = BT_DEV_IDX
550553
self._gesture_cid = DEFAULT_GESTURE_CID
551-
self._gesture_candidates = [DEFAULT_GESTURE_CID]
554+
self._gesture_candidates = list(DEFAULT_GESTURE_CIDS)
552555
self._held = False
553556
self._connected = False # True while HID++ device is open
554557
self._rawxy_enabled = False
555558
self._pending_dpi = None # set by set_dpi(), applied in loop
556559
self._dpi_result = None # True/False after apply
557560
self._pending_battery = None
558561
self._battery_result = None
562+
self._connected_device_info = None
559563

560564
# ── public API ────────────────────────────────────────────────
561565

@@ -580,9 +584,14 @@ def stop(self):
580584
except Exception:
581585
pass
582586
self._dev = None
587+
self._connected_device_info = None
583588
if self._thread:
584589
self._thread.join(timeout=3)
585590

591+
@property
592+
def connected_device(self):
593+
return self._connected_device_info
594+
586595
# ── device discovery ──────────────────────────────────────────
587596

588597
@staticmethod
@@ -777,13 +786,38 @@ def _discover_reprog_controls(self):
777786
)
778787
return controls
779788

780-
def _choose_gesture_candidates(self, controls):
789+
def _choose_gesture_candidates(self, controls, device_spec=None):
781790
present = {c["cid"] for c in controls}
782791
ordered = []
783-
for cid in (DEFAULT_GESTURE_CID,) + FALLBACK_GESTURE_CIDS:
792+
preferred = tuple(
793+
getattr(device_spec, "gesture_cids", ()) or DEFAULT_GESTURE_CIDS
794+
)
795+
796+
def add_candidate(cid):
784797
if cid in present and cid not in ordered:
785798
ordered.append(cid)
786-
return ordered or [DEFAULT_GESTURE_CID]
799+
800+
for cid in preferred:
801+
add_candidate(cid)
802+
803+
for control in controls:
804+
cid = control["cid"]
805+
flags = int(control.get("flags", 0) or 0)
806+
mapping_flags = int(control.get("mapping_flags", 0) or 0)
807+
raw_xy_capable = bool(
808+
flags & 0x0100
809+
or flags & 0x0200
810+
or mapping_flags & 0x0010
811+
or mapping_flags & 0x0040
812+
)
813+
virtual_or_named = bool(
814+
flags & 0x0080
815+
or "gesture" in KNOWN_CID_NAMES.get(cid, "").lower()
816+
)
817+
if raw_xy_capable and virtual_or_named and flags & 0x0020:
818+
add_candidate(cid)
819+
820+
return ordered or list(preferred)
787821

788822
def _divert(self):
789823
"""Divert the selected gesture control and enable raw XY when supported."""
@@ -825,7 +859,7 @@ def _undivert(self):
825859
def set_dpi(self, dpi_value):
826860
"""Queue a DPI change — will be applied on the listener thread.
827861
Can be called from any thread. Returns True on success."""
828-
dpi = max(200, min(8200, int(dpi_value))) # MX Master 3S max is 8000
862+
dpi = clamp_dpi(dpi_value, self._connected_device_info)
829863
self._dpi_result = None
830864
self._pending_dpi = dpi
831865
# Wait up to 3s for the listener thread to apply it
@@ -1027,12 +1061,17 @@ def _try_connect(self):
10271061
pid = info.get("product_id", 0)
10281062
up = info.get("usage_page", 0)
10291063
usage = info.get("usage", 0)
1064+
product = info.get("product_string")
1065+
source = info.get("source", "unknown")
1066+
device_spec = resolve_device(product_id=pid, product_name=product)
10301067
self._feat_idx = None
10311068
self._dpi_idx = None
10321069
self._battery_idx = None
10331070
self._battery_feature_id = None
10341071
self._gesture_cid = DEFAULT_GESTURE_CID
1035-
self._gesture_candidates = [DEFAULT_GESTURE_CID]
1072+
self._gesture_candidates = list(
1073+
getattr(device_spec, "gesture_cids", ()) or DEFAULT_GESTURE_CIDS
1074+
)
10361075
self._rawxy_enabled = False
10371076
open_attempts = []
10381077
if _BACKEND_PREFERENCE in ("auto", "hidapi") and info.get("path"):
@@ -1089,7 +1128,10 @@ def _try_connect(self):
10891128
print(f"[HidGesture] Found REPROG_V4 @0x{fi:02X} "
10901129
f"PID=0x{pid:04X} devIdx=0x{idx:02X}")
10911130
controls = self._discover_reprog_controls()
1092-
self._gesture_candidates = self._choose_gesture_candidates(controls)
1131+
self._gesture_candidates = self._choose_gesture_candidates(
1132+
controls,
1133+
device_spec=device_spec,
1134+
)
10931135
print("[HidGesture] Gesture CID candidates: "
10941136
+ ", ".join(_format_cid(cid) for cid in self._gesture_candidates))
10951137
# Also discover ADJUSTABLE_DPI
@@ -1109,6 +1151,13 @@ def _try_connect(self):
11091151
self._battery_feature_id = FEAT_BATTERY_STATUS
11101152
print(f"[HidGesture] Found BATTERY_STATUS @0x{batt_fi:02X}")
11111153
if self._divert():
1154+
self._connected_device_info = build_connected_device_info(
1155+
product_id=pid,
1156+
product_name=product,
1157+
transport=open_info.get("transport") or transport,
1158+
source=source,
1159+
gesture_cids=self._gesture_candidates,
1160+
)
11121161
return True
11131162
break # right device but divert failed
11141163

@@ -1170,8 +1219,9 @@ def _main_loop(self):
11701219
self._pending_battery = None
11711220
self._held = False
11721221
self._gesture_cid = DEFAULT_GESTURE_CID
1173-
self._gesture_candidates = [DEFAULT_GESTURE_CID]
1222+
self._gesture_candidates = list(DEFAULT_GESTURE_CIDS)
11741223
self._rawxy_enabled = False
1224+
self._connected_device_info = None
11751225
if self._connected:
11761226
self._connected = False
11771227
if self._on_disconnect:

0 commit comments

Comments
 (0)