diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 0e57e63..41d4e4b 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,74 @@ 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. + +### 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/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..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,11 +59,19 @@ 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[]; } 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 +98,22 @@ 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.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 = 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 +124,7 @@ function fakeMouse(options: FakeOptions = {}) { }, } as unknown as HIDDevice; - return { device, sent }; + return { device, sent, sentData }; } describe("MCHOSE A7 V3 driver", () => { @@ -134,7 +162,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 +199,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 +244,113 @@ 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"); + }); +}); + +/** + * 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 b4cefda..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, @@ -26,27 +28,68 @@ 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; +/** + * 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); @@ -64,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; } @@ -112,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); @@ -138,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; @@ -148,8 +229,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")}`; @@ -180,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 @@ -263,9 +350,11 @@ 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.", + : 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.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..f9fe1a3 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. */ @@ -543,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; +}