Skip to content

feat(mx-master-4): expose the haptic thumb pad (CID 0x01A0) as a remappable button slot - #127

Open
FromSi wants to merge 2 commits into
mmaher88:masterfrom
FromSi:feat/mx4-action-ring
Open

FromSi wants to merge 2 commits into
mmaher88:masterfrom
FromSi:feat/mx4-action-ring

Conversation

@FromSi

@FromSi FromSi commented May 16, 2026

Copy link
Copy Markdown
Contributor

1. Where 0x01A0 came from

A capture, not the table. Real MX Master 4, WPID B042, HID++ 4.5, Bolt receiver, solaar 1.1.20:

13: REPROG CONTROLS V4     {1B04} V6
 7: Haptic , default: unknown:0109 => Unknown_1a0
    analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
$ solaar config 'MX Master 4' divert-keys Haptic Diverted
Setting divert-keys of MX Master 4 key NamedInt(416, 'Haptic') to NamedInt(1, 'Diverted')

raw hidraw: "11 02 0d 00 01 a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
button event: CID 1a0 pressed
raw hidraw: "11 02 0d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
button event: CID 0 released

So the CID is right and matches special_keys.py:318 — the label was wrong. The branch conflated two ID spaces:

  • CID 0x01A0 — the physical force-sensing thumb pad, Solaar name Haptic.
  • TID 0x0109 — the task bound to it by default, unnamed in both Solaar and logid.

"Action Ring" is a behaviour, not a control; no canonical table has a CID by that name. The descriptor now reads Haptic thumb pad — derived from the CID, matching the neighbouring multi-word labels.

2. ButtonModel rebuild

loadFromProfile now clears and rebuilds m_buttons to match the incoming list inside one beginResetModel()/endResetModel() pair, so growth, shrink and content changes take the same path. Public API and signals unchanged.

One note on the addMockDevice suggestion: it hardcoded session->m_activeDevice = &m_device, so a second device always had the same control count as the first and the shrink couldn't be expressed at all. It now takes an optional descriptor, with newMockDescriptor() owning the extras so they outlive the sessions pointing at them; MockDevice gained setupMx4Controls() (9 controls) and truncateControls().

Tests:

  • AppRootFixture.CarouselSwitchToFewerControlsShrinksButtonModel — 9→8 and 9→6 through the real carousel-switch path, asserting rowCount() and that no row keeps a buttonId/controlId from the previous device.
  • LoadFewerThanModelSizeButton0ChangesLoadFewerThanModelSizeShrinksModel: the old test asserted the stale-row behaviour being fixed here.
  • LoadFromProfileUpdatesData (8→9) unchanged and still passing.

Both shrink tests were confirmed to fail against the pre-fix loadFromProfile and pass after it.

3. Scope

You're right, and the title is reworded. This adds a remappable button slot, nothing more — no radial menu, no overlay, no dedicated dispatch. The control ships with defaultActionType: "default" and ButtonActionDispatcher returns early on ButtonAction::Default (ButtonActionDispatcher.cpp:124), so nothing happens until the user binds an action to it.

Closes #125

image

@mmaher88 mmaher88 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FromSi Thanks for taking a run at this. I validated the branch before reviewing — it rebases cleanly, builds, the lint passes, and the added test is a real assertion rather than a compile-level placeholder, which I appreciate. But there are two things I need resolved before this can go in, one of which affects devices other than the MX Master 4.

1. The control ID looks wrong

The descriptor registers 0x01A0 as the Action Ring. Cross-checking against Solaar's canonical CID table (logitech_receiver/special_keys.py:318):

"Haptic": 0x01A0,  # Logitech
"Circle": 0x01A3,
"Triangle": 0x01A4,

0x01A0 is documented as Haptic, and neither Solaar nor logid defines any "Action Ring" CID. Our CLAUDE.md asks that HID++ facts come from Solaar/logid rather than being derived independently, precisely because a wrong CID silently diverts the wrong control on real hardware.

Could you share where 0x01A0 came from? If it's from a capture on an actual MX Master 4 (solaar show, or a raw HID++ 0x1b04 control enumeration), please post that output — a real capture beats the table and I'll happily take it. If it was inferred, it needs verifying against the device first.

2. ButtonModel::loadFromProfile grows but never shrinks — this affects other devices

To surface the 9th button, loadFromProfile now extends m_buttons when the incoming list is longer. The problem is that ButtonModel is a single shared instance in AppRoot, not per-device, and the change only ever grows:

  • MX Anywhere family: 6 controls
  • MX Master 2S / 3 / 3S: 8 controls
  • MX Master 4: 9 controls

So selecting the MX Master 4 and then switching to any other device in the same session leaves m_buttons at 9. Rows 7–8 keep stale buttonId/controlId values, rowCount() still reports 9, and ButtonsPage.qml renders phantom button rows for a device that doesn't have them.

The model needs to match the active device's control count in both directions — shrink (or rebuild) on device switch, not just grow. Please also add a test for the shrinking case: the current LoadFromProfileUpdatesData only covers 8→9, so the regression above would pass CI today. tests/helpers/AppRootFixture.h has an addMockDevice helper that makes a switch-to-a-smaller-device test straightforward.

3. Minor: the title oversells the scope

With defaultActionType: "default", ButtonActionDispatcher returns early (if (ba.type == ButtonAction::Default) return;), so this adds a remappable button slot rather than the Action Ring behaviour itself — there's no radial-menu/overlay implementation here, unlike gesture-trigger or smartshift-toggle which have dedicated dispatch and UI. That's completely fine as an incremental step, but please reword the title/description so reviewers don't assume the feature works end to end.

Happy to help with (2) if the ownership model isn't obvious — the cleanest fix is probably rebuilding the model per device rather than mutating it in place. Get me a capture for (1) and this becomes an easy merge.

@FromSi

FromSi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

CID 0x01A0 (control), TID 0x0109 (its default task) — captured on real hardware.**

The CID in my edit is correct; the label was wrong. Here's what the device actually reports and how I got it.

Device: MX Master 4, WPID B042, HID++ 4.5, over a Bolt receiver. solaar 1.1.20.

1. Control enumeration — solaar show

13: REPROG CONTROLS V4     {1B04} V6
...
 7: Haptic                    , default: unknown:0109                => Unknown_1a0
     analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2

Solaar prints Haptic here precisely because the device returned CID 0x01A0 and Solaar resolved it through the canonical table you quoted — the capture and special_keys.py are describing the same control, they don't disagree. The default: unknown:0109 part is the device reporting TID 0x0109 as this control's default task; neither Solaar nor logid has a name for that TID.

The same device also exposes 0x19B0 HAPTIC and 0x19C0 FORCE SENSING BUTTON {0: 5581}.

2. Diverting the control

$ solaar config 'MX Master 4' divert-keys Haptic Diverted
Setting divert-keys of MX Master 4 key NamedInt(416, 'Haptic') to NamedInt(1, 'Diverted')

416 = 0x1A0 — Solaar's own output, number and name side by side.

3. Physical press, raw HID++ notification

Press:

raw hidraw: "11 02 0d 00 01 a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
notification: featureIndex= d functionId= 0
button event: CID 1a0 pressed

Release:

raw hidraw: "11 02 0d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
notification: featureIndex= d functionId= 0
button event: CID 0 released

Long report 0x11, device index 0x02, feature index 0x0d — index 13 in the enumeration above, i.e. REPROG CONTROLS V4 {1B04} — function 0 (divertedButtonsEvent), first CID slot 0x01A0, all-zero on release. So the physical force-sensing thumb pad does report CID 0x01A0.

What this means for the naming

Two separate ID spaces were conflated in my edit:

  • CID 0x01A0 — the physical control (the force-sensing haptic thumb pad). Solaar's canonical name: Haptic.
  • TID 0x0109 — the task bound to it by default. Unnamed in both Solaar and logid.

"Action Ring" is a behaviour, not a button, so it belongs (if anywhere) to the TID, not the CID — which is exactly why no CID by that name exists in either table. I'd guess 0x0109 is what Options+ presents as the Actions Ring, but I haven't verified that and wouldn't state it as fact.


$ solaar show

solaar version 1.1.20 

Bolt Receiver 
  Device path  : /dev/hidraw7 
  USB id       : 046d:C548 
  Serial       : 36363738333544334531343941454142 
  Has 1 paired device(s) out of a maximum of 6. 
  Notifications: (none) 
  Device activity counters: 2=134 

  2: MX Master 4 
     Device path  : None 
     WPID         : B042 
     Codename     : MX Master 4 
     Kind         : mouse 
     Protocol     : HID++ 4.5 
     Serial number: 984BB601 
     Model ID:      B04200000000 
     Unit ID:       984BB601 
                 1: LD 04.00 
                 0: RBM 27.01.B0016 
                 0: RBM 27.01.B0016 
                 2: 72 
     The power switch is located on the (unknown). 
     Supports 46 HID++ 2.0 features: 
         0: ROOT                   {0000} V0      
         1: FEATURE SET            {0001} V0      
         2: DEVICE FW VERSION      {0003} V8      
            Firmware: 1 LD 04.00 0000642BA1A1 
            Firmware: 0 RBM 27.01.B0016 B042B884D623 
            Firmware: 0 RBM 27.01.B0016 B042B884D623 
            Firmware: 2  72  
            Unit ID: 984BB601  Model ID: B04200000000  Transport IDs: {'btleid': 'B042'} 
         3: DEVICE NAME            {0005} V5      
            Name: MX Master 4 
            Kind: mouse 
         4: WIRELESS DEVICE STATUS {1D4B} V0      
         5: CONFIG CHANGE          {0020} V1
           Configuration: 00000000000000000000000000000000 
         6: CRYPTO ID              {0021} V1      
         7: DEVICE FRIENDLY NAME   {0007} V0      
            Friendly Name: MX Master 4 
         8: PROPERTY ACCESS        {0011} V0      
         9: UNIFIED BATTERY        {1004} V5      
            Battery: 85%, BatteryStatus.DISCHARGING. 
        10: unknown:1701           {0117} V0      
        11: HAPTIC                 {19B0} V0      
            Haptic Feedback Level        : 60 
            Play Haptic Waveform        : None 
        12: FORCE SENSING BUTTON   {19C0} V0      
            Force Sensing Button        : {0: 5581} 
        13: REPROG CONTROLS V4     {1B04} V6      
            Key/Button Actions        : {Middle Button:Mouse Middle Button, Back Button:Mouse Back Button, Forward Button:Mouse
 Forward Button, Mouse Gesture Button:Gesture Button, Smart Shift:Smart Shift, Haptic:unknown:0109} 
            Key/Button Diversion        : {Middle Button:Regular, Back Button:Diverted, Forward Button:Diverted, Mouse Gesture 
Button:Diverted, Smart Shift:Diverted, Haptic:Regular} 
        14: CHANGE HOST            {1814} V2      
            Change Host        : 1:fromsi 
        15: HOSTS INFO             {1815} V2      
            Host 0 (paired): fromsi 
            Host 1 (unpaired):  
            Host 2 (unpaired):  
        16: XY STATS               {2250} V1      
        17: SMART SHIFT ENHANCED   {2111} V0      
            Scroll Wheel Ratchet Torque        : 75 
            Scroll Wheel Ratcheted        : Ratcheted 
            Scroll Wheel Ratchet Speed        : 10 
        18: HIRES WHEEL            {2121} V1      
            Multiplier: 15 
            Has invert: Normal wheel motion 
            Has ratchet switch: Normal wheel mode 
            Low resolution mode 
            HID notification 
            Scroll Wheel Direction        : False 
            Scroll Wheel Resolution        : False 
            Scroll Wheel Diversion        : False 
        19: THUMB WHEEL            {2150} V0      
            Thumb Wheel Direction        : False 
            Thumb Wheel Diversion        : False 
        20: ADJUSTABLE DPI         {2201} V2
           Sensitivity (DPI)        : 700 
        21: WHEEL STATS            {2251} V0      
        22: unknown:00D1           {D100} V0      
        23: DEVICE RESET           {1802} V0      
        24: unknown:1803           {0318} V0    internal, hidden, unknown:000010  
        25: unknown:1807           {0718} V0    internal, hidden, unknown:000010  
        26: BLE PRO PRE PAIRING    {1816} V0      
        27: OOBSTATE               {1805} V0      
        28: unknown:1830           {3018} V0    internal, hidden, unknown:000010  
        29: unknown:1891           {9118} V0    internal, hidden, unknown:000008  
        30: unknown:18A1           {A118} V0    internal, hidden, unknown:000010  
        31: ENABLE HIDDEN FEATURES {1E00} V0      
        32: unknown:1E02           {021E} V0    internal, hidden  
        33: unknown:1E22           {221E} V0    internal, hidden, unknown:000010  
        34: unknown:1E30           {301E} V0    internal, hidden, unknown:000010  
        35: unknown:1602           {0216} V0      
        36: unknown:1EB0           {B01E} V0    internal, hidden, unknown:000010  
        37: unknown:1861           {6118} V0    internal, hidden, unknown:000010  
        38: unknown:9205           {0592} V0    internal, hidden, unknown:000010  
        39: unknown:9201           {0192} V0    internal, hidden, unknown:000010  
        40: unknown:9300           {0093} V0    internal, hidden, unknown:000010  
        41: unknown:9401           {0194} V0    internal, hidden, unknown:000010  
        42: unknown:9402           {0294} V0    internal, hidden, unknown:000010  
        43: unknown:9001           {0190} V0    internal, hidden, unknown:000010  
        44: unknown:18B1           {B118} V0    internal, hidden, unknown:000010  
        45: unknown:18C0           {C018} V0    internal, hidden, unknown:000010  
     Has 9 reprogrammable keys:
         0: Left Button               , default: Left Click                  => Left Click                
             analytics_key_events, mse, pos:0, group:1, group mask:empty
             reporting: default
         1: Right Button              , default: Right Click                 => Right Click               
             analytics_key_events, mse, pos:0, group:1, group mask:empty
             reporting: default
         2: Middle Button             , default: Mouse Middle Button         => Mouse Middle Button       
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: default
         3: Back Button               , default: Mouse Back Button           => Mouse Back Button         
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: ['diverted']
         4: Forward Button            , default: Mouse Forward Button        => Mouse Forward Button      
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: ['diverted']
         5: Mouse Gesture Button      , default: Gesture Button              => Gesture Button            
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: ['diverted']
         6: Smart Shift               , default: Smart Shift                 => Smart Shift               
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: ['diverted']
         7: Haptic                    , default: unknown:0109                => Unknown_1a0               
             analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
             reporting: default
         8: Virtual Gesture Button    , default: Virtual Gesture Button      => Virtual Gesture Button    
             force_raw_xy, raw_xy, virtual, divertable, pos:0, group:3, group mask:empty
             reporting: default
     Battery: 85%, BatteryStatus.DISCHARGING.

@FromSi FromSi changed the title feat: add MX4 Action Ring button feat(mx-master-4): expose the haptic thumb pad (CID 0x01A0) as a remappable button slot Sep 14, 2026
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.

Add support for re-binding the new button spot on MX Master 4

2 participants