Repository navigation
MX Master 4: haptics, wheel settings, friendly name and Easy-Switch - #9
Merged
snekxs merged 6 commits intoAug 11, 2026
Merged
Conversation
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.
11 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the features discussed in #1. Codec in
src/logitech/, drivers inLogitechHidppClient, per the boundary agreed there.Companion OpenMouse PR: OpenMouse-Project/openmouse#82 (device panel cards and artwork).
Device
046d:c548, pairing slot 2B042(B04200000000)LD 04.00,RBM 27.00/RBM 27.03,HW 00.00Builds 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. Sochanging 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.
0x19B0Haptic — not implemented in any public tool I can findRead fn
0x01/ write fn0x02, two bytes.100, each read straight back by the getter. Capability byte 3 reports 60,
matching its Medium.
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.
0x04plays an effect. Accepts0x00–0x0Eand0x1B; everythingelse up to
0x3Fanswers INVALID_ARGUMENT. Sixteen ids but thirteen distinctsensations by hand —
0x00≡0x01and0x02≡0x03≡0x04. Options+ plays0x08after a strength write and0x00after re-enabling haptics.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/0x2150wheel settings0x2111carries three bytes in one write, so each setter reads the triofirst. 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).
0x2121capabilities are[multiplier, flags]. Mode writes flip one bit andcarry diversion through untouched — setting it routes the wheel to HID++ and
stops it scrolling.
0x2150invert support is the two-byte capability field at payload[4..5];this device answers
0x0003there. Reading the single byte at payload[4]reports inversion unsupported on a device that demonstrably honours it.
0x2150does not echo its write. Unlike0x2111,0x2121and0x19B0,the reply to fn
0x20carries no payload, so a write is confirmed byre-reading fn
0x10. Confirming from the write reply reads the empty payloadas "not inverted", which makes clearing inversion appear to succeed and
setting it appear to fail while the device has applied both. Found on
hardware.
0x0007Device Friendly Namefn
0x00reports current and maximum lengths (11 of 14 here), fn0x01returnsthe text headed by the requested offset, fn
0x03writes it back. Names arevalidated 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/0x1814Easy-Switch0x1815fn0x00answers[capabilities(2), hostCount, currentHost]. The twoleading 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 slotagainst 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
0x19C0Force 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
0x1B04control, cid0x01A0, which Options+ diverts.0x1815fn0x02returns six non-text bytes perslot 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.
0x0020Config Change. fn0x00reads a cookie and fn0x01writes 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.0x1701has three functions answering01 00 0A;0x1602takes no functionwith empty arguments;
0x00D1returns an identical00 81 92 01plus twelveFFbytes on three separate functions. Recorded as unknown rather than guessed.probed.
Not present on this device, in case it saves a lookup:
0x2202,0x8060/0x8061,0x8100,0x1001,0x1F20,0x6501.Testing
npm run checkgreen: 352 tests, including new codec tests for every bytelayout above and driver tests for the paths that cannot be exercised by hand.
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.
package.
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
getFeatureperforms a fresh root round-trip on every call, with 44 call sitesin 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
getFeaturealone, since changing it affects every device and belongs in its ownchange. Happy to open that separately if it would be welcome.
Unknowns and assumptions that remain
0x19B0byte 0 bits 2-7: always zero here, purpose unknown, carried through.0x19B0fn0x03returns all zeros; unused on this firmware as far as I can tell.0x2111capability reply01 0A 4B 0Ereads as a 10..75 threshold range.That is inferred from both observed Options+ values falling inside it, not
confirmed.
have been exercised.