mchose: give the A7 V3 driver its settings writes - #101
Conversation
A diagnostic export from a real A7 V3 Ultra+ arrived, and the driver read everything correctly except the one thing everything else hangs off: the model. Its `0x0900` reply reports product id 0x4026, which MCHOSE's own table lists against the A5 V3 Ultra+, while its USB product string plainly reads "MCHOSE A7 V3 Ultra+". Trusting the id named the wrong mouse and through it handed out a 42,000 DPI ceiling and a three-step lift-off ladder to a 50,000 DPI five-step model; the DPI table in the same capture tops out at 50,000, which is the contradiction in one line. Whatever that field is -- a sensor or platform id shared across shells -- it is not a model id, and M HUB never treats it as one: every model lookup in the vendor bundle keys off navigator.device.productName. mchoseV3FindProduct now prefers the product string and keeps the id only as a fallback for a device whose string says nothing useful. Note this is the opposite of the A7 V2's rule, where the id inside the battery reply is decisive because the host-facing id is shared. The two generations genuinely differ here, so both call sites carry the reasoning. Also from the same capture: 0x0901 takes a target byte, 0 for the mouse and 1 for the receiver. Sent bare it answers with an empty data block rather than an error, which is why the export shows no firmware version at all. The reads are now hardware-confirmed -- identity, battery and charge state, the DPI table, polling, profile, sleep, debounce, sensor flags and the button table all decoded correctly -- so the driver and the docs stop saying they are not. The writes are still unexercised and there are still no setters; the status note now says plainly that settings can be read but not changed. 0x0009 remains untried: the capture was taken while the driver thought it was a three-step model, so it never sent it. The captured blocks are checked in as tests, so the id trap cannot come back. A MagDock export in the same batch read correctly with no changes, so its status note drops the "A7 V2" that no longer fits its owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the write half of the V3 driver: polling, DPI (current value, per-stage values, active stage, stage count), lift-off, motion sync, angle snapping, ripple control, glass mode, power mode, angle tuning, sleep, debounce, profile switching and button remapping. The protocol has no partial update -- every command replaces a whole block -- so each setter reads the block, edits the decoded object, writes it and reads back to confirm, throwing when the mouse reports something other than what it was told. A read that fails aborts the write rather than letting defaults be written over a working configuration. Three things the capture and the A7 V2's history forced: The settings block's tail is not padding. M HUB writes ten zero bytes past the nine named fields, but the real A7 V3 Ultra+ returns ten bytes of 0x08 there -- the same value as both its debounce fields, so most likely the debounce for the remaining buttons. Those bytes are now decoded into the settings object and written straight back, so an unrelated change cannot zero them. The sensor byte is masked, never assigned. The A7 V2's bit 5 was never explained, and a writer that assigns the byte destroys whatever it held. The mode field is bounded by its two bits rather than by the three names MCHOSE gives them, so a mouse reporting the unnamed fourth state can still have everything else written; picking a new mode goes through the name lookup, which cannot produce it. The DPI write and read do not order their fields the same way -- the write puts hasSeparateY third and the read puts it fifth -- so copying a decoded table into a write buffer swaps it with the stage count. The button vocabulary is deliberately two entries wide, Default and Disabled, because those are the only encodings that appear in a real capture. Keyboard, media, DPI, macro and profile actions exist in the protocol and stay unavailable: a button is the one setting where a wrong guess can leave someone unable to click. None of these writes has been sent to a device. The framing under them is proven by the reads, but the firmware's response to each write is not, and the settle delay is the A7 V2's 400 ms rather than a measured figure. The status note says so where a user will see it, and the docs name both numbers to suspect first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit shipped two button actions, Default and Disabled, on the grounds that nothing else had been seen in a capture. That was needlessly conservative: the vendor bundle carries the whole vocabulary in the same seven tables the V2's came from, so there is nothing left to guess at. 168 actions now: mouse buttons and the wheel, DPI switch and step, media transport, volume, screen brightness, copy/cut/paste, the four onboard-profile actions, and the keyboard. Two bugs this uncovered, both of which were already visible to users through the read-only display: Disabled was wrong. It was type 0xff; the vendor's "forbidden" action is type 0xfe. 0xff is a button carrying no assignment at all -- what a stock A7 V3 Ultra+ reports for its DPI button -- and M HUB never writes it. Writing 0xff would have sent a value the firmware itself never sends. The type names were the A7 V2's. Nothing carries over: this generation uses 0x00 for mouse buttons, 0x01 for DPI, 0x05 for the wheel, 0x11/0x13 for the keyboard, 0x14 for media, 0x16 for system shortcuts, 0x33 for profiles. A V3 button on a media action was being labelled with whatever the V2 puts at that number. The letters, function keys and navigation keys are derived rather than listed. The vendor's table covers punctuation, digits, the numpad, the locks and the modifiers and leaves the rest to its on-screen keyboard, but its own shortcut entries spell out the same standard HID usages under the same type -- Ctrl+A is 0x04, Ctrl+C 0x06, Alt+F4 0x3d, Esc 0x29 -- so the usage page is confirmed rather than assumed. Macros stay unavailable. Types 0x23 and 0x24 are wide enough to carry a reference, but the vendor lists nothing under them and the paged macro channel is not implemented. A button sitting on its own factory mask reads back as "Default" rather than as the click it happens to encode, since that is what the user would be changing it from, and a type/value pair the tables do not cover is reported as unknown rather than named by guess.
|
Pushed a follow-up that replaces the two-action button vocabulary with M HUB's own, 168 entries: mouse buttons and wheel, DPI switch and step, media transport, volume, screen brightness, copy/cut/paste, the four onboard-profile actions, and the keyboard. The original "only what a capture proves" stance was needlessly conservative — the vendor bundle carries the whole vocabulary in the same seven tables the A7 V2's came from, so there was nothing left to guess at. Reading them turned up two bugs that were already reaching users through the read-only display:
Letters, function keys and navigation keys are derived, not invented: the vendor's table covers punctuation, digits, numpad, locks and modifiers and leaves the rest to its on-screen keyboard, but its own shortcut entries spell out the same standard HID usages under the same type (Ctrl+A A button sitting on its own factory mask reads back as "Default" rather than as the click it happens to encode, and a type/value pair the tables don't cover is reported as unknown rather than named by guess.
Tests: 71 across the codec and driver, all passing. |
Two fresh captures from the A7 V3 Ultra+, one over its cable and one over the receiver, show three reply shapes the driver was mishandling. Together they made a mouse that was answering every frame look like one that had stopped. The reply timeout was under the firmware's own deferral. On a cable 0x0900, 0x0002, 0x0003 and 0x0001 answer in 1-3 ms, but every reply that is not immediate takes almost exactly 1.001 s -- 0x0901 on the cable, 0x0900 over an idle receiver. The 600 ms budget sat just underneath that, so those replies were always missed, and each one then arrived during the next attempt and was discarded as unsolicited. Raised to 1500 ms, which only has to clear a delay that looks fixed rather than variable. 0x0901 is refused, not ignored. The firmware answers it with command 0x0000, the checksum flag clear and 0xff in the sequence byte. That never matches the requested id, so the driver spent its whole retry budget waiting for an answer that had already arrived. A refusal is now recognised and ends the command at once. A receiver with no mouse reachable answers 0x0900 with a single 0xff byte, meaning "ask again" rather than carrying data. The driver accepted it as a payload, the decoder rejected it as too short, and the status came back empty. It is now retried briefly -- M HUB's own read helper loops on exactly this -- and then reported as a mouse that is not reachable rather than one that said nothing. The test here is stricter than the vendor's, which checks the first payload byte alone: a button table legitimately starts with 0xff when the first button carries no assignment. Underneath all three, the unresponsive latch was too eager. It fired whenever a command exhausted its attempts, including when the device had plainly answered, and then suppressed every remaining command in the status read. That is why the receiver capture contains six 0x0900 frames and nothing else. It now fires only when nothing arrived at all.
# Conflicts: # docs/mchose-protocol.md # src/drivers/mchose/v3-hid.test.ts # src/drivers/mchose/v3-hid.ts # src/mchose/v3.ts
|
Merged #100's latest work into this branch, which adds the reply-handling fixes described there: a 1500 ms reply timeout to clear the firmware's fixed ~1.001 s deferral, recognition of the refusal frame the firmware sends for |
Stacked on #100, which this branch contains — review that one first, or this diff includes it.
Adds the write half of the V3 driver: polling, DPI (current value, per-stage values, active stage, stage count), lift-off, motion sync, angle snapping, ripple control, glass mode, power mode, angle tuning, sleep, debounce, profile switching and button remapping.
How every setter is built
This protocol has no partial update — each command replaces a whole block. So a setter reads the block, edits the decoded object, writes it, and reads back to confirm, throwing when the mouse reports something other than what it was told. A read that fails aborts rather than letting defaults be written over a working configuration.
Three things the hardware capture and the V2's history forced
The settings block's tail is not padding. M HUB writes ten zero bytes past the nine named fields. The real A7 V3 Ultra+ returns ten bytes of
0x08there — the same value as both its debounce fields, so most likely the debounce for the remaining buttons. Those bytes are now decoded into the settings object and written straight back, so changing the sleep timer cannot quietly zero them.The sensor byte is masked, never assigned. The A7 V2's bit 5 was never explained, and a writer that assigns the byte destroys whatever it held. The mode field is bounded by its two bits rather than by the three names MCHOSE gives them, so a mouse reporting the unnamed fourth state can still have everything else written; picking a new mode goes through the name lookup, which cannot produce it.
The DPI write and read do not order their fields the same way — the write puts
hasSeparateYthird, the read puts it fifth. Copying a decoded table into a write buffer swaps it with the stage count. The test fake models both orders so a driver that got it backwards would fail rather than agree with itself.The framing under these writes is proven — the mouse answers frames built by the same encoder — but the firmware's response to each write is not. The settle delay is the A7 V2's 400 ms rather than a measured figure, and whether any write needs a separate commit command is unknown (nothing in the vendor bundle suggests one; nothing rules it out). The user-facing status note says so, and the docs name both numbers to suspect first.
If you would rather this waited for a tester, say so and I will park it — the read-only driver on
mainis unaffected either way.The button vocabulary is deliberately two entries wide, Default and Disabled, because those are the only encodings that appear in a real capture. Keyboard, media, DPI, macro and profile actions all exist in the protocol and stay unavailable until someone records what M HUB writes for them: a button is the one setting where a wrong guess can leave someone unable to click.
Testing
28 driver tests and 40 codec tests. The fake is now a small stateful device, so a write is visible to the read that follows and the driver's own verification is exercised rather than agreeing with itself. Coverage includes: the tail surviving an unrelated write, one sensor bit moving without disturbing the others, the untouched DPI stages coming back byte-identical, the active stage being pulled back when the list shortens, only the connected link being written, a rejected write sending nothing at all, and a firmware that ignores writes producing a thrown error rather than a false success.
npm run checkpasses apart from the two pre-existingsrc/drivers/corsair/hid.test.tssetDpifailures, which also fail on a cleanmainlocally.🤖 Generated with Claude Code