From 2df5297de395b7d525a11c5c809b8c5da7176478 Mon Sep 17 00:00:00 2001 From: Raj Kishore <40551183+rkzofficial@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:23:52 +0530 Subject: [PATCH 1/5] feat(mchose): support A7 Ultra receivers under VID 0x5253 Some A7 Ultra units enumerate as 5253:1020 (manufacturer "RealTek") with the A7 V2 family's collections and an identical 0xff01 report descriptor, and speak the same inverted feature-report protocol. The picker filter and MchoseHidClient now accept 0x5253, but only with PIDs 0x1020 (8K receiver) and 0x1011 (wired, from a user report), since 0x5253 is not an MCHOSE-owned id. This receiver answers 0x04 and 0x06 with an all-zero payload, so there is no mouse PID to identify the model; an "A7 Ultra" catalog entry is matched by product string. Its DPI ceiling and 8K rate list come from the captured config (a 42000 DPI stage, 2000 Hz at rate index 3); lift-off steps mirror the A7 V2 Ultra and are unverified. Verified on hardware through the 8K receiver: identity and config reads, and a wireless polling write 2000 -> 1000 Hz that read back with no other byte changed, then restored byte-identical. --- docs/mchose-protocol.md | 27 ++++++++++++++ src/drivers/mchose/hid.test.ts | 66 +++++++++++++++++++++++++++++++++- src/drivers/mchose/hid.ts | 6 +++- src/drivers/vendors.ts | 9 +++++ src/mchose/index.test.ts | 1 + src/mchose/index.ts | 14 ++++++-- 6 files changed, 119 insertions(+), 4 deletions(-) diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 16109fb4..7af97bc9 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -225,6 +225,33 @@ 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` + +Some **A7 Ultra** units enumerate as `5253:1020`, manufacturer string +"RealTek", 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; it has not +been exercised here. + +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. +- `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 `0x5253` is not an MCHOSE-owned id, 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..6a69e160 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -5,6 +5,7 @@ import { MCHOSE_COMMAND, MCHOSE_LONG_REPORT_ID, MCHOSE_SHORT_REPORT_ID, + MCHOSE_SHORT_TOKENS, } from "@openmouse/protocol/mchose"; /** @@ -41,6 +42,27 @@ 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() { + const fake = fakeDevice({ + vendorId: 0x5253, + productId: 0x1020, + productName: "MCHOSE A7 Ultra", + } as Partial); + fake.state.config = [...A7_ULTRA_CONFIG]; + fake.state.emptyShortReplies = [MCHOSE_COMMAND.version, MCHOSE_COMMAND.battery]; + return fake; +} + /** 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 +71,14 @@ 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[], + }; let pendingShort = MCHOSE_COMMAND.identity; let pendingLong = MCHOSE_COMMAND.config; @@ -123,6 +152,9 @@ function fakeDevice(overrides?: Partial) { }, 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 @@ -152,6 +184,38 @@ 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("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: [ diff --git a/src/drivers/mchose/hid.ts b/src/drivers/mchose/hid.ts index 10677a8c..99c4300f 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, @@ -97,7 +98,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 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..3c82cd58 100644 --- a/src/mchose/index.test.ts +++ b/src/mchose/index.test.ts @@ -155,6 +155,7 @@ 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..8a903afa 100644 --- a/src/mchose/index.ts +++ b/src/mchose/index.ts @@ -20,6 +20,14 @@ export const MCHOSE_VENDOR_ID = 0x3837; +/** + * Some A7 Ultra receivers enumerate as manufacturer "RealTek" under this vendor + * id, with the family's link product ids and the same configuration channel. + * Not an MCHOSE-owned id, so it is only ever matched together with these PIDs. + */ +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; @@ -486,8 +494,8 @@ export const MCHOSE_LINK_PRODUCT_IDS = { export interface MchoseProduct { name: string; - /** The mouse's own product id, model-specific. */ - productId: number; + /** The mouse's own product id, model-specific; absent when the mouse never reports it. */ + productId?: number; dpiMax: number; /** Lift-off steps in millimetres, in firmware index order. */ liftOffDistances: readonly number[]; @@ -501,6 +509,8 @@ 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 }, + // The 0x5253 receiver leaves the 0x06 reply empty, so this is found by name. + { name: "A7 Ultra", dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, ]; /** Rate lists the firmware exposes, keyed by the model's maximum rate. */ From de4535bfd0b971e3b504aa24968c9c49dcd08234 Mon Sep 17 00:00:00 2001 From: Raj Kishore <40551183+rkzofficial@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:35:31 +0530 Subject: [PATCH 2/5] fix(mchose): treat 0x5253:0x1011 as the wired link isWired() only recognised model PIDs from MCHOSE_PRODUCTS, so the A7 Ultra's cable PID read and wrote the wireless half of the config byte pair. --- src/drivers/mchose/hid.test.ts | 18 ++++++++++++++++-- src/drivers/mchose/hid.ts | 4 ++++ src/mchose/index.ts | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts index 6a69e160..44ca5f8c 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -52,10 +52,10 @@ const A7_ULTRA_CONFIG = [ ...CONFIG.slice(20), ]; -function a7UltraDevice() { +function a7UltraDevice(productId = 0x1020) { const fake = fakeDevice({ vendorId: 0x5253, - productId: 0x1020, + productId, productName: "MCHOSE A7 Ultra", } as Partial); fake.state.config = [...A7_ULTRA_CONFIG]; @@ -645,6 +645,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 99c4300f..290b398c 100644 --- a/src/drivers/mchose/hid.ts +++ b/src/drivers/mchose/hid.ts @@ -11,6 +11,7 @@ import { MCHOSE_DEBOUNCE_MAX_MS, MCHOSE_DOCK_PRODUCT_ID, MCHOSE_REALTEK_PRODUCT_IDS, + MCHOSE_REALTEK_WIRED_PRODUCT_ID, MCHOSE_ANGLE_TUNING_MAX, MCHOSE_SHORT_REPORT_ID, MCHOSE_SHORT_TOKENS, @@ -142,6 +143,9 @@ export class MchoseHidClient { * is live decides which half of the config byte pair applies. */ private isWired(): boolean { + if (this.device.vendorId === VENDOR_ID.mchoseRealtek) { + return this.device.productId === MCHOSE_REALTEK_WIRED_PRODUCT_ID; + } return MCHOSE_PRODUCTS.some((entry) => entry.productId === this.device.productId); } diff --git a/src/mchose/index.ts b/src/mchose/index.ts index 8a903afa..2fcdf0e2 100644 --- a/src/mchose/index.ts +++ b/src/mchose/index.ts @@ -26,7 +26,8 @@ export const MCHOSE_VENDOR_ID = 0x3837; * Not an MCHOSE-owned id, so it is only ever matched together with these PIDs. */ export const MCHOSE_REALTEK_VENDOR_ID = 0x5253; -export const MCHOSE_REALTEK_PRODUCT_IDS: readonly number[] = [0x1020, 0x1011]; +export const MCHOSE_REALTEK_WIRED_PRODUCT_ID = 0x1011; +export const MCHOSE_REALTEK_PRODUCT_IDS: readonly number[] = [0x1020, MCHOSE_REALTEK_WIRED_PRODUCT_ID]; /** Vendor configuration collection. */ export const MCHOSE_CONFIG_USAGE_PAGE = 0xff01; From a95f5e221ef7b8d7267993f5ae3c6b27a18d476a Mon Sep 17 00:00:00 2001 From: Raj Kishore <40551183+rkzofficial@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:22:09 +0530 Subject: [PATCH 3/5] fix(mchose): identify the L7 Pro on 0x5253 and skip never-answered polls The L7 Pro shares the 5253:1020 receiver and reports mouse id 0x00b0, so without a catalog entry it read as 0 Hz with no settable rates. Its specs come from github.com/alexfrih/mchose-linux, which verified them on hardware. The A7 Ultra entry now carries 0x1011, its cable link, as its mouse id, so MchoseProduct.productId stays required and isWired() needs no special case. The A7 Ultra's receiver echoes 0x04 and 0x06 with empty payloads on every poll, which cost 3.6 s per status read. On 0x5253 a command that returns only empty echoes for a whole poll is now skipped for the session; 0x3837 receivers keep the full poll because their RF replies start out empty. --- docs/mchose-protocol.md | 28 +++++++++++------ src/drivers/mchose/hid.test.ts | 55 ++++++++++++++++++++++++++++++++++ src/drivers/mchose/hid.ts | 20 ++++++++++--- src/mchose/index.test.ts | 2 ++ src/mchose/index.ts | 21 +++++++------ 5 files changed, 104 insertions(+), 22 deletions(-) diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md index 7af97bc9..3143fe96 100644 --- a/docs/mchose-protocol.md +++ b/docs/mchose-protocol.md @@ -227,12 +227,18 @@ MCHOSE's own firmware-version table cross-checks this: the Ultra reports ### Receivers under VID `0x5253` -Some **A7 Ultra** units enumerate as `5253:1020`, manufacturer string -"RealTek", 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; it has not -been exercised here. +`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`): @@ -240,7 +246,11 @@ Observed on an 8K receiver (macOS, `node-hid`): 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. + 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 @@ -249,8 +259,8 @@ Observed on an 8K receiver (macOS, `node-hid`): no other byte of the 63 changed, and writing `0x31` back restored a byte-identical config. -Because `0x5253` is not an MCHOSE-owned id, the picker filter and the driver -match it only together with these two PIDs. +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 diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts index 44ca5f8c..65a7a7b7 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -63,6 +63,19 @@ function a7UltraDevice(productId = 0x1020) { 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, @@ -204,6 +217,48 @@ describe("MchoseHidClient", () => { 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); + + counts.clear(); + await client.readStatus(); + assert.equal(counts.get(MCHOSE_COMMAND.battery), firstPoll); + }); + + 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); diff --git a/src/drivers/mchose/hid.ts b/src/drivers/mchose/hid.ts index 290b398c..fa67fb81 100644 --- a/src/drivers/mchose/hid.ts +++ b/src/drivers/mchose/hid.ts @@ -11,7 +11,6 @@ import { MCHOSE_DEBOUNCE_MAX_MS, MCHOSE_DOCK_PRODUCT_ID, MCHOSE_REALTEK_PRODUCT_IDS, - MCHOSE_REALTEK_WIRED_PRODUCT_ID, MCHOSE_ANGLE_TUNING_MAX, MCHOSE_SHORT_REPORT_ID, MCHOSE_SHORT_TOKENS, @@ -89,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; @@ -143,9 +143,6 @@ export class MchoseHidClient { * is live decides which half of the config byte pair applies. */ private isWired(): boolean { - if (this.device.vendorId === VENDOR_ID.mchoseRealtek) { - return this.device.productId === MCHOSE_REALTEK_WIRED_PRODUCT_ID; - } return MCHOSE_PRODUCTS.some((entry) => entry.productId === this.device.productId); } @@ -165,9 +162,12 @@ export class MchoseHidClient { accept: (payload: Uint8Array) => boolean, extraTokens: readonly number[] = [], ): Promise { + const key = (reportId << 8) | command; 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); @@ -175,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])) { @@ -185,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/mchose/index.test.ts b/src/mchose/index.test.ts index 3c82cd58..91515e0b 100644 --- a/src/mchose/index.test.ts +++ b/src/mchose/index.test.ts @@ -149,6 +149,8 @@ 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", () => { diff --git a/src/mchose/index.ts b/src/mchose/index.ts index 2fcdf0e2..17d6dbbb 100644 --- a/src/mchose/index.ts +++ b/src/mchose/index.ts @@ -21,13 +21,13 @@ export const MCHOSE_VENDOR_ID = 0x3837; /** - * Some A7 Ultra receivers enumerate as manufacturer "RealTek" under this vendor - * id, with the family's link product ids and the same configuration channel. - * Not an MCHOSE-owned id, so it is only ever matched together with these PIDs. + * 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_WIRED_PRODUCT_ID = 0x1011; -export const MCHOSE_REALTEK_PRODUCT_IDS: readonly number[] = [0x1020, MCHOSE_REALTEK_WIRED_PRODUCT_ID]; +export const MCHOSE_REALTEK_PRODUCT_IDS: readonly number[] = [0x1020, 0x1011]; /** Vendor configuration collection. */ export const MCHOSE_CONFIG_USAGE_PAGE = 0xff01; @@ -495,8 +495,8 @@ export const MCHOSE_LINK_PRODUCT_IDS = { export interface MchoseProduct { name: string; - /** The mouse's own product id, model-specific; absent when the mouse never reports it. */ - productId?: number; + /** The mouse's own product id, model-specific. */ + productId: number; dpiMax: number; /** Lift-off steps in millimetres, in firmware index order. */ liftOffDistances: readonly number[]; @@ -510,8 +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 }, - // The 0x5253 receiver leaves the 0x06 reply empty, so this is found by name. - { name: "A7 Ultra", 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. */ From 13a3afa8e03b5f2b969c18fa154b0806bf444eab Mon Sep 17 00:00:00 2001 From: Raj Kishore <40551183+rkzofficial@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:03:07 +0530 Subject: [PATCH 4/5] fix(mchose): key skipped polls by their index tokens too An unanswered name read for one button or profile no longer stops the others from being read on 0x5253 receivers. --- src/drivers/mchose/hid.test.ts | 24 ++++++++++++++++++++++++ src/drivers/mchose/hid.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts index 65a7a7b7..164d18e1 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -4,6 +4,7 @@ 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"; @@ -91,7 +92,10 @@ function fakeDevice(overrides?: Partial) { 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; @@ -161,6 +165,7 @@ function fakeDevice(overrides?: Partial) { pendingLong = MCHOSE_COMMAND.setButton; return; } + if (command === MCHOSE_COMMAND.readButtonName) pendingButton = tokens[1]!; pendingLong = command; }, receiveFeatureReport: async (id: number) => { @@ -179,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, @@ -240,11 +252,23 @@ describe("MchoseHidClient", () => { 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.ok(Object.values(status.buttonMappings ?? {}).includes("Macro: Spray")); + }); + it("identifies an L7 Pro on the shared 5253:1020 receiver by its mouse id", async () => { const { device, state } = fakeDevice({ vendorId: 0x5253, diff --git a/src/drivers/mchose/hid.ts b/src/drivers/mchose/hid.ts index fa67fb81..3da9c081 100644 --- a/src/drivers/mchose/hid.ts +++ b/src/drivers/mchose/hid.ts @@ -88,7 +88,7 @@ export class MchoseHidClient { private queue: Promise = Promise.resolve(); private cachedProduct: ReturnType = null; - private readonly unansweredCommands = new Set(); + private readonly unansweredCommands = new Set(); constructor(device: HIDDevice) { this.device = device; @@ -162,7 +162,7 @@ export class MchoseHidClient { accept: (payload: Uint8Array) => boolean, extraTokens: readonly number[] = [], ): Promise { - const key = (reportId << 8) | command; + const key = [reportId, command, ...extraTokens].join(":"); const run = async (): Promise => { if (this.unansweredCommands.has(key)) return null; const body = mchoseEncodeCommand([command, ...extraTokens], tokenCount); From f36ba09012aa77a1d2c960de0431d4a5c46883f8 Mon Sep 17 00:00:00 2001 From: Raj Kishore <40551183+rkzofficial@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:37:31 +0530 Subject: [PATCH 5/5] test(mchose): assert which button each macro label lands on --- src/drivers/mchose/hid.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts index 164d18e1..d4af1c00 100644 --- a/src/drivers/mchose/hid.test.ts +++ b/src/drivers/mchose/hid.test.ts @@ -266,7 +266,8 @@ describe("MchoseHidClient", () => { state.unansweredButtonNames = [1]; state.buttonNames = { 2: "Spray" }; const status = await new MchoseHidClient(device).readStatus(); - assert.ok(Object.values(status.buttonMappings ?? {}).includes("Macro: Spray")); + 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 () => {