Skip to content

Commit d9e4bfe

Browse files
committed
feat: add MX Master 4 support with Actions Ring and haptic feedback
Phase 1 implementation: Core Device Support: - Register MX Master 4 (PID 0xB042) as variant of MX Master 3S - Add Actions Ring button (CID 0x01A0) as fully remappable control - Add dedicated device layout with 7 hotspots including Actions Ring - Use mxm4.png image asset for visual representation Haptic Feedback Backend: - Implement HID++ 2.0 HAPTIC feature (0x19B0) with: - getCapabilities (function 0) - getState (function 1) - setState with 4 discrete levels: subtle/low/medium/high - playWaveform for test feedback - Detect FORCE_SENSING_BUTTON feature (0x19C0) as placeholder - Add queue+wait pattern for HID++ command processing - Replay saved haptic level on device reconnect UI & Configuration: - Add Haptic Feedback page in sidebar (conditional on MX Master 4 connection) - Haptic Feedback page: intensity selector (4 buttons) + test button + note - Config migration v8->v9: register actions_ring button, add haptic_level setting - Add i18n strings for haptic UI (en, zh_CN, zh_TW) Mouse Hook: - Add ACTIONS_RING_DOWN/UP events for actions ring button presses - Implement divert handlers in all platform impls (Windows/macOS/Linux) - Actions ring integration follows existing gesture button patterns Testing: - 16 new tests across 5 test files: - Device registry: button definition, product ID lookup, image asset - Device layouts: dedicated layout exists, hotspots correct, no fallback - Config migration: v8->v9 adds actions_ring and haptic_level - HID++: KNOWN_CID_NAMES includes Actions Ring, feature constants defined - Mouse hook: ACTIONS_RING event constants exist No regressions for MX Master 3S -- all existing behavior unchanged. Phase 1 excludes: - Per-event haptic routing policy (phase 2) - Force Sensing Button configuration (detection only) - Swipe gesture actions (future)
1 parent 945c332 commit d9e4bfe

16 files changed

Lines changed: 846 additions & 10 deletions

core/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"hscroll_right": "Horizontal scroll right",
3434
"mode_shift": "Mode shift button",
3535
"dpi_switch": "DPI switch button",
36+
"actions_ring": "Actions Ring button",
3637
}
3738

3839
GESTURE_DIRECTION_BUTTONS = (
@@ -64,10 +65,11 @@
6465
"hscroll_right": ("hscroll_right",),
6566
"mode_shift": ("mode_shift_down", "mode_shift_up"),
6667
"dpi_switch": ("dpi_switch_down", "dpi_switch_up"),
68+
"actions_ring": ("actions_ring_down", "actions_ring_up"),
6769
}
6870

6971
DEFAULT_CONFIG = {
70-
"version": 8,
72+
"version": 9,
7173
"active_profile": "default",
7274
"profiles": {
7375
"default": {
@@ -85,6 +87,7 @@
8587
"hscroll_left": "browser_back",
8688
"hscroll_right": "browser_forward",
8789
"mode_shift": "switch_scroll_mode",
90+
"actions_ring": "none",
8891
},
8992
}
9093
},
@@ -106,6 +109,7 @@
106109
"debug_mode": False,
107110
"device_layout_overrides": {},
108111
"language": "en",
112+
"haptic_level": 2, # 0=subtle, 1=low, 2=medium, 3=high
109113
},
110114
}
111115

@@ -321,6 +325,16 @@ def _migrate(cfg):
321325
mappings["mode_shift"] = "switch_scroll_mode"
322326
cfg["version"] = 8
323327

328+
if version < 9:
329+
# v8 -> v9: add Actions Ring button mapping for MX Master 4,
330+
# add haptic feedback level setting.
331+
for pdata in cfg.get("profiles", {}).values():
332+
mappings = pdata.setdefault("mappings", {})
333+
mappings.setdefault("actions_ring", "none")
334+
settings = cfg.setdefault("settings", {})
335+
settings.setdefault("haptic_level", 2)
336+
cfg["version"] = 9
337+
324338
cfg.setdefault("settings", {})
325339
cfg["settings"].setdefault("appearance_mode", "system")
326340
cfg["settings"].setdefault("debug_mode", False)

core/device_layouts.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,90 @@
8484
],
8585
}
8686

87+
MX_MASTER_4_LAYOUT = {
88+
"key": "mx_master_4",
89+
"label": "MX Master 4",
90+
"image_asset": "mxm4.png",
91+
"image_width": 460,
92+
"image_height": 360,
93+
"interactive": True,
94+
"manual_selectable": True,
95+
"note": "",
96+
"hotspots": [
97+
{
98+
"buttonKey": "middle",
99+
"label": "Middle button",
100+
"summaryType": "mapping",
101+
"normX": 0.37,
102+
"normY": 0.27,
103+
"labelSide": "left",
104+
"labelOffX": -80,
105+
"labelOffY": -100,
106+
},
107+
{
108+
"buttonKey": "gesture",
109+
"label": "Gesture button",
110+
"summaryType": "gesture",
111+
"normX": 0.44,
112+
"normY": 0.56,
113+
"labelSide": "left",
114+
"labelOffX": -240,
115+
"labelOffY": 60,
116+
},
117+
{
118+
"buttonKey": "xbutton2",
119+
"label": "Forward button",
120+
"summaryType": "mapping",
121+
"normX": 0.48,
122+
"normY": 0.62,
123+
"labelSide": "left",
124+
"labelOffX": -300,
125+
"labelOffY": 0,
126+
},
127+
{
128+
"buttonKey": "xbutton1",
129+
"label": "Back button",
130+
"summaryType": "mapping",
131+
"normX": 0.53,
132+
"normY": 0.50,
133+
"labelSide": "right",
134+
"labelOffX": 200,
135+
"labelOffY": 50,
136+
},
137+
{
138+
"buttonKey": "hscroll_left",
139+
"label": "Horizontal scroll",
140+
"summaryType": "hscroll",
141+
"isHScroll": True,
142+
"normX": 0.25,
143+
"normY": 0.46,
144+
"labelSide": "left",
145+
"labelOffX": -200,
146+
"labelOffY": -30,
147+
},
148+
{
149+
"buttonKey": "mode_shift",
150+
"label": "Mode shift button",
151+
"summaryType": "mapping",
152+
"normX": 0.38,
153+
"normY": 0.40,
154+
"labelSide": "right",
155+
"labelOffX": 150,
156+
"labelOffY": -80,
157+
},
158+
{
159+
"buttonKey": "actions_ring",
160+
"label": "Actions Ring",
161+
"summaryType": "mapping",
162+
"normX": 0.62,
163+
"normY": 0.70,
164+
"labelSide": "right",
165+
"labelOffX": 160,
166+
"labelOffY": 50,
167+
},
168+
],
169+
}
170+
87171
GENERIC_MOUSE_LAYOUT = {
88172
"key": "generic_mouse",
89173
"label": "Generic mouse",
@@ -208,6 +292,7 @@
208292

209293
DEVICE_LAYOUTS = {
210294
"mx_master": MX_MASTER_LAYOUT,
295+
"mx_master_4": MX_MASTER_4_LAYOUT,
211296
"mx_anywhere": MX_ANYWHERE_LAYOUT,
212297
"mx_vertical": MX_VERTICAL_LAYOUT,
213298
"generic_mouse": GENERIC_MOUSE_LAYOUT,
@@ -217,7 +302,6 @@
217302
# Entries here let per-device keys fall back to the family layout until a
218303
# dedicated layout is added. Extend this dict as new devices are cataloged.
219304
_FAMILY_FALLBACKS = {
220-
"mx_master_4": "mx_master",
221305
"mx_master_3s": "mx_master",
222306
"mx_master_3": "mx_master",
223307
"mx_master_2s": "mx_master",

core/engine.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ def _setup_hooks(self):
118118
)
119119
)
120120

121+
# Divert Actions Ring CID (0x01A0) on MX Master 4 when mapped.
122+
has_actions_ring = device_buttons is None or "actions_ring" in device_buttons
123+
self.hook.divert_actions_ring = (
124+
has_actions_ring
125+
and any(
126+
pdata.get("mappings", {}).get("actions_ring", "none") != "none"
127+
for pdata in self.cfg.get("profiles", {}).values()
128+
)
129+
)
130+
121131
self._emit_mapping_snapshot("Hook mappings refreshed", mappings)
122132

123133
for btn_key, action_id in mappings.items():
@@ -491,6 +501,11 @@ def _replay_saved_settings_once(self):
491501
pass
492502
else:
493503
replay_ok = False
504+
505+
saved_haptic = self.cfg.get("settings", {}).get("haptic_level")
506+
if saved_haptic is not None and getattr(hg, "haptic_supported", False):
507+
if hasattr(hg, "set_haptic_level"):
508+
hg.set_haptic_level(saved_haptic)
494509
return replay_ok
495510

496511
def _replay_saved_settings_worker(self):
@@ -667,6 +682,30 @@ def smart_shift_supported(self):
667682
hg = self.hook._hid_gesture
668683
return hg.smart_shift_supported if hg else False
669684

685+
@property
686+
def haptic_supported(self):
687+
hg = self.hook._hid_gesture
688+
return hg.haptic_supported if hg else False
689+
690+
def set_haptic_level(self, level):
691+
"""Send haptic level to the mouse and persist to config."""
692+
level = max(0, min(3, int(level)))
693+
settings = self.cfg.setdefault("settings", {})
694+
settings["haptic_level"] = level
695+
save_config(self.cfg)
696+
hg = self.hook._hid_gesture
697+
if hg:
698+
return hg.set_haptic_level(level)
699+
print("[Engine] No HID++ connection -- haptic level not applied")
700+
return False
701+
702+
def play_haptic_waveform(self, waveform_id=0):
703+
"""Trigger a haptic waveform on the mouse."""
704+
hg = self.hook._hid_gesture
705+
if hg:
706+
return hg.play_haptic_waveform(waveform_id)
707+
return False
708+
670709
def reload_mappings(self):
671710
"""
672711
Called by the UI when the user changes a mapping.

0 commit comments

Comments
 (0)