Skip to content

docs: MX Master 4 firmware-first + HID++ wheel native-invert#169

Open
hughesyadaddy wants to merge 8 commits into
TomBadash:masterfrom
hughesyadaddy:docs/mx-master-4-firmware-first
Open

docs: MX Master 4 firmware-first + HID++ wheel native-invert#169
hughesyadaddy wants to merge 8 commits into
TomBadash:masterfrom
hughesyadaddy:docs/mx-master-4-firmware-first

Conversation

@hughesyadaddy
Copy link
Copy Markdown
Contributor

@hughesyadaddy hughesyadaddy commented May 14, 2026

Summary

  • README.md: the scroll-inversion bullet now mentions that MX Master
    devices apply inversion at the source via HID++ (0x2121 + 0x2150)
    and survives Synergy / DeskFlow / KVM forwarding. The Limitations
    section documents the wheel_divert: "off" kill switch.
  • DEVELOPMENT.md: replace the stale divert+inject description with
    the actual native-invert flow (the firmware flips the wheel sign in
    the device, the OS still receives native HID scroll, and Mouser
    stays out of the scroll path entirely). Bump the config-version
    reference from 9 to 11 to match the recent migrations.
  • CONTRIBUTING_DEVICES.md: compact note explaining the MX Master 4
    role swap (CID 0x01a0 Sense Panel -- the touch surface Logitech
    markets as "Haptic Sense" and Logi Options+ exposes under the
    "Action Ring" overlay -- drives gestures; small Thumb button CID
    0x00c3 is the single-press trigger), the spec wiring snippet, and
    the runtime fields the platform mouse hooks read to pick the active
    path. The new-device template gains has_hires_wheel /
    has_thumbwheel so contributors know which fields matter for wheel
    inversion.

Validation

  • python -m pytest (sanity: doc changes should not affect tests)
  • git diff --check

@hughesyadaddy hughesyadaddy force-pushed the docs/mx-master-4-firmware-first branch 4 times, most recently from 306f1e9 to 483cef3 Compare May 14, 2026 22:38
Extend the per-device catalog with the metadata follow-up PRs need to
drive the MX Master 4's two thumb-area buttons. None of this changes
runtime behavior yet; consumers land in the HID listener, engine, and
mouse-hook PRs.

MX_MASTER_4_BUTTONS adds `thumb_button` to the standard MX Master button
set so the relocated Mouse Gesture Button (CID 0x00C3) has its own UI
mapping target. LogiDeviceSpec gains `has_hires_wheel`, `has_thumbwheel`,
`gesture_via_sense_panel`, and `thumb_button_cid` so device entries
declare HID++ feature presence and CID roles up front. ConnectedDeviceInfo
mirrors those four plus runtime-state siblings (`hires_wheel_active`,
`thumbwheel_active`, `active_gesture_cid`, `thumb_button_via_hid`) so
platform mouse hooks read state without re-walking the capability
inventory inline. `build_connected_device_info` accepts the new state
kwargs, folds in spec hints only when the caller did not provide a value,
and normalizes `active_gesture_cid` to int.

Naming follows Logitech's firmware/marketing terminology (verified
against Solaar `special_keys.py`, the MX Master 4 reprog-controls dump
in pwr-Solaar/Solaar#2964, and Logitech's own MX Master 4 marketing):
0x01A0 is the haptic Sense Panel, 0x00C3 is the relocated Mouse Gesture
Button. "Action Ring" is a Logi Options+ software overlay invoked by
pressing the Sense Panel, not a hardware button, so internal symbols use
`thumb_button` / `gesture_via_sense_panel` instead.

The MX Master 4 catalog entry lists `gesture_cids` with 0x01A0 first so
the listener prefers diverting the larger Sense Panel with rawXY, points
`thumb_button_cid` at 0x00C3 so the relocated Mouse Gesture Button is
wired as a button-only extra, and sets `gesture_via_sense_panel = True`
so the device is eligible for an OS-level fallback swap when firmware
rejects the panel divert. Both CIDs are confirmed divertable + rawXY-
capable on real MX Master 4 firmware (Solaar issue #2964).

`tests/test_logi_devices.py` updates the haptic-control test to assert
`thumb_button` is in `supported_buttons` while the Sense Panel CID still
produces no UI button entry, and adds tests for spec-to-runtime mirroring
and `active_gesture_cid` int normalization.
…back tests

- Replace ``bool`` capability defaults in ``build_connected_device_info`` with
  ``bool | None``. ``None`` means "no probe performed" and defers to the
  catalog hint; explicit ``True``/``False`` is definitive runtime evidence.
  Prior behavior silently dropped a definitive ``False`` runtime probe under
  an optimistic catalog ``True``, which would let the listener try to divert
  a feature the firmware did not actually expose.
- Extract ``_coerce_cid`` so caller-supplied CIDs (int, ``"0x01A0"`` hex
  string, ``None``) all funnel through one normalizer; downstream consumers
  no longer compare ``int`` against ``str`` and silently miss matches.
- Stop letting an empty ``gesture_cids=()`` collapse into the spec default
  via short-circuit ``or``. An empty tuple is the runtime's explicit "I saw
  none"; treat it as truth.

New tests cover: hex-string CID normalization, malformed CID resolves to
None, unknown PID falls back to generic + no MX-family buttons, explicit
runtime ``False`` overrides catalog ``True``, runtime ``True`` upgrades an
unknown device, and empty ``gesture_cids`` is respected on both spec and
fallback paths.
clamp_dpi used a bare int(value) cast against whatever flowed in from
config.json, QML bindings, or HID reads. A user-edited config that quoted
the DPI as a string, a stale None from a partial HID report, or a leaked
bool flag would raise ValueError/TypeError and abort the engine's DPI
path -- the cursor freezes until restart.

Coerce defensively instead. Accept ints, floats, decimal/hex strings, and
fall back to the device's dpi_min on anything else. Reject bool
explicitly so a truthy flag never silently clamps to 0 or 1 DPI via
int(True). The clamp range itself is unchanged.
Two MX Master 4 hardware affordances need firmware-level wiring to
work without freezing the cursor or fighting external KVM forwarding.
This change wires both through the HID++ vendor channel, with an
OS-level fallback path for cases where the firmware divert is
rejected.

The first is the small thumb button (CID 0x00c3, Solaar's
`Mouse_Gesture_Button` with the `Thumb_Button` alias on MX Master 4).
MX Master 4 also ships the Sense Panel (CID 0x01a0, Solaar's
`Haptic`), which is what Logitech markets as "Haptic Sense" and what
Logi Options+ binds the "Action Ring" software overlay to. The Sense
Panel also surfaces as OS-level mouse btn=6 (BTN_TASK on Linux evdev)
when not diverted, and without divert that report stays high for the
entire press-and-drag, which freezes the cursor in many apps.
HidGestureListener lists 0x01a0 first in gesture_cids so it diverts
the Sense Panel with rawXY; gesture press / release / motion flow
over the HID++ vendor channel and the OS never sees btn=6. The small
button (0x00c3) is diverted as a button-only extra in the same
setCidReporting round so its press / release fire
on_thumb_button_down/up.

Adds an OS-level fallback swap for cases where firmware rejects the
Sense Panel divert: the platform mouse hook treats btn=6 / BTN_TASK
as the gesture button and the small HID++ button becomes the
thumb_button trigger.

The second is wheel scroll inversion. OS-layer post-injection is
unreliable when Synergy / DeskFlow / KVM forwards events to a remote
machine because the inversion only applies on the host. Drive
inversion at the device instead. HidGestureListener discovers
FEAT_HIRES_WHEEL_ENHANCED (0x2121) and FEAT_THUMB_WHEEL (0x2150) and
exposes them as has_hires_wheel / has_thumbwheel on
ConnectedDeviceInfo. request_wheel_native_invert is a cross-thread
API: the engine calls it from the Qt thread, the listener loop
applies the device write on the HID thread via a pending-request
slot, and the caller blocks on a result event with a 3 s timeout.
_abort_pending_wheel_divert wakes the caller with result=False if
the connection drops mid-request so callers never strand. The
engine drives this via _apply_wheel_invert_setting on every profile
or device change. When the device acknowledges, the engine and the
platform mouse hook both flip wheel_native_invert_active and the
hook suppresses its OS-layer inversion path.

Cross-cutting changes: core/config.py adds a thumb_button entry to
BUTTON_NAMES and BUTTON_TO_EVENTS, bumps DEFAULT_CONFIG version 9 to
11, and adds migrations v10 (wheel_divert default) and v11
(thumb_button mapping default). core/mouse_hook_contract.py adds
wheel_native_invert_active to the Protocol so the engine reads it
without an isinstance check. core/mouse_hook_macos.py routes btn=6
OtherMouseDown / Up to THUMB_BUTTON events and to
_begin_gesture_capture / _end_gesture_capture (HID-first swallow,
OS-fallback gesture capture). State mutations protected by a new
_gesture_lock that covers both the CGEventTap main-thread callback
and the HID listener background thread.
core/mouse_hook_linux.py adds a BTN_TASK branch to _handle_button
with the same routing, and core/mouse_hook_windows.py guards
WM_MOUSEWHEEL / WM_MOUSEHWHEEL on wheel_native_invert_active. tests
cover MX4 thumb-button dispatch, the wheel native-invert request /
timeout / replay machinery, and the new config migrations.
…vice

The wheel-invert toggle was designed to flip Logitech scroll across KVM /
Synergy by asking the firmware to invert at the source. The OS-layer
fallback (event-tap negate on macOS, uinput sign flip on Linux, raw-input
inject on Windows) was a safety net for devices that cannot do the
firmware path -- but it ran any time ``wheel_native_invert_active`` was
False, including when no Logitech was connected at all. The practical bug:
turning the toggle on while only a trackpad or generic USB mouse is
attached inverted every scroll event flowing through Mouser, even though
the toggle was never meant to apply to those devices.

The fix centralises the gate as ``BaseMouseHook._apply_v/hscroll_invert_fallback``,
which now requires ``self._connected_device is not None`` (a Logitech is
currently bound) in addition to ``not wheel_native_invert_active``. All
three platform hooks route through the new helpers so future ports inherit
the same contract.

Also fixes a stack of FANG-level defects in the firmware-runtime layer:

core/hid_gesture.py
- Gate ``_install_thumb_button_extra`` on the live REPROG_V4 controls
  list. We never queue a divert for a CID the firmware does not actually
  advertise on the current connection -- previously the static catalog
  was the sole source of truth, which let setCidReporting hammer the
  device for controls that did not exist.
- Track per-CID divert acknowledgments in ``_extra_divert_acks`` and
  recompute ``thumb_button_via_hid`` against that set. Previously the
  property flipped to True immediately on install, before the
  setCidReporting call ran; the hook layer trusted that to suppress the
  OS BTN_TASK fallback, eating presses whenever the divert was rejected.
- Drop failed CIDs from ``_extra_diverts`` so the OS-fallback path stays
  in charge of buttons the firmware refused.
- Replace bare ``except Exception: pass`` in ``_atexit_stop_listeners``
  and ``stop()`` with narrowed handlers that log the failure. A stuck
  divert state is a user-visible bug; the next session needs the
  breadcrumb to diagnose it.
- Move ``self._wheel_divert_target = target`` inside
  ``_wheel_divert_call_lock`` in ``request_wheel_native_invert`` so two
  concurrent callers cannot interleave updates to the reconnect-replay
  cache.

core/mouse_hook_macos.py
- Resolve ``kCGScrollWheelEventIsContinuous`` through the Quartz symbol
  when available; fall back to the integer literal so the event-tap path
  no longer carries a naked magic number and picks up future SDK
  renumbering automatically.

Tests
- New: ``test_install_thumb_button_extra_skipped_when_cid_absent_from_reprog``
  and ``test_divert_extras_clears_acks_and_drops_failed_cids`` lock the
  capability-gating + ack-tracking invariants.
- New: ``test_os_inversion_skipped_when_no_logitech_connected`` and
  ``test_os_inversion_resumes_when_logitech_reconnects`` pin the
  connected-device gate on the macOS path.
- Updated: existing ``MacOSSuppressionTests`` cases now set
  ``hook._connected_device`` explicitly so they exercise the fallback
  path the gate intends.
Two FANG-grade cleanups that turn stringly-typed config + magic protocol
constants into named, validated surfaces:

core/config.py
- Introduce ``WHEEL_DIVERT_AUTO`` / ``WHEEL_DIVERT_OFF`` /
  ``WHEEL_DIVERT_VALID_VALUES`` / ``WHEEL_DIVERT_DEFAULT`` so call sites
  compare against named constants instead of bare strings.
- ``coerce_wheel_divert_setting`` normalizes case + whitespace, rejects
  unknowns into the safe default, and logs one warning per distinct typo
  so a misconfigured ``config.json`` produces one actionable message
  instead of a per-event flood.
- ``load_config`` now routes ``settings.wheel_divert`` through the
  coercer so the on-disk value is the sealed value before any engine
  branch sees it.

core/engine.py
- ``_apply_wheel_invert_setting`` and ``_run_saved_settings_replay`` both
  pull the kill-switch decision through ``coerce_wheel_divert_setting``,
  removing the two direct string comparisons that previously trusted the
  config file's value.

core/hid_gesture.py
- Replace the bare ``0x33`` / ``0x03`` / ``0x22`` / ``0x02`` REPROG_V4
  ``setCidReporting`` mode bytes with named constants
  ``_DIVERT_RAW_XY``, ``_DIVERT_BUTTON_ONLY``, ``_UNDIVERT_RAW_XY``,
  ``_UNDIVERT_BUTTON``. Drop a single block-comment explaining the bit
  layout so the next reader does not have to spelunk the HID++ spec to
  understand what ``0x33`` means.
- ``_undivert`` now logs each teardown failure rather than swallowing the
  exception; teardown still completes regardless because callers
  (disconnect, atexit) depend on it never raising.

tests/test_config.py
- New ``WheelDivertCoercionTests`` (10 cases) pinning: canonical values
  round-trip, case + whitespace normalize, unknown strings fall back to
  the default, warnings are deduped per distinct value, ``None`` resolves
  silently, non-string types warn once per type, end-to-end
  ``load_config`` normalizes case on disk, and an unknown typo persisted
  to disk surfaces as the sealed default.
A handful of adjacent FANG-grade tightenings against the same code
paths the HID++ runtime exercises. Each one stands alone but they share
the same theme: never swallow a callback failure, never mutate state
after teardown, never read shared state without the lock that protects
its writes.

core/mouse_hook_base.py
- ``_set_device_connected`` and the three ``_emit_*`` helpers used to
  swallow callback exceptions with ``except Exception: pass``. Replace
  with narrow handlers that log the offending callback by name; nothing
  propagates because the call sites are inside hot paths and must
  continue, but the breadcrumb tells you which integration is broken.

core/mouse_hook_windows.py
- Add ``_gesture_lock`` and serialize the begin / end transitions of
  ``_gesture_active`` / ``_gesture_triggered`` across the LL hook
  thread (XBUTTON path), the Raw Input window-proc thread, and the HID
  listener thread. macOS and Linux already had this lock; Windows did
  not, so a side-button press racing with a HID gesture-down could
  leave the state machine half-flipped.
- Move ``_dispatch`` and ``_emit_*`` out of the lock so an engine
  callback that re-enters the hook can never deadlock.

core/mouse_hook_macos.py
- Drop the event-tap callback early when ``_running`` is False. The
  CGEventTap keeps firing briefly after ``stop()`` -- macOS does not
  drain in-flight callbacks before disabling the tap, so without the
  guard we still enqueue events into a torn-down dispatch worker and
  apply scroll inversion after the connection has been released.
- Log the previously silent Quartz user-data probe failure so a borked
  binding cannot make the injected-event marker silently misfire for
  the rest of the session.

core/engine.py
- ``stop()`` now cancels every outstanding entry in
  ``_mouse_release_timers``. The safety auto-release timer scheduled by
  ``execute_action`` could otherwise fire after the hook had already
  been torn down and call ``inject_mouse_up`` against nothing.
- ``_battery_poll_loop`` reads ``_replay_inflight`` under
  ``_replay_lock``. Without it the Smart Shift poll can sneak in
  partway through a replay round-trip and the firmware queues
  conflicting HID++ writes.
Update the docs to match the firmware-first implementation that landed
with the MX Master 4 catalog work and the HID++ wheel native-invert
plumbing. README.md's scroll-inversion bullet now mentions that MX
Master devices apply inversion at the source via HID++ (0x2121 +
0x2150) and that inversion therefore survives Synergy / DeskFlow /
KVM forwarding. The Limitations section documents the
`wheel_divert: "off"` kill switch.

DEVELOPMENT.md replaces the older "divert + inject" description (which
described a flow Mouser doesn't implement) with the actual
native-invert flow: the firmware flips the wheel sign in the device,
the OS still receives native HID scroll, and Mouser stays out of the
scroll path entirely. The config-version reference is bumped to 11 to
match the recent migrations.

CONTRIBUTING_DEVICES.md gains a compact note explaining the MX Master
4 thumb-area role assignment. The Sense Panel (CID 0x01A0, Solaar's
`Haptic` feature, marketed by Logitech as "Haptic Sense" and exposed
by Logi Options+ under the "Action Ring" software overlay) drives
directional gestures because it's far more comfortable for swipes
than the small side button. The small Thumb button (CID 0x00C3,
Solaar's `Mouse_Gesture_Button` with the `Thumb_Button` alias on MX
Master 4) is the single-press mapping target. The note includes the
spec wiring snippet (`gesture_cids`, `thumb_button_cid`,
`gesture_via_sense_panel`) and lists the runtime fields the platform
mouse hooks read to pick the active path. The new-device template
adds has_hires_wheel / has_thumbwheel so contributors know which
fields matter for wheel inversion on future devices.
@hughesyadaddy hughesyadaddy force-pushed the docs/mx-master-4-firmware-first branch from 42ecc64 to 2718895 Compare May 18, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant