From 78e4f30bdb078e5282903bb62557a25438e71ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sat, 12 Sep 2026 19:01:18 +0200 Subject: [PATCH 1/2] mchose: name the V3 by its product string, not its reported id 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 --- docs/mchose-protocol.md | 53 ++++++++++++++- src/drivers/mchose/dock-hid.ts | 4 +- src/drivers/mchose/v3-hid.test.ts | 44 +++++++++++-- src/drivers/mchose/v3-hid.ts | 37 +++++++---- src/mchose/v3.test.ts | 106 ++++++++++++++++++++++++++++++ src/mchose/v3.ts | 38 ++++++++--- 6 files changed, 253 insertions(+), 29 deletions(-) diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 0e57e63..48a8d4e 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -452,8 +452,12 @@ family and its siblings — use a second, unrelated protocol on the *same vendor id and the same usage page*. M HUB ships both UIs side by side, with a model list (`W8` in the bundle) picking which one a device gets. -**Nothing in this section has been confirmed on hardware.** It is a reading of -the vendor bundle, which is why `src/drivers/mchose/v3-hid.ts` only reads. +**The reads in this section are confirmed on hardware; the writes are not.** An +A7 V3 Ultra+ on its 2.4 GHz receiver (host PID `0x1014`/`0x1018`) answered +`0x0900`, `0x0002`, `0x0003` and `0x0001` exactly as read out of the vendor +bundle — see [what the hardware said](#what-the-hardware-said) at the end. No +byte has ever been written to a V3, which is why +`src/drivers/mchose/v3-hid.ts` only reads. | | A7 V2 | A7 V3 | | --- | --- | --- | @@ -595,3 +599,48 @@ The reads are the whole driver today. To turn it into a full one: Do not skip step 3. The V2's stale-reply buffer meant a read taken too early returned a *different command's* payload, and one of those nearly went back out as a config write. + +### What the hardware said + +An **A7 V3 Ultra+** behind its receiver (host PID `0x1018`), from an OpenMouse +diagnostic export dated 2026-09-12. Data blocks only; the framing is stripped. + +``` +OUT 0x0900 -> 37 38 26 40 04 00 00 00 00 10 02 01 55 00 08 e4 +OUT 0x0002 -> 00 41 41 03 00 41 00 08 08 08 08 … +OUT 0x0003 [00] -> 00 00 06 01 00 90 01 20 03 40 06 80 0c 00 19 50 c3 +OUT 0x0001 [00 00 06] -> 00 01 00 00 02 00 00 04 00 00 10 00 00 08 00 ff ff ff +OUT 0x0901 -> (empty) +``` + +Everything decoded correctly: battery 85 % and charging, four profiles, DPI +stages 400/800/1600/3200/6400/**50000** with the second active, 2000 Hz, a +three-minute sleep timer, 8 ms on both debounce bytes, sensor `0x41` (eSports, +every processing toggle off), and six stock button assignments. + +Two things the capture corrected. + +> **`0x0900`'s product id is not a model id.** This mouse's USB product string +> is `MCHOSE A7 V3 Ultra+`, and it reports `0x4026` — the id MCHOSE's own table +> gives the *A5 V3 Ultra+*. Believing it 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. `mchoseV3FindProduct` now prefers the product string and +> keeps the id only as a fallback. M HUB agrees: every model lookup in the +> vendor bundle keys off `navigator.device.productName`, never off this field. +> +> This is the opposite of the A7 V2's rule, where the id inside the battery +> reply *is* decisive. Do not carry one habit across to the other generation. + +> **`0x0901` needs 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 first capture shows no firmware version at all. + +Also worth recording: the `0xff01` collection on this receiver declares `0x4d` +as an **input, output *and* feature** report. The driver uses output plus input +and that works; the feature path is untried. + +The lift-off command `0x0009` still has not been exercised. The capture was +taken while the driver believed it was talking to a three-step model, so it +read lift-off from the sensor byte and never sent `0x0009`. With the model +resolved correctly the Ultra+ now takes that branch, and a device that does not +answer it degrades to a blank lift-off rather than a wrong one. diff --git a/src/drivers/mchose/dock-hid.ts b/src/drivers/mchose/dock-hid.ts index f8e36f6..91b0e02 100644 --- a/src/drivers/mchose/dock-hid.ts +++ b/src/drivers/mchose/dock-hid.ts @@ -141,7 +141,9 @@ export class MchoseDockHidClient { settingsReady: false, defaultDisplayName: "MCHOSE MagDock", statusNote: lighting - ? "Charging base — lighting only. The A7 V2 mice have no LEDs of their own." + // Not just the V2's base: an A7 V3 owner's MagDock enumerates + // identically and reads correctly through this same driver. + ? "Charging base — lighting only. The A7 mice have no LEDs of their own." : "Charging base — the lighting state could not be read.", }, }; diff --git a/src/drivers/mchose/v3-hid.test.ts b/src/drivers/mchose/v3-hid.test.ts index 4e5d165..5025dfa 100644 --- a/src/drivers/mchose/v3-hid.test.ts +++ b/src/drivers/mchose/v3-hid.test.ts @@ -53,11 +53,15 @@ interface FakeOptions { silent?: number[]; /** Emit an unrelated input report before every real answer. */ noisy?: boolean; + /** Override the `0x0900` reply, to replay a real capture. */ + deviceInfo?: number[]; } function fakeMouse(options: FakeOptions = {}) { const listeners: Array<(event: unknown) => void> = []; const sent: number[] = []; + /** The data block sent with each command, so arguments can be asserted. */ + const sentData = new Map(); const emit = (body: Uint8Array): void => { const event = { data: new DataView(body.buffer.slice(0)) }; @@ -84,8 +88,11 @@ function fakeMouse(options: FakeOptions = {}) { const body = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); const command = body[3]! | (body[4]! << 8); sent.push(command); + sentData.set(command, [...body.subarray(7, 7 + body[2]!)]); if (options.silent?.includes(command)) return; - const answer = ANSWERS[command]; + const answer = command === MCHOSE_V3_COMMAND.readDeviceInfo && options.deviceInfo + ? options.deviceInfo + : ANSWERS[command]; if (!answer) return; queueMicrotask(() => { // The mouse pushes movement and battery down the same pipe; a driver @@ -96,7 +103,7 @@ function fakeMouse(options: FakeOptions = {}) { }, } as unknown as HIDDevice; - return { device, sent }; + return { device, sent, sentData }; } describe("MCHOSE A7 V3 driver", () => { @@ -134,7 +141,8 @@ describe("MCHOSE A7 V3 driver", () => { const status = await new MchoseV3HidClient(device).readStatus(); assert.equal(status.brand, "MCHOSE"); - // Resolved from the id inside the device-info reply, not the receiver's. + // This fake's product string names no model, so the id in the device-info + // reply is the fallback that resolves it. assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); assert.equal(status.batteryPercent, 87); assert.equal(status.batteryState, "Discharging"); @@ -170,7 +178,7 @@ describe("MCHOSE A7 V3 driver", () => { assert.equal(status.ui!.settingsReady, false, "nothing here can be written yet"); assert.equal(status.ui!.valuesVerified, true, "but what is shown was read off the mouse"); - assert.match(status.ui!.statusNote!, /not been confirmed on hardware/); + assert.match(status.ui!.statusNote!, /cannot change them yet/); // The read-only promise is part of the contract, not just the prose. assert.equal("setDpi" in client, false); assert.equal("setPollingRate" in client, false); @@ -215,4 +223,32 @@ describe("MCHOSE A7 V3 driver", () => { assert.equal(status.activeProfile, null, "but nothing behind the link answered"); assert.equal(status.dpi, 0); }); + + /** + * Replays the real A7 V3 Ultra+ capture: its 0x0900 reply carries 0x4026, + * the id MCHOSE lists for the A5 V3 Ultra+. Before the product string won, + * this mouse was named A5 V3 Ultra+ and inherited a 42,000 DPI ceiling and a + * three-step lift-off ladder it does not have. + */ + it("names the mouse from its product string, not its reported id", async () => { + const { device } = fakeMouse({ + productName: "MCHOSE A7 V3 Ultra+", + deviceInfo: [ + 0x37, 0x38, 0x26, 0x40, 0x04, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x02, 0x01, 0x55, 0x00, 0x08, 0xe4, + ], + }); + const status = await new MchoseV3HidClient(device).readStatus(); + assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); + assert.equal(status.batteryPercent, 85); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.profileCount, 4); + }); + + it("asks 0x0901 which side it wants the version from", async () => { + // Sent bare, the mouse answers with an empty block and no firmware at all. + const { device, sentData } = fakeMouse(); + await new MchoseV3HidClient(device).readStatus(); + assert.deepEqual(sentData.get(MCHOSE_V3_COMMAND.readVersion), [0], "the mouse, not the receiver"); + }); }); diff --git a/src/drivers/mchose/v3-hid.ts b/src/drivers/mchose/v3-hid.ts index b4cefda..df07efc 100644 --- a/src/drivers/mchose/v3-hid.ts +++ b/src/drivers/mchose/v3-hid.ts @@ -26,27 +26,35 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; /** - * MCHOSE A7 V3 and its siblings — **read-only, and untested on hardware**. + * MCHOSE A7 V3 and its siblings — **read-only**. * * This generation abandoned the A7 V2's inverted feature reports for a * `0x4d`-magic output report (see `src/mchose/v3.ts`). The command set was read - * out of MCHOSE's own M HUB bundle; unlike the V2 driver next door, none of it - * has been exercised against a physical mouse. + * out of MCHOSE's own M HUB bundle, and the **reads** have since been confirmed + * against a real A7 V3 Ultra+ on its 2.4 GHz receiver: identity, battery and + * charge state, the DPI table, polling, profile, sleep, debounce, the sensor + * flags and the button table all came back correctly. See the capture notes in + * docs/mchose-protocol.md. * - * That is why there are no setters here. Every value below is a read, so the - * worst a wrong guess costs is a blank or nonsensical field — where a - * speculative write could leave a stranger's mouse in a state they cannot get - * out of. `settingsReady` is false for the same reason: the shell would - * otherwise offer controls with nothing behind them. `valuesVerified` stays - * true so the DPI and polling rate it does read are still worth showing. + * **The writes have not.** No setter is exposed, and that is the whole point of + * the split: a wrong read costs a blank field, where a speculative write could + * leave a stranger's mouse in a state they cannot get out of. Nothing here has + * ever put a byte into a V3's configuration, and the settle timings that the V2 + * work could only find empirically are still unknown for this generation. + * + * `settingsReady` is false so the shell offers no inert controls; + * `valuesVerified` stays true so what it does read is still shown. * * Adding writes is a small change on top of this — the encoders are already in - * the codec — but it should wait for someone with the hardware. + * the codec — but it should wait for someone who can watch the hardware. */ const REPLY_TIMEOUT_MS = 600; const READ_ATTEMPTS = 3; +/** `0x0901`'s target byte: the mouse rather than the receiver in front of it. */ +const VERSION_TARGET_MOUSE = 0; + /** The receivers serve every model in the generation. */ const LINK_PRODUCT_IDS: readonly number[] = Object.values(MCHOSE_V3_LINK_PRODUCT_IDS); @@ -148,8 +156,13 @@ export class MchoseV3HidClient { return payload ? mchoseV3DecodeSettings(payload) : null; } + /** + * `0x0901` takes a target byte: 0 for the mouse, 1 for the receiver. Sent + * without one it answers with an **empty** data block rather than an error, + * which is how the first hardware capture came back with no firmware at all. + */ private async readVersion(): Promise { - const payload = await this.request(MCHOSE_V3_COMMAND.readVersion); + const payload = await this.request(MCHOSE_V3_COMMAND.readVersion, [VERSION_TARGET_MOUSE]); if (!payload || payload.length < 2) return null; const raw = `${(payload[0] ?? 0).toString(16).padStart(2, "0")}` + `${(payload[1] ?? 0).toString(16).padStart(2, "0")}`; @@ -263,7 +276,7 @@ export class MchoseV3HidClient { statusNote: settings ? [ liftOffHeight ? `Lift-off ${liftOffHeight}.` : "", - "Read-only: this model's protocol is implemented from vendor software and has not been confirmed on hardware.", + "Read-only: this driver can report settings but cannot change them yet.", ].filter(Boolean).join(" ") : "Read-only, and this mouse did not answer. Please report the model and how it is connected.", }, diff --git a/src/mchose/v3.test.ts b/src/mchose/v3.test.ts index 1f1cc3b..9d8ba6a 100644 --- a/src/mchose/v3.test.ts +++ b/src/mchose/v3.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MCHOSE_V3_BODY_LENGTH, + MCHOSE_V3_BUTTON_UNSET, MCHOSE_V3_COMMAND, MCHOSE_V3_MODES, MCHOSE_V3_PRODUCTS, @@ -306,3 +307,108 @@ test("a V3 reply does not decode as a plausible A7 V2 config", () => { "a V3 frame must not pass as a V2 config with a believable DPI stage", ); }); + +/** + * Captured from a real **MCHOSE A7 V3 Ultra+** on its 2.4 GHz receiver + * (host PID 0x1018), 2026-09-12. These are the exact data blocks the mouse + * returned, lifted out of an OpenMouse diagnostic export. + */ +const CAPTURE = { + deviceInfo: [ + 0x37, 0x38, 0x26, 0x40, 0x04, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x02, 0x01, 0x55, 0x00, 0x08, 0xe4, + ], + settings: [ + 0x00, 0x41, 0x41, 0x03, 0x00, 0x41, 0x00, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, + ], + dpi: [ + 0x00, 0x00, 0x06, 0x01, 0x00, + 0x90, 0x01, 0x20, 0x03, 0x40, 0x06, 0x80, 0x0c, 0x00, 0x19, 0x50, 0xc3, + ], + buttons: [ + 0x00, 0x01, 0x00, 0x00, 0x02, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x10, 0x00, 0x00, 0x08, 0x00, 0xff, 0xff, 0xff, + ], +}; + +test("the captured A7 V3 Ultra+ device info decodes to its real state", () => { + const info = mchoseV3DecodeDeviceInfo(new Uint8Array(CAPTURE.deviceInfo))!; + assert.equal(info.vendorId, 0x3837); + assert.equal(info.batteryPercent, 85); + assert.equal(info.chargeStatus, 1, "it was on the dock, charging"); + assert.equal(info.profileCount, 4); + assert.notEqual(info.connectStatus, 0, "the mouse was linked"); +}); + +/** + * The reason {@link mchoseV3FindProduct} prefers the product string. This exact + * reply came from a mouse whose USB product string reads "MCHOSE A7 V3 Ultra+", + * and its `0x0900` id is the one MCHOSE's table gives the A5 V3 Ultra+. + */ +test("a real A7 V3 Ultra+ reports an id belonging to another model", () => { + const info = mchoseV3DecodeDeviceInfo(new Uint8Array(CAPTURE.deviceInfo))!; + assert.equal(info.productId, 0x4026); + assert.equal( + MCHOSE_V3_PRODUCTS.find((p) => p.productId === 0x4026)!.name, "A5 V3 Ultra+", + "the id alone names the wrong mouse", + ); + + const resolved = mchoseV3FindProduct(info.productId, "MCHOSE A7 V3 Ultra+")!; + assert.equal(resolved.name, "A7 V3 Ultra+", "the product string must win"); + // The consequences of getting this wrong, both visible to the user. + assert.equal(resolved.dpiMax, 50000, "not the A5's 42000"); + assert.equal(resolved.liftOffDistances.length, 5, "not the A5's three-step ladder"); + assert.equal(resolved.liftOffCommand, true, "and so lift-off comes from 0x0009"); +}); + +test("the id still resolves a model when the product string says nothing", () => { + assert.equal(mchoseV3FindProduct(0x4033, "USB Receiver")!.name, "A7 V3 Ultra+"); + assert.equal(mchoseV3FindProduct(0x4033, null)!.name, "A7 V3 Ultra+"); + assert.equal(mchoseV3FindProduct(null, "Some Other Mouse"), null); +}); + +test("the captured settings block decodes to the state the mouse was in", () => { + const settings = mchoseV3DecodeSettings(new Uint8Array(CAPTURE.settings))!; + assert.equal(settings.profileIndex, 0); + assert.equal(settings.dpiIndex, 1); + // Slot 4 on the wire is option 3, which is 2000 Hz on an 8K model. + assert.equal(settings.wirelessRateIndex, 3); + assert.equal(mchoseV3PollingRates(mchoseV3FindProduct(0x4033)!)[settings.wirelessRateIndex], 2000); + assert.equal(settings.sleep, 3, "three minutes"); + assert.equal(settings.leftDebounceMs, 8); + assert.equal(settings.rightDebounceMs, 8); + assert.equal(settings.angleTuning, 0); + + const sensor = mchoseV3DecodeSensor(settings.sensor); + assert.equal(MCHOSE_V3_MODES[sensor.modeIndex], "eSports"); + assert.equal(sensor.motionSync, false); + assert.equal(sensor.angleSnapping, false); + assert.equal(sensor.rippleControl, false); + assert.equal(sensor.glassMode, false); +}); + +test("the captured DPI table decodes to six stages with the second active", () => { + const dpi = mchoseV3DecodeDpi(new Uint8Array(CAPTURE.dpi))!; + assert.equal(dpi.stageCount, 6); + assert.equal(dpi.activeStage, 1); + assert.equal(dpi.hasSeparateY, false); + assert.deepEqual(dpi.stages, [400, 800, 1600, 3200, 6400, 50000]); + assert.equal(dpi.stages[dpi.activeStage], 800); + // The top stage is the A7 V3 Ultra+'s ceiling, and another reason the id's + // A5 V3 Ultra+ (42000) cannot be the right model. + assert.equal(dpi.stages[5], mchoseV3FindProduct(0x4033)!.dpiMax); +}); + +test("the captured button table walks six stock assignments", () => { + const buttons = mchoseV3DecodeButtons(new Uint8Array(CAPTURE.buttons))!; + assert.equal(Object.keys(buttons).length, 6); + // Five factory-default buttons carrying their own mouse-button mask... + for (const name of ["Left", "Right", "Middle", "Forward", "Back"]) { + assert.equal(buttons[name]!.type, 0x00, `${name} is on its factory default`); + } + assert.deepEqual(buttons.Left!.value, [0x00, 0x01]); + assert.deepEqual(buttons.Back!.value, [0x00, 0x08]); + // ...and a DPI button the firmware marks unset rather than defaulted. + assert.equal(buttons.DPI!.type, MCHOSE_V3_BUTTON_UNSET); +}); diff --git a/src/mchose/v3.ts b/src/mchose/v3.ts index ad1c362..1c09439 100644 --- a/src/mchose/v3.ts +++ b/src/mchose/v3.ts @@ -180,7 +180,11 @@ const u16 = (data: Uint8Array, offset: number): number => export interface MchoseV3DeviceInfo { vendorId: number; - /** The mouse's own product id, even when the host is talking to a receiver. */ + /** + * **Not a model id**, despite looking like one: an A7 V3 Ultra+ reports + * `0x4026`, which MCHOSE's own table lists against the A5 V3 Ultra+. Use + * {@link mchoseV3FindProduct}, which prefers the USB product string. + */ productId: number; /** Onboard profile count. */ profileCount: number; @@ -497,21 +501,35 @@ export const MCHOSE_V3_POLLING_RATES: Readonly }; /** - * Resolve a model from the id `0x0900` reports, falling back to the product - * string. An unrecognised device yields null rather than a wrong DPI ceiling. + * Resolve a model, **preferring the USB product string over the id `0x0900` + * reports**. An unrecognised device yields null rather than a wrong DPI ceiling. + * + * The id ordering is the opposite of the A7 V2's, and deliberately so. On the + * V2, the id inside the battery reply is decisive because the host-facing id is + * shared. Here that reasoning does not hold: a capture from a real **A7 V3 + * Ultra+** has `0x0900` reporting `0x4026`, which this table — and MCHOSE's own + * — lists against the *A5 V3 Ultra+*. Trusting it 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. + * + * Whatever `0x0900` byte 2 is — a sensor or platform id, shared across shells — + * it is not a model id. M HUB agrees: every model lookup in the vendor bundle + * keys off `navigator.device.productName`, never off this field. The id is kept + * only as a fallback for a device whose product string says nothing useful. */ export function mchoseV3FindProduct( mouseProductId: number | null, productName?: string | null, ): MchoseV3Product | null { - const byId = MCHOSE_V3_PRODUCTS.find((product) => product.productId === mouseProductId); - if (byId) return byId; const name = productName?.trim().toUpperCase() ?? ""; - if (!name) return null; - // Longest name first so "A7 V3 Pro+" is not swallowed by "A7 V3 Pro". - return [...MCHOSE_V3_PRODUCTS] - .sort((a, b) => b.name.length - a.name.length) - .find((product) => name.includes(product.name.toUpperCase())) ?? null; + if (name) { + // Longest name first so "A7 V3 Pro+" is not swallowed by "A7 V3 Pro". + const byName = [...MCHOSE_V3_PRODUCTS] + .sort((a, b) => b.name.length - a.name.length) + .find((product) => name.includes(product.name.toUpperCase())); + if (byName) return byName; + } + return MCHOSE_V3_PRODUCTS.find((product) => product.productId === mouseProductId) ?? null; } /** Polling rates available to a model. */ From a92e865687479f2578e57e0b6d852c06570abd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sat, 12 Sep 2026 21:06:21 +0200 Subject: [PATCH 2/2] mchose: stop reading the V3's non-data replies as silence 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. --- docs/mchose-protocol.md | 26 +++++++ src/drivers/mchose/v3-hid.test.ts | 104 ++++++++++++++++++++++++++- src/drivers/mchose/v3-hid.ts | 114 +++++++++++++++++++++++++----- src/mchose/v3.ts | 28 ++++++++ 4 files changed, 252 insertions(+), 20 deletions(-) diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 48a8d4e..41d4e4b 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -644,3 +644,29 @@ taken while the driver believed it was talking to a three-step model, so it read lift-off from the sensor byte and never sent `0x0009`. With the model resolved correctly the Ultra+ now takes that branch, and a device that does not answer it degrades to a blank lift-off rather than a wrong one. + +### Replies that are not data + +Three shapes, all captured from a real A7 V3 Ultra+ on 2026-09-12. Reading any +of them as silence is enough to make a working mouse look dead. + +| Shape | Meaning | +| --- | --- | +| command `0x0000`, flags `0x00`, length 0, `0xff` in the sequence byte | **refusal.** The firmware will not serve that command. Retrying changes nothing | +| a payload that is the single byte `0xff` | **ask again.** The device is listening but cannot answer yet | +| nothing at all | genuinely not listening | + +`0x0901` is answered with a refusal on an A7 V3 Ultra+, request after request. +A receiver with no mouse reachable behind it answers `0x0900` with the one-byte +ask-again — M HUB's own read helper loops while the first payload byte is `0xff` +for exactly this reason. Distinguishing the three matters because only the last +one justifies abandoning the rest of a status read. + +> **Timings.** On a cable, `0x0900`, `0x0002`, `0x0003` and `0x0001` answer in +> **1–3 ms**. Every reply that is not immediate takes **almost exactly +> 1.001 s** — the refusals above, and `0x0900` over an idle receiver. That looks +> like a fixed deferral in the firmware rather than a variable delay, so a reply +> timeout only has to clear one second. An earlier 600 ms budget sat just +> underneath it, which meant those replies were always missed and then mistaken +> for the *next* attempt's answer. + diff --git a/src/drivers/mchose/v3-hid.test.ts b/src/drivers/mchose/v3-hid.test.ts index 5025dfa..1aba470 100644 --- a/src/drivers/mchose/v3-hid.test.ts +++ b/src/drivers/mchose/v3-hid.test.ts @@ -3,7 +3,13 @@ import assert from "node:assert/strict"; import { MchoseV3HidClient } from "./v3-hid.ts"; import { MchoseHidClient } from "./hid.ts"; import { MchoseDockHidClient } from "./dock-hid.ts"; -import { MCHOSE_V3_BODY_LENGTH, MCHOSE_V3_COMMAND } from "@openmouse/protocol/mchose"; +import { + MCHOSE_V3_BODY_LENGTH, + MCHOSE_V3_COMMAND, + mchoseV3IsBusy, + mchoseV3IsRejection, + mchoseV3Payload, +} from "@openmouse/protocol/mchose"; /** What an A7 V3 Ultra+ behind its receiver would answer, command by command. */ const ANSWERS: Readonly> = { @@ -53,6 +59,10 @@ interface FakeOptions { silent?: number[]; /** Emit an unrelated input report before every real answer. */ noisy?: boolean; + /** Answer these commands with the firmware's refusal frame. */ + reject?: number[]; + /** Answer everything with the one-byte ask-again a stranded receiver sends. */ + busy?: boolean; /** Override the `0x0900` reply, to replay a real capture. */ deviceInfo?: number[]; } @@ -89,6 +99,17 @@ function fakeMouse(options: FakeOptions = {}) { const command = body[3]! | (body[4]! << 8); sent.push(command); sentData.set(command, [...body.subarray(7, 7 + body[2]!)]); + if (options.reject?.includes(command)) { + // Command 0x0000, checksum flag clear, 0xff in the sequence byte. + const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH); + nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]); + queueMicrotask(() => { emit(nak); }); + return; + } + if (options.busy) { + queueMicrotask(() => { emit(frame(command, [0xff])); }); + return; + } if (options.silent?.includes(command)) return; const answer = command === MCHOSE_V3_COMMAND.readDeviceInfo && options.deviceInfo ? options.deviceInfo @@ -252,3 +273,84 @@ describe("MCHOSE A7 V3 driver", () => { assert.deepEqual(sentData.get(MCHOSE_V3_COMMAND.readVersion), [0], "the mouse, not the receiver"); }); }); + +/** + * Three reply shapes a real A7 V3 Ultra+ sends that are not data, captured + * 2026-09-12. Mistaking any of them for silence is what made a live mouse look + * dead. + */ +describe("MCHOSE A7 V3 reply handling", () => { + it("recognises the refusal the firmware sends for an unsupported command", () => { + // Verbatim: what 0x0901 answers, about a second after every request. + const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH); + nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]); + assert.equal(mchoseV3IsRejection(nak), true); + // It carries command 0x0000, so it can never be mistaken for a payload. + assert.equal(mchoseV3Payload(nak, MCHOSE_V3_COMMAND.readVersion), null); + + // A real reply is not a refusal. + const real = frame(MCHOSE_V3_COMMAND.readLiftOff, [0x04]); + assert.equal(mchoseV3IsRejection(real), false); + }); + + it("recognises the one-byte ask-again a receiver sends with no mouse behind it", () => { + const payload = mchoseV3Payload( + frame(MCHOSE_V3_COMMAND.readDeviceInfo, [0xff]), MCHOSE_V3_COMMAND.readDeviceInfo, + )!; + assert.equal(mchoseV3IsBusy(payload), true); + + // Stricter than M HUB's own test, which looks at the first byte alone: a + // button table legitimately starts with 0xff when button one is unassigned. + const buttons = new Uint8Array([0xff, 0xff, 0xff, 0x00, 0x00, 0x02]); + assert.equal(mchoseV3IsBusy(buttons), false); + }); + + it("gives up on a refused command instead of spending the whole budget", async () => { + const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] }); + const status = await new MchoseV3HidClient(device).readStatus(); + + const versionAsks = sent.filter((c) => c === MCHOSE_V3_COMMAND.readVersion).length; + assert.equal(versionAsks, 1, "asked once, told no, moved on"); + // And a refusal on one command must not poison the rest of the read. + assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); + assert.equal(status.dpi, 800); + assert.deepEqual(status.firmware, []); + }); + + it("keeps reading the rest of the status after a command is refused", async () => { + const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] }); + await new MchoseV3HidClient(device).readStatus(); + // A device that says "no" is awake; the old code treated it as silence and + // abandoned every command after it. + for (const command of [ + MCHOSE_V3_COMMAND.readDeviceInfo, + MCHOSE_V3_COMMAND.readSettings, + MCHOSE_V3_COMMAND.readDpi, + MCHOSE_V3_COMMAND.readButtons, + ]) { + assert.ok(sent.includes(command), `0x${command.toString(16)} was still asked`); + } + }); + + it("stops asking a receiver whose mouse is not reachable, and says so", async () => { + // A real receiver reports the paired mouse's name, which is how the panel + // still names the model while the mouse itself is unreachable. + const { device, sent } = fakeMouse({ busy: true, productName: "MCHOSE A7 V3 Ultra+" }); + const status = await new MchoseV3HidClient(device).readStatus(); + + assert.equal( + sent.filter((c) => c === MCHOSE_V3_COMMAND.readDeviceInfo).length, 2, + "one retry, then stop — each ask costs a second on real hardware", + ); + assert.match(status.ui!.statusNote!, /not reachable/); + assert.doesNotMatch(status.ui!.statusNote!, /did not answer/); + // The model still comes from the product string, so the panel is not blank. + assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); + }); + + it("still concludes nothing is listening when nothing ever arrives", async () => { + const { device } = fakeMouse({ silent: Object.values(MCHOSE_V3_COMMAND) }); + const status = await new MchoseV3HidClient(device).readStatus(); + assert.match(status.ui!.statusNote!, /did not answer/); + }); +}); diff --git a/src/drivers/mchose/v3-hid.ts b/src/drivers/mchose/v3-hid.ts index df07efc..6433266 100644 --- a/src/drivers/mchose/v3-hid.ts +++ b/src/drivers/mchose/v3-hid.ts @@ -13,7 +13,9 @@ import { mchoseV3DecodeSettings, mchoseV3Encode, mchoseV3FindProduct, + mchoseV3IsBusy, mchoseV3IsProductId, + mchoseV3IsRejection, mchoseV3LiftOffLabels, mchoseV3LiftOffStop, mchoseV3Payload, @@ -49,12 +51,45 @@ import { VENDOR_ID } from "../vendors.ts"; * the codec — but it should wait for someone who can watch the hardware. */ -const REPLY_TIMEOUT_MS = 600; +/** + * How long to wait for a reply. + * + * Measured, not guessed. On a cable, `0x0900`, `0x0002`, `0x0003` and `0x0001` + * all answer in 1–3 ms. Everything else answers in **almost exactly 1.001 s**: + * `0x0901` does it on the cable, and `0x0900` does it over the receiver when + * the mouse is not currently reachable. That looks like a fixed deferral inside + * the firmware rather than a variable delay, so the budget only has to clear + * it — an earlier 600 ms timeout sat just underneath, which meant those replies + * were *always* missed and then mistaken for the next attempt's answer. + */ +const REPLY_TIMEOUT_MS = 1500; const READ_ATTEMPTS = 3; +/** + * How many "ask again" replies to accept before giving up on a command. Each + * one costs a full second, and a receiver with no mouse behind it will keep + * sending them, so this stays low: the point is to ride out a mouse waking up, + * not to wait for one that is switched off. + */ +const BUSY_ATTEMPTS = 2; + +/** M HUB pauses this long before re-asking a busy device. */ +const BUSY_RETRY_MS = 30; + +/** What came back for one attempt. */ +type Reply = + | { kind: "payload"; payload: Uint8Array } + /** A one-byte 0xff: the device is there but cannot answer yet. */ + | { kind: "busy" } + /** The firmware refused the command outright. Retrying changes nothing. */ + | { kind: "rejected" } + | { kind: "timeout" }; + /** `0x0901`'s target byte: the mouse rather than the receiver in front of it. */ const VERSION_TARGET_MOUSE = 0; +const delay = (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); + /** The receivers serve every model in the generation. */ const LINK_PRODUCT_IDS: readonly number[] = Object.values(MCHOSE_V3_LINK_PRODUCT_IDS); @@ -72,6 +107,13 @@ export class MchoseV3HidClient { */ private unresponsive = false; + /** + * Set when the device answered "ask again" rather than with data. It means + * the receiver is plugged in but the mouse behind it is not reachable, which + * is a different thing to tell the user than "nothing answered". + */ + private linkBusy = false; + constructor(device: HIDDevice) { this.device = device; } @@ -120,25 +162,31 @@ export class MchoseV3HidClient { const run = async (): Promise => { if (this.unresponsive) return null; const body = mchoseV3Encode(command, data); + let busy = 0; + let heardAnything = false; + for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) { - const reply = await new Promise((resolve) => { - const finish = (value: Uint8Array | null): void => { - clearTimeout(timer); - this.device.removeEventListener("inputreport", listener); - resolve(value); - }; - const listener = (event: Event): void => { - const report = event as HIDInputReportEvent; - const payload = mchoseV3Payload(new Uint8Array(report.data.buffer), command); - if (payload) finish(payload); - }; - const timer = setTimeout(() => { finish(null); }, REPLY_TIMEOUT_MS); - this.device.addEventListener("inputreport", listener); - this.device.sendReport(MCHOSE_V3_REPORT_ID, body).catch(() => { finish(null); }); - }); - if (reply) return reply; + const reply = await this.attempt(command, body); + if (reply.kind === "payload") return reply.payload; + if (reply.kind !== "timeout") heardAnything = true; + + // A refusal is final: the firmware answered, and it said no. + if (reply.kind === "rejected") return null; + if (reply.kind === "busy") { + busy += 1; + this.linkBusy = true; + if (busy >= BUSY_ATTEMPTS) return null; + await delay(BUSY_RETRY_MS); + // A busy reply does not count against the timeout budget; the device + // is plainly listening, it just has nothing to say yet. + attempt -= 1; + } } - this.unresponsive = true; + + // Only conclude nothing is listening when nothing ever arrived. A device + // that answered "busy" or "no" is awake, and shutting the rest of the + // status read down would throw away commands it would have answered. + if (!heardAnything) this.unresponsive = true; return null; }; const next = this.queue.then(run, run); @@ -146,6 +194,31 @@ export class MchoseV3HidClient { return next; } + /** One send-and-wait, classifying whatever comes back. */ + private attempt(command: number, body: Uint8Array): Promise { + return new Promise((resolve) => { + const finish = (reply: Reply): void => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + resolve(reply); + }; + const listener = (event: Event): void => { + const frame = new Uint8Array((event as HIDInputReportEvent).data.buffer); + const payload = mchoseV3Payload(frame, command); + if (payload) { + finish(mchoseV3IsBusy(payload) ? { kind: "busy" } : { kind: "payload", payload }); + return; + } + // A refusal carries command 0x0000, so it never matches the id above. + if (mchoseV3IsRejection(frame)) finish({ kind: "rejected" }); + }; + const timer = setTimeout(() => { finish({ kind: "timeout" }); }, REPLY_TIMEOUT_MS); + this.device.addEventListener("inputreport", listener); + this.device.sendReport(MCHOSE_V3_REPORT_ID, body) + .catch(() => { finish({ kind: "timeout" }); }); + }); + } + private async readDeviceInfo(): Promise { const payload = await this.request(MCHOSE_V3_COMMAND.readDeviceInfo); return payload ? mchoseV3DecodeDeviceInfo(payload) : null; @@ -193,6 +266,7 @@ export class MchoseV3HidClient { async readStatus(): Promise { await this.open(); this.unresponsive = false; + this.linkBusy = false; // Identity first: the host-facing product id is shared across the whole // generation, so this is the only thing that says which model is on the @@ -278,7 +352,9 @@ export class MchoseV3HidClient { liftOffHeight ? `Lift-off ${liftOffHeight}.` : "", "Read-only: this driver can report settings but cannot change them yet.", ].filter(Boolean).join(" ") - : "Read-only, and this mouse did not answer. Please report the model and how it is connected.", + : this.linkBusy + ? "Receiver connected, but the mouse is not reachable. Wake it or check it is switched on." + : "Read-only, and this mouse did not answer. Please report the model and how it is connected.", }, }; } diff --git a/src/mchose/v3.ts b/src/mchose/v3.ts index 1c09439..f9fe1a3 100644 --- a/src/mchose/v3.ts +++ b/src/mchose/v3.ts @@ -561,3 +561,31 @@ export function mchoseV3LiftOffStop( if (index === steps - 1) return "High"; return "Medium"; } + +/** + * A reply the firmware sends to refuse a command outright: command id `0x0000` + * with the checksum flag clear and `0xff` in the sequence byte. It is not a + * malformed frame and not noise — an A7 V3 Ultra+ answers `0x0901` with one, + * about a second after the request, every time. + * + * Worth recognising because the alternative is spending the whole retry budget + * waiting for an answer that has already arrived. + */ +export function mchoseV3IsRejection(body: Uint8Array): boolean { + return mchoseV3ReplyCommand(body) === 0x0000 + && (body[FLAGS_OFFSET] ?? 0) === 0 + && (body[LENGTH_OFFSET] ?? 0) === 0; +} + +/** + * A one-byte `0xff` payload, which means "ask again" rather than carrying data. + * A receiver whose mouse is not currently reachable answers `0x0900` with it. + * + * M HUB's own retry helper loops while the first payload byte is `0xff`. This + * is stricter — it requires the payload to be *only* that byte — because a + * button table legitimately starts with `0xff` when the first button carries no + * assignment, and the vendor's looser test would reject that as busy. + */ +export function mchoseV3IsBusy(payload: Uint8Array): boolean { + return payload.length === 1 && payload[0] === 0xff; +}