fix(smartshift): stop runaway free-spin scroll and SmartShift control snap-back#333
fix(smartshift): stop runaway free-spin scroll and SmartShift control snap-back#333AprilNEA wants to merge 1 commit into
Conversation
…317) Three independent defects made the wheel scroll "insanely fast" and the SmartShift GUI controls snap back to defaults on MX Master 3s / 3 and MX Anywhere 3s. - set_status rejected a 0-valued auto_disengage / tunable_torque instead of treating 0 as the firmware's documented "do not change" sentinel (x2110 / x2111). On a device that reports tunable_torque == 0 (no tunable-torque hardware) every SmartShift write failed silently, so the panel optimistically showed the change then snapped back. 0 is now sent as preserve; the redundant set_mode_preserving_status / nonzero_smartshift_value helpers are gone and the toggle shares set_status. - The panel clamped a 0 readback to auto_disengage = 1 (~0.25 turn/s), which releases the ratchet into free-spin on the gentlest scroll. The friendly slider floor is raised to a usable 8 (~2 turn/s) and sub-floor / sentinel values normalise to the 16 default; the floor and default are single-sourced in openlogi-core, and a deserialize heal repairs already-persisted low thresholds on load so reapply pushes the good value on reconnect. - Native scroll inversion (0x2121) was re-written on every config reload, needless traffic that could race other writes. The write now short-circuits when the wheel already holds the desired (native, invert) state. Adds unit tests for the threshold clamp and the config heal.
Greptile SummaryThis PR fixes three independent bugs that caused runaway free-spin scroll and SmartShift panel snap-back on MX Master 3/3s and MX Anywhere 3s. The root causes were: (1)
Confidence Score: 4/5Safe to merge; the three bug fixes are correctly implemented, tests cover the new floor/heal boundary, and no regressions were identified in the changed paths. The write-path unification (merging
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User action / config reload] --> B{Source}
B -->|Slider release / mode pill / permanent toggle| C[commit_smartshift GUI]
B -->|Device reconnect| D[set_smartshift_on_channel]
B -->|Toggle hotkey| E[toggle_smartshift_on_channel]
B -->|Config reload| F[apply_native_scroll_inversions]
C --> G[set_smartshift_on_channel]
D --> G
E --> H[SmartShift::status read]
H --> I[set_status mode=flipped rest=status]
G --> J[SmartShift::set_status]
I --> J
J --> K{Feature}
K -->|Enhanced 0x2111| L["NonZeroU8::new(auto_disengage) — 0 becomes None preserve"]
K -->|Legacy 0x2110| M["Some(auto_disengage) — 0 becomes 0x00 preserve"]
L --> N[set_ratchet_control_mode Enhanced]
M --> O[set_ratchet_control_mode Legacy]
F --> P[set_scroll_inversion_on_channel]
P --> Q[get_wheel_mode]
Q --> R{Already Native and correct invert?}
R -->|Yes| S[skip return Ok]
R -->|No| T[set_wheel_mode Native + inverted]
subgraph Config load heal
U[TOML deserialize SmartShift] --> V{auto_disengage < 8?}
V -->|Yes| W[heal to DEFAULT 16]
V -->|No| X[pass through unchanged]
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[User action / config reload] --> B{Source}
B -->|Slider release / mode pill / permanent toggle| C[commit_smartshift GUI]
B -->|Device reconnect| D[set_smartshift_on_channel]
B -->|Toggle hotkey| E[toggle_smartshift_on_channel]
B -->|Config reload| F[apply_native_scroll_inversions]
C --> G[set_smartshift_on_channel]
D --> G
E --> H[SmartShift::status read]
H --> I[set_status mode=flipped rest=status]
G --> J[SmartShift::set_status]
I --> J
J --> K{Feature}
K -->|Enhanced 0x2111| L["NonZeroU8::new(auto_disengage) — 0 becomes None preserve"]
K -->|Legacy 0x2110| M["Some(auto_disengage) — 0 becomes 0x00 preserve"]
L --> N[set_ratchet_control_mode Enhanced]
M --> O[set_ratchet_control_mode Legacy]
F --> P[set_scroll_inversion_on_channel]
P --> Q[get_wheel_mode]
Q --> R{Already Native and correct invert?}
R -->|Yes| S[skip return Ok]
R -->|No| T[set_wheel_mode Native + inverted]
subgraph Config load heal
U[TOML deserialize SmartShift] --> V{auto_disengage < 8?}
V -->|Yes| W[heal to DEFAULT 16]
V -->|No| X[pass through unchanged]
end
|
| fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error> | ||
| where | ||
| D: serde::Deserializer<'de>, | ||
| { | ||
| let value = u8::deserialize(deserializer)?; | ||
| Ok(if value < SMARTSHIFT_MIN_AUTO_DISENGAGE { | ||
| SMARTSHIFT_AUTO_DISENGAGE_DEFAULT | ||
| } else { | ||
| value | ||
| }) | ||
| } |
There was a problem hiding this comment.
Silent config heal — no diagnostic on altered value
deserialize_auto_disengage silently replaces any value below SMARTSHIFT_MIN_AUTO_DISENGAGE with the default. A user who hand-edits config.toml to, say, auto_disengage = 3 will see no indication that the value was changed to 16 on the next agent start. Adding a tracing::warn! when the branch fires would let users (and future developers) diagnose this substitution without having to diff the file.
Summary
Three independent defects made the wheel scroll "insanely fast / unusable" and the SmartShift GUI controls (Permanent Ratchet, sensitivity slider) snap back to defaults on MX Master 3 / 3s and MX Anywhere 3s.
Root causes & fixes
1. SmartShift writes rejected the
0"do not change" sentinel → silent failure / snap-backset_statusvalidatedauto_disengage/tunable_torqueas non-zero and bailed out before the HID++ call. Per the x2110 / x2111setRatchetControlModespec,0means "do not change" (real values are0x01..=0xFF). On a device that reportstunable_torque == 0(no tunable-torque hardware, e.g. MX Anywhere 3s) every SmartShift write failed silently — the panel showed the change optimistically, then the confirm read snapped it back. Now0is sent as preserve; the redundantset_mode_preserving_status/nonzero_smartshift_valuehelpers are removed and the toggle sharesset_status.2. A
0readback clamped toauto_disengage = 1(≈0.25 turn/s) → constant free-spinThat threshold releases the ratchet into free-spin on the gentlest scroll, which reads as "insanely fast." The sensitivity slider floor is raised from
1to a usable8(≈2 turn/s); sub-floor / sentinel values normalise to the16default. The floor and default are single-sourced inopenlogi-core, and a deserialize-time heal repairs already-persisted low thresholds on load — so aconfig.tomlthat already holds a runaway value is fixed automatically on the next agent start (the values live in volatile RAM and the agent re-applies config on reconnect, #189).3. Native scroll inversion (
0x2121) rewritten on every config reloadreload_configfires on every DPI / SmartShift / binding edit, and each one re-wrote the wheel mode — needless HID++ traffic that could race other writes. The write now short-circuits when the wheel already holds the desired (native, invert) state.Notes
0xFF(permanent ratchet) and real thresholds ≥ 8 are untouched.0x2121write is now idempotent, the re-apply stays unconditional and just skips at the HID++ layer.Testing
low_auto_disengage_heals_to_default_on_load) and the panel threshold clamp.cargo testandcargo clippy --all-targets -- -D warningspass across the changed crates.auto_disengage = 1config heals to 16 on restart.Fixes #317