Skip to content

Commit 42ecc64

Browse files
committed
Merge branch 'feat/mx-master-4-firmware-runtime' into docs/mx-master-4-firmware-first
2 parents 751e44d + 5af01f6 commit 42ecc64

6 files changed

Lines changed: 139 additions & 35 deletions

File tree

core/engine.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,8 +778,15 @@ def _battery_poll_loop(self, stop_event):
778778
except Exception:
779779
pass
780780

781+
# Read ``_replay_inflight`` under the same lock that the
782+
# replay thread uses to flip it, otherwise the battery
783+
# loop can issue a Smart Shift poll partway through a
784+
# replay round-trip and the firmware queues conflicting
785+
# HID++ writes.
786+
with self._replay_lock:
787+
replay_inflight = self._replay_inflight
781788
if (
782-
not self._replay_inflight
789+
not replay_inflight
783790
and now - _last_ss >= _ss_poll_interval
784791
and hg.smart_shift_supported
785792
):
@@ -928,3 +935,15 @@ def stop(self):
928935
self._battery_poll_thread = None
929936
self._app_detector.stop()
930937
self.hook.stop()
938+
# Cancel any pending safety auto-release timers. Without this the
939+
# threading.Timer scheduled by execute_action can still fire after
940+
# ``stop()`` returns and call ``inject_mouse_up`` against a hook
941+
# that has already been torn down -- one of the timers fires a
942+
# phantom release every time Mouser quits during a long press.
943+
timers = list(self._mouse_release_timers.values())
944+
self._mouse_release_timers.clear()
945+
for timer in timers:
946+
try:
947+
timer.cancel()
948+
except Exception as exc: # noqa: BLE001 - shutdown must complete
949+
print(f"[Engine] stop: failed to cancel release timer: {exc!r}")

core/logi_devices.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,9 +442,32 @@ def iter_known_devices() -> Iterable[LogiDeviceSpec]:
442442

443443

444444
def clamp_dpi(value, device=None) -> int:
445+
"""Clamp ``value`` into the device's DPI range, defaulting to the safe
446+
floor on malformed input.
447+
448+
``value`` may arrive from a JSON config file, a QML binding, or a HID
449+
report -- so the raw ``int(value)`` cast that was here before could
450+
raise on a stringified hex value, on ``None``, or on a partial HID
451+
read. Crashing the engine's DPI path because the user edited
452+
``config.json`` by hand is the wrong failure mode; we coerce
453+
defensively and return the device minimum so the cursor never freezes.
454+
"""
445455
dpi_min = getattr(device, "dpi_min", DEFAULT_DPI_MIN) or DEFAULT_DPI_MIN
446456
dpi_max = getattr(device, "dpi_max", DEFAULT_DPI_MAX) or DEFAULT_DPI_MAX
447-
dpi = int(value)
457+
if isinstance(value, bool):
458+
# ``bool`` is a subclass of ``int`` -- reject it explicitly so a
459+
# leaked truthy flag never silently resolves to 0 or 1 DPI.
460+
return dpi_min
461+
if isinstance(value, str):
462+
try:
463+
dpi = int(value, 0)
464+
except (TypeError, ValueError):
465+
return dpi_min
466+
else:
467+
try:
468+
dpi = int(value)
469+
except (TypeError, ValueError):
470+
return dpi_min
448471
return max(dpi_min, min(dpi_max, dpi))
449472

450473

core/mouse_hook_base.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,11 @@ def _set_device_connected(self, connected):
197197
if self._connection_change_cb:
198198
try:
199199
self._connection_change_cb(connected)
200-
except Exception:
201-
pass
200+
except Exception as exc: # noqa: BLE001 - callback boundary
201+
print(
202+
f"[MouseHook] connection_change_cb raised on "
203+
f"{state.lower()}: {exc!r}"
204+
)
202205

203206
def set_debug_callback(self, callback):
204207
self._debug_callback = callback
@@ -213,22 +216,24 @@ def _emit_debug(self, message):
213216
if self.debug_mode and self._debug_callback:
214217
try:
215218
self._debug_callback(message)
216-
except Exception:
217-
pass
219+
except Exception as exc: # noqa: BLE001 - callback boundary
220+
# ``_emit_debug`` is itself the diagnostic channel, so the
221+
# failure goes straight to print() rather than recursing.
222+
print(f"[MouseHook] debug_callback raised: {exc!r}")
218223

219224
def _emit_status(self, message):
220225
if self._status_callback:
221226
try:
222227
self._status_callback(message)
223-
except Exception:
224-
pass
228+
except Exception as exc: # noqa: BLE001 - callback boundary
229+
print(f"[MouseHook] status_callback raised: {exc!r}")
225230

226231
def _emit_gesture_event(self, event):
227232
if self.debug_mode and self._gesture_callback:
228233
try:
229234
self._gesture_callback(event)
230-
except Exception:
231-
pass
235+
except Exception as exc: # noqa: BLE001 - callback boundary
236+
print(f"[MouseHook] gesture_callback raised: {exc!r}")
232237

233238
def _dispatch(self, event):
234239
callbacks = self._callbacks.get(event.event_type, [])

core/mouse_hook_macos.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,14 @@ def _dispatch_worker(self):
229229

230230
@_autoreleased
231231
def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
232+
# The CGEventTap continues to fire briefly after ``stop()`` sets
233+
# ``_running = False`` -- macOS does not synchronously drain
234+
# in-flight callbacks before disabling the tap. Drop the event
235+
# untouched so we never enqueue into a torn-down dispatch worker,
236+
# mutate shared state, or apply scroll inversion after the device
237+
# connection has already been released.
238+
if not self._running:
239+
return cg_event
232240
try:
233241
if event_type in (
234242
_kCGEventTapDisabledByTimeout,
@@ -254,8 +262,13 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
254262
== _INJECTED_EVENT_MARKER
255263
):
256264
return cg_event
257-
except Exception:
258-
pass
265+
except Exception as exc: # noqa: BLE001 - Quartz boundary
266+
# Surface failures so a borked Quartz binding cannot make
267+
# the injected-event marker silently misfire on every
268+
# event for the rest of the session.
269+
self._emit_debug(
270+
f"CGEventGetIntegerValueField(kCGEventSourceUserData) failed: {exc!r}"
271+
)
259272
mouse_event = None
260273
should_block = False
261274

core/mouse_hook_windows.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ def __init__(self):
241241
self._startup_ok = False
242242
self._prev_raw_buttons = {}
243243
self._last_rehook_time = 0
244+
# Serializes gesture begin/end transitions across the LL hook
245+
# thread (XBUTTON), the Raw Input window proc thread, and the HID
246+
# listener thread. Held only around the `_gesture_active` /
247+
# `_gesture_triggered` flips -- dispatch happens outside so an
248+
# engine callback that re-enters the hook never deadlocks.
249+
self._gesture_lock = threading.Lock()
244250
self._init_dispatch_queue(maxsize=512)
245251
self._dispatch_worker_thread = None
246252

@@ -562,15 +568,20 @@ def _check_raw_mouse_gesture(self, hDevice, buffer):
562568
if extra_now == extra_prev:
563569
return
564570
if extra_now and not extra_prev:
565-
if not self._gesture_active:
571+
with self._gesture_lock:
572+
if self._gesture_active:
573+
return
566574
self._gesture_active = True
567575
self._gesture_triggered = False
568-
print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})")
569-
elif not extra_now and extra_prev:
570-
if self._gesture_active:
576+
print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})")
577+
return
578+
if not extra_now and extra_prev:
579+
with self._gesture_lock:
580+
if not self._gesture_active:
581+
return
571582
self._gesture_active = False
572-
print("[MouseHook] Gesture UP")
573-
self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK))
583+
print("[MouseHook] Gesture UP")
584+
self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK))
574585

575586
def _setup_raw_input(self):
576587
instance = GetModuleHandleW(None)
@@ -709,34 +720,42 @@ def _reinstall_hook(self):
709720
print("[MouseHook] Failed to reinstall hook!")
710721

711722
def _on_hid_gesture_down(self):
712-
if not self._gesture_active:
723+
with self._gesture_lock:
724+
if self._gesture_active:
725+
return
713726
self._gesture_active = True
714727
self._gesture_triggered = False
715-
self._emit_debug("HID gesture button down")
716-
self._emit_gesture_event({"type": "button_down"})
717-
if self._gesture_direction_enabled and not self._gesture_cooldown_active():
728+
tracking = (
729+
self._gesture_direction_enabled
730+
and not self._gesture_cooldown_active()
731+
)
732+
if tracking:
718733
self._start_gesture_tracking()
719734
else:
720735
self._gesture_tracking = False
721-
self._gesture_triggered = False
736+
self._emit_debug("HID gesture button down")
737+
self._emit_gesture_event({"type": "button_down"})
722738

723739
def _on_hid_gesture_up(self):
724-
if self._gesture_active:
740+
should_click = False
741+
with self._gesture_lock:
742+
if not self._gesture_active:
743+
return
725744
should_click = not self._gesture_triggered
726745
self._gesture_active = False
727746
self._finish_gesture_tracking()
728747
self._gesture_triggered = False
729-
self._emit_debug(
730-
f"HID gesture button up click_candidate={str(should_click).lower()}"
731-
)
732-
self._emit_gesture_event(
733-
{
734-
"type": "button_up",
735-
"click_candidate": should_click,
736-
}
737-
)
738-
if should_click:
739-
self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK))
748+
self._emit_debug(
749+
f"HID gesture button up click_candidate={str(should_click).lower()}"
750+
)
751+
self._emit_gesture_event(
752+
{
753+
"type": "button_up",
754+
"click_candidate": should_click,
755+
}
756+
)
757+
if should_click:
758+
self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK))
740759

741760
def _on_hid_mode_shift_down(self):
742761
self._emit_debug("HID mode shift button down")

tests/test_logi_devices.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,31 @@ def test_clamp_dpi_defaults_without_device(self):
233233
self.assertEqual(clamp_dpi(100, None), 200)
234234
self.assertEqual(clamp_dpi(9000, None), 8000)
235235

236+
def test_clamp_dpi_accepts_string_int(self):
237+
"""Hand-edited config files can persist DPI as a JSON string when
238+
users copy values around. Coerce instead of throwing."""
239+
self.assertEqual(clamp_dpi("1500", None), 1500)
240+
241+
def test_clamp_dpi_accepts_hex_string(self):
242+
self.assertEqual(clamp_dpi("0x5DC", None), 1500)
243+
244+
def test_clamp_dpi_falls_back_to_min_on_garbage_string(self):
245+
self.assertEqual(clamp_dpi("not-a-number", None), 200)
246+
247+
def test_clamp_dpi_falls_back_to_min_on_none(self):
248+
self.assertEqual(clamp_dpi(None, None), 200)
249+
250+
def test_clamp_dpi_rejects_bool(self):
251+
"""``bool`` is a subclass of ``int`` -- without the explicit check
252+
``True``/``False`` would silently clamp to ``dpi_min``/``dpi_min``
253+
because ``int(True) == 1`` and ``min(200, 1) == 1`` becomes 200 via
254+
the floor, which masks the upstream bug."""
255+
self.assertEqual(clamp_dpi(True, None), 200)
256+
self.assertEqual(clamp_dpi(False, None), 200)
257+
258+
def test_clamp_dpi_accepts_float(self):
259+
self.assertEqual(clamp_dpi(1500.7, None), 1500)
260+
236261
def test_mx_anywhere_2s_supported_buttons_include_middle_and_hscroll(self):
237262
device = resolve_device(product_id=0xB01A)
238263

0 commit comments

Comments
 (0)