Skip to content

MX Master 4: haptics, wheel settings, friendly name and Easy-Switch - #9

Merged
snekxs merged 6 commits into
OpenMouse-Project:mainfrom
weltern:feat/logitech-mx-master-4-haptics
Aug 11, 2026
Merged

snekxs merged 6 commits into
OpenMouse-Project:mainfrom
weltern:feat/logitech-mx-master-4-haptics

Conversation

@weltern

@weltern weltern commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Implements the features discussed in #1. Codec in src/logitech/, drivers in
LogitechHidppClient, per the boundary agreed there.

Companion OpenMouse PR: OpenMouse-Project/openmouse#82 (device panel cards and artwork).

Device

Model Logitech MX Master 4
Connection Logi Bolt receiver 046d:c548, pairing slot 2
WPID / model id B042 (B04200000000)
Firmware LD 04.00, RBM 27.00 / RBM 27.03, HW 00.00
Protocol HID++ 4.5, 45 features

Builds on #46, which brought Bolt support and the MX Master 3S. Its closing note
lists SmartShift, hi-res and thumb wheel, host switching and button remaps as
not yet implemented; the first three are here, and button remapping is held back
for a follow-up so this stays reviewable.

Evidence

Almost all of it came from watching Logi Options+ rather than guessing. The Bolt
receiver broadcasts replies to every open handle, and the low nibble of the
function byte carries the requester's software id — Options+ uses 0x0F. So
changing a setting in the vendor application and reading the reply gives the
function id and the byte layout directly. No packet capture involved.

Where something is inferred rather than observed, it says so below.

0x19B0 Haptic — not implemented in any public tool I can find

Read fn 0x01 / write fn 0x02, two bytes.

  • Byte 1 is strength. The four Options+ presets write exactly 25, 45, 60 and
    100, each read straight back by the getter. Capability byte 3 reports 60,
    matching its Medium.
  • Byte 0 is a flag bitmask — bit 0 haptics on/off, bit 1 battery saving.
    Established by flipping each Options+ switch and observing which bit moved:
    ten writes for ten toggles, and only those two pairs changed anything. Its
    per-context switches (Actions Ring, Gestures, Switch Screens) move no device
    byte at all, so those are decided in its own software. Bits 2-7 were zero
    throughout and their purpose is unknown, so writes carry them through.
  • fn 0x04 plays an effect. Accepts 0x00–0x0E and 0x1B; everything
    else up to 0x3F answers INVALID_ARGUMENT. Sixteen ids but thirteen distinct
    sensations by hand — 0x00≡0x01 and 0x02≡0x03≡0x04. Options+ plays
    0x08 after a strength write and 0x00 after re-enabling haptics.
  • Byte 1 of a play reply is a motor-busy flag, not a property of the effect.
    Playing one id from idle and again 120 ms after the longest effect answered
    0,0,0 then 1,1,1 for the same id.

0x2111 / 0x2121 / 0x2150 wheel settings

  • 0x2111 carries three bytes in one write, so each setter reads the trio
    first. Byte 0 is the wheel's ratchet mode — the same thing the button behind
    the wheel toggles, and not SmartShift on/off. Byte 1 is the threshold,
    255 disabling SmartShift; proven by moving the Options+ sensitivity slider and
    watching only that byte follow (9% wrote 46, 75% wrote 15, mode fixed at 2).
  • 0x2121 capabilities are [multiplier, flags]. Mode writes flip one bit and
    carry diversion through untouched — setting it routes the wheel to HID++ and
    stops it scrolling.
  • 0x2150 invert support is the two-byte capability field at payload[4..5];
    this device answers 0x0003 there. Reading the single byte at payload[4]
    reports inversion unsupported on a device that demonstrably honours it.
  • 0x2150 does not echo its write. Unlike 0x2111, 0x2121 and 0x19B0,
    the reply to fn 0x20 carries no payload, so a write is confirmed by
    re-reading fn 0x10. Confirming from the write reply reads the empty payload
    as "not inverted", which makes clearing inversion appear to succeed and
    setting it appear to fail while the device has applied both. Found on
    hardware.

0x0007 Device Friendly Name

fn 0x00 reports current and maximum lengths (11 of 14 here), fn 0x01 returns
the text headed by the requested offset, fn 0x03 writes it back. Names are
validated before any bytes go out, and the result is read back afterwards —
firmware that acknowledges a write and keeps its old value would otherwise look
like a successful rename.

0x1815 / 0x1814 Easy-Switch

0x1815 fn 0x00 answers [capabilities(2), hostCount, currentHost]. The two
leading capability bytes matter: reading the counts one byte earlier reports
eight slots on a device whose slots 3 and up refuse outright. This unit reports
three slots, two paired, sitting on slot 0.

requestHostSwitch(slot) follows the shape agreed in #1. It validates the slot
against HOSTS INFO first and resolves once the command reached the device —
either acknowledged, or having timed out because the mouse left before it could
answer. Disconnection is the expected success path, so the method promises
only that the switch was requested. A refusal from a mouse that is still present
is surfaced rather than mistaken for departure. An empty slot is refused: it
would leave the mouse unreachable until someone presses the button underneath.

What is deliberately not here

  • 0x19C0 Force Sensing Button is dormant config, not a pressure stream.
    With a positive control proving the watcher live, it emitted nothing and its
    values never moved while the panel was pressed. The Actions Ring panel is an
    ordinary 0x1B04 control, cid 0x01A0, which Options+ diverts.
  • Easy-Switch host names. 0x1815 fn 0x02 returns six non-text bytes per
    slot regardless of the reported name length — an address, not a label. The
    remaining function ids there are reportedly set-name, move and delete-host, and
    a blind call to the last would unpair one of the user's computers, so I stopped.
  • 0x0020 Config Change. fn 0x00 reads a cookie and fn 0x01 writes it,
    proven by writing this device's original value back. It is a marker
    applications write to notice each other, not a notification from the mouse.
  • 0x1701, 0x1602, 0x00D1 — present and flagged plain, unidentified.
    0x1701 has three functions answering 01 00 0A; 0x1602 takes no function
    with empty arguments; 0x00D1 returns an identical 00 81 92 01 plus twelve
    FF bytes on three separate functions. Recorded as unknown rather than guessed.
  • Everything the firmware flags hidden or engineering — 22 of the 45 — was never
    probed.

Not present on this device, in case it saves a lookup: 0x2202,
0x8060/0x8061, 0x8100, 0x1001, 0x1F20, 0x6501.

Testing

  • npm run check green: 352 tests, including new codec tests for every byte
    layout above and driver tests for the paths that cannot be exercised by hand.
  • Host switching has fake-HID tests for both outcomes — the mouse
    acknowledging, and the mouse leaving before it can — plus a refusal from a
    still-present device, a report that never reaches the transport, and every
    rejected slot.
  • OpenMouse builds and its full suite passes against a local install of this
    package.
  • On hardware (MX Master 4 over Bolt): haptic strength across all four
    presets, both haptic flags independently, effect playback, wheel mode,
    SmartShift threshold and off, hi-res scrolling, scroll inversion, thumb-wheel
    inversion, rename with a round trip back to the original, and a host switch
    including the return trip via the button underneath.

One observation, not addressed here

getFeature performs a fresh root round-trip on every call, with 44 call sites
in the Logitech driver. A steady-state refresh on this device spends most of its
traffic there. I have cached what this PR adds — Easy-Switch state, the friendly
name and the wheel capability flags are read once per connection — but left
getFeature alone, since changing it affects every device and belongs in its own
change. Happy to open that separately if it would be welcome.

Unknowns and assumptions that remain

  • 0x19B0 byte 0 bits 2-7: always zero here, purpose unknown, carried through.
  • 0x19B0 fn 0x03 returns all zeros; unused on this firmware as far as I can tell.
  • The 0x2111 capability reply 01 0A 4B 0E reads as a 10..75 threshold range.
    That is inferred from both observed Options+ values falling inside it, not
    confirmed.
  • Haptic strength is accepted anywhere in 0..100; only the four preset values
    have been exercised.

The MX Master 4 is the only Logitech mouse known to carry feature 0x19B0, and
no public documentation describes it. Everything here was read off hardware by
watching Logi Options+ talk to the mouse: a Bolt receiver broadcasts replies to
every open handle, and the low nibble of the function byte carries the
requester's software id, so the vendor application's traffic is visible
alongside your own.

The configuration is a two-byte pair behind get 0x10 / set 0x20. Byte 1 is the
strength; Options+'s four presets write 25, 45, 60 and 100, and capability byte
3 reports 60 as the factory default, matching its Medium. Byte 0 is a flag
bitmask: turning haptics off cleared bit 0, turning battery saving off cleared
bit 1, and both returned together as 0x03. Ten writes for ten switch toggles,
and only those two pairs moved anything, so Options+'s per-context switches are
decided in its own software rather than in the mouse. Bits 2-7 were zero
throughout and their purpose is unknown, so a write carries them through.

fn 0x04 fires the motor. It accepts sixteen effect ids, 0x00-0x0E and 0x1B,
answering INVALID_ARGUMENT to everything else up to 0x3F; by hand those are
thirteen distinct sensations, since 0x00 and 0x01 are indistinguishable, as are
0x02, 0x03 and 0x04. Options+ plays 0x08 after a strength write and 0x00 after
re-enabling haptics.

Byte 1 of a play reply is a motor-busy flag rather than anything about the
effect. It first read as a duration flag, which is backwards — 0x0E is two long
vibrates and reports 0, while 0x0B is three quick taps and reports 1. Playing
one id from idle and again on the heels of the longest effect settled it: 0
three times, then 1 three times, for the same id.

Not decoded, and deliberately left alone: force sensing 0x19C0 is dormant
config rather than a pressure stream, and the Actions Ring panel is an ordinary
0x1B04 control, cid 0x01A0, which Options+ diverts.

Verified on hardware: MX Master 4, WPID B042, firmware LD 04.00, over a Logi
Bolt receiver 046d:c548 on pairing slot 2.
The device does not buzz when haptics are switched back on. Options+ plays
an effect straight after that write, and someone who has used the vendor
software notices its absence — but it is feedback rather than protocol, so
the driver exposes the effect and leaves the decision to the caller.
The wire format belongs in src/logitech/ and reaches consumers through the
Logitech entry point; only feature discovery, WebHID requests and read-back
verification stay in the driver. Following the mode-status module put it
under src/drivers/ instead, which leaves the already-large HID++ driver
owning the packet layout too.

The codec folder uses .js extensions on relative imports and the driver
folder uses .ts, so the moved file and its test follow the folder they now
live in.
…itch

Adds the remaining MX Master 4 features alongside haptics, all read off the
same hardware and all following the codec/driver split: wire format under
src/logitech/, WebHID and read-back verification in the driver.

0x2111 SmartShift Enhanced carries three bytes in one write, so each setter
reads the trio first. Byte 0 is the wheel's ratchet mode — the same thing the
button behind the wheel toggles, and not SmartShift on/off, which is what it
was first mistaken for. Byte 1 is the threshold, where 255 disables SmartShift
and lower values release the ratchet on a gentler flick; proven by moving the
Logi Options+ sensitivity slider and watching only that byte follow.

0x2121 capabilities are [multiplier, flags]; reading them reversed reports a
multiplier of 28 on a wheel whose real multiplier is 15. Mode writes flip a
single bit and carry diversion through untouched: setting it routes the wheel
to HID++ and stops it scrolling, and clearing it takes that away from whatever
set it. 0x2150 thumb-wheel invert support is the two-byte capability field at
payload[4..5], which an MX Master 4 answers 0x0003 — reading the single byte
there reports inversion unsupported on a device that demonstrably honours it.

0x0007 friendly name is validated before any bytes go out, because a device
that takes half a name and rejects the rest leaves the user with neither the
old one nor the new, and the name is read back afterwards: firmware that
acknowledges a write and keeps its old value would otherwise look like success.

Easy-Switch is named requestHostSwitch for what it can prove. A successful
switch disconnects this host, so there is no state left to read back — the
method resolves when the command reached the device, either acknowledged or
having timed out because the mouse left before it could answer. A refusal from
a mouse that is still present is surfaced rather than mistaken for departure,
and the acknowledgement timeout is short so the caller is not left waiting six
seconds for an answer that cannot arrive. An empty slot is refused outright:
switching there leaves the mouse unreachable until someone presses the button
on its underside.

Fake-HID tests cover both the acknowledged and the disconnected paths, plus
refusal, a report that never reaches the transport, and every rejected slot.

Verified on hardware: MX Master 4, WPID B042, firmware LD 04.00, over a Logi
Bolt receiver 046d:c548 on pairing slot 2.
0x2150 does not echo the values it was given — unlike 0x2111, 0x2121 and
0x19B0, its write reply carries nothing. Confirming from that reply read
the empty payload as "not inverted", so restoring the direction appeared
to succeed and inverting it appeared to fail, while the device had in
fact applied both.

Found on hardware, not by the suite: the fake this was first written
against echoed the written values, so the test agreed with the
assumption instead of with the mouse. The new fake answers an empty
payload the way the device does, and three of its four tests fail
against the previous implementation.
A status refresh was making 35 round-trips, 19 of them added by the new
features. On a wireless mouse that is enough for some reads to time out,
and a timed-out read returns null, which surfaced as controls vanishing
from the interface and reappearing a few seconds later.

Easy-Switch and the friendly name are now read once per connection and
cleared on close. The slot count is fixed and the current slot changing
IS the connection ending, because that is what switching host does; the
name only moves when something renames it, and this client drops its
entry after its own write. Whether the wheel can invert is a property of
the hardware, so that is read once too.

25 round-trips per refresh now, measured the same way. A failed read is
deliberately left uncached so the next refresh retries, rather than one
transient timeout hiding a control for the whole session.
@snekxs
snekxs merged commit 2682956 into OpenMouse-Project:main Aug 11, 2026
snekxs pushed a commit that referenced this pull request Aug 14, 2026
Adds feature 0x1B04 (REPROG CONTROLS V4): read the control table, point one
control at another, and hand diverted controls back to the hardware. Codec in
src/logitech/controls.ts, driver methods on LogitechHidppClient. Follow-up to
#9, whose scope held button remapping back to stay reviewable.

This remaps control to control only. It does not implement Options+-style
actions like launching an app; Options+ does those by diverting the button and
handling it in its own software, which nothing here receives. So diversion is
exposed only to be cleared, never set.

The device decides what is legal: every control reports a group and a mask of
the groups it accepts, and the primary buttons report a mask of zero, so the
firmware refuses to move them rather than any rule here. They still appear as
targets for other controls, because any control in an accepted group is a legal
destination even when it is not a legal source.

Two decode details. The mapping flags are one 16-bit field split across two
non-adjacent bytes (low at payload[2], high at payload[5], with the remap
target between), so reading them as a contiguous pair mistakes a button
remapped to a high control id for a diverted one. And each mapping flag is a
value plus a valid bit one position higher, so a pure remap writes a zero flags
byte and leaves diversion untouched; clearing diversion sets only the valid
bits, so it can only ever turn diversion off.

A control merges getControlIdInfo (key flags) with getControlIdReporting (the
16-bit mapping field); those are named apart (flags vs mappingFlags) so neither
overwrites the other, and virtual/reprogrammable are exposed as booleans.

Verified on an MX Master 4 over a Logi Bolt receiver. Codec tests cover every
byte layout above; fake-HID driver tests cover read-back, refusal of illegal or
absent controls, the acknowledge-but-ignore path, and restoring diversion
without disturbing an existing mapping.
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.

2 participants