Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions docs/mchose-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand Down Expand Up @@ -595,3 +599,74 @@ The reads are the whole driver today. To turn it into a full one:
Do not skip step 3. The V2's stale-reply buffer meant a read taken too early
returned a *different command's* payload, and one of those nearly went back out
as a config write.

### What the hardware said

An **A7 V3 Ultra+** behind its receiver (host PID `0x1018`), from an OpenMouse
diagnostic export dated 2026-09-12. Data blocks only; the framing is stripped.

```
OUT 0x0900 -> 37 38 26 40 04 00 00 00 00 10 02 01 55 00 08 e4
OUT 0x0002 -> 00 41 41 03 00 41 00 08 08 08 08 …
OUT 0x0003 [00] -> 00 00 06 01 00 90 01 20 03 40 06 80 0c 00 19 50 c3
OUT 0x0001 [00 00 06] -> 00 01 00 00 02 00 00 04 00 00 10 00 00 08 00 ff ff ff
OUT 0x0901 -> (empty)
```

Everything decoded correctly: battery 85 % and charging, four profiles, DPI
stages 400/800/1600/3200/6400/**50000** with the second active, 2000 Hz, a
three-minute sleep timer, 8 ms on both debounce bytes, sensor `0x41` (eSports,
every processing toggle off), and six stock button assignments.

Two things the capture corrected.

> **`0x0900`'s product id is not a model id.** This mouse's USB product string
> is `MCHOSE A7 V3 Ultra+`, and it reports `0x4026` — the id MCHOSE's own table
> gives the *A5 V3 Ultra+*. Believing it named the wrong mouse and, through it,
> handed out a 42,000 DPI ceiling and a three-step lift-off ladder to a 50,000
> DPI five-step model. `mchoseV3FindProduct` now prefers the product string and
> keeps the id only as a fallback. M HUB agrees: every model lookup in the
> vendor bundle keys off `navigator.device.productName`, never off this field.
>
> This is the opposite of the A7 V2's rule, where the id inside the battery
> reply *is* decisive. Do not carry one habit across to the other generation.

> **`0x0901` needs a target byte** — 0 for the mouse, 1 for the receiver. Sent
> bare it answers with an empty data block rather than an error, which is why
> the first capture shows no firmware version at all.

Also worth recording: the `0xff01` collection on this receiver declares `0x4d`
as an **input, output *and* feature** report. The driver uses output plus input
and that works; the feature path is untried.

The lift-off command `0x0009` still has not been exercised. The capture was
taken while the driver believed it was talking to a three-step model, so it
read lift-off from the sensor byte and never sent `0x0009`. With the model
resolved correctly the Ultra+ now takes that branch, and a device that does not
answer it degrades to a blank lift-off rather than a wrong one.

### Replies that are not data

Three shapes, all captured from a real A7 V3 Ultra+ on 2026-09-12. Reading any
of them as silence is enough to make a working mouse look dead.

| Shape | Meaning |
| --- | --- |
| command `0x0000`, flags `0x00`, length 0, `0xff` in the sequence byte | **refusal.** The firmware will not serve that command. Retrying changes nothing |
| a payload that is the single byte `0xff` | **ask again.** The device is listening but cannot answer yet |
| nothing at all | genuinely not listening |

`0x0901` is answered with a refusal on an A7 V3 Ultra+, request after request.
A receiver with no mouse reachable behind it answers `0x0900` with the one-byte
ask-again — M HUB's own read helper loops while the first payload byte is `0xff`
for exactly this reason. Distinguishing the three matters because only the last
one justifies abandoning the rest of a status read.

> **Timings.** On a cable, `0x0900`, `0x0002`, `0x0003` and `0x0001` answer in
> **1–3 ms**. Every reply that is not immediate takes **almost exactly
> 1.001 s** — the refusals above, and `0x0900` over an idle receiver. That looks
> like a fixed deferral in the firmware rather than a variable delay, so a reply
> timeout only has to clear one second. An earlier 600 ms budget sat just
> underneath it, which meant those replies were always missed and then mistaken
> for the *next* attempt's answer.

4 changes: 3 additions & 1 deletion src/drivers/mchose/dock-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
};
Expand Down
148 changes: 143 additions & 5 deletions src/drivers/mchose/v3-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<number, number[]>> = {
Expand Down Expand Up @@ -53,11 +59,19 @@ interface FakeOptions {
silent?: number[];
/** Emit an unrelated input report before every real answer. */
noisy?: boolean;
/** Answer these commands with the firmware's refusal frame. */
reject?: number[];
/** Answer everything with the one-byte ask-again a stranded receiver sends. */
busy?: boolean;
/** Override the `0x0900` reply, to replay a real capture. */
deviceInfo?: number[];
}

function fakeMouse(options: FakeOptions = {}) {
const listeners: Array<(event: unknown) => void> = [];
const sent: number[] = [];
/** The data block sent with each command, so arguments can be asserted. */
const sentData = new Map<number, number[]>();

const emit = (body: Uint8Array): void => {
const event = { data: new DataView(body.buffer.slice(0)) };
Expand All @@ -84,8 +98,22 @@ function fakeMouse(options: FakeOptions = {}) {
const body = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
const command = body[3]! | (body[4]! << 8);
sent.push(command);
sentData.set(command, [...body.subarray(7, 7 + body[2]!)]);
if (options.reject?.includes(command)) {
// Command 0x0000, checksum flag clear, 0xff in the sequence byte.
const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH);
nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]);
queueMicrotask(() => { emit(nak); });
return;
}
if (options.busy) {
queueMicrotask(() => { emit(frame(command, [0xff])); });
return;
}
if (options.silent?.includes(command)) return;
const answer = ANSWERS[command];
const answer = command === MCHOSE_V3_COMMAND.readDeviceInfo && options.deviceInfo
? options.deviceInfo
: ANSWERS[command];
if (!answer) return;
queueMicrotask(() => {
// The mouse pushes movement and battery down the same pipe; a driver
Expand All @@ -96,7 +124,7 @@ function fakeMouse(options: FakeOptions = {}) {
},
} as unknown as HIDDevice;

return { device, sent };
return { device, sent, sentData };
}

describe("MCHOSE A7 V3 driver", () => {
Expand Down Expand Up @@ -134,7 +162,8 @@ describe("MCHOSE A7 V3 driver", () => {
const status = await new MchoseV3HidClient(device).readStatus();

assert.equal(status.brand, "MCHOSE");
// Resolved from the id inside the device-info reply, not the receiver's.
// This fake's product string names no model, so the id in the device-info
// reply is the fallback that resolves it.
assert.equal(status.name, "MCHOSE A7 V3 Ultra+");
assert.equal(status.batteryPercent, 87);
assert.equal(status.batteryState, "Discharging");
Expand Down Expand Up @@ -170,7 +199,7 @@ describe("MCHOSE A7 V3 driver", () => {

assert.equal(status.ui!.settingsReady, false, "nothing here can be written yet");
assert.equal(status.ui!.valuesVerified, true, "but what is shown was read off the mouse");
assert.match(status.ui!.statusNote!, /not been confirmed on hardware/);
assert.match(status.ui!.statusNote!, /cannot change them yet/);
// The read-only promise is part of the contract, not just the prose.
assert.equal("setDpi" in client, false);
assert.equal("setPollingRate" in client, false);
Expand Down Expand Up @@ -215,4 +244,113 @@ describe("MCHOSE A7 V3 driver", () => {
assert.equal(status.activeProfile, null, "but nothing behind the link answered");
assert.equal(status.dpi, 0);
});

/**
* Replays the real A7 V3 Ultra+ capture: its 0x0900 reply carries 0x4026,
* the id MCHOSE lists for the A5 V3 Ultra+. Before the product string won,
* this mouse was named A5 V3 Ultra+ and inherited a 42,000 DPI ceiling and a
* three-step lift-off ladder it does not have.
*/
it("names the mouse from its product string, not its reported id", async () => {
const { device } = fakeMouse({
productName: "MCHOSE A7 V3 Ultra+",
deviceInfo: [
0x37, 0x38, 0x26, 0x40, 0x04, 0x00, 0x00, 0x00,
0x00, 0x10, 0x02, 0x01, 0x55, 0x00, 0x08, 0xe4,
],
});
const status = await new MchoseV3HidClient(device).readStatus();
assert.equal(status.name, "MCHOSE A7 V3 Ultra+");
assert.equal(status.batteryPercent, 85);
assert.equal(status.batteryState, "Charging");
assert.equal(status.profileCount, 4);
});

it("asks 0x0901 which side it wants the version from", async () => {
// Sent bare, the mouse answers with an empty block and no firmware at all.
const { device, sentData } = fakeMouse();
await new MchoseV3HidClient(device).readStatus();
assert.deepEqual(sentData.get(MCHOSE_V3_COMMAND.readVersion), [0], "the mouse, not the receiver");
});
});

/**
* Three reply shapes a real A7 V3 Ultra+ sends that are not data, captured
* 2026-09-12. Mistaking any of them for silence is what made a live mouse look
* dead.
*/
describe("MCHOSE A7 V3 reply handling", () => {
it("recognises the refusal the firmware sends for an unsupported command", () => {
// Verbatim: what 0x0901 answers, about a second after every request.
const nak = new Uint8Array(MCHOSE_V3_BODY_LENGTH);
nak.set([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00]);
assert.equal(mchoseV3IsRejection(nak), true);
// It carries command 0x0000, so it can never be mistaken for a payload.
assert.equal(mchoseV3Payload(nak, MCHOSE_V3_COMMAND.readVersion), null);

// A real reply is not a refusal.
const real = frame(MCHOSE_V3_COMMAND.readLiftOff, [0x04]);
assert.equal(mchoseV3IsRejection(real), false);
});

it("recognises the one-byte ask-again a receiver sends with no mouse behind it", () => {
const payload = mchoseV3Payload(
frame(MCHOSE_V3_COMMAND.readDeviceInfo, [0xff]), MCHOSE_V3_COMMAND.readDeviceInfo,
)!;
assert.equal(mchoseV3IsBusy(payload), true);

// Stricter than M HUB's own test, which looks at the first byte alone: a
// button table legitimately starts with 0xff when button one is unassigned.
const buttons = new Uint8Array([0xff, 0xff, 0xff, 0x00, 0x00, 0x02]);
assert.equal(mchoseV3IsBusy(buttons), false);
});

it("gives up on a refused command instead of spending the whole budget", async () => {
const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] });
const status = await new MchoseV3HidClient(device).readStatus();

const versionAsks = sent.filter((c) => c === MCHOSE_V3_COMMAND.readVersion).length;
assert.equal(versionAsks, 1, "asked once, told no, moved on");
// And a refusal on one command must not poison the rest of the read.
assert.equal(status.name, "MCHOSE A7 V3 Ultra+");
assert.equal(status.dpi, 800);
assert.deepEqual(status.firmware, []);
});

it("keeps reading the rest of the status after a command is refused", async () => {
const { device, sent } = fakeMouse({ reject: [MCHOSE_V3_COMMAND.readVersion] });
await new MchoseV3HidClient(device).readStatus();
// A device that says "no" is awake; the old code treated it as silence and
// abandoned every command after it.
for (const command of [
MCHOSE_V3_COMMAND.readDeviceInfo,
MCHOSE_V3_COMMAND.readSettings,
MCHOSE_V3_COMMAND.readDpi,
MCHOSE_V3_COMMAND.readButtons,
]) {
assert.ok(sent.includes(command), `0x${command.toString(16)} was still asked`);
}
});

it("stops asking a receiver whose mouse is not reachable, and says so", async () => {
// A real receiver reports the paired mouse's name, which is how the panel
// still names the model while the mouse itself is unreachable.
const { device, sent } = fakeMouse({ busy: true, productName: "MCHOSE A7 V3 Ultra+" });
const status = await new MchoseV3HidClient(device).readStatus();

assert.equal(
sent.filter((c) => c === MCHOSE_V3_COMMAND.readDeviceInfo).length, 2,
"one retry, then stop — each ask costs a second on real hardware",
);
assert.match(status.ui!.statusNote!, /not reachable/);
assert.doesNotMatch(status.ui!.statusNote!, /did not answer/);
// The model still comes from the product string, so the panel is not blank.
assert.equal(status.name, "MCHOSE A7 V3 Ultra+");
});

it("still concludes nothing is listening when nothing ever arrives", async () => {
const { device } = fakeMouse({ silent: Object.values(MCHOSE_V3_COMMAND) });
const status = await new MchoseV3HidClient(device).readStatus();
assert.match(status.ui!.statusNote!, /did not answer/);
});
});
Loading