Skip to content
Open
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
37 changes: 37 additions & 0 deletions docs/mchose-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ used directly when the mouse is on a cable:
MCHOSE's own firmware-version table cross-checks this: the Ultra reports
`5.44.2.4` and the Ultra+ `5.46.2.4`. The test hardware reported `5.46.2.4`.

### Receivers under VID `0x5253`

`0x5253` (manufacturer string "RealTek") is MCHOSE's older vendor id. Some
**A7 Ultra** units enumerate under it as `5253:1020`, product string "MCHOSE A7
Ultra". The interface is identical to the A7 V2's — same five collections, same
report ids and sizes, byte-identical `0xff01` report descriptor — and it speaks
the same inverted feature-report protocol. `0x1011` is the same mouse on its
cable, per a user report, and is taken to be its mouse id because a cable link
enumerates as the mouse itself; it has not been exercised here.

The **L7 Pro** uses the same `5253:1020` receiver and reports mouse id `0x00b0`
in `0x06`, with 26000 DPI, lift-off 1 mm / 2 mm and 8K on the receiver
([alexfrih/mchose-linux](https://github.com/alexfrih/mchose-linux/blob/main/PROTOCOL.md),
verified on that hardware and cross-checked against M HUB's model table).

Observed on an 8K receiver (macOS, `node-hid`):

- `0x11 0x03` answers `01 53 52 20 10 01 00` — bonded, the receiver's own ids,
connected.
- `0x11 0x04` and `0x11 0x06` echo the command but every payload byte is zero,
across 60 polls with the mouse awake. There is therefore no battery, firmware
string or mouse PID, and the model is identified by the product string. On
this vendor id the driver stops sending a command once a full poll has come
back as nothing but empty echoes, so later status reads skip the 3.6 s these
two would otherwise cost. `0x3837` receivers keep the full poll, because
their RF replies are empty until the mouse answers.
- `0x12 0x67` returns a plausible config:
`00 31 31 01 20 03 80 0c 40 06 80 0c 00 19 10 a4 02 81 08 03` — 2000 Hz on
both links, stage 1 of 800/3200/1600/3200/6400/42000, two stages, 8 ms
debounce, 3 min sleep.
- A `0x57` write of the wireless byte `0x31 → 0x21` read back as 1000 Hz with
no other byte of the 63 changed, and writing `0x31` back restored a
byte-identical config.

Because other MCHOSE product lines share `0x5253`, the picker filter and the
driver match it only together with these two PIDs.

## Dead ends, so the next person can skip them

- **The `0x4d`-magic framing in the same bundle is a different *generation*.**
Expand Down
160 changes: 159 additions & 1 deletion src/drivers/mchose/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { MchoseHidClient } from "./hid.ts";
import {
MCHOSE_COMMAND,
MCHOSE_LONG_REPORT_ID,
MCHOSE_LONG_TOKENS,
MCHOSE_SHORT_REPORT_ID,
MCHOSE_SHORT_TOKENS,
} from "@openmouse/protocol/mchose";

/**
Expand Down Expand Up @@ -41,6 +43,40 @@ function wireReply(reportId: number, command: number, payload: number[]): DataVi
* id, and a config that a 0x57 write actually mutates, so a setter's read-back
* check is exercised rather than stubbed.
*/
/**
* An A7 Ultra on a `5253:1020` receiver, captured at 2000 Hz on stage 1. Its
* receiver answers 0x04 and 0x06 with an all-zero payload.
*/
const A7_ULTRA_CONFIG = [
0x00, 0x31, 0x31, 0x01, 0x20, 0x03, 0x80, 0x0c, 0x40, 0x06,
0x80, 0x0c, 0x00, 0x19, 0x10, 0xa4, 0x02, 0x81, 0x08, 0x03,
...CONFIG.slice(20),
];

function a7UltraDevice(productId = 0x1020) {
const fake = fakeDevice({
vendorId: 0x5253,
productId,
productName: "MCHOSE A7 Ultra",
} as Partial<HIDDevice>);
fake.state.config = [...A7_ULTRA_CONFIG];
fake.state.emptyShortReplies = [MCHOSE_COMMAND.version, MCHOSE_COMMAND.battery];
return fake;
}

/** Counts how often each command is sent, keyed by the un-inverted command byte. */
function countCommands(device: HIDDevice): Map<number, number> {
const counts = new Map<number, number>();
const send = device.sendFeatureReport;
device.sendFeatureReport = async (id: number, data: BufferSource) => {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
const command = (~bytes[0]!) & 0xff;
counts.set(command, (counts.get(command) ?? 0) + 1);
return send(id, data);
};
return counts;
}

/** Profile 2 as captured from the same mouse: 1000 Hz on stage 2. */
const PROFILE_2 = [
0x02, 0x32, 0x22, 0x00, 0x40, 0x06, 0x20, 0x03, 0x40, 0x06,
Expand All @@ -49,7 +85,17 @@ const PROFILE_2 = [
];

function fakeDevice(overrides?: Partial<HIDDevice>) {
const state = { config: [...CONFIG], writes: [] as number[][], profile: 0, sleepEnabled: 0, mouseProductId: 0x4021 };
const state = {
config: [...CONFIG],
writes: [] as number[][],
profile: 0,
sleepEnabled: 0,
mouseProductId: 0x4021,
emptyShortReplies: [] as number[],
buttonNames: {} as Record<number, string>,
unansweredButtonNames: [] as number[],
};
let pendingButton = 0;
let pendingShort = MCHOSE_COMMAND.identity;
let pendingLong = MCHOSE_COMMAND.config;

Expand Down Expand Up @@ -119,10 +165,14 @@ function fakeDevice(overrides?: Partial<HIDDevice>) {
pendingLong = MCHOSE_COMMAND.setButton;
return;
}
if (command === MCHOSE_COMMAND.readButtonName) pendingButton = tokens[1]!;
pendingLong = command;
},
receiveFeatureReport: async (id: number) => {
if (id === MCHOSE_SHORT_REPORT_ID) {
if (state.emptyShortReplies.includes(pendingShort)) {
return wireReply(id, pendingShort, new Array(MCHOSE_SHORT_TOKENS - 1).fill(0));
}
const payload = pendingShort === MCHOSE_COMMAND.identity ? IDENTITY
: pendingShort === MCHOSE_COMMAND.version ? VERSION
: pendingShort === MCHOSE_COMMAND.battery
Expand All @@ -134,6 +184,13 @@ function fakeDevice(overrides?: Partial<HIDDevice>) {
if (pendingLong === MCHOSE_COMMAND.writeConfig || pendingLong === MCHOSE_COMMAND.setButton) {
return wireReply(id, pendingLong, []);
}
if (pendingLong === MCHOSE_COMMAND.readButtonName) {
if (state.unansweredButtonNames.includes(pendingButton)) {
return wireReply(id, pendingLong, new Array(MCHOSE_LONG_TOKENS - 1).fill(0));
}
const name = state.buttonNames[pendingButton] ?? "";
return wireReply(id, pendingLong, [pendingButton, name.length, ...[...name].map((c) => c.charCodeAt(0))]);
}
return wireReply(id, MCHOSE_COMMAND.config, state.config);
},
...overrides,
Expand All @@ -152,6 +209,93 @@ describe("MchoseHidClient", () => {
assert.equal(MchoseHidClient.isSupported(device), false);
});

it("matches the A7 Ultra receiver under vendor id 0x5253", () => {
assert.equal(MchoseHidClient.isSupported(a7UltraDevice().device), true);
});

it("rejects other 0x5253 product ids on the same usage page", () => {
const { device } = fakeDevice({ vendorId: 0x5253, productId: 0x1234 } as Partial<HIDDevice>);
assert.equal(MchoseHidClient.isSupported(device), false);
});

it("reads an A7 Ultra whose receiver leaves battery and version empty", async () => {
const status = await new MchoseHidClient(a7UltraDevice().device).readStatus();
assert.equal(status.name, "MCHOSE A7 Ultra", "identified by the product string");
assert.equal(status.batteryPercent, null);
assert.deepEqual(status.firmware, []);
assert.equal(status.pollingRateHz, 2000, "wireless byte 0x31 -> rate index 3");
assert.equal(status.dpi, 3200, "stage 1");
assert.deepEqual(status.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]);
assert.equal(status.ui?.dpiStageEditor?.maxDpi, 42000);
});

it("stops polling commands the 0x5253 receiver only ever answers empty", async () => {
const { device } = a7UltraDevice();
const counts = countCommands(device);
const client = new MchoseHidClient(device);
await client.readStatus();
assert.ok((counts.get(MCHOSE_COMMAND.battery) ?? 0) > 1, "the first read polls in full");

counts.clear();
const status = await client.readStatus();
assert.equal(counts.get(MCHOSE_COMMAND.battery), undefined);
assert.equal(counts.get(MCHOSE_COMMAND.version), undefined);
assert.ok(counts.get(MCHOSE_COMMAND.config), "config is still read");
assert.equal(status.pollingRateHz, 2000);
});

it("keeps polling empty replies in full on a 0x3837 receiver", async () => {
const { device, state } = fakeDevice();
state.emptyShortReplies = [MCHOSE_COMMAND.battery];
const counts = countCommands(device);
const client = new MchoseHidClient(device);
await client.readStatus();
const firstPoll = counts.get(MCHOSE_COMMAND.battery);

assert.ok((firstPoll ?? 0) > 1, "the first read polls in full");

counts.clear();
await client.readStatus();
assert.equal(counts.get(MCHOSE_COMMAND.battery), firstPoll);
});

it("one unanswered macro name on 0x5253 does not stop the others being read", async () => {
const { device, state } = a7UltraDevice();
state.config[24] = 0x14;
state.config[28] = 0x24;
state.unansweredButtonNames = [1];
state.buttonNames = { 2: "Spray" };
const status = await new MchoseHidClient(device).readStatus();
assert.equal(status.buttonMappings?.Right, "Macro: Spray");
assert.equal(status.buttonMappings?.Middle, "Macro");
});

it("identifies an L7 Pro on the shared 5253:1020 receiver by its mouse id", async () => {
const { device, state } = fakeDevice({
vendorId: 0x5253,
productId: 0x1020,
productName: "MCHOSE 8K Receiver",
} as Partial<HIDDevice>);
state.mouseProductId = 0x00b0;
const status = await new MchoseHidClient(device).readStatus();
assert.equal(status.name, "MCHOSE L7 Pro");
assert.deepEqual(status.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]);
assert.equal(status.ui?.dpiStageEditor?.maxDpi, 26000);
assert.equal(status.supportedLiftOffDistances?.length, 2);
});

it("sets the A7 Ultra's polling rate through the same config write", async () => {
const { device, state } = a7UltraDevice();
const client = new MchoseHidClient(device);
await client.readStatus();
await client.setPollingRate(1000);
assert.equal(state.config[2], 0x21);
assert.deepEqual(
state.config.filter((_, index) => index !== 2),
A7_ULTRA_CONFIG.filter((_, index) => index !== 2),
);
});

it("rejects an MCHOSE device with only the firmware-update collection", () => {
const { device } = fakeDevice({
collections: [
Expand Down Expand Up @@ -581,6 +725,20 @@ describe("MchoseHidClient", () => {
assert.equal((await client.readStatus()).pollingRateHz, 500);
});

it("treats the A7 Ultra's 0x5253:0x1011 cable link as wired", async () => {
const { device, state } = a7UltraDevice(0x1011);
state.config[1] = 0x10; // wired: rate index 1 (500 Hz), stage 0
state.config[2] = 0x33; // wireless: rate index 3, stage 3
const client = new MchoseHidClient(device);
const status = await client.readStatus();
assert.equal(status.connectionType, "Wired");
assert.equal(status.pollingRateHz, 500, "the wired byte, not the wireless one");

await client.setPollingRate(1000);
assert.equal(state.config[1] >> 4, 2, "wired rate index moved");
assert.equal(state.config[2], 0x33, "wireless byte untouched");
});

it("degrades to identity-only when the mouse never answers", async () => {
const { device } = fakeDevice({
receiveFeatureReport: async () => new DataView(new ArrayBuffer(66)),
Expand Down
22 changes: 21 additions & 1 deletion src/drivers/mchose/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MCHOSE_SLEEP_OPTIONS,
MCHOSE_DEBOUNCE_MAX_MS,
MCHOSE_DOCK_PRODUCT_ID,
MCHOSE_REALTEK_PRODUCT_IDS,
MCHOSE_ANGLE_TUNING_MAX,
MCHOSE_SHORT_REPORT_ID,
MCHOSE_SHORT_TOKENS,
Expand Down Expand Up @@ -87,6 +88,7 @@ export class MchoseHidClient {

private queue: Promise<unknown> = Promise.resolve();
private cachedProduct: ReturnType<typeof mchoseFindProduct> = null;
private readonly unansweredCommands = new Set<string>();

constructor(device: HIDDevice) {
this.device = device;
Expand All @@ -97,7 +99,10 @@ export class MchoseHidClient {
(collection.usagePage === MCHOSE_CONFIG_USAGE_PAGE
&& collection.usage === MCHOSE_CONFIG_USAGE)
|| collection.children.some(search);
return device.vendorId === VENDOR_ID.mchose
const vendorMatches = device.vendorId === VENDOR_ID.mchose
|| (device.vendorId === VENDOR_ID.mchoseRealtek
&& MCHOSE_REALTEK_PRODUCT_IDS.includes(device.productId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High mchose/hid.ts:103

The newly accepted 5253:1011 device is reported as wireless, so readStatus() shows its wireless DPI/rate and the polling-rate and active-stage setters update the wireless config fields instead of the active wired fields. isWired() only recognizes product IDs in MCHOSE_PRODUCTS, which does not include 0x1011; classify this PID as wired when adding support for it.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/drivers/mchose/hid.ts around line 103:

The newly accepted `5253:1011` device is reported as wireless, so `readStatus()` shows its wireless DPI/rate and the polling-rate and active-stage setters update the wireless config fields instead of the active wired fields. `isWired()` only recognizes product IDs in `MCHOSE_PRODUCTS`, which does not include `0x1011`; classify this PID as wired when adding support for it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de4535b: isWired() now treats 0x5253:0x1011 as wired, with a test that it reads and writes the wired byte and leaves the wireless one alone.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return vendorMatches
// The MagDock is the same vendor but a different protocol entirely, and
// has its own driver; it must never be claimed as a mouse.
&& device.productId !== MCHOSE_DOCK_PRODUCT_ID
Expand Down Expand Up @@ -157,26 +162,41 @@ export class MchoseHidClient {
accept: (payload: Uint8Array) => boolean,
extraTokens: readonly number[] = [],
): Promise<Uint8Array | null> {
const key = [reportId, command, ...extraTokens].join(":");
const run = async (): Promise<Uint8Array | null> => {
if (this.unansweredCommands.has(key)) return null;
const body = mchoseEncodeCommand([command, ...extraTokens], tokenCount);
let previous: Uint8Array | null = null;
let onlyEmptyEchoes = true;
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
await this.device.sendFeatureReport(reportId, body);
await delay(POLL_DELAY_MS);
let raw: DataView;
try {
raw = await this.device.receiveFeatureReport(reportId);
} catch {
onlyEmptyEchoes = false;
continue;
}
const reply = mchoseDecodeReply(new Uint8Array(raw.buffer));
const empty = reply?.command === command
&& reply.payload.subarray(0, tokenCount - 1).every((byte) => byte === 0);
if (!empty) onlyEmptyEchoes = false;
if (!reply || reply.command !== command) continue;
if (!accept(reply.payload)) continue;
if (previous && reply.payload.every((byte, index) => byte === previous![index])) {
return reply.payload;
}
previous = Uint8Array.from(reply.payload);
}
// The A7 Ultra's 0x5253 receiver acknowledges some commands with an
// empty payload every time. Once it has done so for a whole poll, skip
// that command for the rest of the session instead of polling it again.
// The 0x3837 receivers answer empty while waiting on the RF link, so
// they keep the full poll every time.
if (onlyEmptyEchoes && this.device.vendorId === VENDOR_ID.mchoseRealtek) {
this.unansweredCommands.add(key);
}
return null;
};
const next = this.queue.then(run, run);
Expand Down
9 changes: 9 additions & 0 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
MCHOSE_A5_GEN1_PRODUCTS,
MCHOSE_A5_GEN1_USAGE_PAGE,
MCHOSE_A5_GEN1_VENDOR_ID,
MCHOSE_REALTEK_PRODUCT_IDS,
MCHOSE_REALTEK_VENDOR_ID,
} from "@openmouse/protocol/mchose";
import {
HIDPP_BLUETOOTH_USAGE_PAGE,
Expand Down Expand Up @@ -194,6 +196,7 @@ export const VENDOR_ID = {
gloriousClassicIWired: 0x320f, // Model O V2 / Model I 2 wired
gloriousO3: 0x3794, // Model O3 Wireless / receiver (newer CORE-v2 generation)
mchose: 0x3837,
mchoseRealtek: MCHOSE_REALTEK_VENDOR_ID,
mchoseA5Gen1: MCHOSE_A5_GEN1_VENDOR_ID,
ksnakeUsb: 0xa8a4, // K-snake X11 wired
ksnakeDongle: 0xa8a5, // K-snake X11 2.4 GHz dongle
Expand Down Expand Up @@ -924,6 +927,12 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [
// feature reports, the A7 V3 with its own output-report protocol — so one
// filter offers the whole mouse line and the drivers split it by product id.
{ vendorId: VENDOR_ID.mchose, usagePage: MCHOSE_CONFIG_USAGE_PAGE, usage: MCHOSE_CONFIG_USAGE },
...MCHOSE_REALTEK_PRODUCT_IDS.map((productId) => ({
vendorId: VENDOR_ID.mchoseRealtek,
productId,
usagePage: MCHOSE_CONFIG_USAGE_PAGE,
usage: MCHOSE_CONFIG_USAGE,
})),
// The MagDock is a separate device on its own usage page; it carries the RGB.
{ vendorId: VENDOR_ID.mchose, productId: MCHOSE_DOCK_PRODUCT_ID, usagePage: MCHOSE_DOCK_USAGE_PAGE, usage: MCHOSE_DOCK_USAGE },
...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, productId })),
Expand Down
3 changes: 3 additions & 0 deletions src/mchose/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,15 @@ test("the mouse id identifies the model, since host PIDs are shared", () => {
assert.equal(mchoseFindProduct(0x4021)?.name, "A7 V2 Ultra+");
assert.equal(mchoseFindProduct(0x4019)?.name, "A7 V2 Ultra");
assert.equal(mchoseFindProduct(0x4018)?.dpiMax, 26000);
assert.equal(mchoseFindProduct(0x1011)?.name, "A7 Ultra");
assert.equal(mchoseFindProduct(0x00b0)?.name, "L7 Pro");
});

test("the product string disambiguates when the mouse id is unknown", () => {
assert.equal(mchoseFindProduct(null, "MCHOSE A7 V2 Ultra+")?.name, "A7 V2 Ultra+");
// The plus model must not be swallowed by the shorter name.
assert.equal(mchoseFindProduct(null, "MCHOSE A7 V2 Ultra")?.name, "A7 V2 Ultra");
assert.equal(mchoseFindProduct(null, "MCHOSE A7 Ultra")?.name, "A7 Ultra");
assert.equal(mchoseFindProduct(null, "Some Other Mouse"), null);
assert.equal(mchoseFindProduct(null, null), null);
});
Expand Down
Loading
Loading