diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 0e57e63..16109fb 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,152 @@ 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. + +### 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 + +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. + +### Replies that are not data + +Three shapes, all captured from a real A7 V3 Ultra+ on 2026-09-12. Reading any +of them as silence is enough to make a working mouse look dead. + +| Shape | Meaning | +| --- | --- | +| command `0x0000`, flags `0x00`, length 0, `0xff` in the sequence byte | **refusal.** The firmware will not serve that command. Retrying changes nothing | +| a payload that is the single byte `0xff` | **ask again.** The device is listening but cannot answer yet | +| nothing at all | genuinely not listening | + +`0x0901` is answered with a refusal on an A7 V3 Ultra+, request after request. +A receiver with no mouse reachable behind it answers `0x0900` with the one-byte +ask-again — M HUB's own read helper loops while the first payload byte is `0xff` +for exactly this reason. Distinguishing the three matters because only the last +one justifies abandoning the rest of a status read. + +> **Timings.** On a cable, `0x0900`, `0x0002`, `0x0003` and `0x0001` answer in +> **1–3 ms**. Every reply that is not immediate takes **almost exactly +> 1.001 s** — the refusals above, and `0x0900` over an idle receiver. That looks +> like a fixed deferral in the firmware rather than a variable delay, so a reply +> timeout only has to clear one second. An earlier 600 ms budget sat just +> underneath it, which meant those replies were always missed and then mistaken +> for the *next* attempt's answer. diff --git a/src/drivers/mchose/dock-hid.ts b/src/drivers/mchose/dock-hid.ts index f8e36f6..91b0e02 100644 --- a/src/drivers/mchose/dock-hid.ts +++ b/src/drivers/mchose/dock-hid.ts @@ -141,7 +141,9 @@ export class MchoseDockHidClient { settingsReady: false, defaultDisplayName: "MCHOSE MagDock", statusNote: lighting - ? "Charging base — lighting only. The A7 V2 mice have no LEDs of their own." + // Not just the V2's base: an A7 V3 owner's MagDock enumerates + // identically and reads correctly through this same driver. + ? "Charging base — lighting only. The A7 mice have no LEDs of their own." : "Charging base — the lighting state could not be read.", }, }; diff --git a/src/drivers/mchose/v3-hid.test.ts b/src/drivers/mchose/v3-hid.test.ts index 4e5d165..9385cf7 100644 --- a/src/drivers/mchose/v3-hid.test.ts +++ b/src/drivers/mchose/v3-hid.test.ts @@ -3,7 +3,13 @@ import assert from "node:assert/strict"; import { MchoseV3HidClient } from "./v3-hid.ts"; import { MchoseHidClient } from "./hid.ts"; import { MchoseDockHidClient } from "./dock-hid.ts"; -import { MCHOSE_V3_BODY_LENGTH, MCHOSE_V3_COMMAND } from "@openmouse/protocol/mchose"; +import { + MCHOSE_V3_BODY_LENGTH, + MCHOSE_V3_COMMAND, + mchoseV3IsBusy, + mchoseV3IsRejection, + mchoseV3Payload, +} from "@openmouse/protocol/mchose"; /** What an A7 V3 Ultra+ behind its receiver would answer, command by command. */ const ANSWERS: Readonly> = { @@ -53,11 +59,71 @@ 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; + + /** 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[]; +} + +/** 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(); const emit = (body: Uint8Array): void => { const event = { data: new DataView(body.buffer.slice(0)) }; @@ -84,8 +150,33 @@ 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 (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.reject?.includes(command)) { + // Command 0x0000, checksum flag clear, 0xff in the sequence byte. + const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH); + nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]); + queueMicrotask(() => { emit(nak); }); + return; + } + if (options.busy) { + queueMicrotask(() => { emit(frame(command, [0xff])); }); + return; + } if (options.silent?.includes(command)) return; - const answer = ANSWERS[command]; + const 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]; if (!answer) return; queueMicrotask(() => { // The mouse pushes movement and battery down the same pipe; a driver @@ -96,7 +187,7 @@ function fakeMouse(options: FakeOptions = {}) { }, } as unknown as HIDDevice; - return { device, sent }; + return { device, sent, sentData, writes }; } describe("MCHOSE A7 V3 driver", () => { @@ -134,7 +225,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"); @@ -163,20 +255,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!, /not been confirmed on hardware/); - // 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(); @@ -215,4 +293,277 @@ 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"); + }); +}); + +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", "Teleport"), + /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.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/); + }); +}); + +/** + * Three reply shapes a real A7 V3 Ultra+ sends that are not data, captured + * 2026-09-12. Mistaking any of them for silence is what made a live mouse look + * dead. + */ +describe("MCHOSE A7 V3 reply handling", () => { + it("recognises the refusal the firmware sends for an unsupported command", () => { + // Verbatim: what 0x0901 answers, about a second after every request. + const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH); + nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]); + assert.equal(mchoseV3IsRejection(nak), true); + // It carries command 0x0000, so it can never be mistaken for a payload. + assert.equal(mchoseV3Payload(nak, MCHOSE_V3_COMMAND.readVersion), null); + + // A real reply is not a refusal. + const real = frame(MCHOSE_V3_COMMAND.readLiftOff, [0x04]); + assert.equal(mchoseV3IsRejection(real), false); + }); + + it("recognises the one-byte ask-again a receiver sends with no mouse behind it", () => { + const payload = mchoseV3Payload( + frame(MCHOSE_V3_COMMAND.readDeviceInfo, [0xff]), MCHOSE_V3_COMMAND.readDeviceInfo, + )!; + assert.equal(mchoseV3IsBusy(payload), true); + + // Stricter than M HUB's own test, which looks at the first byte alone: a + // button table legitimately starts with 0xff when button one is unassigned. + const buttons = new Uint8Array([0xff, 0xff, 0xff, 0x00, 0x00, 0x02]); + assert.equal(mchoseV3IsBusy(buttons), false); + }); + + it("gives up on a refused command instead of spending the whole budget", async () => { + const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] }); + const status = await new MchoseV3HidClient(device).readStatus(); + + const versionAsks = sent.filter((c) => c === MCHOSE_V3_COMMAND.readVersion).length; + assert.equal(versionAsks, 1, "asked once, told no, moved on"); + // And a refusal on one command must not poison the rest of the read. + assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); + assert.equal(status.dpi, 800); + assert.deepEqual(status.firmware, []); + }); + + it("keeps reading the rest of the status after a command is refused", async () => { + const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] }); + await new MchoseV3HidClient(device).readStatus(); + // A device that says "no" is awake; the old code treated it as silence and + // abandoned every command after it. + for (const command of [ + MCHOSE_V3_COMMAND.readDeviceInfo, + MCHOSE_V3_COMMAND.readSettings, + MCHOSE_V3_COMMAND.readDpi, + MCHOSE_V3_COMMAND.readButtons, + ]) { + assert.ok(sent.includes(command), `0x${command.toString(16)} was still asked`); + } + }); + + it("stops asking a receiver whose mouse is not reachable, and says so", async () => { + // A real receiver reports the paired mouse's name, which is how the panel + // still names the model while the mouse itself is unreachable. + const { device, sent } = fakeMouse({ busy: true, productName: "MCHOSE A7 V3 Ultra+" }); + const status = await new MchoseV3HidClient(device).readStatus(); + + assert.equal( + sent.filter((c) => c === MCHOSE_V3_COMMAND.readDeviceInfo).length, 2, + "one retry, then stop — each ask costs a second on real hardware", + ); + assert.match(status.ui!.statusNote!, /not reachable/); + assert.doesNotMatch(status.ui!.statusNote!, /did not answer/); + // The model still comes from the product string, so the panel is not blank. + assert.equal(status.name, "MCHOSE A7 V3 Ultra+"); + }); + + it("still concludes nothing is listening when nothing ever arrives", async () => { + const { device } = fakeMouse({ silent: Object.values(MCHOSE_V3_COMMAND) }); + const status = await new MchoseV3HidClient(device).readStatus(); + assert.match(status.ui!.statusNote!, /did not answer/); + }); }); diff --git a/src/drivers/mchose/v3-hid.ts b/src/drivers/mchose/v3-hid.ts index b4cefda..a8688c8 100644 --- a/src/drivers/mchose/v3-hid.ts +++ b/src/drivers/mchose/v3-hid.ts @@ -1,5 +1,11 @@ import { + MCHOSE_V3_BUTTONS, 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 +17,27 @@ import { mchoseV3DecodeLiftOff, mchoseV3DecodeSensor, mchoseV3DecodeSettings, + mchoseV3ButtonAction, + mchoseV3ButtonActionLabels, + mchoseV3ButtonActionName, + mchoseV3CheckSettings, mchoseV3Encode, + mchoseV3EncodeButtons, + mchoseV3EncodeDpiTable, + mchoseV3EncodeLiftOff, + mchoseV3EncodeSensor, + mchoseV3EncodeSettings, mchoseV3FindProduct, + mchoseV3IsBusy, mchoseV3IsProductId, + mchoseV3IsRejection, mchoseV3LiftOffLabels, mchoseV3LiftOffStop, mchoseV3Payload, mchoseV3PollingRates, + mchoseV3RoundDpi, type MchoseV3DeviceInfo, + type MchoseV3Dpi, type MchoseV3Product, type MchoseV3Settings, } from "@openmouse/protocol/mchose"; @@ -26,27 +45,85 @@ 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. * * 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 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: * - * 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. + * - 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 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. */ -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; + +/** 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); @@ -64,6 +141,16 @@ export class MchoseV3HidClient { */ private unresponsive = false; + /** Resolved once per session; every range check needs the model's limits. */ + private cachedProduct: MchoseV3Product | null = null; + + /** + * Set when the device answered "ask again" rather than with data. It means + * the receiver is plugged in but the mouse behind it is not reachable, which + * is a different thing to tell the user than "nothing answered". + */ + private linkBusy = false; + constructor(device: HIDDevice) { this.device = device; } @@ -112,25 +199,31 @@ export class MchoseV3HidClient { const run = async (): Promise => { if (this.unresponsive) return null; const body = mchoseV3Encode(command, data); + let busy = 0; + let heardAnything = false; + for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) { - const reply = await new Promise((resolve) => { - const finish = (value: Uint8Array | null): void => { - clearTimeout(timer); - this.device.removeEventListener("inputreport", listener); - resolve(value); - }; - const listener = (event: Event): void => { - const report = event as HIDInputReportEvent; - const payload = mchoseV3Payload(new Uint8Array(report.data.buffer), command); - if (payload) finish(payload); - }; - const timer = setTimeout(() => { finish(null); }, REPLY_TIMEOUT_MS); - this.device.addEventListener("inputreport", listener); - this.device.sendReport(MCHOSE_V3_REPORT_ID, body).catch(() => { finish(null); }); - }); - if (reply) return reply; + const reply = await this.attempt(command, body); + if (reply.kind === "payload") return reply.payload; + if (reply.kind !== "timeout") heardAnything = true; + + // A refusal is final: the firmware answered, and it said no. + if (reply.kind === "rejected") return null; + if (reply.kind === "busy") { + busy += 1; + this.linkBusy = true; + if (busy >= BUSY_ATTEMPTS) return null; + await delay(BUSY_RETRY_MS); + // A busy reply does not count against the timeout budget; the device + // is plainly listening, it just has nothing to say yet. + attempt -= 1; + } } - this.unresponsive = true; + + // Only conclude nothing is listening when nothing ever arrived. A device + // that answered "busy" or "no" is awake, and shutting the rest of the + // status read down would throw away commands it would have answered. + if (!heardAnything) this.unresponsive = true; return null; }; const next = this.queue.then(run, run); @@ -138,6 +231,31 @@ export class MchoseV3HidClient { return next; } + /** One send-and-wait, classifying whatever comes back. */ + private attempt(command: number, body: Uint8Array): Promise { + return new Promise((resolve) => { + const finish = (reply: Reply): void => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + resolve(reply); + }; + const listener = (event: Event): void => { + const frame = new Uint8Array((event as HIDInputReportEvent).data.buffer); + const payload = mchoseV3Payload(frame, command); + if (payload) { + finish(mchoseV3IsBusy(payload) ? { kind: "busy" } : { kind: "payload", payload }); + return; + } + // A refusal carries command 0x0000, so it never matches the id above. + if (mchoseV3IsRejection(frame)) finish({ kind: "rejected" }); + }; + const timer = setTimeout(() => { finish({ kind: "timeout" }); }, REPLY_TIMEOUT_MS); + this.device.addEventListener("inputreport", listener); + this.device.sendReport(MCHOSE_V3_REPORT_ID, body) + .catch(() => { finish({ kind: "timeout" }); }); + }); + } + private async readDeviceInfo(): Promise { const payload = await this.request(MCHOSE_V3_COMMAND.readDeviceInfo); return payload ? mchoseV3DecodeDeviceInfo(payload) : null; @@ -148,8 +266,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")}`; @@ -177,9 +300,277 @@ 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 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(); + 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; + 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 @@ -247,47 +638,43 @@ 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(name, action)]), ) : undefined, + buttonOptions: buttons ? mchoseV3ButtonActionLabels() : 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 model's protocol is implemented from vendor software and has not been confirmed on hardware.", + "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.linkBusy + ? "Receiver connected, but the mouse is not reachable. Wake it or check it is switched on." + : "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-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 1f1cc3b..47b48e0 100644 --- a/src/mchose/v3.test.ts +++ b/src/mchose/v3.test.ts @@ -2,6 +2,18 @@ import assert from "node:assert/strict"; 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, + mchoseV3ButtonAction, + mchoseV3CheckSettings, + mchoseV3EncodeButtons, + mchoseV3EncodeDpiTable, + mchoseV3EncodeLiftOff, + mchoseV3EncodeSensor, + mchoseV3RoundDpi, MCHOSE_V3_COMMAND, MCHOSE_V3_MODES, MCHOSE_V3_PRODUCTS, @@ -149,13 +161,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", ); }); @@ -306,3 +321,321 @@ 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); +}); + +// ── 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"); +}); + +/** + * 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("Back", "Switch to profile 2"), { type: 0x33, value: [0x00, 0x01] }, + ); + + // "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( + () => 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 ad1c362..93ea400 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. */ @@ -180,7 +183,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; @@ -244,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. */ @@ -262,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)], }; } @@ -286,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 @@ -370,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> = { @@ -497,21 +520,35 @@ export const MCHOSE_V3_POLLING_RATES: Readonly }; /** - * Resolve a model from the id `0x0900` reports, falling back to the product - * string. An unrecognised device yields null rather than a wrong DPI ceiling. + * Resolve a model, **preferring the USB product string over the id `0x0900` + * reports**. An unrecognised device yields null rather than a wrong DPI ceiling. + * + * The id ordering is the opposite of the A7 V2's, and deliberately so. On the + * V2, the id inside the battery reply is decisive because the host-facing id is + * shared. Here that reasoning does not hold: a capture from a real **A7 V3 + * Ultra+** has `0x0900` reporting `0x4026`, which this table — and MCHOSE's own + * — lists against the *A5 V3 Ultra+*. Trusting it named the wrong mouse and, + * through it, handed out a 42,000 DPI ceiling and a three-step lift-off ladder + * to a 50,000 DPI five-step model. + * + * Whatever `0x0900` byte 2 is — a sensor or platform id, shared across shells — + * it is not a model id. M HUB agrees: every model lookup in the vendor bundle + * keys off `navigator.device.productName`, never off this field. The id is kept + * only as a fallback for a device whose product string says nothing useful. */ export function mchoseV3FindProduct( mouseProductId: number | null, productName?: string | null, ): MchoseV3Product | null { - const byId = MCHOSE_V3_PRODUCTS.find((product) => product.productId === mouseProductId); - if (byId) return byId; const name = productName?.trim().toUpperCase() ?? ""; - if (!name) return null; - // Longest name first so "A7 V3 Pro+" is not swallowed by "A7 V3 Pro". - return [...MCHOSE_V3_PRODUCTS] - .sort((a, b) => b.name.length - a.name.length) - .find((product) => name.includes(product.name.toUpperCase())) ?? null; + if (name) { + // Longest name first so "A7 V3 Pro+" is not swallowed by "A7 V3 Pro". + const byName = [...MCHOSE_V3_PRODUCTS] + .sort((a, b) => b.name.length - a.name.length) + .find((product) => name.includes(product.name.toUpperCase())); + if (byName) return byName; + } + return MCHOSE_V3_PRODUCTS.find((product) => product.productId === mouseProductId) ?? null; } /** Polling rates available to a model. */ @@ -543,3 +580,203 @@ 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; +} + + +/** 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"; + +/** + * 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; +}