Skip to content

mchose: name the V3 by its product string, not its reported id - #100

Merged
snekxs merged 2 commits into
OpenMouse-Project:mainfrom
jamezrin:fix-mchose-v3-model-id
Sep 14, 2026
Merged

snekxs merged 2 commits into
OpenMouse-Project:mainfrom
jamezrin:fix-mchose-v3-model-id

Conversation

@jamezrin

Copy link
Copy Markdown
Contributor

Follows #84. A user with a real A7 V3 Ultra+ sent a diagnostic export, and the driver read everything correctly except the one thing everything else hangs off: the model.

The bug

Its 0x0900 reply reports product id 0x4026. MCHOSE's own table lists that id against the A5 V3 Ultra+. Its USB product string plainly reads MCHOSE A7 V3 Ultra+.

Trusting the id named the wrong mouse, and through it:

reported actual
model A5 V3 Ultra+ A7 V3 Ultra+
DPI ceiling 42,000 50,000
lift-off ladder 3 steps, read from the sensor byte 5 steps, read via 0x0009

The contradiction is visible in the same capture: the DPI table it returned tops out at 50000, which the A5 V3 Ultra+ cannot do.

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.

This is deliberately 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, so both call sites carry the reasoning rather than looking like an inconsistency.

Also from the capture

0x0901 takes a target byte — 0 for the mouse, 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.

What is now confirmed, and what still is not

The reads are hardware-confirmed: identity, battery and charge state, the DPI table, polling, profile, sleep, debounce, sensor flags and the button table all decoded correctly against a live mouse. The driver docblock, the protocol doc and the user-facing status note stop claiming otherwise — the note now says settings can be read but not changed.

The writes remain unexercised and there are still no setters. And 0x0009 is still untried: the capture was taken while the driver believed it was a three-step model, so it read lift-off from the sensor byte and never sent it. With the model resolved correctly the Ultra+ now takes that branch; a device that does not answer degrades to a blank lift-off rather than a wrong one.

A MagDock export arrived in the same batch and read correctly with no changes, so its status note just drops the "A7 V2" that no longer fits its owner.

Testing

The captured data blocks are checked in verbatim as tests, so the id trap cannot come back — including one that asserts the id alone names the wrong mouse and the product string must win. Plus a driver test replaying the capture end to end, and one asserting 0x0901 is sent with its target byte.

npm run check passes apart from the two pre-existing src/drivers/corsair/hid.test.ts setDpi failures, which also fail on a clean main locally and are unrelated.

🤖 Generated with Claude Code

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>
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.
@jamezrin

Copy link
Copy Markdown
Contributor Author

Two more captures from the same A7 V3 Ultra+ (one on its cable, one over the receiver) turned up three reply shapes this driver was reading as silence. Together they made a mouse that was answering every single frame look like one that had stopped. Pushed as a second commit here.

The reply timeout sat underneath 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 s0x0901 on the cable, 0x0900 over an idle receiver. The 600 ms budget was just below that, so those replies were always missed, and each then arrived during the next attempt and was thrown away as unsolicited. Raised to 1500 ms; the delay looks fixed rather than variable, so the budget only has to clear one second.

0x0901 is refused, not ignored. The firmware answers it with command 0x0000, checksum flag clear, 0xff in the sequence byte. That never matches the requested id, so the driver burned its whole retry budget waiting for something that had already come back. A refusal now ends the command immediately.

A receiver with no mouse reachable answers 0x0900 with a single 0xff byte — "ask again", not data. The driver accepted it as a payload, the decoder rejected it as too short, and the status came back blank. It is now retried briefly (M HUB's own read helper loops on exactly this) and then reported as "Receiver connected, but the mouse is not reachable" rather than "did not answer". The check here is stricter than the vendor's, which tests the first payload byte alone: a button table legitimately starts with 0xff when button one is unassigned.

Underneath all three, the unresponsive latch was too eager — it fired whenever a command ran out of 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.

Also confirmed from these captures: the DPI button came back as type 0x01 value 000000, which is the vendor's mouse.dpiSwitch. The old V2-derived labels called that "Mouse button"; the vocabulary work in #101 names it "DPI switch". Good independent check on that table.

Docs updated with the three shapes and the measured timings. 77 V3 tests pass; npm run check is clean apart from the two pre-existing Corsair setDpi failures.

@snekxs
snekxs merged commit 8e9d639 into OpenMouse-Project:main Sep 14, 2026
1 check passed
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.

2 participants