Skip to content

MX Master 4: haptics, Easy-Switch and friendly name decoded on hardware — offering a driver PR #1

Description

@weltern

Opening this before writing code, per CONTRIBUTING, to avoid duplicating
reverse-engineering work and to check where you want the boundaries drawn.

This builds directly on #46, which brought Bolt (046d:C548) 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 — all of those are working
here on the MX Master 4, along with haptics, which I have not seen implemented
anywhere.

Device

Model Logitech MX Master 4
Connection Logi Bolt receiver, 046d:C548
Device index 0x02 on the receiver (not 0x01)
Protocol HID++ 4.5, 45 features
Firmware LD 04.00, RBM 27.00 / RBM 27.03, HW 00.00
WPID / model id B042 (B04200000000)

Everything below was read from that device. Where something is inferred rather
than observed, it says so.

What is decoded and working on hardware

0x19B0 HAPTIC — not implemented in any public tool I can find; Solaar
lists the id and nothing else.

  • Read fn 0x01 / write fn 0x02, two bytes.
  • Byte 1 is strength. Logi Options+ writes exactly 25 / 45 / 60 / 100 for
    its Subtle / Low / Medium / High presets, and fn 0x00 reports 60 as the
    device default.
  • Byte 0 is a flag bitmask — bit 0 haptics on/off, bit 1 battery-saving
    mode. Established by flipping each Options+ switch and observing which bit
    moved; ten writes for ten toggles, only those two pairs changed anything.
  • fn 0x04 plays a haptic effect. Accepts effect ids 0x00–0x0E and
    0x1B; everything else answers 0x02 invalid argument. Sixteen accepted ids
    and thirteen distinct sensations by hand — 0x00≡0x01 and
    0x02≡0x03≡0x04 are indistinguishable. 0x1B sits apart from the
    contiguous block and feels like a lighter variant of another effect; I have
    not pinned down which. Options+ plays 0x08 after a strength change and
    0x00 after re-enabling haptics.
  • Byte 1 of a play reply is a motor-busy flag, not a property of the effect:
    playing 0x0B from idle returned 0 three times, and 120 ms after the longest
    effect it returned 1 three times, for the same id.

0x1815 HOSTS INFO v2 / 0x1814 CHANGE HOST v2 — Easy-Switch.

  • 0x1815 fn 0x00 → [capabilities(2), hostCount, currentHost]. Reading the
    counts one byte earlier reports eight slots on a device whose slots 3+ refuse
    outright, so the two leading capability bytes matter.
  • fn 0x01 [slot] → [slot, status, …, nameLen, maxNameLen], status 1 = paired.
  • 0x1814 fn 0x01 [slot] switches. Verified end to end: the mouse left this
    computer, the button underneath brought it back, the panel reconnected.

0x0007 DEVICE FRIENDLY NAME — the editable name, 11 of a maximum 14
characters, holding "MX Master 4". fn 0x00 reports both lengths, fn 0x01
returns the text headed by the requested offset, fn 0x03 writes it back.
Verified by renaming and reading back.

Also verified on this device, and listed as not-yet-implemented in #46:
SmartShift and wheel mode (0x2111), hi-res wheel and invert (0x2121), thumb
wheel invert (0x2150), and button remapping (0x1B04 v6, nine controls).

What I deliberately did not decode

  • 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 — so
    the names live on a higher function. I stopped rather than probe further:
    those ids are reportedly set-name / move / delete-host, and a blind call to
    the last would unpair one of the user's computers.
  • 0x0020 CONFIG CHANGE. fn 0x00 reads a cookie, fn 0x01 writes it.
    It is a marker applications write to notice each other's changes, not a
    notification from the mouse, so it does not replace polling.
  • 0x1701, 0x1602, 0x00D1 — present and flagged plain, unidentified by
    Solaar or anything else I could find. 0x1701 has three functions and answers
    01 00 0A; 0x1602 answers all four functions but takes none of them with
    empty arguments — two reject the arguments, two return 0x05 logitech
    internal; 0x00D1 returns an
    identical 00 81 92 01 followed by twelve FF bytes on three separate
    functions, which is what unprogrammed memory looks like. Recorded as unknown
    rather than guessed at.
  • Everything the firmware flags hidden or engineering — 22 of the 45, including
    0x1E22 SPI DIRECT ACCESS and 0x1802 DEVICE RESET — was never probed.

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

Method, since the guide asks for evidence

Almost all of it came from watching Logi Options+ rather than guessing. The
Bolt receiver broadcasts replies to every open handle, so a WebHID page sees
the vendor application's own traffic; the low nibble of the function byte is
the requester's software id, and Options+ uses 0x0F. Changing a setting in
Options+ and reading the reply gives the function id and the byte layout
directly. No packet capture or kernel driver involved.

Two corrections worth passing on, both of which cost me a wrong answer first:

  • A blind function-id walk cannot be made safe by choosing a low ceiling.
    Setters are not always high — 0x0007's is fn 0x03 and 0x0020's is fn
    0x01. A walk of mine silently overwrote this device's configuration cookie
    and went unnoticed for three runs. It is restored, and the tooling now
    snapshots before and after and says loudly when a walk changed something.
  • Print raw bytes beside every decode. A decode with nothing to check it
    against is how the host count first read as eight slots.

What I would like to contribute

A driver PR here for src/drivers/logitech/, plus the matching OpenMouse PR
against dev for the UI, cross-referenced as the guide asks.

To be clear about the state of it: my working branch predates the protocol
split, so it is written against the old single-file layout. I am not going to
submit that — I have read why #35 and #39 were closed, and porting it into the
codec and driver separation is my job, not the reviewer's. I would rather agree
the shape here first and submit something that fits.

Before I do that, two questions:

  1. Do you want haptics as its own module under src/drivers/logitech/, or
    folded into hidpp.ts?
  2. Easy-Switch changes which computer the mouse is on, so a successful write
    disconnects the caller and cannot be confirmed by reading back. Is a driver
    method that can only report "command sent" acceptable, or would you rather
    that stayed out of the protocol package?

Happy to split it smaller if that suits review better.

Activity

  1. snekxs commented on Aug 11, 2026

    @snekxs
    Member

    Thanks for opening this first, and especially for separating observed behavior from inference. The hardware evidence and the notes about unsafe function walking are exactly what we need.

    For the two questions:

    1. Please keep the pure HAPTIC packet definitions, encoders/decoders, value types, and validation in a focused module under src/logitech/ (for example, src/logitech/haptics.ts) and re-export it through the Logitech package entry point. Put feature discovery, WebHID requests, delays, and read-back verification in LogitechHidppClient under src/drivers/logitech/hidpp.ts. That follows the existing codec/driver boundary without making the already-large HID++ driver own the wire format too.

    2. Easy-Switch can live in the protocol package. The unavoidable disconnect is a valid exception to the normal read-back rule, provided the API describes what it can prove. A method such as requestHostSwitch(slot): Promise<void> is preferable to a name that implies the active host was confirmed. It should validate the slot against HOSTS INFO first, send the command, and resolve once the HID++ command is acknowledged - or, if the device leaves before an acknowledgement is observable, once sendReport succeeds. Please document that disconnection is the expected success path and cover both acknowledgement and disconnect/timeout behavior with fake-HID tests. The OpenMouse UI should likewise say "switch requested" rather than "host switched."

    A focused first PR for the MX Master 4 codec/driver work is welcome. If button remapping makes it large, splitting that into a follow-up would make review safer; haptics, friendly name, wheel settings, and Easy-Switch are coherent together if the tests remain focused.

  2. weltern commented on Aug 14, 2026

    @weltern
    ContributorAuthor

    Everything proposed here is now merged into main, so closing this out.

    Both are decoded and verified on MX Master 4 hardware over a Logi Bolt receiver, following the boundaries settled in this thread: pure codec in src/logitech/, requestHostSwitch resolving on either an ack or the mouse's departure, and diversion exposed only to be cleared. Thanks for the guidance on where to draw the lines.

    The companion device-panel cards shipped alongside in openmouse (#82 and #83), so the MX Master 4 is fully usable in the app now.

  3. added a commit that references this issue on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions