From c5aa6357456c9de02430b8b6dfe01f58caf952f2 Mon Sep 17 00:00:00 2001 From: dot-mike <586280+dot-mike@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:26:02 +0200 Subject: [PATCH] feat(asus): support the ROG Omni receiver --- src/asus/devices.ts | 6 +- src/asus/index.ts | 24 ++-- src/asus/omni.ts | 122 ++++++++++++++++++ src/drivers/asus/hid.test.ts | 237 +++++++++++++++++++++++++++++++++-- src/drivers/asus/hid.ts | 206 ++++++++++++++++++++++++++---- src/drivers/mouse-types.ts | 13 ++ src/drivers/vendors.ts | 17 ++- 7 files changed, 573 insertions(+), 52 deletions(-) create mode 100644 src/asus/omni.ts diff --git a/src/asus/devices.ts b/src/asus/devices.ts index 0b7b17f..4cf7763 100644 --- a/src/asus/devices.ts +++ b/src/asus/devices.ts @@ -30,10 +30,8 @@ * touch firmware or calibration except the lift-off write, which is only * enabled where G-Helper already sends it. * - * ## Deliberately absent - * - * The ROG Omni receiver (`0x1ace`) is shared by several mice and needs a model - * identification handshake before any of this applies. + * The ROG Omni receiver (`0x1ace`) is not in this table. It names the paired + * mouse by its own receiver PID, which is looked up here (see `omni.ts`). */ /** Effect names, spelled as the shared `MouseLightingMode` values. */ diff --git a/src/asus/index.ts b/src/asus/index.ts index dbd55bb..5767d88 100644 --- a/src/asus/index.ts +++ b/src/asus/index.ts @@ -28,6 +28,7 @@ import type { AsusMouseModel } from "./devices.js"; export * from "./devices.js"; +export * from "./omni.js"; export const ASUS_VENDOR_ID = 0x0b05; @@ -37,7 +38,8 @@ export const ASUS_USAGE = 0x0001; export const ASUS_REPORT_ID = 0; export const ASUS_REPORT_SIZE = 64; -export const ASUS_POLLING_RATES = [125, 250, 500, 1000] as const; +/** Wire value is the index. 2000 Hz and up need the polling booster. */ +export const ASUS_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; export const ASUS_DEBOUNCE_MS = [12, 16, 20, 24, 28, 32] as const; @@ -76,6 +78,13 @@ const FIELD_RATE = 0; const FIELD_DEBOUNCE = 1; const FIELD_SNAPPING = 2; +/** Wire zone ids by position. A model lists only the zones it has, so its index is not the id. */ +const ZONE_IDS: readonly string[] = ["Logo", "Scroll wheel", "Underglow"]; + +function zoneId(model: AsusMouseModel, zone: number): number { + return ZONE_IDS.indexOf(model.zones[zone]); +} + function settingField(model: AsusMouseModel, field: number): number { return model.dpiStages + field; } @@ -120,12 +129,13 @@ function requireStage(model: AsusMouseModel, stage: number): void { * READ DECODERS * --------------------------------- */ -export function asusDecodeSettings(model: AsusMouseModel, data: Uint8Array): AsusSettings { +export function asusDecodeSettings(model: AsusMouseModel, data: Uint8Array, booster = false): AsusSettings { const rateOffset = settingOffset(model, FIELD_RATE); requirePrefix(data, [0x12, 0x04, 0x00], "ASUS settings", settingOffset(model, FIELD_SNAPPING) + 1); - // Newer firmware keeps a polling-booster value in the high nibble. - const pollingRaw = data[rateOffset] & 0x07; + // Newer firmware keeps a polling-booster value in the high nibble. G-Helper reads it only when the booster is on. + const boosterRaw = booster ? data[rateOffset] >> 4 : 0; + const pollingRaw = boosterRaw || data[rateOffset] & 0x07; const pollingRateHz = ASUS_POLLING_RATES[pollingRaw]; if (pollingRateHz === undefined) { throw new Error(`Unknown ASUS polling value 0x${pollingRaw.toString(16).padStart(2, "0")}.`); @@ -181,7 +191,7 @@ export function asusDecodeLiftOffDistance(data: Uint8Array): AsusLiftOffDistance } export function asusDecodeLighting(model: AsusMouseModel, data: Uint8Array, zone: number): AsusRawLightingZone { - const offset = model.lightingAllZones ? 4 + zone * 5 : 4; + const offset = model.lightingAllZones ? 4 + zoneId(model, zone) * 5 : 4; requirePrefix(data, [0x12, 0x03], "ASUS lighting", offset + 5); return { @@ -227,7 +237,7 @@ export function asusReadLiftOffRequest(): Uint8Array { } export function asusReadLightingRequest(model: AsusMouseModel, zone: number): Uint8Array { - return request(0x12, 0x03, model.lightingAllZones ? 0x00 : zone); + return request(0x12, 0x03, model.lightingAllZones ? 0x00 : zoneId(model, zone)); } export function asusReadBatteryRequest(): Uint8Array { @@ -309,7 +319,7 @@ export function asusSetLightingRequest( } // Bytes 9 and 10 are the animation direction and random-colour flag, unused here. - return request(0x51, 0x28, zone, 0x00, mode, brightness, red, green, blue, 0x00, 0x00, speed); + return request(0x51, 0x28, zoneId(model, zone), 0x00, mode, brightness, red, green, blue, 0x00, 0x00, speed); } export function asusSaveRequest(): Uint8Array { diff --git a/src/asus/omni.ts b/src/asus/omni.ts new file mode 100644 index 0000000..d3cd1d2 --- /dev/null +++ b/src/asus/omni.ts @@ -0,0 +1,122 @@ +/** + * ROG Omni receiver (`0x1ACE`), shared by ASUS mice and keyboards. + * + * Interface 2 holds three vendor collections. Report 1 on usage page `0xFF02` + * controls the receiver itself. Each paired device gets its own report id, + * which the pair list names (keyboard 2, mouse 3 on the receiver we tested). + * A mouse speaks the normal ASUS protocol on its report, in 63-byte reports. + * + * Receiver commands are `[command, sub, arg low, arg high, data...]`. + * + * ## Provenance + * + * - Hardware: pair list, firmware read, booster read and every mouse read, + * on a receiver with firmware 7.00.04 paired to a Harpe Ace Aim Lab Edition + * and a ROG Azoth. + * - G-Helper (`app/Peripherals/PeripheralsProvider.cs`): pair-list handshake, + * the mouse collection, and the booster read. + * - ASUS GearLink web app: pairing mode, unpair and reboot. Read for facts + * only; no code was taken. + */ + +export const ASUS_OMNI_PRODUCT_ID = 0x1ace; + +export const ASUS_OMNI_USAGE_PAGE = 0xff02; +export const ASUS_OMNI_REPORT_ID = 1; + +/** Every Omni report, receiver and paired device alike, carries 63 data bytes. */ +export const ASUS_OMNI_REPORT_SIZE = 63; + +export type AsusOmniDeviceKind = "mouse" | "keyboard" | "unknown"; + +export interface AsusOmniSlot { + /** The paired device's own product id, as it enumerates on its own receiver. */ + productId: number; + /** Report id that carries this device's commands through the receiver. */ + reportId: number; + notifyReportId: number; +} + +/** Report ids the receiver hands out per device kind. */ +const KIND_BY_REPORT_ID: Readonly> = { 2: "keyboard", 3: "mouse" }; + +/** Keyboard product ids as they appear in the pair list (from G-Helper). */ +export const ASUS_OMNI_KEYBOARDS: ReadonlyMap = new Map([ + [0x1a85, "ROG Azoth"], + [0x1b42, "ROG Azoth Extreme"], + [0x1cf1, "ROG Azoth Extreme SE"], + [0x1ab0, "ROG Strix Scope II 96 Wireless"], + [0x1b7a, "ROG Strix Scope II 96 RX Wireless"], + [0x1b06, "ROG Falchion RX Low Profile"], +]); + +function command(code: number, sub: number, data: readonly number[] = []): Uint8Array { + const report = new Uint8Array(ASUS_OMNI_REPORT_SIZE); + report.set([code, sub, 0x00, 0x00, ...data]); + return report; +} + +export function asusOmniPairedDevicesRequest(): Uint8Array { + return command(0xa0, 0x00); +} + +export function asusOmniFirmwareRequest(): Uint8Array { + return command(0xa1, 0x01); +} + +export function asusOmniPairingModeRequest(enabled: boolean): Uint8Array { + return command(0xa2, 0x00, [enabled ? 0x01 : 0x00]); +} + +/** GearLink also sends the device serial after the PID; the receiver accepts it empty. */ +export function asusOmniUnpairRequest(productId: number): Uint8Array { + if (!Number.isInteger(productId) || productId <= 0 || productId > 0xffff) { + throw new Error(`Invalid ROG Omni device id: ${productId}`); + } + + return command(0xa2, 0x01, [0x05, 0x0b, productId & 0xff, productId >> 8]); +} + +/** The receiver drops off USB and enumerates again. GearLink sends it after a pairing. */ +export function asusOmniRebootRequest(): Uint8Array { + return command(0xb0, 0x00, [...new TextEncoder().encode("reset")]); +} + +/** Mouse-side read: data byte 4 is 1 when 2000-8000 Hz polling is available. */ +export function asusOmniBoosterRequest(): Uint8Array { + const report = new Uint8Array(ASUS_OMNI_REPORT_SIZE); + report.set([0x7d, 0x20, 0x02]); + return report; +} + +export function asusOmniDecodePairedDevices(data: Uint8Array): AsusOmniSlot[] { + if (data.length < 4 || data[0] !== 0xa0) { + throw new Error("ROG Omni pair list reply is malformed."); + } + + const count = data[2] | (data[3] << 8); + const slots: AsusOmniSlot[] = []; + for (let i = 0; i < count && 8 + i * 4 <= data.length; i++) { + const offset = 4 + i * 4; + slots.push({ + productId: data[offset] | (data[offset + 1] << 8), + reportId: data[offset + 2], + notifyReportId: data[offset + 3], + }); + } + return slots; +} + +export function asusOmniDeviceKind(slot: AsusOmniSlot): AsusOmniDeviceKind { + return KIND_BY_REPORT_ID[slot.reportId] ?? "unknown"; +} + +/** `a.bb.cc`, in hex like ASUS prints it. */ +export function asusOmniDecodeFirmware(data: Uint8Array): string { + if (data.length < 8 || data[0] !== 0xa1 || data[1] !== 0x01) { + throw new Error("ROG Omni firmware reply is malformed."); + } + + const hex = (value: number): string => value.toString(16).padStart(2, "0"); + return `${data[6].toString(16)}.${hex(data[5])}.${hex(data[4])}`; +} diff --git a/src/drivers/asus/hid.test.ts b/src/drivers/asus/hid.test.ts index b4086e7..2ba507c 100644 --- a/src/drivers/asus/hid.test.ts +++ b/src/drivers/asus/hid.test.ts @@ -3,6 +3,10 @@ import test from "node:test"; import { ASUS_MICE, + ASUS_OMNI_PRODUCT_ID, + ASUS_OMNI_REPORT_ID, + ASUS_OMNI_REPORT_SIZE, + ASUS_OMNI_USAGE_PAGE, ASUS_REPORT_SIZE, ASUS_USAGE, ASUS_USAGE_PAGE, @@ -11,6 +15,14 @@ import { asusDecodeLighting, asusDecodeProfile, asusDecodeSettings, + asusOmniDecodeFirmware, + asusOmniDecodePairedDevices, + asusOmniFirmwareRequest, + asusOmniPairedDevicesRequest, + asusOmniPairingModeRequest, + asusOmniRebootRequest, + asusOmniUnpairRequest, + asusReadLightingRequest, asusReadProfileRequest, asusReadSettingsRequest, asusSaveRequest, @@ -94,7 +106,7 @@ test("builds the Gladius II requests confirmed on hardware", () => { test("rejects values outside a model's range", () => { assert.throws(() => asusSetDpiRequest(GLADIUS_II, 0, 1650), /100-DPI steps/); assert.throws(() => asusSetDpiRequest(GLADIUS_II, 2, 1600), /stage must be 0-1/); - assert.throws(() => asusSetPollingRateRequest(GLADIUS_II, 2000), /2000 Hz/); + assert.throws(() => asusSetPollingRateRequest(GLADIUS_II, 1500), /1500 Hz/); assert.throws(() => asusSetProfileRequest(GLADIUS_II, 4), /profile must be 1-3/); assert.throws(() => asusSetDebounceRequest(GLADIUS_II, 10), /debounce must be one of/); assert.throws(() => asusSetLightingRequest(GLADIUS_II, 3, 0, 4, 255, 0, 0), /RGB zone must be 0-2/); @@ -154,10 +166,69 @@ test("decodes one-reply and per-zone lighting", () => { }); }); +/* --------------------------------------------------------- + * VERIFIED HARPE ACE OVER A ROG OMNI RECEIVER (FIRMWARE 7.00.04) + * --------------------------------------------------------- */ + +test("addresses the Harpe Ace scroll wheel as wire zone 1", () => { + // Zone 0 is Logo, which this mouse lacks; it stores the write but lights nothing. + expectPrefix(asusReadLightingRequest(HARPE_ACE, 0), [0x12, 0x03, 0x01]); + expectPrefix( + asusSetLightingRequest(HARPE_ACE, 0, 0x00, 100, 0xff, 0x00, 0x00), + [0x51, 0x28, 0x01, 0x00, 0x00, 0x64, 0xff, 0x00, 0x00], + ); + + assert.deepEqual(asusDecodeLighting(HARPE_ACE, packet("12 03 00 00 02 32 00 00 00"), 0), { + mode: 0x02, brightness: 50, red: 0x00, green: 0x00, blue: 0x00, + }); +}); + +test("decodes the booster polling value only when the booster is on", () => { + const block = packet("12 04 00 00 FF FF FF FF FF FF FF FF 06 00 02 00 00 00"); + + assert.equal(asusDecodeSettings(HARPE_ACE, block, true).pollingRateHz, 8000); + assert.equal(asusDecodeSettings(HARPE_ACE, block, true).debounceMs, 12); + assert.equal(asusDecodeSettings(HARPE_ACE, packet("12 04 00 00 0 0 0 0 0 0 0 0 63 00"), true).pollingRateHz, 8000); + assert.equal(asusDecodeSettings(HARPE_ACE, packet("12 04 00 00 0 0 0 0 0 0 0 0 63 00")).pollingRateHz, 1000); +}); + +test("decodes the receiver pair list and firmware", () => { + const omni = (hex: string): Uint8Array => packet(hex).subarray(0, ASUS_OMNI_REPORT_SIZE); + + assert.deepEqual(asusOmniDecodePairedDevices(omni("A0 00 02 00 85 1A 02 04 94 1A 03 05")), [ + { productId: 0x1a85, reportId: 2, notifyReportId: 4 }, + { productId: 0x1a94, reportId: 3, notifyReportId: 5 }, + ]); + assert.deepEqual(asusOmniDecodePairedDevices(omni("A0 00 00 00")), []); + assert.equal(asusOmniDecodeFirmware(omni("A1 01 00 00 04 00 07 00")), "7.00.04"); + assert.throws(() => asusOmniDecodePairedDevices(omni("A1 00")), /malformed/); +}); + +test("builds receiver commands as 63-byte reports with data from byte 4", () => { + const cases: Array<[Uint8Array, number[]]> = [ + [asusOmniPairedDevicesRequest(), [0xa0, 0x00, 0x00, 0x00]], + [asusOmniFirmwareRequest(), [0xa1, 0x01, 0x00, 0x00]], + [asusOmniPairingModeRequest(true), [0xa2, 0x00, 0x00, 0x00, 0x01]], + [asusOmniPairingModeRequest(false), [0xa2, 0x00, 0x00, 0x00, 0x00]], + [asusOmniUnpairRequest(0x1a94), [0xa2, 0x01, 0x00, 0x00, 0x05, 0x0b, 0x94, 0x1a]], + [asusOmniRebootRequest(), [0xb0, 0x00, 0x00, 0x00, 0x72, 0x65, 0x73, 0x65, 0x74]], + ]; + + for (const [actual, expected] of cases) { + assert.equal(actual.length, ASUS_OMNI_REPORT_SIZE); + assert.deepEqual([...actual.slice(0, expected.length)], expected); + assert.ok(actual.slice(expected.length).every((value) => value === 0)); + } + + assert.throws(() => asusOmniUnpairRequest(0), /Invalid ROG Omni device id/); +}); + /* --------------------------------------------------------- * CLIENT AGAINST A SIMULATED MOUSE * --------------------------------------------------------- */ +const WIRE_ZONES = ["Logo", "Scroll wheel", "Underglow"]; + /** Firmware stand-in: keeps the settings block in the model's own layout. */ class FakeAsusMouse { readonly vendorId = ASUS_VENDOR_ID; @@ -184,6 +255,7 @@ class FakeAsusMouse { readonly zones: number[][]; liftOff = 0; battery = 80; + booster = false; private listeners = new Set<(event: HIDInputReportEvent) => void>(); @@ -221,7 +293,15 @@ class FakeAsusMouse { }); } - private reply(p: Uint8Array): Uint8Array { + private zoneId(index: number): number { + return WIRE_ZONES.indexOf(this.model.zones[index]); + } + + private zoneIndex(id: number): number { + return this.model.zones.indexOf(WIRE_ZONES[id]); + } + + reply(p: Uint8Array): Uint8Array { const r = new Uint8Array(ASUS_REPORT_SIZE); if (this.asleep) { r.set([0xff, 0xaa]); @@ -243,8 +323,8 @@ class FakeAsusMouse { } else if (command === 0x1200) { r.set([this.profile, this.stage], 10); } else if (command === 0x1203) { - if (this.model.lightingAllZones) this.zones.forEach((z, i) => r.set(z, 4 + i * 5)); - else r.set(this.zones[p[2]], 4); + if (this.model.lightingAllZones) this.zones.forEach((z, i) => r.set(z, 4 + this.zoneId(i) * 5)); + else r.set(this.zones[this.zoneIndex(p[2])], 4); } else if (command === 0x1206) { r[7] = this.liftOff; } else if (command === 0x1207) { @@ -259,7 +339,9 @@ class FakeAsusMouse { this.fields[p[2] - stages] = p[4]; } } else if (command === 0x5128) { - this.zones[p[2]] = [...p.subarray(4, 9)]; + this.zones[this.zoneIndex(p[2])] = [...p.subarray(4, 9)]; + } else if (command === 0x7d20) { + r[4] = this.booster ? 0x01 : 0x00; } else if (command === 0x5002) { this.profile = p[2]; } else if (command === 0x5135) { @@ -281,11 +363,150 @@ function sentWith(fake: FakeAsusMouse, ...prefix: number[]): number[][] { .map((p) => [...p.subarray(0, 9)]); } -test("only claims product ids in the table", () => { +function collection(usagePage: number, reportId: number) { + return { + usagePage, + usage: ASUS_USAGE, + children: [], + featureReports: [], + inputReports: [{ reportId, items: [] }], + outputReports: [{ reportId, items: [] }], + }; +} + +/** Receiver stand-in: answers its own commands on report 1 and relays report 3 to a paired Harpe Ace. */ +class FakeOmniReceiver { + readonly vendorId = ASUS_VENDOR_ID; + readonly productId = ASUS_OMNI_PRODUCT_ID; + opened = false; + collections = [collection(ASUS_OMNI_USAGE_PAGE, 1), collection(0xff00, 2), collection(ASUS_USAGE_PAGE, 3)]; + + readonly mouse = new FakeAsusMouse(0x1a94); + readonly sent: Array<{ reportId: number; data: Uint8Array }> = []; + /** Product id, report id, notify report id; captured from hardware. */ + slots: number[][] = [[0x1a85, 2, 4], [0x1a94, 3, 5]]; + + private listeners = new Set<(event: HIDInputReportEvent) => void>(); + + async open(): Promise { + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + addEventListener(type: string, listener: (event: HIDInputReportEvent) => void): void { + if (type === "inputreport") this.listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: HIDInputReportEvent) => void): void { + if (type === "inputreport") this.listeners.delete(listener); + } + + async sendReport(reportId: number, data: BufferSource): Promise { + const request = new Uint8Array(data as ArrayBuffer); + this.sent.push({ reportId, data: request }); + const reply = reportId === ASUS_OMNI_REPORT_ID ? this.receiverReply(request) : this.mouse.reply(request); + if (!reply) return; + + queueMicrotask(() => { + for (const listener of this.listeners) { + listener({ reportId, data: new DataView(reply.buffer, 0, ASUS_OMNI_REPORT_SIZE) } as HIDInputReportEvent); + } + }); + } + + private receiverReply(p: Uint8Array): Uint8Array | null { + const r = new Uint8Array(ASUS_OMNI_REPORT_SIZE); + if (p[0] === 0xa0) { + r.set([0xa0, 0x00, this.slots.length, 0x00]); + this.slots.forEach(([pid, rid, nid], i) => r.set([pid & 0xff, pid >> 8, rid, nid], 4 + i * 4)); + return r; + } + if (p[0] === 0xa1 && p[1] === 0x01) { + r.set([0xa1, 0x01, 0x00, 0x00, 0x04, 0x00, 0x07, 0x00]); + return r; + } + if (p[0] === 0xa2 && p[1] === 0x01) { + const pid = p[6] | (p[7] << 8); + this.slots = this.slots.filter(([slotPid]) => slotPid !== pid); + } + return null; + } +} + +function connectOmni(): { fake: FakeOmniReceiver; client: AsusHidClient } { + const fake = new FakeOmniReceiver(); + return { fake, client: new AsusHidClient(fake as unknown as HIDDevice) }; +} + +test("claims table mice and the Omni receiver's control collection", () => { assert.equal(AsusHidClient.isSupported(new FakeAsusMouse(0x1845) as unknown as HIDDevice), true); + assert.equal(AsusHidClient.isSupported(new FakeOmniReceiver() as unknown as HIDDevice), true); + + // An Omni id without the receiver's 0xFF02 collection is some other interface. + const otherInterface = Object.assign(new FakeAsusMouse(0x1845), { productId: ASUS_OMNI_PRODUCT_ID }); + assert.equal(AsusHidClient.isSupported(otherInterface as unknown as HIDDevice), false); +}); + +test("drives the paired Harpe Ace through the Omni receiver on its own report", async () => { + const { fake, client } = connectOmni(); + fake.mouse.booster = true; + fake.mouse.fields[0] = 0x06; + + const status = await client.readStatus(); + + assert.equal(status.name, "ROG Harpe Ace Aim Lab Edition"); + assert.equal(status.connectionDetail, "ROG Omni receiver"); + assert.deepEqual(status.firmware, ["Receiver 7.00.04"]); + assert.deepEqual(status.asusOmni, { + firmware: "7.00.04", + devices: [ + { productId: 0x1a85, kind: "keyboard", name: "ROG Azoth" }, + { productId: 0x1a94, kind: "mouse", name: "ROG Harpe Ace Aim Lab Edition" }, + ], + }); + assert.equal(status.pollingRateHz, 8000); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); + + for (const { reportId, data } of fake.sent) { + assert.equal(data.length, ASUS_OMNI_REPORT_SIZE); + assert.equal(reportId, data[0] >= 0xa0 && data[0] <= 0xa2 ? 1 : 3); + } + + await client.setLighting({ ...status.lightingZones![0], mode: "Static", color: "#ff0000", brightness: 100 }); + assert.deepEqual(fake.mouse.zones, [[0x00, 100, 0xff, 0x00, 0x00]]); +}); + +test("offers only 125-1000 Hz without the polling booster", async () => { + const { client } = connectOmni(); + const status = await client.readStatus(); + + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + await assert.rejects(client.setPollingRate(8000), /8000 Hz is not supported/); +}); + +test("reports a receiver with no mouse paired without reading mouse settings", async () => { + const { fake, client } = connectOmni(); + fake.slots = [[0x1a85, 2, 4]]; + + const status = await client.readStatus(); + + assert.equal(status.ui?.settingsReady, false); + assert.deepEqual(status.asusOmni?.devices.map((device) => device.name), ["ROG Azoth"]); + assert.ok(fake.sent.every(({ reportId }) => reportId === ASUS_OMNI_REPORT_ID)); + assert.throws(() => client.model, /No supported mouse is paired/); +}); + +test("unpairs by product id and waits until the pair list drops it", async () => { + const { fake, client } = connectOmni(); + + await client.unpairOmniDevice(0x1a85); - const omni = Object.assign(new FakeAsusMouse(0x1845), { productId: 0x1ace }); - assert.equal(AsusHidClient.isSupported(omni as unknown as HIDDevice), false); + const unpair = fake.sent.find(({ data }) => data[0] === 0xa2 && data[1] === 0x01); + assert.deepEqual([...unpair!.data.subarray(0, 8)], [0xa2, 0x01, 0x00, 0x00, 0x05, 0x0b, 0x85, 0x1a]); + assert.deepEqual(fake.slots, [[0x1a94, 3, 5]]); }); test("reads a Harpe Ace receiver's DPI from the X/Y block, plus battery", async () => { diff --git a/src/drivers/asus/hid.ts b/src/drivers/asus/hid.ts index 0904c42..923f3e0 100644 --- a/src/drivers/asus/hid.ts +++ b/src/drivers/asus/hid.ts @@ -1,14 +1,28 @@ -import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.js"; +import type { AsusOmniDevice, AsusOmniInfo, MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.js"; import { ASUS_DEBOUNCE_MS, ASUS_MICE, + ASUS_OMNI_KEYBOARDS, + ASUS_OMNI_PRODUCT_ID, + ASUS_OMNI_REPORT_ID, + ASUS_OMNI_REPORT_SIZE, + ASUS_OMNI_USAGE_PAGE, ASUS_POLLING_RATES, ASUS_REPORT_ID, ASUS_REPORT_SIZE, ASUS_USAGE, ASUS_USAGE_PAGE, ASUS_VENDOR_ID, + asusOmniBoosterRequest, + asusOmniDecodeFirmware, + asusOmniDecodePairedDevices, + asusOmniDeviceKind, + asusOmniFirmwareRequest, + asusOmniPairedDevicesRequest, + asusOmniPairingModeRequest, + asusOmniRebootRequest, + asusOmniUnpairRequest, asusDecodeBattery, asusDecodeDpiColors, asusDecodeDpiXY, @@ -36,6 +50,7 @@ import { type AsusLightingEffect, type AsusLiftOffDistance, type AsusMouseModel, + type AsusOmniSlot, type AsusProfile, type AsusRawLightingZone, type AsusRgb, @@ -44,6 +59,12 @@ import { const RESPONSE_TIMEOUT_MS = 1000; +const UNPAIR_TIMEOUT_MS = 10_000; + +const UNPAIR_POLL_MS = 500; + +const OMNI_NAME = "ROG Omni receiver"; + const COLOR_MODES: readonly MouseLightingMode[] = ["Static", "Breathing single", "Reactive", "Wave"]; const BRIGHTNESS_LEVELS = [0, 25, 50, 75, 100] as const; @@ -52,17 +73,28 @@ function copyDataView(view: DataView): Uint8Array { return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); } -function hasConfigCollection(collections: readonly HIDCollectionInfo[]): boolean { +function hasConfigCollection(collections: readonly HIDCollectionInfo[], usagePage: number, reportId: number): boolean { return collections.some( (collection) => - (collection.usagePage === ASUS_USAGE_PAGE && + (collection.usagePage === usagePage && collection.usage === ASUS_USAGE && - collection.inputReports.some((report) => report.reportId === ASUS_REPORT_ID) && - collection.outputReports.some((report) => report.reportId === ASUS_REPORT_ID)) || - hasConfigCollection(collection.children), + collection.inputReports.some((report) => report.reportId === reportId) && + collection.outputReports.some((report) => report.reportId === reportId)) || + hasConfigCollection(collection.children, usagePage, reportId), ); } +function omniDevice(slot: AsusOmniSlot): AsusOmniDevice { + const name = ASUS_MICE.get(slot.productId)?.name + ?? ASUS_OMNI_KEYBOARDS.get(slot.productId) + ?? `ASUS device 0x${slot.productId.toString(16).padStart(4, "0")}`; + return { productId: slot.productId, kind: asusOmniDeviceKind(slot), name }; +} + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function hexByte(value: number): string { return value.toString(16).padStart(2, "0"); } @@ -80,24 +112,46 @@ function parseHexColor(color: string | null): [number, number, number] { export class AsusHidClient { readonly device: HIDDevice; - readonly model: AsusMouseModel; + readonly isOmni: boolean; + + /** On an Omni receiver, the paired mouse from the last pair-list read. */ + private pairedModel: AsusMouseModel | null; + + private mouseReportId = ASUS_REPORT_ID; + + private mouseProductId: number | null = null; + + private booster = false; private queue: Promise = Promise.resolve(); constructor(device: HIDDevice) { - const model = ASUS_MICE.get(device.productId); - if (!model) { + this.isOmni = device.productId === ASUS_OMNI_PRODUCT_ID; + this.pairedModel = ASUS_MICE.get(device.productId) ?? null; + if (!this.pairedModel && !this.isOmni) { throw new Error(`ASUS product 0x${device.productId.toString(16)} is not supported.`); } this.device = device; - this.model = model; } static isSupported(device: HIDDevice): boolean { - return ( - device.vendorId === ASUS_VENDOR_ID && ASUS_MICE.has(device.productId) && hasConfigCollection(device.collections) - ); + if (device.vendorId !== ASUS_VENDOR_ID) return false; + if (device.productId === ASUS_OMNI_PRODUCT_ID) { + return hasConfigCollection(device.collections, ASUS_OMNI_USAGE_PAGE, ASUS_OMNI_REPORT_ID); + } + return ASUS_MICE.has(device.productId) && hasConfigCollection(device.collections, ASUS_USAGE_PAGE, ASUS_REPORT_ID); + } + + get model(): AsusMouseModel { + if (!this.pairedModel) { + throw new Error(`No supported mouse is paired to this ${OMNI_NAME}.`); + } + return this.pairedModel; + } + + private get label(): string { + return this.pairedModel?.name ?? OMNI_NAME; } async open(): Promise { @@ -122,7 +176,7 @@ export class AsusHidClient { } getSupportedPollingRates(): number[] { - return [...ASUS_POLLING_RATES]; + return ASUS_POLLING_RATES.filter((rate) => this.booster || rate <= 1000); } private async run(task: () => Promise): Promise { @@ -131,11 +185,21 @@ export class AsusHidClient { return started; } + /** Report 0 is the mouse's own 64-byte channel; every Omni report is 63 bytes. */ + private payload(request: Uint8Array, reportId: number): ArrayBuffer { + const size = reportId === ASUS_REPORT_ID ? ASUS_REPORT_SIZE : ASUS_OMNI_REPORT_SIZE; + const payload = new ArrayBuffer(size); + new Uint8Array(payload).set(request.subarray(0, size)); + return payload; + } + /** Sends `request` and resolves with the first reply echoing its first `matchLength` bytes. */ - private async exchange(request: Uint8Array, matchLength = 3): Promise { + private async exchange(request: Uint8Array, matchLength = 3, reportId = this.mouseReportId): Promise { return this.run(async () => { await this.open(); + const payload = this.payload(request, reportId); + return new Promise((resolve, reject) => { const cleanup = (): void => { clearTimeout(timer); @@ -143,15 +207,15 @@ export class AsusHidClient { }; const listener = (event: HIDInputReportEvent): void => { - if (event.reportId !== ASUS_REPORT_ID) return; + if (event.reportId !== reportId) return; const data = copyDataView(event.data); - if (data.length < ASUS_REPORT_SIZE) return; + if (data.length < payload.byteLength) return; // `FF AA` is the firmware's reply while asleep, out of range, or refusing a request. if (data[0] === 0xff && data[1] === 0xaa) { cleanup(); - reject(new Error(`${this.model.name} is asleep or refused the request.`)); + reject(new Error(`${this.label} is asleep or refused the request.`)); return; } @@ -160,20 +224,17 @@ export class AsusHidClient { } cleanup(); - resolve(data.subarray(0, ASUS_REPORT_SIZE)); + resolve(data.subarray(0, payload.byteLength)); }; const timer = setTimeout(() => { this.device.removeEventListener("inputreport", listener); - reject(new Error(`${this.model.name} did not answer the HID request.`)); + reject(new Error(`${this.label} did not answer the HID request.`)); }, RESPONSE_TIMEOUT_MS); this.device.addEventListener("inputreport", listener); - const payload = new ArrayBuffer(request.byteLength); - new Uint8Array(payload).set(request); - - this.device.sendReport(ASUS_REPORT_ID, payload).catch((error: unknown) => { + this.device.sendReport(reportId, payload).catch((error: unknown) => { cleanup(); reject(error); }); @@ -181,12 +242,69 @@ export class AsusHidClient { }); } + /** For receiver commands that GearLink sends without waiting for a reply. */ + private async sendToReceiver(request: Uint8Array): Promise { + if (!this.isOmni) { + throw new Error(`The ${this.label} is not a ${OMNI_NAME}.`); + } + + await this.run(async () => { + await this.open(); + await this.device.sendReport(ASUS_OMNI_REPORT_ID, this.payload(request, ASUS_OMNI_REPORT_ID)); + }); + } + + /** Reads the pair list and switches to whichever supported mouse it names. */ + async readOmniReceiver(): Promise { + const firmware = asusOmniDecodeFirmware(await this.exchange(asusOmniFirmwareRequest(), 2, ASUS_OMNI_REPORT_ID)); + const slots = asusOmniDecodePairedDevices( + await this.exchange(asusOmniPairedDevicesRequest(), 2, ASUS_OMNI_REPORT_ID), + ); + + const mouse = slots.find((slot) => asusOmniDeviceKind(slot) === "mouse" && ASUS_MICE.has(slot.productId)); + if (!mouse) { + this.pairedModel = null; + this.mouseProductId = null; + this.booster = false; + } else if (mouse.productId !== this.mouseProductId || mouse.reportId !== this.mouseReportId) { + this.booster = (await this.exchange(asusOmniBoosterRequest(), 3, mouse.reportId))[4] === 0x01; + this.pairedModel = { ...ASUS_MICE.get(mouse.productId)!, wireless: true }; + this.mouseReportId = mouse.reportId; + this.mouseProductId = mouse.productId; + } + + return { firmware, devices: slots.map(omniDevice) }; + } + + async setOmniPairingMode(enabled: boolean): Promise { + await this.sendToReceiver(asusOmniPairingModeRequest(enabled)); + } + + /** Resolves once the pair list no longer holds `productId`. */ + async unpairOmniDevice(productId: number): Promise { + await this.sendToReceiver(asusOmniUnpairRequest(productId)); + + const deadline = Date.now() + UNPAIR_TIMEOUT_MS; + while (Date.now() < deadline) { + await wait(UNPAIR_POLL_MS); + const { devices } = await this.readOmniReceiver(); + if (!devices.some((device) => device.productId === productId)) return; + } + + throw new Error(`The ${OMNI_NAME} still lists device 0x${productId.toString(16)}.`); + } + + /** The receiver drops off USB and comes back, so this client's device handle ends here. */ + async rebootOmniReceiver(): Promise { + await this.sendToReceiver(asusOmniRebootRequest()); + } + private async save(): Promise { await this.exchange(asusSaveRequest(), 2); } private async readSettings(): Promise { - const settings = asusDecodeSettings(this.model, await this.exchange(asusReadSettingsRequest())); + const settings = asusDecodeSettings(this.model, await this.exchange(asusReadSettingsRequest()), this.booster); if (this.model.dpiXY) { settings.dpiStages = asusDecodeDpiXY(this.model, await this.exchange(asusReadDpiXYRequest())); @@ -283,6 +401,10 @@ export class AsusHidClient { } async setPollingRate(rate: number): Promise { + if (!this.getSupportedPollingRates().includes(rate)) { + throw new Error(`${rate} Hz is not supported by the ${this.model.name} on this connection.`); + } + await this.exchange(asusSetPollingRateRequest(this.model, rate)); await this.save(); @@ -396,7 +518,36 @@ export class AsusHidClient { return (await this.readLighting())[zone]; } + /** An Omni receiver with no supported mouse: only the receiver card has anything to show. */ + private receiverOnlyStatus(omni: AsusOmniInfo): MouseStatus { + return { + brand: "ASUS", + name: OMNI_NAME, + ui: { + family: "asus", + settingsReady: false, + statusNote: `No supported mouse is paired to this ${OMNI_NAME}. Pair one under Advanced.`, + defaultDisplayName: OMNI_NAME, + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: "Wireless", + connectionDetail: OMNI_NAME, + liftOffDistance: null, + firmware: [`Receiver ${omni.firmware}`], + asusOmni: omni, + }; + } + async readStatus(): Promise { + const omni = this.isOmni ? await this.readOmniReceiver() : undefined; + if (omni && !this.pairedModel) { + return this.receiverOnlyStatus(omni); + } + const { model } = this; const settings = await this.readSettings(); @@ -449,7 +600,7 @@ export class AsusHidClient { profileCount: model.profiles, connectionType: model.wireless ? "Wireless" : "Wired", - connectionDetail: model.wireless ? "2.4 GHz receiver" : "Wired USB", + connectionDetail: omni ? OMNI_NAME : model.wireless ? "2.4 GHz receiver" : "Wired USB", debounceMs: model.debounce ? settings.debounceMs : null, angleSnapping: settings.angleSnapping, @@ -460,7 +611,8 @@ export class AsusHidClient { lighting: lightingZones[0], lightingZones, - firmware: [], + firmware: omni ? [`Receiver ${omni.firmware}`] : [], + asusOmni: omni, }; } } diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ddc7bce..5f8de42 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -152,6 +152,17 @@ export interface AtkReceiverInfo { pairingSecondsRemaining: number | null; } +export interface AsusOmniDevice { + productId: number; + kind: "mouse" | "keyboard" | "unknown"; + name: string; +} + +export interface AsusOmniInfo { + firmware: string; + devices: AsusOmniDevice[]; +} + export interface MagneticButtonStatus { /** Null when the mouse has a single switch type or did not report it. */ switchType: "magnetic" | "optical" | null; @@ -231,6 +242,8 @@ export interface MouseStatus { atkButtonMappings?: AtkStoredButton[]; /** Receiver and pairing telemetry. Pairing controls are intentionally separate. */ atkReceiver?: AtkReceiverInfo; + /** ROG Omni receiver firmware and paired devices. */ + asusOmni?: AsusOmniInfo; /** * F1 Ultimate sensor sampling-rate mode: 0 Basic, 1 Shard, 2 Shard MAX. * Null until read; drivers without the 0x00b5 row leave it undefined. diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 17c9faa..1e7a57d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,4 +1,6 @@ import { + ASUS_OMNI_PRODUCT_ID, + ASUS_OMNI_USAGE_PAGE, ASUS_PRODUCT_IDS, ASUS_USAGE, ASUS_USAGE_PAGE, @@ -199,12 +201,15 @@ export const VENDOR_ID = { * ASUS ROG / TUF configuration interface (usage page 0xFF01, usage 0x0001), * verified on the Gladius II P502 and assumed for the rest of the table. */ -export const ASUS_HID_FILTERS: HIDDeviceFilter[] = ASUS_PRODUCT_IDS.map((productId) => ({ - vendorId: ASUS_VENDOR_ID, - productId, - usagePage: ASUS_USAGE_PAGE, - usage: ASUS_USAGE, -})); +export const ASUS_HID_FILTERS: HIDDeviceFilter[] = [ + ...ASUS_PRODUCT_IDS.map((productId) => ({ + vendorId: ASUS_VENDOR_ID, + productId, + usagePage: ASUS_USAGE_PAGE, + usage: ASUS_USAGE, + })), + { vendorId: ASUS_VENDOR_ID, productId: ASUS_OMNI_PRODUCT_ID, usagePage: ASUS_OMNI_USAGE_PAGE, usage: ASUS_USAGE }, +]; export const MCHOSE_A5_HID_FILTERS: HIDDeviceFilter[] = [ ...[...MCHOSE_A5_GEN1_PRODUCTS.keys()].map((productId) => ({