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/4] 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 5b0b2a93719158182176253da4c3574d47981212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sat, 12 Sep 2026 19:30:58 +0200 Subject: [PATCH 2/4] mchose: give the A7 V3 driver its settings writes 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 --- docs/mchose-protocol.md | 47 ++++ src/drivers/mchose/v3-hid.test.ts | 240 +++++++++++++++++-- src/drivers/mchose/v3-hid.ts | 375 +++++++++++++++++++++++++++--- src/mchose/v3.test.ts | 172 +++++++++++++- src/mchose/v3.ts | 257 +++++++++++++++++++- 5 files changed, 1032 insertions(+), 59 deletions(-) diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 48a8d4e..84fba9c 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -644,3 +644,50 @@ 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. + +### Writing + +Every V3 write **replaces a whole block**. There is no partial update and no +read-modify-write on the device side, so a caller reads the block, edits the +decoded structure and sends the whole thing back. That is why the encoders take +a structure rather than a set of changes, and why a failed read has to abort the +write instead of falling back to defaults. + +| Command | Data | +| --- | --- | +| `0x0102` | the `0x0002` settings block, same layout | +| `0x0103` | `[profile, axis, hasY, count, activeStage, six uint16 stages]` | +| `0x0104` | `[profile, stage, axis, dpi uint16]` — one stage, no table rewrite | +| `0x0109` | `[profile, liftOffIndex]` | +| `0x0101` | `[profile, 0, buttonCount]` then the variable-width button entries | + +> **The DPI write and read do not order their fields the same way.** The write +> puts `hasSeparateY` third and the read puts it fifth. Copying a decoded table +> straight into a write buffer silently swaps the stage count with it. + +**The settings block's tail is not padding.** M HUB writes ten zero bytes past +the nine named fields, but a 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. Writing the vendor's zeros would quietly set them all +to nothing on every unrelated write, so those bytes are carried through from the +read instead. + +**The sensor byte must be masked, not assigned.** The A7 V2's bit 5 was never +explained; a writer that assigns the byte destroys whatever a field it does not +know about was holding. + +**None of these writes has been sent to a real device.** The framing under them +is proven — the mouse answers frames built by the same encoder — but the +firmware's response to each write is not. Two numbers are the likely first +suspects if something misbehaves: + +- the settle delay before the read-back, currently the A7 V2's 400 ms; +- whether a write needs a separate save or commit command at all. Nothing in the + vendor bundle suggests one, but nothing rules it out either. + +The button vocabulary is deliberately two entries wide — "Default" and +"Disabled" — because those are the only two encodings that appear in a real +capture. Keyboard, media, DPI, macro and profile actions all exist in the +protocol; they stay unavailable until someone records what M HUB writes for +them, since a button is the one setting where a wrong guess can leave someone +unable to click. diff --git a/src/drivers/mchose/v3-hid.test.ts b/src/drivers/mchose/v3-hid.test.ts index 5025dfa..6bc6a59 100644 --- a/src/drivers/mchose/v3-hid.test.ts +++ b/src/drivers/mchose/v3-hid.test.ts @@ -53,13 +53,64 @@ interface FakeOptions { silent?: number[]; /** Emit an unrelated input report before every real answer. */ noisy?: boolean; + /** Record writes but never apply them, as a firmware ignoring a command would. */ + ignoreWrites?: boolean; /** Override the `0x0900` reply, to replay a real capture. */ deviceInfo?: number[]; } +/** Which slice of the fake's state each read command serves. */ +const STATE_BY_READ: Readonly> = { + [MCHOSE_V3_COMMAND.readSettings]: "settings", + [MCHOSE_V3_COMMAND.readDpi]: "dpi", + [MCHOSE_V3_COMMAND.readButtons]: "buttons", + [MCHOSE_V3_COMMAND.readLiftOff]: "liftOff", +}; + +/** + * Apply a write the way the firmware would, so the driver's read-back + * verification is actually exercised rather than always agreeing with itself. + */ +function applyWrite( + state: { settings: number[]; dpi: number[]; buttons: number[]; liftOff: number[] }, + command: number, + data: number[], +): void { + switch (command) { + case MCHOSE_V3_COMMAND.writeSettings: + state.settings = data.slice(0, state.settings.length); + break; + case MCHOSE_V3_COMMAND.writeDpi: { + // The write and read orders differ: the write puts hasSeparateY third + // and the read puts it fifth. Getting this backwards in the fake would + // hide a driver that had it backwards too. + const [profile, axis, hasY, count, active, ...stages] = data; + state.dpi = [profile!, axis!, count!, active!, hasY!, ...stages]; + break; + } + case MCHOSE_V3_COMMAND.writeLiftOff: + state.liftOff = [data[1]!]; + break; + case MCHOSE_V3_COMMAND.writeButtons: + state.buttons = data.slice(3); + break; + default: + break; + } +} + function fakeMouse(options: FakeOptions = {}) { const listeners: Array<(event: unknown) => void> = []; const sent: number[] = []; + /** Mutable device state, so a write is visible to the read that follows. */ + const state = { + settings: [...ANSWERS[MCHOSE_V3_COMMAND.readSettings]!], + dpi: [...ANSWERS[MCHOSE_V3_COMMAND.readDpi]!], + buttons: [...ANSWERS[MCHOSE_V3_COMMAND.readButtons]!], + liftOff: [...ANSWERS[MCHOSE_V3_COMMAND.readLiftOff]!], + }; + /** Every write, by command, so the exact bytes can be asserted. */ + const writes = new Map(); /** The data block sent with each command, so arguments can be asserted. */ const sentData = new Map(); @@ -89,7 +140,18 @@ function fakeMouse(options: FakeOptions = {}) { const command = body[3]! | (body[4]! << 8); sent.push(command); sentData.set(command, [...body.subarray(7, 7 + body[2]!)]); + if (command >= 0x0100 && command <= 0x01ff) { + writes.set(command, [...body.subarray(7, 7 + body[2])]); + if (!options.ignoreWrites) applyWrite(state, command, writes.get(command)); + return; + } if (options.silent?.includes(command)) return; + const live = STATE_BY_READ[command]; + if (live) { + const current = state[live]; + queueMicrotask(() => { emit(frame(command, current)); }); + return; + } const answer = command === MCHOSE_V3_COMMAND.readDeviceInfo && options.deviceInfo ? options.deviceInfo : ANSWERS[command]; @@ -103,7 +165,7 @@ function fakeMouse(options: FakeOptions = {}) { }, } as unknown as HIDDevice; - return { device, sent, sentData }; + return { device, sent, sentData, writes }; } describe("MCHOSE A7 V3 driver", () => { @@ -171,20 +233,6 @@ describe("MCHOSE A7 V3 driver", () => { }); }); - it("offers no settings, and says why", async () => { - const { device } = fakeMouse(); - const client = new MchoseV3HidClient(device); - const status = await client.readStatus(); - - 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!, /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); - assert.equal("setLiftOffDistance" in client, false); - }); - it("ignores unrelated input reports while waiting for its answer", async () => { const { device } = fakeMouse({ noisy: true }); const status = await new MchoseV3HidClient(device).readStatus(); @@ -252,3 +300,165 @@ describe("MCHOSE A7 V3 driver", () => { assert.deepEqual(sentData.get(MCHOSE_V3_COMMAND.readVersion), [0], "the mouse, not the receiver"); }); }); + +describe("MCHOSE A7 V3 writes", () => { + it("reads, edits and writes back the whole settings block", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setPollingRate(1000); + + const write = writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + assert.ok(write, "a settings write went out"); + // The block that came back from the read, with one nibble changed. + assert.equal(write[0], 0x01, "the profile it read, not a default"); + assert.equal(write[3], 0x0a, "sleep untouched"); + assert.equal(write[5], 0x45, "sensor flags untouched"); + assert.equal(write[6], 0xf1, "the negative angle survived the round trip"); + }); + + it("writes only the link it is connected through", async () => { + const wireless = fakeMouse(); + await new MchoseV3HidClient(wireless.device).setPollingRate(1000); + const overRf = wireless.writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + assert.equal(overRf[1], 0x32, "the wired byte is exactly what was read"); + assert.notEqual(overRf[2], 0x62, "and the wireless byte moved"); + + const wired = fakeMouse({ productId: 0x4033 }); + await new MchoseV3HidClient(wired.device).setPollingRate(2000); + const overCable = wired.writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + assert.equal(overCable[2], 0x62, "the wireless byte is exactly what was read"); + assert.notEqual(overCable[1], 0x32, "and the wired byte moved"); + }); + + it("refuses a rate the model does not have, without writing anything", async () => { + const { device, writes } = fakeMouse(); + await assert.rejects( + new MchoseV3HidClient(device).setPollingRate(16000), + /does not support/, + ); + assert.equal(writes.has(MCHOSE_V3_COMMAND.writeSettings), false, "nothing was sent"); + }); + + it("refuses a DPI outside the model's range, without writing anything", async () => { + const { device, writes } = fakeMouse(); + await assert.rejects(new MchoseV3HidClient(device).setDpi(90000), RangeError); + assert.equal(writes.has(MCHOSE_V3_COMMAND.writeDpi), false); + }); + + it("rounds a DPI to a step the firmware stores", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setDpi(1637); + const write = writes.get(MCHOSE_V3_COMMAND.writeDpi)!; + // Stage 1 is the active one in the fake's table; 1637 rounds to 1650. + assert.deepEqual(write.slice(7, 9), [1650 & 0xff, 1650 >> 8]); + }); + + it("leaves the other DPI stages exactly as they were read", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setDpi(1650); + const write = writes.get(MCHOSE_V3_COMMAND.writeDpi)!; + assert.deepEqual(write.slice(5, 7), [0x90, 0x01], "stage 0 untouched"); + assert.deepEqual(write.slice(9, 11), [0x40, 0x06], "stage 2 untouched"); + assert.deepEqual(write.slice(15, 17), [0x50, 0xc3], "stage 5 untouched"); + }); + + it("does not let the active stage point past a shortened list", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setDpiStageCount(1); + const write = writes.get(MCHOSE_V3_COMMAND.writeDpi)!; + assert.equal(write[3], 1, "one stage"); + assert.equal(write[4], 0, "and the active stage pulled back into range"); + }); + + it("moves one sensor bit and preserves the rest of the byte", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setMotionSync(true); + const write = writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + // Read sensor was 0x45: ripple on, eSports, lift-off 2. + assert.equal(write[5], 0x55, "motion sync added, nothing else disturbed"); + }); + + it("takes the dedicated lift-off command on a five-step model", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setLiftOffDistance("1.7 mm"); + assert.deepEqual(writes.get(MCHOSE_V3_COMMAND.writeLiftOff), [0x01, 4]); + assert.equal( + writes.has(MCHOSE_V3_COMMAND.writeSettings), false, + "the sensor byte is not where this model keeps it", + ); + }); + + it("refuses a lift-off step the model does not have", async () => { + const { device, writes } = fakeMouse(); + await assert.rejects(new MchoseV3HidClient(device).setLiftOffDistance("2 mm"), /no 2 mm/); + assert.equal(writes.size, 0); + }); + + it("throws when the mouse reports something other than what it was told", async () => { + // The mouse answers every read with its original block, so the read-back + // never matches — which is exactly what a silently ignored write looks like. + const { device } = fakeMouse({ ignoreWrites: true }); + await assert.rejects( + new MchoseV3HidClient(device).setPollingRate(1000), + /did not accept/, + ); + }); + + it("aborts rather than writing defaults over a block it could not read", async () => { + const { device, writes } = fakeMouse({ silent: [MCHOSE_V3_COMMAND.readSettings] }); + await assert.rejects( + new MchoseV3HidClient(device).setDebounceTime(4), + /did not return its settings/, + ); + assert.equal(writes.size, 0, "nothing invented and sent"); + }); + + it("moves both primary debounce fields together", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setDebounceTime(4); + const write = writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + assert.equal(write[7], 4); + assert.equal(write[8], 4, "the right button cannot be left on a value nobody can see"); + }); + + it("refuses a debounce the firmware will not take", async () => { + const { device, writes } = fakeMouse(); + await assert.rejects(new MchoseV3HidClient(device).setDebounceTime(30), RangeError); + assert.equal(writes.size, 0); + }); + + it("treats a zero sleep timeout as never", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setSleepTimeout(0); + const write = writes.get(MCHOSE_V3_COMMAND.writeSettings)!; + assert.equal(write[3], 0, "no minutes"); + assert.equal(write[4], 1, "and the mode byte agrees"); + }); + + it("rewrites the button table with five entries untouched", async () => { + const { device, writes } = fakeMouse(); + await new MchoseV3HidClient(device).setButtonMapping("Back", "Disabled"); + const write = writes.get(MCHOSE_V3_COMMAND.writeButtons)!; + assert.deepEqual(write.slice(0, 3), [0x01, 0, 6], "profile, reserved, count"); + // The fake's table: left type 1 (4 bytes), then four 3-byte entries. + assert.deepEqual(write.slice(3, 7), [0x01, 0x00, 0x00, 0x01], "left untouched"); + assert.equal(write[write.length - 3], 0x05, "the DPI button kept its own action"); + }); + + it("refuses an action whose encoding has never been captured", async () => { + const { device, writes } = fakeMouse(); + await assert.rejects( + new MchoseV3HidClient(device).setButtonMapping("Back", "Keyboard"), + /Unknown button action/, + ); + assert.equal(writes.has(MCHOSE_V3_COMMAND.writeButtons), false); + }); + + it("offers the settings grid now that something is behind it", async () => { + const { device } = fakeMouse(); + const status = await new MchoseV3HidClient(device).readStatus(); + assert.equal(status.ui!.settingsReady, true); + assert.deepEqual(status.buttonOptions, ["Default", "Disabled"]); + assert.equal(status.ui!.dpiStageEditor!.maxDpi, 50000, "this model's own ceiling"); + assert.match(status.ui!.statusNote!, /not been confirmed on hardware/); + }); +}); diff --git a/src/drivers/mchose/v3-hid.ts b/src/drivers/mchose/v3-hid.ts index df07efc..8be0824 100644 --- a/src/drivers/mchose/v3-hid.ts +++ b/src/drivers/mchose/v3-hid.ts @@ -1,5 +1,12 @@ import { + MCHOSE_V3_BUTTONS, + MCHOSE_V3_BUTTON_ACTIONS, MCHOSE_V3_COMMAND, + MCHOSE_V3_DEBOUNCE_MAX_MS, + MCHOSE_V3_DPI_MIN, + MCHOSE_V3_DPI_STAGES, + MCHOSE_V3_DPI_STEP, + MCHOSE_V3_SLEEP_OPTIONS, MCHOSE_V3_MODES, MCHOSE_V3_LINK_PRODUCT_IDS, MCHOSE_V3_REPORT_ID, @@ -11,14 +18,24 @@ import { mchoseV3DecodeLiftOff, mchoseV3DecodeSensor, mchoseV3DecodeSettings, + mchoseV3ButtonAction, + mchoseV3ButtonActionName, + mchoseV3CheckSettings, mchoseV3Encode, + mchoseV3EncodeButtons, + mchoseV3EncodeDpiTable, + mchoseV3EncodeLiftOff, + mchoseV3EncodeSensor, + mchoseV3EncodeSettings, mchoseV3FindProduct, mchoseV3IsProductId, mchoseV3LiftOffLabels, mchoseV3LiftOffStop, mchoseV3Payload, mchoseV3PollingRates, + mchoseV3RoundDpi, type MchoseV3DeviceInfo, + type MchoseV3Dpi, type MchoseV3Product, type MchoseV3Settings, } from "@openmouse/protocol/mchose"; @@ -26,7 +43,7 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; /** - * MCHOSE A7 V3 and its siblings — **read-only**. + * MCHOSE A7 V3 and its siblings. * * 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 @@ -36,17 +53,23 @@ import { VENDOR_ID } from "../vendors.ts"; * flags and the button table all came back correctly. See the capture notes in * docs/mchose-protocol.md. * - * **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. + * **The writes have not been exercised on hardware.** They are built to the + * same shape M HUB uses, and the framing under them is proven by the reads, but + * no byte here has been watched going into a real V3. Three things follow: * - * `settingsReady` is false so the shell offers no inert controls; - * `valuesVerified` stays true so what it does read is still shown. + * - every write is a read-modify-write of a **whole block**, because this + * protocol has no partial update. A setter reads, edits the decoded object, + * writes and reads back; a failed read aborts rather than writing defaults + * over a working configuration, and bytes this codec does not understand are + * carried through rather than zeroed; + * - every setter verifies, and throws when the mouse reports something other + * than what it was told; + * - the button vocabulary is two entries wide. The A7 V2's value tables were + * confirmed key by key; none of this generation's have been, and a button is + * the one setting where a wrong guess can leave someone unable to click. * - * Adding writes is a small change on top of this — the encoders are already in - * the codec — but it should wait for someone who can watch the hardware. + * {@link WRITE_SETTLE_MS} is the number most likely to be wrong: it is the A7 + * V2's figure, and this generation's has never been measured. */ const REPLY_TIMEOUT_MS = 600; @@ -55,6 +78,20 @@ const READ_ATTEMPTS = 3; /** `0x0901`'s target byte: the mouse rather than the receiver in front of it. */ const VERSION_TARGET_MOUSE = 0; +/** The X axis of the DPI table; Y is a separate table on the models that have one. */ +const DPI_AXIS_X = 0; + +/** + * How long to let a write commit before reading it back. + * + * Untimed on this generation. The A7 V2 needed 400 ms for its ordinary config + * write and up to two seconds for its slowest, so this starts at the V2's + * figure; if a V3 write reads back stale, this is the first number to raise. + */ +const WRITE_SETTLE_MS = 400; + +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 +109,9 @@ export class MchoseV3HidClient { */ private unresponsive = false; + /** Resolved once per session; every range check needs the model's limits. */ + private cachedProduct: MchoseV3Product | null = null; + constructor(device: HIDDevice) { this.device = device; } @@ -190,6 +230,273 @@ export class MchoseV3HidClient { return []; } + /** Sleep timeouts this driver offers, in seconds. 0 disables the timer. */ + getSleepOptions(): number[] { + return [...MCHOSE_V3_SLEEP_OPTIONS]; + } + + getDebounceMaxMs(): number { + return MCHOSE_V3_DEBOUNCE_MAX_MS; + } + + // ── Writes ───────────────────────────────────────────────────────────────── + // + // This protocol has no partial update: every command below replaces a whole + // block. So each setter reads the block, edits the decoded object, sends it + // back and reads again to confirm — never building a block from defaults, + // because the bytes this codec does not understand would be invented rather + // than preserved. A read that fails aborts the write rather than writing a + // guess over a working configuration. + + /** Send a write and give the firmware time to commit before reading back. */ + private async write(command: number, data: readonly number[]): Promise { + const body = mchoseV3Encode(command, data); + const send = async (): Promise => { + await this.device.sendReport(MCHOSE_V3_REPORT_ID, body); + await delay(WRITE_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + } + + /** The model, needed for every range check. Resolved the same way as status. */ + private async resolveProduct(): Promise { + if (this.cachedProduct) return this.cachedProduct; + const info = await this.readDeviceInfo(); + const product = mchoseV3FindProduct(info?.productId ?? null, this.device.productName); + if (!product) throw new Error("This MCHOSE model is not recognised, so its limits are unknown."); + this.cachedProduct = product; + return product; + } + + /** + * Read the settings block, apply `edit` to it, write it back and return what + * the mouse reports afterwards. + */ + private async updateSettings( + edit: (settings: MchoseV3Settings) => void, + ): Promise { + await this.open(); + this.unresponsive = false; + const before = await this.readSettings(); + if (!before) throw new Error("The mouse did not return its settings."); + + const next: MchoseV3Settings = { ...before, extra: [...before.extra] }; + edit(next); + mchoseV3CheckSettings(next); + + await this.write(MCHOSE_V3_COMMAND.writeSettings, mchoseV3EncodeSettings(next)); + + const after = await this.readSettings(); + if (!after) throw new Error("The mouse did not confirm the new settings."); + return after; + } + + async setPollingRate(hertz: number): Promise { + const product = await this.resolveProduct(); + const rates = mchoseV3PollingRates(product); + const index = rates.indexOf(hertz); + if (index < 0) throw new Error(`This mouse does not support ${hertz} Hz.`); + const wired = this.isWired(); + // Each link stores its own rate; only the one in use is touched. + const after = await this.updateSettings((settings) => { + if (wired) settings.wiredRateIndex = index; + else settings.wirelessRateIndex = index; + }); + const applied = wired ? after.wiredRateIndex : after.wirelessRateIndex; + if (applied !== index) throw new Error("The mouse did not accept the new polling rate."); + } + + async setSleepTimeout(seconds: number): Promise { + const minutes = Math.round(seconds / 60); + const after = await this.updateSettings((settings) => { + settings.sleep = minutes; + // Zero minutes is "never", and the firmware wants the mode byte to agree. + settings.sleepMode = minutes === 0 ? 1 : 0; + }); + if (after.sleep !== minutes) throw new Error("The mouse did not accept the new sleep timer."); + } + + /** + * Both primary buttons move together. The firmware keeps them separately and + * the shell offers one control, so writing only the left would leave the + * right on a value the user cannot see or change. + */ + async setDebounceTime(ms: number): Promise { + const after = await this.updateSettings((settings) => { + settings.leftDebounceMs = ms; + settings.rightDebounceMs = ms; + }); + if (after.leftDebounceMs !== ms) throw new Error("The mouse did not accept the new debounce time."); + } + + async setAngleTuning(degrees: number): Promise { + const after = await this.updateSettings((settings) => { settings.angleTuning = degrees; }); + if (after.angleTuning !== degrees) throw new Error("The mouse did not accept the new angle."); + } + + async setProfile(oneBased: number): Promise { + const index = oneBased - 1; + const after = await this.updateSettings((settings) => { settings.profileIndex = index; }); + if (after.profileIndex !== index) throw new Error("The mouse did not switch profile."); + } + + async setPowerMode(name: string): Promise { + const modeIndex = MCHOSE_V3_MODES.indexOf(name as (typeof MCHOSE_V3_MODES)[number]); + if (modeIndex < 0) throw new Error(`Unknown power mode "${name}".`); + const after = await this.setSensor({ modeIndex }); + if (mchoseV3DecodeSensor(after.sensor).modeIndex !== modeIndex) { + throw new Error("The mouse did not accept the new power mode."); + } + } + + private setSensor( + changes: Parameters[1], + ): Promise { + return this.updateSettings((settings) => { + settings.sensor = mchoseV3EncodeSensor(settings.sensor, changes); + }); + } + + private async setProcessing( + key: "motionSync" | "angleSnapping" | "rippleControl" | "glassMode", + enabled: boolean, + ): Promise { + const after = await this.setSensor({ [key]: enabled }); + if (mchoseV3DecodeSensor(after.sensor)[key] !== enabled) { + throw new Error("The mouse did not accept the new sensor setting."); + } + } + + setMotionSync(enabled: boolean): Promise { return this.setProcessing("motionSync", enabled); } + setAngleSnapping(enabled: boolean): Promise { return this.setProcessing("angleSnapping", enabled); } + setRippleControl(enabled: boolean): Promise { return this.setProcessing("rippleControl", enabled); } + setGlassMode(enabled: boolean): Promise { return this.setProcessing("glassMode", enabled); } + + /** + * Lift-off lives in two places depending on the model, and the write has to + * follow the read: the five-step ladders do not fit the sensor byte's two + * bits and answer `0x0109` instead. + */ + async setLiftOffDistance(label: string): Promise { + const product = await this.resolveProduct(); + const index = mchoseV3LiftOffLabels(product).indexOf(label); + if (index < 0) throw new Error(`This mouse has no ${label} lift-off step.`); + + if (!product.liftOffCommand) { + const after = await this.setSensor({ liftOffIndex: index }); + if (mchoseV3DecodeSensor(after.sensor).liftOffIndex !== index) { + throw new Error("The mouse did not accept the new lift-off distance."); + } + return; + } + + await this.open(); + this.unresponsive = false; + const settings = await this.readSettings(); + if (!settings) throw new Error("The mouse did not return its settings."); + await this.write( + MCHOSE_V3_COMMAND.writeLiftOff, + mchoseV3EncodeLiftOff(settings.profileIndex, index, product), + ); + const applied = await this.readLiftOffIndex(product, settings); + if (applied !== index) throw new Error("The mouse did not accept the new lift-off distance."); + } + + /** Read the DPI table for the profile in use, so a write can edit it. */ + private async readDpiTable(): Promise<{ dpi: MchoseV3Dpi; profileIndex: number }> { + await this.open(); + this.unresponsive = false; + const settings = await this.readSettings(); + if (!settings) throw new Error("The mouse did not return its settings."); + const payload = await this.request( + MCHOSE_V3_COMMAND.readDpi, [settings.profileIndex, DPI_AXIS_X], + ); + const dpi = payload ? mchoseV3DecodeDpi(payload) : null; + if (!dpi) throw new Error("The mouse did not return its DPI table."); + return { dpi, profileIndex: settings.profileIndex }; + } + + private async updateDpiTable(edit: (dpi: MchoseV3Dpi) => void): Promise { + const product = await this.resolveProduct(); + const { dpi } = await this.readDpiTable(); + const next: MchoseV3Dpi = { ...dpi, stages: [...dpi.stages] }; + edit(next); + await this.write(MCHOSE_V3_COMMAND.writeDpi, mchoseV3EncodeDpiTable(next, product)); + const after = (await this.readDpiTable()).dpi; + return after; + } + + /** Change the stage currently in use, which is what the DPI box edits. */ + async setDpi(dpi: number): Promise { + const product = await this.resolveProduct(); + const value = mchoseV3RoundDpi(dpi, product); + const after = await this.updateDpiTable((table) => { table.stages[table.activeStage] = value; }); + if (after.stages[after.activeStage] !== value) { + throw new Error("The mouse did not accept the new DPI."); + } + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + const product = await this.resolveProduct(); + const value = mchoseV3RoundDpi(dpi, product); + const after = await this.updateDpiTable((table) => { table.stages[stage] = value; }); + if (after.stages[stage] !== value) throw new Error("The mouse did not accept the new DPI stage."); + } + + async setActiveDpiStage(stage: number): Promise { + const after = await this.updateDpiTable((table) => { table.activeStage = stage; }); + if (after.activeStage !== stage) throw new Error("The mouse did not switch DPI stage."); + } + + async setDpiStageCount(count: number): Promise { + const after = await this.updateDpiTable((table) => { + table.stageCount = count; + // The active stage cannot point past the end of the shortened list. + if (table.activeStage >= count) table.activeStage = count - 1; + }); + if (after.stageCount !== count) throw new Error("The mouse did not accept the new stage count."); + } + + /** + * Reassign one button, leaving the other five exactly as they were read. + * + * The action vocabulary is deliberately small. The A7 V2's value tables were + * confirmed key by key on hardware; none of this generation's have been, so + * only the actions whose encoding is visible in a real capture are offered + * rather than guessing at keyboard and media values. + */ + async setButtonMapping(button: string, action: string): Promise { + await this.open(); + this.unresponsive = false; + const settings = await this.readSettings(); + if (!settings) throw new Error("The mouse did not return its settings."); + const payload = await this.request( + MCHOSE_V3_COMMAND.readButtons, [settings.profileIndex, 0, MCHOSE_V3_BUTTONS.length], + ); + const buttons = payload ? mchoseV3DecodeButtons(payload) : null; + if (!buttons) throw new Error("The mouse did not return its button table."); + if (!buttons[button]) throw new Error(`This mouse has no "${button}" button.`); + + const assignment = mchoseV3ButtonAction(button, action); + if (!assignment) throw new Error(`Unknown button action "${action}".`); + + const next = { ...buttons, [button]: assignment }; + await this.write( + MCHOSE_V3_COMMAND.writeButtons, + mchoseV3EncodeButtons(settings.profileIndex, next), + ); + + const confirmPayload = await this.request( + MCHOSE_V3_COMMAND.readButtons, [settings.profileIndex, 0, MCHOSE_V3_BUTTONS.length], + ); + const confirmed = confirmPayload ? mchoseV3DecodeButtons(confirmPayload) : null; + if (!confirmed || confirmed[button]?.type !== assignment.type) { + throw new Error("The mouse did not accept the new button assignment."); + } + } + async readStatus(): Promise { await this.open(); this.unresponsive = false; @@ -260,47 +567,41 @@ export class MchoseV3HidClient { powerModes: sensor ? [...MCHOSE_V3_MODES] : undefined, buttonMappings: buttons ? Object.fromEntries( - Object.entries(buttons).map(([name, action]) => [name, describeButton(action.type)]), + Object.entries(buttons).map(([name, action]) => [name, mchoseV3ButtonActionName(action)]), ) : undefined, + buttonOptions: buttons ? [...MCHOSE_V3_BUTTON_ACTIONS] : undefined, firmware, ui: { family: "mchose-v3", - // No setters exist yet, so the settings grid would be inert. - settingsReady: false, - // …but what is shown was genuinely read off the mouse. - valuesVerified: true, + settingsReady: Boolean(settings), + valuesVerified: Boolean(settings), defaultDisplayName: "MCHOSE", forceShowBattery: true, hideSignalCard: true, + hideUnsupportedPollingRates: true, + showAdvancedSection: true, + // Each link stores its own polling rate, so say which one is being set. + pollingNote: this.isWired() + ? "Applies to the wired connection." + : "Applies to the 2.4 GHz connection.", statusNote: settings ? [ liftOffHeight ? `Lift-off ${liftOffHeight}.` : "", - "Read-only: this driver can report settings but cannot change them yet.", + "Settings for this model are written to the same commands MCHOSE's own software uses, but have not been confirmed on hardware yet.", ].filter(Boolean).join(" ") - : "Read-only, and this mouse did not answer. Please report the model and how it is connected.", + : "This mouse did not answer. Please report the model and how it is connected.", + dpiStageEditor: product + ? { + maxStages: MCHOSE_V3_DPI_STAGES, + countEditable: true, + minDpi: MCHOSE_V3_DPI_MIN, + maxDpi: product.dpiMax, + stepDpi: MCHOSE_V3_DPI_STEP, + } + : undefined, }, }; } } -/** - * A human label for a button's action type. Only the type is named, not the - * value: the V2's value tables were confirmed key by key on hardware, and - * nothing here has been, so naming a specific key would be a guess presented - * as a fact. - */ -function describeButton(type: number): string { - switch (type) { - case 0x00: return "Default"; - case 0x01: return "Mouse button"; - case 0x02: return "Keyboard"; - case 0x03: return "Media"; - case 0x04: return "Macro"; - case 0x05: return "DPI"; - case 0x08: return "System"; - case 0x0a: return "Profile"; - case 0xff: return "Unassigned"; - default: return `Type ${type}`; - } -} diff --git a/src/mchose/v3.test.ts b/src/mchose/v3.test.ts index 9d8ba6a..adbbefa 100644 --- a/src/mchose/v3.test.ts +++ b/src/mchose/v3.test.ts @@ -2,7 +2,17 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MCHOSE_V3_BODY_LENGTH, + MCHOSE_V3_BUTTON_ACTIONS, MCHOSE_V3_BUTTON_UNSET, + MCHOSE_V3_SENSOR_MOTION_SYNC, + MCHOSE_V3_SENSOR_RIPPLE, + mchoseV3ButtonAction, + mchoseV3CheckSettings, + mchoseV3EncodeButtons, + mchoseV3EncodeDpiTable, + mchoseV3EncodeLiftOff, + mchoseV3EncodeSensor, + mchoseV3RoundDpi, MCHOSE_V3_COMMAND, MCHOSE_V3_MODES, MCHOSE_V3_PRODUCTS, @@ -150,13 +160,16 @@ test("encoding settings restores the wire's rate slots", () => { angleTuning: -15, leftDebounceMs: 8, rightDebounceMs: 4, + extra: [], }; const data = mchoseV3EncodeSettings(settings); assert.equal(data[1], 0x32, "wired slot 3 with stage 2"); assert.equal(data[2], 0x62, "wireless slot 6 with stage 2"); assert.equal(data[6], 0xf1, "the negative angle goes back out as two's complement"); assert.deepEqual( - mchoseV3DecodeSettings(new Uint8Array(data)), settings, + mchoseV3DecodeSettings(new Uint8Array(data)), + // A block built from nothing pads the tail, exactly as M HUB does. + { ...settings, extra: new Array(10).fill(0) }, "a decode of the encode is the settings that went in", ); }); @@ -412,3 +425,160 @@ test("the captured button table walks six stock assignments", () => { // ...and a DPI button the firmware marks unset rather than defaulted. assert.equal(buttons.DPI!.type, MCHOSE_V3_BUTTON_UNSET); }); + +// ── Writes ─────────────────────────────────────────────────────────────────── + +/** + * The whole reason the settings block carries an `extra` tail. A real A7 V3 + * Ultra+ returns ten bytes of 0x08 past the nine named fields — the same value + * as its two debounce fields, so almost certainly the other buttons' debounce. + * M HUB pads that region with zeros; doing the same would set them all to 0 on + * every unrelated write. + */ +test("a settings write puts back the tail the mouse reported", () => { + const settings = mchoseV3DecodeSettings(new Uint8Array(CAPTURE.settings))!; + assert.deepEqual(settings.extra, new Array(10).fill(0x08), "the mouse's own tail"); + + settings.sleep = 5; + const data = mchoseV3EncodeSettings(settings); + assert.deepEqual( + data.slice(9), new Array(10).fill(0x08), + "the tail goes back untouched, not zeroed", + ); + assert.equal(data[3], 5, "and the edited field did change"); +}); + +test("a settings block is range-checked before it can reach the wire", () => { + const base = mchoseV3DecodeSettings(new Uint8Array(CAPTURE.settings))!; + assert.doesNotThrow(() => mchoseV3CheckSettings(base)); + + const bad = (edit: (s: MchoseV3Settings) => void): (() => void) => () => { + const copy: MchoseV3Settings = { ...base, extra: [...base.extra] }; + edit(copy); + mchoseV3CheckSettings(copy); + }; + assert.throws(bad((s) => { s.leftDebounceMs = 21; }), RangeError, "debounce past 20 ms"); + assert.throws(bad((s) => { s.angleTuning = 31; }), RangeError, "angle past +30"); + assert.throws(bad((s) => { s.angleTuning = -31; }), RangeError, "angle past -30"); + assert.throws(bad((s) => { s.dpiIndex = 6; }), RangeError, "a seventh DPI stage"); + assert.throws(bad((s) => { s.sleep = 61; }), RangeError, "more than an hour of sleep"); +}); + +test("the sensor encoder changes one field and leaves the rest of the byte alone", () => { + // Bit 5 of the A7 V2's sensor byte was never explained; assigning rather than + // masking is how a codec quietly destroys a field it does not know about. + const before = 0b1010_0101; + const after = mchoseV3EncodeSensor(before, { motionSync: true }); + assert.equal(after & MCHOSE_V3_SENSOR_MOTION_SYNC, MCHOSE_V3_SENSOR_MOTION_SYNC); + assert.equal( + after & ~MCHOSE_V3_SENSOR_MOTION_SYNC & 0xff, + before & ~MCHOSE_V3_SENSOR_MOTION_SYNC & 0xff, + "every other bit survived", + ); + + assert.equal(mchoseV3EncodeSensor(0xff, { rippleControl: false }) & MCHOSE_V3_SENSOR_RIPPLE, 0); + assert.equal(mchoseV3DecodeSensor(mchoseV3EncodeSensor(0x00, { modeIndex: 2 })).modeIndex, 2); + assert.equal(mchoseV3DecodeSensor(mchoseV3EncodeSensor(0x00, { liftOffIndex: 3 })).liftOffIndex, 3); + assert.throws(() => mchoseV3EncodeSensor(0, { liftOffIndex: 4 }), RangeError, "two bits only"); + assert.throws(() => mchoseV3EncodeSensor(0, { modeIndex: 4 }), RangeError, "two bits here too"); +}); + +/** + * A decode fed straight back into the encoder must be a no-op, including for + * the mode's unnamed fourth value: MCHOSE labels three of the two bits' four + * states, and a mouse reporting the fourth must not be blocked from having any + * other setting written. + */ +test("every sensor round trip survives its own encoder", () => { + for (const sensor of [0x00, 0x41, 0x45, 0x80, 0xff, 0b1010_1010]) { + const decoded = mchoseV3DecodeSensor(sensor); + const reencoded = mchoseV3EncodeSensor(sensor, decoded); + assert.equal(reencoded, sensor, `0x${sensor.toString(16)} came back changed`); + } +}); + +test("DPI is rounded to a step the firmware stores, and refused out of range", () => { + const ultra = mchoseV3FindProduct(0x4033)!; + assert.equal(mchoseV3RoundDpi(1600, ultra), 1600); + assert.equal(mchoseV3RoundDpi(1620, ultra), 1600, "rounds to the nearest 50"); + assert.equal(mchoseV3RoundDpi(1630, ultra), 1650); + assert.equal(mchoseV3RoundDpi(50000, ultra), 50000); + assert.throws(() => mchoseV3RoundDpi(199, ultra), RangeError, "below the vendor's own floor"); + assert.throws(() => mchoseV3RoundDpi(50050, ultra), RangeError, "past this model's ceiling"); + + // A 26,000 DPI model must not be handed the Ultra+'s ceiling. + assert.throws(() => mchoseV3RoundDpi(42000, mchoseV3FindProduct(0x4030)!), RangeError); +}); + +test("the DPI table encodes back into the shape it was decoded from", () => { + const dpi = mchoseV3DecodeDpi(new Uint8Array(CAPTURE.dpi))!; + const ultra = mchoseV3FindProduct(0x4033)!; + const data = mchoseV3EncodeDpiTable(dpi, ultra); + + assert.equal(data[0], dpi.profileIndex); + assert.equal(data[1], dpi.axis); + assert.equal(data[2], 0, "hasSeparateY"); + assert.equal(data[3], dpi.stageCount); + assert.equal(data[4], dpi.activeStage); + // Stage values go back little-endian, matching the read. + assert.deepEqual([...data.slice(5, 7)], [0x90, 0x01]); + assert.deepEqual([...data.slice(15, 17)], [0x50, 0xc3]); +}); + +test("a DPI table with an impossible shape is refused", () => { + const dpi = mchoseV3DecodeDpi(new Uint8Array(CAPTURE.dpi))!; + const ultra = mchoseV3FindProduct(0x4033)!; + assert.throws( + () => mchoseV3EncodeDpiTable({ ...dpi, activeStage: dpi.stageCount }, ultra), + RangeError, + "an active stage past the end of the enabled list", + ); + assert.throws(() => mchoseV3EncodeDpiTable({ ...dpi, stageCount: 0 }, ultra), RangeError); + assert.throws( + () => mchoseV3EncodeDpiTable({ ...dpi, stages: dpi.stages.slice(0, 5) }, ultra), + RangeError, + "a short table would leave a stage holding whatever was there", + ); +}); + +test("lift-off writes are bounded by the model's own ladder", () => { + const ultra = mchoseV3FindProduct(0x4033)!; + assert.deepEqual(mchoseV3EncodeLiftOff(1, 4, ultra), [1, 4]); + assert.throws(() => mchoseV3EncodeLiftOff(0, 5, ultra), RangeError, "five steps, not six"); + // The three-step models must not be handed a five-step index. + assert.throws(() => mchoseV3EncodeLiftOff(0, 3, mchoseV3FindProduct(0x4031)!), RangeError); +}); + +test("the button table is written in the same variable-width shape it is read", () => { + const buttons = mchoseV3DecodeButtons(new Uint8Array(CAPTURE.buttons))!; + const data = mchoseV3EncodeButtons(0, buttons); + assert.deepEqual( + data.slice(3), [...CAPTURE.buttons], + "re-encoding an untouched table reproduces the capture byte for byte", + ); + assert.deepEqual(data.slice(0, 3), [0, 0, 6], "profile, reserved, button count"); +}); + +test("only actions with a captured encoding are offered", () => { + assert.deepEqual([...MCHOSE_V3_BUTTON_ACTIONS], ["Default", "Disabled"]); + assert.deepEqual(mchoseV3ButtonAction("Back", "Default"), { type: 0x00, value: [0x00, 0x08] }); + assert.deepEqual( + mchoseV3ButtonAction("DPI", "Disabled"), + { type: MCHOSE_V3_BUTTON_UNSET, value: [0xff, 0xff] }, + ); + // Nothing is invented for the actions whose values have never been seen. + assert.equal(mchoseV3ButtonAction("Left", "Keyboard"), null); + assert.equal(mchoseV3ButtonAction("Nonexistent", "Default"), null); +}); + +test("a button assignment whose value is the wrong width is refused", () => { + const buttons = mchoseV3DecodeButtons(new Uint8Array(CAPTURE.buttons))!; + assert.throws( + () => mchoseV3EncodeButtons(0, { ...buttons, Left: { type: 0x00, value: [1] } }), + RangeError, + "a short value would shift every entry after it", + ); + const withoutDpi = { ...buttons }; + delete withoutDpi.DPI; + assert.throws(() => mchoseV3EncodeButtons(0, withoutDpi), /missing "DPI"/); +}); diff --git a/src/mchose/v3.ts b/src/mchose/v3.ts index 1c09439..eb3e659 100644 --- a/src/mchose/v3.ts +++ b/src/mchose/v3.ts @@ -32,9 +32,12 @@ * * The checksum is an XOR of `body[1]` through `body[6 + length]`. * - * **Nothing in this file has been exercised on hardware.** It is a reading of - * the vendor bundle, and the driver that uses it is read-only for that reason; - * see docs/mchose-protocol.md. + * The **decoders** are confirmed against a real A7 V3 Ultra+; the framing that + * carries them is therefore confirmed too, since the mouse answered these + * frames. The **encoders below them have never been sent to a device.** Every + * write in this protocol replaces a whole block, so each one takes a structure + * a caller has just read back rather than a set of changes — see the Writes + * section and docs/mchose-protocol.md. */ /** Output report the whole V3 command set rides on; also the `'M'` magic. */ @@ -248,6 +251,18 @@ export interface MchoseV3Settings { angleTuning: number; leftDebounceMs: number; rightDebounceMs: number; + /** + * Everything past the nine fields above, kept verbatim so a write can put it + * back untouched. + * + * M HUB pads this with ten zero bytes, but a real A7 V3 Ultra+ returns ten + * bytes of `0x08` here — the same value sitting in both debounce fields, so + * these are most likely the remaining buttons' debounce. Writing the + * vendor's zeros would quietly set them all to 0. Nothing here knows what + * they are, which is exactly why they are carried through rather than + * invented. + */ + extra: number[]; } /** `0x0002` — the settings the V2 kept in its one big config blob. */ @@ -266,6 +281,7 @@ export function mchoseV3DecodeSettings(data: Uint8Array): MchoseV3Settings | nul angleTuning: (data[6]! << 24) >> 24, leftDebounceMs: data[7]!, rightDebounceMs: data[8]!, + extra: [...data.slice(9)], }; } @@ -290,12 +306,16 @@ export function mchoseV3EncodeSettings(settings: MchoseV3Settings): number[] { settings.angleTuning & 0xff, settings.leftDebounceMs & 0xff, settings.rightDebounceMs & 0xff, - // The vendor pads ten zero bytes past the block; the firmware may well - // read them, so they are not dropped. - ...new Array(10).fill(0), + // Whatever the mouse had here goes straight back. Ten zeros only when a + // caller built settings from nothing, which matches M HUB's own padding. + ...(settings.extra.length ? settings.extra.map((byte) => byte & 0xff) + : new Array(SETTINGS_EXTRA_BYTES).fill(0)), ]; } +/** Bytes past the nine named settings fields, as M HUB pads them. */ +const SETTINGS_EXTRA_BYTES = 10; + /** * Sensor byte layout — **note that it is not the V2's**. The V2 puts lift-off * in bits 0-1 and the performance mode in bits 6-7; this generation swaps @@ -561,3 +581,228 @@ export function mchoseV3LiftOffStop( if (index === steps - 1) return "High"; return "Medium"; } + +// ── Writes ─────────────────────────────────────────────────────────────────── +// +// Every write below replaces a whole block: this protocol has no partial +// update, so a caller must read, edit the decoded object and send it back. +// That is why each encoder takes a full structure rather than a set of changes. + +/** DPI bounds. The vendor's own slider starts at 200 and steps in 50s. */ +export const MCHOSE_V3_DPI_MIN = 200; +export const MCHOSE_V3_DPI_STEP = 50; + +/** The firmware takes 0-20 ms, the same window as the A7 V2's. */ +export const MCHOSE_V3_DEBOUNCE_MAX_MS = 20; + +/** Auto-sleep in minutes, where 0 means never. */ +export const MCHOSE_V3_SLEEP_MAX_MINUTES = 60; + +export const MCHOSE_V3_ANGLE_TUNING_MIN = -30; +export const MCHOSE_V3_ANGLE_TUNING_MAX = 30; + +function checkRange(name: string, value: number, min: number, max: number): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${name} must be an integer from ${min} to ${max}.`); + } +} + +/** + * Round a DPI to something the firmware stores exactly, and refuse anything + * outside the model's range rather than clamping — a silently clamped value + * reads back as a mismatch and looks like a protocol fault. + */ +export function mchoseV3RoundDpi(dpi: number, product: MchoseV3Product): number { + checkRange("DPI", Math.round(dpi), MCHOSE_V3_DPI_MIN, product.dpiMax); + const rounded = Math.round(dpi / MCHOSE_V3_DPI_STEP) * MCHOSE_V3_DPI_STEP; + return Math.min(Math.max(rounded, MCHOSE_V3_DPI_MIN), product.dpiMax); +} + +/** + * Validate a settings block before it goes near the wire. The block is written + * whole, so one bad field would be written alongside eight good ones. + */ +export function mchoseV3CheckSettings(settings: MchoseV3Settings): void { + checkRange("Profile", settings.profileIndex, 0, 15); + checkRange("DPI stage", settings.dpiIndex, 0, MCHOSE_V3_DPI_STAGES - 1); + checkRange("Wired polling", settings.wiredRateIndex, 0, 15); + checkRange("Wireless polling", settings.wirelessRateIndex, 0, 15); + checkRange("Sleep", settings.sleep, 0, MCHOSE_V3_SLEEP_MAX_MINUTES); + checkRange("Left debounce", settings.leftDebounceMs, 0, MCHOSE_V3_DEBOUNCE_MAX_MS); + checkRange("Right debounce", settings.rightDebounceMs, 0, MCHOSE_V3_DEBOUNCE_MAX_MS); + checkRange( + "Angle tuning", settings.angleTuning, + MCHOSE_V3_ANGLE_TUNING_MIN, MCHOSE_V3_ANGLE_TUNING_MAX, + ); + checkRange("Sensor flags", settings.sensor, 0, 0xff); +} + +/** + * Rewrite the sensor byte, changing only the named fields and **preserving + * every bit this codec does not claim**. The A7 V2 taught this the hard way: + * its bit 5 was never explained, and assigning the byte rather than masking it + * would have cleared whatever it meant. + */ +export function mchoseV3EncodeSensor( + current: number, + changes: Partial> & { liftOffIndex?: number }, +): number { + let sensor = current & 0xff; + const set = (mask: number, on: boolean): void => { + sensor = on ? (sensor | mask) : (sensor & ~mask & 0xff); + }; + if (changes.rippleControl !== undefined) set(MCHOSE_V3_SENSOR_RIPPLE, changes.rippleControl); + if (changes.angleSnapping !== undefined) set(MCHOSE_V3_SENSOR_LINEAR, changes.angleSnapping); + if (changes.motionSync !== undefined) set(MCHOSE_V3_SENSOR_MOTION_SYNC, changes.motionSync); + if (changes.glassMode !== undefined) set(MCHOSE_V3_SENSOR_GLASS, changes.glassMode); + if (changes.modeIndex !== undefined) { + // Bounded by the field, not by the label table: the mode is two bits and + // MCHOSE names only three of the four, so a faithful decode of an unknown + // 0b11 has to survive being written straight back. Picking a *new* mode + // goes through the name lookup in the driver, which cannot produce a 3. + checkRange("Mode", changes.modeIndex, 0, MCHOSE_V3_SENSOR_MODE_MASK); + sensor = (sensor & ~MCHOSE_V3_SENSOR_MODE_MASK & 0xff) | changes.modeIndex; + } + if (changes.liftOffIndex !== undefined) { + // Only the models that keep lift-off here, and only two bits of it. + checkRange("Lift-off", changes.liftOffIndex, 0, MCHOSE_V3_SENSOR_LOD_MASK >> MCHOSE_V3_SENSOR_LOD_SHIFT); + sensor = (sensor & ~MCHOSE_V3_SENSOR_LOD_MASK & 0xff) + | ((changes.liftOffIndex << MCHOSE_V3_SENSOR_LOD_SHIFT) & MCHOSE_V3_SENSOR_LOD_MASK); + } + return sensor; +} + +/** + * `0x0103` — the whole DPI table for one axis. Six stages go out every time, + * so the untouched ones must be the values just read back. + */ +export function mchoseV3EncodeDpiTable(dpi: MchoseV3Dpi, product: MchoseV3Product): number[] { + checkRange("Stage count", dpi.stageCount, 1, MCHOSE_V3_DPI_STAGES); + checkRange("Active stage", dpi.activeStage, 0, dpi.stageCount - 1); + if (dpi.stages.length !== MCHOSE_V3_DPI_STAGES) { + throw new RangeError(`The DPI table needs all ${MCHOSE_V3_DPI_STAGES} stages.`); + } + const data = [ + dpi.profileIndex & 0xff, + dpi.axis & 0xff, + dpi.hasSeparateY ? 1 : 0, + dpi.stageCount & 0xff, + dpi.activeStage & 0xff, + ]; + for (const stage of dpi.stages) { + const value = mchoseV3RoundDpi(stage, product); + data.push(value & 0xff, (value >> 8) & 0xff); + } + return data; +} + +/** `0x0104` — one stage, without rewriting the table around it. */ +export function mchoseV3EncodeSingleDpi( + profileIndex: number, + stage: number, + axis: number, + dpi: number, + product: MchoseV3Product, +): number[] { + checkRange("Stage", stage, 0, MCHOSE_V3_DPI_STAGES - 1); + const value = mchoseV3RoundDpi(dpi, product); + return [profileIndex & 0xff, stage & 0xff, axis & 0xff, value & 0xff, (value >> 8) & 0xff]; +} + +/** `0x0109` — lift-off on the models that keep it outside the sensor byte. */ +export function mchoseV3EncodeLiftOff( + profileIndex: number, + index: number, + product: MchoseV3Product, +): number[] { + checkRange("Lift-off", index, 0, product.liftOffDistances.length - 1); + return [profileIndex & 0xff, index & 0xff]; +} + +/** + * `0x0101` — the button table, written whole in the same variable-width shape + * it is read in. Every button goes out, so the ones not being changed must be + * the assignments just read back. + */ +export function mchoseV3EncodeButtons( + profileIndex: number, + buttons: Readonly>, +): number[] { + const data = [profileIndex & 0xff, 0, MCHOSE_V3_BUTTONS.length]; + for (const name of MCHOSE_V3_BUTTONS) { + const action = buttons[name]; + if (!action) throw new Error(`The button table is missing "${name}".`); + const width = BUTTON_VALUE_WIDTH[action.type] ?? 2; + if (action.value.length !== width) { + throw new RangeError( + `"${name}": type 0x${action.type.toString(16)} takes ${width} value bytes, not ${action.value.length}.`, + ); + } + data.push(action.type & 0xff); + // Decode reversed all but the two big-endian types, so encoding mirrors it. + const value = BUTTON_BIG_ENDIAN_TYPES.has(action.type) + ? [...action.value] + : [...action.value].reverse(); + for (const byte of value) data.push(byte & 0xff); + } + return data; +} + +/** + * Factory value for each button — the mask a stock mouse carries under type 0, + * read straight off a real A7 V3 Ultra+'s button table. + */ +const BUTTON_DEFAULT_VALUE: Readonly> = { + Left: [0x00, 0x01], + Right: [0x00, 0x02], + Middle: [0x00, 0x04], + Forward: [0x00, 0x10], + Back: [0x00, 0x08], + DPI: [0x00, 0x00], +}; + +/** + * The actions this driver will write. + * + * Deliberately two. The A7 V2's type and value tables were confirmed key by key + * on hardware; this generation's have not been, and a button is the one setting + * where a wrong guess can leave someone unable to click. Both of these appear + * verbatim in a real capture: five buttons on type 0 carrying their own mask, + * and an unassigned one on type `0xff`. + * + * Keyboard, media, DPI and macro actions all exist in the protocol and are + * listed in docs/mchose-protocol.md. They are not offered until someone with + * the hardware captures what M HUB writes for them. + */ +export const MCHOSE_V3_BUTTON_ACTIONS: readonly string[] = ["Default", "Disabled"]; + +export function mchoseV3ButtonAction( + button: string, + action: string, +): MchoseV3ButtonAssignment | null { + if (action === "Default") { + const value = BUTTON_DEFAULT_VALUE[button]; + return value ? { type: 0x00, value: [...value] } : null; + } + if (action === "Disabled") return { type: MCHOSE_V3_BUTTON_UNSET, value: [0xff, 0xff] }; + return null; +} + +/** Name the action an assignment represents, for display. */ +export function mchoseV3ButtonActionName(action: MchoseV3ButtonAssignment): string { + switch (action.type) { + case 0x00: return "Default"; + case 0x01: return "Mouse button"; + case 0x02: return "Keyboard"; + case 0x03: return "Media"; + case 0x04: return "Macro"; + case 0x05: return "DPI"; + case 0x08: return "System"; + case 0x0a: return "Profile"; + case MCHOSE_V3_BUTTON_UNSET: return "Disabled"; + default: return `Type ${action.type}`; + } +} + +/** Sleep timeouts the panel offers, in seconds; 0 is "never". */ +export const MCHOSE_V3_SLEEP_OPTIONS: readonly number[] = [0, 60, 120, 180, 300, 600, 1800]; From 4dd96051b05fe93411cf515b6f74b61f82ea0446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sat, 12 Sep 2026 19:43:49 +0200 Subject: [PATCH 3/4] mchose: take the A7 V3 button vocabulary from M HUB's own tables 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. --- docs/mchose-protocol.md | 44 ++++- src/drivers/mchose/v3-hid.test.ts | 6 +- src/drivers/mchose/v3-hid.ts | 20 +-- src/mchose/v3-buttons.ts | 289 ++++++++++++++++++++++++++++++ src/mchose/v3.test.ts | 69 ++++++- src/mchose/v3.ts | 64 +------ 6 files changed, 409 insertions(+), 83 deletions(-) create mode 100644 src/mchose/v3-buttons.ts diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 84fba9c..abbecac 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -685,9 +685,41 @@ suspects if something misbehaves: - whether a write needs a separate save or commit command at all. Nothing in the vendor bundle suggests one, but nothing rules it out either. -The button vocabulary is deliberately two entries wide — "Default" and -"Disabled" — because those are the only two encodings that appear in a real -capture. Keyboard, media, DPI, macro and profile actions all exist in the -protocol; they stay unavailable until someone records what M HUB writes for -them, since a button is the one setting where a wrong guess can leave someone -unable to click. +### The button vocabulary + +Taken from M HUB's own action tables, not guessed at. The vendor stores each +action as a hex string whose first byte is the type and whose rest is the +value: `"0x13042b"` is Alt+Tab, type `0x13`, modifier `0x04`, usage `0x2b`. + +**These type numbers are not the A7 V2's.** Nothing carries over. + +| Type | Value bytes | Order | Meaning | +| --- | --- | --- | --- | +| `0x00` | 2 | LE | mouse button — left `0001`, right `0002`, middle `0004`, forward `0010`, back `0008` | +| `0x01` | 3 | LE | DPI — switch `000000`, + `000002`, − `000003` | +| `0x05` | 2 | LE | wheel — up `0000`, down `0001` | +| `0x11` | 2 | LE | keyboard key, value is `00` + HID usage | +| `0x13` | 2 | **BE** | modifier + key, value is the modifier mask + usage | +| `0x14` | 2 | LE | consumer control: media keys and screen brightness | +| `0x16` | 2 | **BE** | system shortcut — copy `0106`, cut `011b`, paste `0119` | +| `0x22` | 3 | LE | present in the width table, no entries in the vendor's lists | +| `0x23` `0x24` | 7 | LE | likewise, and wide enough to be macro references | +| `0x33` | 2 | LE | onboard profile — 1/2/3 `0000`/`0001`/`0002`, cycle `00ff` | +| `0xfe` | 2 | LE | disabled, the vendor's "forbidden" | +| `0xff` | 2 | — | **no assignment at all.** The firmware reports it, M HUB never writes it | + +> **`0xfe` and `0xff` are not the same thing.** A disabled button is `0xfe`; +> `0xff` is a button carrying nothing, which is what a stock A7 V3 Ultra+ +> reports for its DPI button. Writing `0xff` would send a value the firmware +> itself never sends. + +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. 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 are still not writable: types `0x23`/`0x24` are wide enough to carry a +reference, but the vendor's tables list nothing under them and the paged +`0x090c` macro channel is not implemented. diff --git a/src/drivers/mchose/v3-hid.test.ts b/src/drivers/mchose/v3-hid.test.ts index 6bc6a59..cb342d7 100644 --- a/src/drivers/mchose/v3-hid.test.ts +++ b/src/drivers/mchose/v3-hid.test.ts @@ -447,7 +447,7 @@ describe("MCHOSE A7 V3 writes", () => { it("refuses an action whose encoding has never been captured", async () => { const { device, writes } = fakeMouse(); await assert.rejects( - new MchoseV3HidClient(device).setButtonMapping("Back", "Keyboard"), + new MchoseV3HidClient(device).setButtonMapping("Back", "Teleport"), /Unknown button action/, ); assert.equal(writes.has(MCHOSE_V3_COMMAND.writeButtons), false); @@ -457,7 +457,9 @@ describe("MCHOSE A7 V3 writes", () => { const { device } = fakeMouse(); const status = await new MchoseV3HidClient(device).readStatus(); assert.equal(status.ui!.settingsReady, true); - assert.deepEqual(status.buttonOptions, ["Default", "Disabled"]); + assert.ok(status.buttonOptions!.length > 100, "M HUB's whole vocabulary"); + assert.equal(status.buttonOptions![0], "Default"); + assert.ok(status.buttonOptions!.includes("Alt + Tab")); assert.equal(status.ui!.dpiStageEditor!.maxDpi, 50000, "this model's own ceiling"); assert.match(status.ui!.statusNote!, /not been confirmed on hardware/); }); diff --git a/src/drivers/mchose/v3-hid.ts b/src/drivers/mchose/v3-hid.ts index 8be0824..d4c1835 100644 --- a/src/drivers/mchose/v3-hid.ts +++ b/src/drivers/mchose/v3-hid.ts @@ -1,6 +1,5 @@ import { MCHOSE_V3_BUTTONS, - MCHOSE_V3_BUTTON_ACTIONS, MCHOSE_V3_COMMAND, MCHOSE_V3_DEBOUNCE_MAX_MS, MCHOSE_V3_DPI_MIN, @@ -19,6 +18,7 @@ import { mchoseV3DecodeSensor, mchoseV3DecodeSettings, mchoseV3ButtonAction, + mchoseV3ButtonActionLabels, mchoseV3ButtonActionName, mchoseV3CheckSettings, mchoseV3Encode, @@ -64,9 +64,9 @@ import { VENDOR_ID } from "../vendors.ts"; * carried through rather than zeroed; * - every setter verifies, and throws when the mouse reports something other * than what it was told; - * - the button vocabulary is two entries wide. The A7 V2's value tables were - * confirmed key by key; none of this generation's have been, and a button is - * the one setting where a wrong guess can leave someone unable to click. + * - the button vocabulary is M HUB's own, lifted from the vendor bundle's + * action tables rather than guessed at. See `src/mchose/v3-buttons.ts`; the + * type numbers are **not** the A7 V2's. * * {@link WRITE_SETTLE_MS} is the number most likely to be wrong: it is the A7 * V2's figure, and this generation's has never been measured. @@ -462,10 +462,10 @@ export class MchoseV3HidClient { /** * Reassign one button, leaving the other five exactly as they were read. * - * The action vocabulary is deliberately small. The A7 V2's value tables were - * confirmed key by key on hardware; none of this generation's have been, so - * only the actions whose encoding is visible in a real capture are offered - * rather than guessing at keyboard and media values. + * The vocabulary comes from M HUB's own action tables — see + * `src/mchose/v3-buttons.ts` — so nothing here is a guess at what a value + * means. What has not been verified is the firmware accepting the write, + * which is why the read-back below decides whether it worked. */ async setButtonMapping(button: string, action: string): Promise { await this.open(); @@ -567,10 +567,10 @@ export class MchoseV3HidClient { powerModes: sensor ? [...MCHOSE_V3_MODES] : undefined, buttonMappings: buttons ? Object.fromEntries( - Object.entries(buttons).map(([name, action]) => [name, mchoseV3ButtonActionName(action)]), + Object.entries(buttons).map(([name, action]) => [name, mchoseV3ButtonActionName(name, action)]), ) : undefined, - buttonOptions: buttons ? [...MCHOSE_V3_BUTTON_ACTIONS] : undefined, + buttonOptions: buttons ? mchoseV3ButtonActionLabels() : undefined, firmware, ui: { family: "mchose-v3", diff --git a/src/mchose/v3-buttons.ts b/src/mchose/v3-buttons.ts new file mode 100644 index 0000000..452512d --- /dev/null +++ b/src/mchose/v3-buttons.ts @@ -0,0 +1,289 @@ +/** + * What an A7 V3 button can be set to, read out of M HUB's own tables. + * + * The vendor stores each action as a hex string whose first byte is the type + * and whose remaining bytes are the value — `"0x13042b"` is Alt+Tab, type + * `0x13`, modifier `0x04`, usage `0x2b`. The rows below are those strings split + * in two, in the same orientation {@link mchoseV3DecodeButtons} produces, so a + * decoded assignment can be compared against this table directly. + * + * **Type and value only mean anything together.** `0x0002` is "right click" + * under type `0x00`, "DPI −" under type `0x01` and a media key under `0x14`. + * + * Note the type numbers are **not** the A7 V2's: this generation uses `0x00` + * for mouse buttons, `0x11`/`0x13` for the keyboard, `0x14` for media, `0x16` + * for system shortcuts, `0x33` for profiles and `0xfe` for a disabled button. + * The V2's 1/2/3/5/8/9/10 mean nothing here. + */ + +export interface MchoseV3ButtonAction { + label: string; + type: number; + /** Value bytes after the type, most significant first. */ + value: readonly number[]; + /** Display grouping, matching the A7 V2 driver's. */ + group: string; +} + +/** + * A button the firmware reports as carrying no assignment at all. M HUB never + * writes this — it writes {@link MCHOSE_V3_BUTTON_ACTIONS}'s "Disabled", + * which is type `0xfe` — but a stock mouse can report it, so it has to be + * nameable. A real A7 V3 Ultra+ shipped with its DPI button on `0xff`. + */ +export const MCHOSE_V3_BUTTON_UNSET = 0xff; + +/** + * Factory value for each button: the mask a stock mouse carries under type 0, + * read straight off a real A7 V3 Ultra+'s button table and matching the + * vendor's own "mouse.left"/"mouse.right"/… entries below. + */ +const BUTTON_DEFAULT_VALUE: Readonly> = { + Left: [0x00, 0x01], + Right: [0x00, 0x02], + Middle: [0x00, 0x04], + Forward: [0x00, 0x10], + Back: [0x00, 0x08], + DPI: [0x00, 0x00], +}; + +/** + * Every action M HUB offers, plus the letters, function keys and navigation + * keys the vendor's table leaves to its on-screen keyboard. Those are derived, + * not invented: the vendor's own shortcuts 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. + */ +export const MCHOSE_V3_BUTTON_ACTIONS: readonly MchoseV3ButtonAction[] = [ + { label: "Default", type: 0x00, value: [], group: "Basic" }, + { label: "Disabled", type: 0xfe, value: [0x00, 0x00], group: "Basic" }, + + { label: "Left click", type: 0x00, value: [0x00, 0x01], group: "Mouse" }, + { label: "Right click", type: 0x00, value: [0x00, 0x02], group: "Mouse" }, + { label: "Middle click", type: 0x00, value: [0x00, 0x04], group: "Mouse" }, + { label: "Forward", type: 0x00, value: [0x00, 0x10], group: "Mouse" }, + { label: "Back", type: 0x00, value: [0x00, 0x08], group: "Mouse" }, + { label: "Wheel up", type: 0x05, value: [0x00, 0x00], group: "Mouse" }, + { label: "Wheel down", type: 0x05, value: [0x00, 0x01], group: "Mouse" }, + + { label: "DPI +", type: 0x01, value: [0x00, 0x00, 0x02], group: "DPI" }, + { label: "DPI -", type: 0x01, value: [0x00, 0x00, 0x03], group: "DPI" }, + { label: "DPI switch", type: 0x01, value: [0x00, 0x00, 0x00], group: "DPI" }, + + { label: "Volume +", type: 0x14, value: [0x00, 0xe9], group: "Media" }, + { label: "Volume -", type: 0x14, value: [0x00, 0xea], group: "Media" }, + { label: "Mute", type: 0x14, value: [0x00, 0xe2], group: "Media" }, + { label: "Play / Pause", type: 0x14, value: [0x00, 0xcd], group: "Media" }, + { label: "Previous track", type: 0x14, value: [0x00, 0xb6], group: "Media" }, + { label: "Next track", type: 0x14, value: [0x00, 0xb5], group: "Media" }, + { label: "Stop", type: 0x14, value: [0x00, 0xb7], group: "Media" }, + + { label: "Screen brightness +", type: 0x14, value: [0x00, 0x6f], group: "System" }, + { label: "Screen brightness -", type: 0x14, value: [0x00, 0x70], group: "System" }, + { label: "Copy", type: 0x16, value: [0x01, 0x06], group: "System" }, + { label: "Cut", type: 0x16, value: [0x01, 0x1b], group: "System" }, + { label: "Paste", type: 0x16, value: [0x01, 0x19], group: "System" }, + + { label: "Switch to profile 1", type: 0x33, value: [0x00, 0x00], group: "Profile" }, + { label: "Switch to profile 2", type: 0x33, value: [0x00, 0x01], group: "Profile" }, + { label: "Switch to profile 3", type: 0x33, value: [0x00, 0x02], group: "Profile" }, + { label: "Cycle profiles", type: 0x33, value: [0x00, 0xff], group: "Profile" }, + + { label: "- _", type: 0x11, value: [0x00, 0x2d], group: "Keyboard" }, + { label: ", <", type: 0x11, value: [0x00, 0x36], group: "Keyboard" }, + { label: "; :", type: 0x11, value: [0x00, 0x33], group: "Keyboard" }, + { label: ". >", type: 0x11, value: [0x00, 0x37], group: "Keyboard" }, + { label: "' \"", type: 0x11, value: [0x00, 0x34], group: "Keyboard" }, + { label: "[ {", type: 0x11, value: [0x00, 0x2f], group: "Keyboard" }, + { label: "] }", type: 0x11, value: [0x00, 0x30], group: "Keyboard" }, + { label: "/ ?", type: 0x11, value: [0x00, 0x38], group: "Keyboard" }, + { label: "\\ |", type: 0x11, value: [0x00, 0x31], group: "Keyboard" }, + { label: "` ~", type: 0x11, value: [0x00, 0x35], group: "Keyboard" }, + { label: "= +", type: 0x11, value: [0x00, 0x2e], group: "Keyboard" }, + { label: "0 )", type: 0x11, value: [0x00, 0x27], group: "Keyboard" }, + { label: "1 !", type: 0x11, value: [0x00, 0x1e], group: "Keyboard" }, + { label: "2 @", type: 0x11, value: [0x00, 0x1f], group: "Keyboard" }, + { label: "3 #", type: 0x11, value: [0x00, 0x20], group: "Keyboard" }, + { label: "4 $", type: 0x11, value: [0x00, 0x21], group: "Keyboard" }, + { label: "5 %", type: 0x11, value: [0x00, 0x22], group: "Keyboard" }, + { label: "6 ^", type: 0x11, value: [0x00, 0x23], group: "Keyboard" }, + { label: "7 &", type: 0x11, value: [0x00, 0x24], group: "Keyboard" }, + { label: "8 *", type: 0x11, value: [0x00, 0x25], group: "Keyboard" }, + { label: "9 (", type: 0x11, value: [0x00, 0x26], group: "Keyboard" }, + { label: "Caps Lock", type: 0x11, value: [0x00, 0x39], group: "Keyboard" }, + { label: "Num -", type: 0x11, value: [0x00, 0x56], group: "Keyboard" }, + { label: "Num *", type: 0x11, value: [0x00, 0x55], group: "Keyboard" }, + { label: "Num /", type: 0x11, value: [0x00, 0x54], group: "Keyboard" }, + { label: "Num +", type: 0x11, value: [0x00, 0x57], group: "Keyboard" }, + { label: "Num 0", type: 0x11, value: [0x00, 0x62], group: "Keyboard" }, + { label: "Num 1", type: 0x11, value: [0x00, 0x59], group: "Keyboard" }, + { label: "Num 2", type: 0x11, value: [0x00, 0x5a], group: "Keyboard" }, + { label: "Num 3", type: 0x11, value: [0x00, 0x5b], group: "Keyboard" }, + { label: "Num 4", type: 0x11, value: [0x00, 0x5c], group: "Keyboard" }, + { label: "Num 5", type: 0x11, value: [0x00, 0x5d], group: "Keyboard" }, + { label: "Num 6", type: 0x11, value: [0x00, 0x5e], group: "Keyboard" }, + { label: "Num 7", type: 0x11, value: [0x00, 0x5f], group: "Keyboard" }, + { label: "Num 8", type: 0x11, value: [0x00, 0x60], group: "Keyboard" }, + { label: "Num 9", type: 0x11, value: [0x00, 0x61], group: "Keyboard" }, + { label: "Num Del", type: 0x11, value: [0x00, 0x63], group: "Keyboard" }, + { label: "Num Enter", type: 0x11, value: [0x00, 0x58], group: "Keyboard" }, + { label: "Num Lock", type: 0x11, value: [0x00, 0x53], group: "Keyboard" }, + { label: "Print Screen", type: 0x11, value: [0x00, 0x46], group: "Keyboard" }, + { label: "Page Down", type: 0x11, value: [0x00, 0x4e], group: "Keyboard" }, + { label: "Page Up", type: 0x11, value: [0x00, 0x4b], group: "Keyboard" }, + { label: "Left Windows", type: 0x11, value: [0x00, 0xe3], group: "Keyboard" }, + { label: "Right Windows", type: 0x11, value: [0x00, 0xe7], group: "Keyboard" }, + { label: "Left Alt", type: 0x11, value: [0x00, 0xe2], group: "Keyboard" }, + { label: "Right Alt", type: 0x11, value: [0x00, 0xe6], group: "Keyboard" }, + { label: "Left Ctrl", type: 0x11, value: [0x00, 0xe0], group: "Keyboard" }, + { label: "Right Ctrl", type: 0x11, value: [0x00, 0xe4], group: "Keyboard" }, + { label: "Left Shift", type: 0x11, value: [0x00, 0xe1], group: "Keyboard" }, + { label: "Right Shift", type: 0x11, value: [0x00, 0xe5], group: "Keyboard" }, + { label: "Scroll Lock", type: 0x11, value: [0x00, 0x47], group: "Keyboard" }, + { label: "Ctrl + -", type: 0x13, value: [0x01, 0x2d], group: "Keyboard" }, + { label: "Ctrl + Y", type: 0x13, value: [0x01, 0x1c], group: "Keyboard" }, + { label: "Ctrl + =", type: 0x13, value: [0x01, 0x2e], group: "Keyboard" }, + { label: "Ctrl + 0", type: 0x13, value: [0x01, 0x27], group: "Keyboard" }, + { label: "Ctrl + C", type: 0x13, value: [0x01, 0x06], group: "Keyboard" }, + { label: "Ctrl + A", type: 0x13, value: [0x01, 0x04], group: "Keyboard" }, + { label: "Ctrl + N", type: 0x13, value: [0x01, 0x11], group: "Keyboard" }, + { label: "Ctrl + X", type: 0x13, value: [0x01, 0x1b], group: "Keyboard" }, + { label: "Ctrl + S", type: 0x13, value: [0x01, 0x16], group: "Keyboard" }, + { label: "Ctrl + V", type: 0x13, value: [0x01, 0x19], group: "Keyboard" }, + { label: "Ctrl + Z", type: 0x13, value: [0x01, 0x1d], group: "Keyboard" }, + { label: "Ctrl + O", type: 0x13, value: [0x01, 0x12], group: "Keyboard" }, + { label: "Ctrl + T", type: 0x13, value: [0x01, 0x17], group: "Keyboard" }, + { label: "Ctrl + W", type: 0x13, value: [0x01, 0x1a], group: "Keyboard" }, + { label: "Alt + Left", type: 0x13, value: [0x04, 0x50], group: "Keyboard" }, + { label: "Alt + Right", type: 0x13, value: [0x04, 0x4f], group: "Keyboard" }, + { label: "Win + Tab", type: 0x13, value: [0x08, 0x2b], group: "Keyboard" }, + { label: "Win + D", type: 0x13, value: [0x08, 0x07], group: "Keyboard" }, + { label: "Win + Ctrl + Enter", type: 0x13, value: [0x09, 0x28], group: "Keyboard" }, + { label: "Win + T", type: 0x13, value: [0x08, 0x17], group: "Keyboard" }, + { label: "Win + A", type: 0x13, value: [0x08, 0x04], group: "Keyboard" }, + { label: "Win + E", type: 0x13, value: [0x08, 0x08], group: "Keyboard" }, + { label: "Win + I", type: 0x13, value: [0x08, 0x0c], group: "Keyboard" }, + { label: "Win + L", type: 0x13, value: [0x08, 0x0f], group: "Keyboard" }, + { label: "Win + K", type: 0x13, value: [0x08, 0x0e], group: "Keyboard" }, + { label: "Win + B", type: 0x13, value: [0x08, 0x05], group: "Keyboard" }, + { label: "Win + G", type: 0x13, value: [0x08, 0x0a], group: "Keyboard" }, + { label: "Win + R", type: 0x13, value: [0x08, 0x15], group: "Keyboard" }, + { label: "Win + S", type: 0x13, value: [0x08, 0x16], group: "Keyboard" }, + { label: "Win + U", type: 0x13, value: [0x08, 0x18], group: "Keyboard" }, + { label: "Win + X", type: 0x13, value: [0x08, 0x1b], group: "Keyboard" }, + { label: "Win + .", type: 0x13, value: [0x08, 0x37], group: "Keyboard" }, + { label: "Win + =", type: 0x13, value: [0x08, 0x2e], group: "Keyboard" }, + { label: "Ctrl + Shift + Esc", type: 0x13, value: [0x03, 0x29], group: "Keyboard" }, + { label: "Ctrl + Esc", type: 0x13, value: [0x01, 0x29], group: "Keyboard" }, + { label: "Alt + Tab", type: 0x13, value: [0x04, 0x2b], group: "Keyboard" }, + { label: "Alt + F4", type: 0x13, value: [0x04, 0x3d], group: "Keyboard" }, + { label: "Alt + Esc", type: 0x13, value: [0x04, 0x29], group: "Keyboard" }, + { label: "A", type: 0x11, value: [0x00, 0x04], group: "Keyboard" }, + { label: "B", type: 0x11, value: [0x00, 0x05], group: "Keyboard" }, + { label: "C", type: 0x11, value: [0x00, 0x06], group: "Keyboard" }, + { label: "D", type: 0x11, value: [0x00, 0x07], group: "Keyboard" }, + { label: "E", type: 0x11, value: [0x00, 0x08], group: "Keyboard" }, + { label: "F", type: 0x11, value: [0x00, 0x09], group: "Keyboard" }, + { label: "G", type: 0x11, value: [0x00, 0x0a], group: "Keyboard" }, + { label: "H", type: 0x11, value: [0x00, 0x0b], group: "Keyboard" }, + { label: "I", type: 0x11, value: [0x00, 0x0c], group: "Keyboard" }, + { label: "J", type: 0x11, value: [0x00, 0x0d], group: "Keyboard" }, + { label: "K", type: 0x11, value: [0x00, 0x0e], group: "Keyboard" }, + { label: "L", type: 0x11, value: [0x00, 0x0f], group: "Keyboard" }, + { label: "M", type: 0x11, value: [0x00, 0x10], group: "Keyboard" }, + { label: "N", type: 0x11, value: [0x00, 0x11], group: "Keyboard" }, + { label: "O", type: 0x11, value: [0x00, 0x12], group: "Keyboard" }, + { label: "P", type: 0x11, value: [0x00, 0x13], group: "Keyboard" }, + { label: "Q", type: 0x11, value: [0x00, 0x14], group: "Keyboard" }, + { label: "R", type: 0x11, value: [0x00, 0x15], group: "Keyboard" }, + { label: "S", type: 0x11, value: [0x00, 0x16], group: "Keyboard" }, + { label: "T", type: 0x11, value: [0x00, 0x17], group: "Keyboard" }, + { label: "U", type: 0x11, value: [0x00, 0x18], group: "Keyboard" }, + { label: "V", type: 0x11, value: [0x00, 0x19], group: "Keyboard" }, + { label: "W", type: 0x11, value: [0x00, 0x1a], group: "Keyboard" }, + { label: "X", type: 0x11, value: [0x00, 0x1b], group: "Keyboard" }, + { label: "Y", type: 0x11, value: [0x00, 0x1c], group: "Keyboard" }, + { label: "Z", type: 0x11, value: [0x00, 0x1d], group: "Keyboard" }, + { label: "F1", type: 0x11, value: [0x00, 0x3a], group: "Keyboard" }, + { label: "F2", type: 0x11, value: [0x00, 0x3b], group: "Keyboard" }, + { label: "F3", type: 0x11, value: [0x00, 0x3c], group: "Keyboard" }, + { label: "F4", type: 0x11, value: [0x00, 0x3d], group: "Keyboard" }, + { label: "F5", type: 0x11, value: [0x00, 0x3e], group: "Keyboard" }, + { label: "F6", type: 0x11, value: [0x00, 0x3f], group: "Keyboard" }, + { label: "F7", type: 0x11, value: [0x00, 0x40], group: "Keyboard" }, + { label: "F8", type: 0x11, value: [0x00, 0x41], group: "Keyboard" }, + { label: "F9", type: 0x11, value: [0x00, 0x42], group: "Keyboard" }, + { label: "F10", type: 0x11, value: [0x00, 0x43], group: "Keyboard" }, + { label: "F11", type: 0x11, value: [0x00, 0x44], group: "Keyboard" }, + { label: "F12", type: 0x11, value: [0x00, 0x45], group: "Keyboard" }, + { label: "Enter", type: 0x11, value: [0x00, 0x28], group: "Keyboard" }, + { label: "Esc", type: 0x11, value: [0x00, 0x29], group: "Keyboard" }, + { label: "Backspace", type: 0x11, value: [0x00, 0x2a], group: "Keyboard" }, + { label: "Tab", type: 0x11, value: [0x00, 0x2b], group: "Keyboard" }, + { label: "Space", type: 0x11, value: [0x00, 0x2c], group: "Keyboard" }, + { label: "Insert", type: 0x11, value: [0x00, 0x49], group: "Keyboard" }, + { label: "Home", type: 0x11, value: [0x00, 0x4a], group: "Keyboard" }, + { label: "Delete", type: 0x11, value: [0x00, 0x4c], group: "Keyboard" }, + { label: "End", type: 0x11, value: [0x00, 0x4d], group: "Keyboard" }, + { label: "Right", type: 0x11, value: [0x00, 0x4f], group: "Keyboard" }, + { label: "Left", type: 0x11, value: [0x00, 0x50], group: "Keyboard" }, + { label: "Down", type: 0x11, value: [0x00, 0x51], group: "Keyboard" }, + { label: "Up", type: 0x11, value: [0x00, 0x52], group: "Keyboard" }, + { label: "Menu", type: 0x11, value: [0x00, 0x65], group: "Keyboard" }, +]; + +/** + * Resolve an action for a specific button. + * + * "Default" is the one entry whose value depends on which button it is for: + * the firmware stores each button's own factory mask rather than a shared + * "restore" sentinel. + */ +export function mchoseV3ButtonAction( + button: string, + label: string, +): { type: number; value: number[] } | null { + if (label === "Default") { + const value = BUTTON_DEFAULT_VALUE[button]; + return value ? { type: 0x00, value: [...value] } : null; + } + const action = MCHOSE_V3_BUTTON_ACTIONS.find( + (entry) => entry.label === label && entry.label !== "Default", + ); + return action ? { type: action.type, value: [...action.value] } : null; +} + +/** + * Name a stored assignment. Type and value are matched together, because the + * same value means different things under different types. A button sitting on + * its own factory mask is reported as "Default" rather than as the click it + * happens to encode, since that is what the user changed it from. + */ +export function mchoseV3ButtonActionName( + button: string, + action: { type: number; value: readonly number[] }, +): string { + if (action.type === MCHOSE_V3_BUTTON_UNSET) return "Not set"; + + const factory = BUTTON_DEFAULT_VALUE[button]; + if (action.type === 0x00 && factory && sameValue(action.value, factory)) return "Default"; + + const match = MCHOSE_V3_BUTTON_ACTIONS.find( + (entry) => entry.type === action.type + && entry.label !== "Default" + && sameValue(entry.value, action.value), + ); + if (match) return match.label; + + const value = action.value.map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `Unknown (0x${action.type.toString(16).padStart(2, "0")}${value})`; +} + +function sameValue(a: readonly number[], b: readonly number[]): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +/** Labels for the picker, in display order. */ +export function mchoseV3ButtonActionLabels(): string[] { + return MCHOSE_V3_BUTTON_ACTIONS.map((action) => action.label); +} diff --git a/src/mchose/v3.test.ts b/src/mchose/v3.test.ts index adbbefa..47b48e0 100644 --- a/src/mchose/v3.test.ts +++ b/src/mchose/v3.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { MCHOSE_V3_BODY_LENGTH, MCHOSE_V3_BUTTON_ACTIONS, + mchoseV3ButtonActionName, MCHOSE_V3_BUTTON_UNSET, MCHOSE_V3_SENSOR_MOTION_SYNC, MCHOSE_V3_SENSOR_RIPPLE, @@ -559,18 +560,74 @@ test("the button table is written in the same variable-width shape it is read", assert.deepEqual(data.slice(0, 3), [0, 0, 6], "profile, reserved, button count"); }); -test("only actions with a captured encoding are offered", () => { - assert.deepEqual([...MCHOSE_V3_BUTTON_ACTIONS], ["Default", "Disabled"]); +/** + * The vocabulary is M HUB's own, so the spot checks below are the vendor's + * hex strings split into type and value: "0x000008" is the back button, + * "0x13042b" is Alt+Tab, "0xfe0000" is a disabled button. + */ +test("the action vocabulary is MCHOSE's own, not the A7 V2's", () => { + const find = (label: string) => MCHOSE_V3_BUTTON_ACTIONS.find((a) => a.label === label)!; + assert.deepEqual(mchoseV3ButtonAction("Back", "Default"), { type: 0x00, value: [0x00, 0x08] }); + assert.deepEqual(mchoseV3ButtonAction("Left", "Right click"), { type: 0x00, value: [0x00, 0x02] }); + assert.deepEqual(mchoseV3ButtonAction("Back", "Alt + Tab"), { type: 0x13, value: [0x04, 0x2b] }); + assert.deepEqual(mchoseV3ButtonAction("Back", "Play / Pause"), { type: 0x14, value: [0x00, 0xcd] }); + assert.deepEqual(mchoseV3ButtonAction("Back", "Copy"), { type: 0x16, value: [0x01, 0x06] }); + assert.deepEqual(mchoseV3ButtonAction("DPI", "DPI +"), { type: 0x01, value: [0x00, 0x00, 0x02] }); assert.deepEqual( - mchoseV3ButtonAction("DPI", "Disabled"), - { type: MCHOSE_V3_BUTTON_UNSET, value: [0xff, 0xff] }, + mchoseV3ButtonAction("Back", "Switch to profile 2"), { type: 0x33, value: [0x00, 0x01] }, ); - // Nothing is invented for the actions whose values have never been seen. - assert.equal(mchoseV3ButtonAction("Left", "Keyboard"), null); + + // "Disabled" is type 0xfe, the vendor's "forbidden". The 0xff a stock + // mouse reports is a button with no assignment at all, which M HUB never + // writes — mixing the two up would send a value the firmware never sends. + assert.deepEqual(mchoseV3ButtonAction("Back", "Disabled"), { type: 0xfe, value: [0x00, 0x00] }); + assert.notEqual(find("Disabled").type, MCHOSE_V3_BUTTON_UNSET); + + // The A7 V2's numbering means nothing here. + assert.equal(find("Play / Pause").type, 0x14, "not the V2's type 3"); + assert.equal(find("Switch to profile 1").type, 0x33, "not the V2's type 10"); + + assert.equal(mchoseV3ButtonAction("Left", "Teleport"), null); assert.equal(mchoseV3ButtonAction("Nonexistent", "Default"), null); }); +test("every action's value is the width its type declares", () => { + for (const action of MCHOSE_V3_BUTTON_ACTIONS) { + if (action.label === "Default") continue; + const expected = action.type === 0x01 || action.type === 0x22 ? 3 + : action.type === 0x23 || action.type === 0x24 ? 7 : 2; + assert.equal( + action.value.length, expected, + `${action.label}: type 0x${action.type.toString(16)} takes ${expected} value bytes`, + ); + } +}); + +test("no two actions share a type and value", () => { + const seen = new Map(); + for (const action of MCHOSE_V3_BUTTON_ACTIONS) { + if (action.label === "Default") continue; + const key = `${action.type}:${action.value.join(",")}`; + assert.equal(seen.get(key), undefined, `${action.label} collides with ${seen.get(key)}`); + seen.set(key, action.label); + } +}); + +test("a stored assignment is named back, and a stock button reads as Default", () => { + const buttons = mchoseV3DecodeButtons(new Uint8Array(CAPTURE.buttons))!; + assert.equal(mchoseV3ButtonActionName("Left", buttons.Left!), "Default"); + assert.equal(mchoseV3ButtonActionName("Back", buttons.Back!), "Default"); + // The captured DPI button carries no assignment at all. + assert.equal(mchoseV3ButtonActionName("DPI", buttons.DPI!), "Not set"); + + assert.equal(mchoseV3ButtonActionName("Back", { type: 0x13, value: [0x04, 0x2b] }), "Alt + Tab"); + assert.equal(mchoseV3ButtonActionName("Back", { type: 0xfe, value: [0x00, 0x00] }), "Disabled"); + // A value the tables do not cover is reported as unknown, not as a guess. + assert.match(mchoseV3ButtonActionName("Back", { type: 0x77, value: [0x12] }), /^Unknown/); +}); + + test("a button assignment whose value is the wrong width is refused", () => { const buttons = mchoseV3DecodeButtons(new Uint8Array(CAPTURE.buttons))!; assert.throws( diff --git a/src/mchose/v3.ts b/src/mchose/v3.ts index eb3e659..edf2067 100644 --- a/src/mchose/v3.ts +++ b/src/mchose/v3.ts @@ -394,11 +394,10 @@ export function mchoseV3DecodeLiftOff(data: Uint8Array): number | null { } /** - * Button action types. The same numbering as the V2's, except that the V3 - * marks an unassigned button `0xff` rather than leaving it at type 0, and its - * value width varies with the type. + * Button action types are **not** the A7 V2's numbering; the vocabulary and + * the width of each value live in `./v3-buttons.ts`, which this file + * re-exports at the bottom. */ -export const MCHOSE_V3_BUTTON_UNSET = 0xff; /** Value byte counts by type; anything unlisted carries two. */ const BUTTON_VALUE_WIDTH: Readonly> = { @@ -748,61 +747,8 @@ export function mchoseV3EncodeButtons( return data; } -/** - * Factory value for each button — the mask a stock mouse carries under type 0, - * read straight off a real A7 V3 Ultra+'s button table. - */ -const BUTTON_DEFAULT_VALUE: Readonly> = { - Left: [0x00, 0x01], - Right: [0x00, 0x02], - Middle: [0x00, 0x04], - Forward: [0x00, 0x10], - Back: [0x00, 0x08], - DPI: [0x00, 0x00], -}; - -/** - * The actions this driver will write. - * - * Deliberately two. The A7 V2's type and value tables were confirmed key by key - * on hardware; this generation's have not been, and a button is the one setting - * where a wrong guess can leave someone unable to click. Both of these appear - * verbatim in a real capture: five buttons on type 0 carrying their own mask, - * and an unassigned one on type `0xff`. - * - * Keyboard, media, DPI and macro actions all exist in the protocol and are - * listed in docs/mchose-protocol.md. They are not offered until someone with - * the hardware captures what M HUB writes for them. - */ -export const MCHOSE_V3_BUTTON_ACTIONS: readonly string[] = ["Default", "Disabled"]; - -export function mchoseV3ButtonAction( - button: string, - action: string, -): MchoseV3ButtonAssignment | null { - if (action === "Default") { - const value = BUTTON_DEFAULT_VALUE[button]; - return value ? { type: 0x00, value: [...value] } : null; - } - if (action === "Disabled") return { type: MCHOSE_V3_BUTTON_UNSET, value: [0xff, 0xff] }; - return null; -} - -/** Name the action an assignment represents, for display. */ -export function mchoseV3ButtonActionName(action: MchoseV3ButtonAssignment): string { - switch (action.type) { - case 0x00: return "Default"; - case 0x01: return "Mouse button"; - case 0x02: return "Keyboard"; - case 0x03: return "Media"; - case 0x04: return "Macro"; - case 0x05: return "DPI"; - case 0x08: return "System"; - case 0x0a: return "Profile"; - case MCHOSE_V3_BUTTON_UNSET: return "Disabled"; - default: return `Type ${action.type}`; - } -} /** Sleep timeouts the panel offers, in seconds; 0 is "never". */ export const MCHOSE_V3_SLEEP_OPTIONS: readonly number[] = [0, 60, 120, 180, 300, 600, 1800]; + +export * from "./v3-buttons.ts"; 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 4/4] 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; +}