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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/asus/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
24 changes: 17 additions & 7 deletions src/asus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import type { AsusMouseModel } from "./devices.js";

export * from "./devices.js";
export * from "./omni.js";

export const ASUS_VENDOR_ID = 0x0b05;

Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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")}.`);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
122 changes: 122 additions & 0 deletions src/asus/omni.ts
Original file line number Diff line number Diff line change
@@ -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<Record<number, AsusOmniDeviceKind>> = { 2: "keyboard", 3: "mouse" };

/** Keyboard product ids as they appear in the pair list (from G-Helper). */
export const ASUS_OMNI_KEYBOARDS: ReadonlyMap<number, string> = 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])}`;
}
Loading
Loading