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
1310Requires: pip install hidapi
1411Falls back gracefully if the package or device are unavailable.
1916import threading
2017import 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+
2226try :
2327 import hid as _hid
2428 HIDAPI_OK = True
@@ -439,8 +443,7 @@ def read(self, _size, timeout_ms=0):
439443FEAT_ADJ_DPI = 0x2201 # Adjustable DPI
440444FEAT_UNIFIED_BATT = 0x1004 # Unified Battery (preferred)
441445FEAT_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
445448MY_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