diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 16109fb4..3143fe96 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -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*.** diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts index de5f9437..d4af1c00 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -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"; /** @@ -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); + 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 { + const counts = new Map(); + 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, @@ -49,7 +85,17 @@ const PROFILE_2 = [ ]; function fakeDevice(overrides?: Partial) { - 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, + unansweredButtonNames: [] as number[], + }; + let pendingButton = 0; let pendingShort = MCHOSE_COMMAND.identity; let pendingLong = MCHOSE_COMMAND.config; @@ -119,10 +165,14 @@ function fakeDevice(overrides?: Partial) { 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 @@ -134,6 +184,13 @@ function fakeDevice(overrides?: Partial) { 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, @@ -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); + 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); + 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: [ @@ -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)), diff --git a/src/drivers/mchose/hid.ts b/src/drivers/mchose/hid.ts index 10677a8c..3da9c081 100644 --- a/src/drivers/mchose/hid.ts +++ b/src/drivers/mchose/hid.ts @@ -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, @@ -87,6 +88,7 @@ export class MchoseHidClient { private queue: Promise = Promise.resolve(); private cachedProduct: ReturnType = null; + private readonly unansweredCommands = new Set(); constructor(device: HIDDevice) { this.device = device; @@ -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)); + 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 @@ -157,9 +162,12 @@ export class MchoseHidClient { accept: (payload: Uint8Array) => boolean, extraTokens: readonly number[] = [], ): Promise { + const key = [reportId, command, ...extraTokens].join(":"); const run = async (): Promise => { + 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); @@ -167,9 +175,13 @@ export class MchoseHidClient { 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])) { @@ -177,6 +189,14 @@ export class MchoseHidClient { } 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); diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 9b581453..2385ad4f 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -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, @@ -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 @@ -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 })), diff --git a/src/mchose/index.test.ts b/src/mchose/index.test.ts index 79b464f4..91515e0b 100644 --- a/src/mchose/index.test.ts +++ b/src/mchose/index.test.ts @@ -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); }); diff --git a/src/mchose/index.ts b/src/mchose/index.ts index dc9fac24..17d6dbbb 100644 --- a/src/mchose/index.ts +++ b/src/mchose/index.ts @@ -20,6 +20,15 @@ export const MCHOSE_VENDOR_ID = 0x3837; +/** + * MCHOSE's older id, manufacturer string "RealTek", still used by L7 and some + * A7 units with the same configuration channel. Other MCHOSE product lines sit + * under it too, so it is only ever matched together with these PIDs: the 8K + * receiver and the A7 Ultra's cable link. + */ +export const MCHOSE_REALTEK_VENDOR_ID = 0x5253; +export const MCHOSE_REALTEK_PRODUCT_IDS: readonly number[] = [0x1020, 0x1011]; + /** Vendor configuration collection. */ export const MCHOSE_CONFIG_USAGE_PAGE = 0xff01; export const MCHOSE_CONFIG_USAGE = 0x0001; @@ -501,6 +510,11 @@ export const MCHOSE_PRODUCTS: readonly MchoseProduct[] = [ { name: "A7 V2 Pro+", productId: 0x4023, dpiMax: 26000, liftOffDistances: [1, 2], maxPollingRate: 8000 }, { name: "A7 V2 Ultra", productId: 0x4019, dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, { name: "A7 V2 Ultra+", productId: 0x4021, dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, + // Under VID 0x5253. Its receiver leaves the 0x06 reply empty, so it is found + // by name; 0x1011 is its cable link, which enumerates as the mouse's own id. + { name: "A7 Ultra", productId: 0x1011, dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, + // Under VID 0x5253, per github.com/alexfrih/mchose-linux (verified on hardware). + { name: "L7 Pro", productId: 0x00b0, dpiMax: 26000, liftOffDistances: [1, 2], maxPollingRate: 8000 }, ]; /** Rate lists the firmware exposes, keyed by the model's maximum rate. */