diff --git a/docs/ajazz-aj200p.md b/docs/ajazz-aj200p.md new file mode 100644 index 00000000..272fcba0 --- /dev/null +++ b/docs/ajazz-aj200p.md @@ -0,0 +1,152 @@ +# AJAZZ AJ200 / AJ200P AI family + +This driver uses VID `0x363c`, usage `0xffa0:0x02`, output report `0x0b` +(127-byte body), and input reports `0x0a`/`0x0b`. It is independent of the +NJ07/NJ08 and GearHub AJAZZ protocols. + +## Device catalogue + +All IDs below share the configuration command family in AJAZZ desktop +configurator 2.2.5. Capability data is from that configurator's device catalogue +and packet encoders. This implementation contains original codecs and tests; +no vendor code, firmware binaries, serial numbers or radio addresses are included. + +| Model | Sensor | Wired PID | Receiver PIDs | Hardware verification | +| --- | --- | --- | --- | --- | +| AJ200 NL AI MC | PAW3311 | `ed03` | `ed05` | Not tested | +| AJ200P NL AI MC | PAW3311 | `ed06` | `ed00` | Not tested | +| AJ200 NL AI PRO+ | PAW3395 | `ed07` | `ed08`, `fc08` | Not tested | +| AJ200P NL AI ULTRA | PAW3950 | `ed09` | `ed0a`, `fc0a` | Not tested | +| AJ200P NL AI ULTRA+ | PAW3950 | `ed0b` | `ed0c`, `fc0c` | Not tested | +| AJ200P NL AI ULTRA-3955 | PAW3955 | `ed1d` | `ed1e`, `fc1e` | Not tested | +| AJ200P AI MASTER | PAW3955 | `ed1b` | `ed1c`, `fc1c` | **Only `ed1c`, firmware 3.3.13** | +| AJ200P NL AI S ULTRA | PAW3955 | `ed25` | `ed26`, `fc26` | Not tested | + +The PAW3955 `fc1e` nano receiver is named AJ200P NL AI ULTRA and advertises +8 kHz, whereas `ed1e` advertises 4 kHz. ULTRA+ excludes 125 Hz wirelessly. +These differences are retained rather than inferred from model names. + +## Packet layout + +The report ID is separate from the body. Outgoing bodies are zero-padded to 127 +bytes: `[command, payloadLength, ...payload]`. Incoming bodies start with +`[command, length, status, ...payload]`; zero status means success. Reply length +includes the status byte except for DPI tables, where it counts data only. + +| Command | Operation | Payload bytes | +| --- | --- | --- | +| `0x13` | Read identity, firmware, battery and active bank | 54 | +| `0x15` / `0x16` | Read / write button map | 21 or 33 | +| `0x17` / `0x18` | Read / write settings | 30, 31 or 39 for the listed products | +| `0x19` / `0x1a` | Read / write all eight DPI slots | 48 for legacy sensors; 64 for PAW3955 | +| `0x21` | Select active onboard bank | One zero-based bank index, `0..4` | + +Read requests have no payload. Writes return an acknowledgement; settings, key +maps and DPI tables are then read back in full. Offsets below refer to payloads, +without report ID, command, length or status. + +| Settings offset | Meaning | +| --- | --- | +| 0 / 1 | Wireless / wired polling-rate code | +| 2 / 3 | Enabled DPI count / zero-based active slot | +| 6 | LOD code, constrained by the product catalogue | +| 7 | Debounce in milliseconds, `0..20` | +| 8 / 10 | Motion Sync / angle snapping | +| 11 | Three-state ripple control, preserved | +| 15 | Sensor power saving: `1` enabled, `0` performance | +| 21–23 | DPI lighting theme RGB, separate from DPI-slot colours | +| 25 / 26 | Normal / deep sleep in 10-second units | +| 27 / 28 / 29 | DPI theme effect / brightness / speed | +| 30 | Optional BLE setting, preserved | +| 31–38 | Optional flywheel settings, preserved | + +Button entries are three bytes: `[actionType, argument1, argument2]`. The seventh +wire entry (`key7`, zero-based slot 6, bytes 18–20) is retained without assigning +it a physical button. Wheel entries follow it on flywheel models. + +Legacy DPI entries are six bytes. They pack two 10-bit, 50-DPI-unit axis values +minus one plus RGB; unused bits in the packed byte are preserved. PAW3955 entries +are eight bytes, with a shared high-nibble byte, two 16-bit little-endian axis +values and RGB. Both encodings are decoded and patched by the pure codec. + +## Supported settings + +- Five firmware-managed onboard banks; edit the active bank after selecting it. +- Six physical button entries; ULTRA+ and MASTER add four wheel/tilt entries. + Wire slot 7 (`key7`) has no corresponding control in the vendor UI and is + preserved without exposing a fictitious AI button. The vendor's separate + AI features attach to the forward/back buttons instead. +- Mouse buttons, keyboard keys/modifiers including the numpad, consumer/system + controls (media, volume, brightness, browser and app launch), DPI actions and + disabled buttons. Existing shortcuts, macros and unknown actions are preserved. +- Eight DPI slots, active slot and enabled slot count, separate X/Y values and + per-slot RGB colours. Legacy sensors use six-byte/50-DPI entries. PAW3955 uses + eight-byte entries with independent 20-bit axes, 1–80000 DPI. +- Model/connection-specific polling rates, LOD where supported, 0–20 ms debounce, + Motion Sync, angle snapping and sensor performance/power-saving mode. +- One sleep control writes both normal and deep-sleep timers in the same settings + block. Deep sleep is set to normal sleep plus 60 seconds, in the protocol's + 10-second units (including 1800/1860 seconds at the upper end). Reading does not + change existing independent timers. The UI explains the coupling before saving. +- A separate DPI lighting theme colour, off/static/breathing, brightness and speed. + +Three-state ripple control, BLE options and flywheel tuning are +preserved byte-for-byte. They are not reduced to unrelated generic controls. +Macro bodies, AI service features, receiver lighting, pairing, reset and firmware +update are outside this change. + +## Experimental button actions + +`[8,255,0]` cycles banks; `[8,0..4,0]` selects a bank. `[7,0,0]` cycles polling +rate; `[7,code,0]` chooses one rate (`125:8`, `250:4`, `500:2`, `1000:1`, +`2000:16`, `4000:32`, `8000:64`). These actions are exposed across the family +with an experimental connection-status note outside MASTER firmware 3.3.13. +Fixed-rate choices respect the current model/connection's advertised rates. +The cycle action's exact sequence on other firmware is unverified. + +Evidence for MASTER 3.3.13 is static analysis of its key-action dispatcher: +type 7 at `0x901393da`, rate table at `0x901334d4`; type 8 at `0x90139400`, +next-bank routine at `0x901333ae`. Bank cycling has also been invoked using the +physical bottom button. The other three paths have not yet been physically +invoked. Types 12 and 13 have no established effect and are not offered. +Short-press mapping does not rewrite the firmware's long-press pairing handler. + +## Transaction and verification rules + +Entire read/modify/write operations are queued. Every write waits for its +acknowledgement, settles, then reads back the complete block. Acknowledgement +alone is not success. The driver checks the active bank before and after a +write and refuses a stale bank after a physical profile change. + +Settings are 30 bytes plus an optional BLE byte and eight flywheel bytes. +Key maps are 21 or 33 bytes; DPI tables are 48 or 64. Unknown layouts are +rejected. Input length includes status except for the DPI-table response. +Status reads do not switch profiles or write configuration. + +The MASTER receiver has been exercised through this driver on Windows, including +identity, battery, the full eleven-slot key map, DPI, lighting and settings. +Write/read-back checks cover key assignment, separate DPI axes, stage values, +colours and count, active stage, polling, LOD, debounce (including 0 ms), Motion +Sync, angle snapping, sensor performance/power-saving mode and lighting. +Coupled normal/deep sleep was accepted at 10/70, 180/240 and 1800/1860 seconds. +These checks verify stored timer bytes, not elapsed sleep/wake timing. +The values survived closing and reopening the HID connection; power cycling has +not been verified. Tests restored fresh snapshots of the user's settings. + +Earlier hardware experiments verified stored bank cycling and both kinds of RGB +data across all five banks, with the configurator closed. Other listed interfaces +remain catalogue-based and covered by simulated transport tests only. + +## OpenMouse integration + +The public `@openmouse/protocol/ajazz/aj200p` subpath exports codecs and the +catalogue. The root namespace is `ajazzAj200p`; the registered WebHID client is +`AjazzAj200pHidClient`. Settings use existing `MouseStatus` fields and setters. +Two optional, declarative `MouseUiHints` additions let consumers keep lighting +controls in one place (`hideAdvancedLighting`) and explain coupled sleep timers +(`sleepNote`). Consumers that do not use these hints remain compatible. + +The OpenMouse companion changes enable this family's generic settings cards, +editable per-DPI-stage colours and staged-edit protection when the physical +button switches banks. They are being tested locally and have not yet been +submitted as a separate application PR. diff --git a/package.json b/package.json index df2a8035..55cc1de9 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,10 @@ "types": "./dist/ajazz/index.d.ts", "import": "./dist/ajazz/index.js" }, + "./ajazz/aj200p": { + "types": "./dist/ajazz/aj200p.d.ts", + "import": "./dist/ajazz/aj200p.js" + }, "./coolermaster": { "types": "./dist/coolermaster/index.d.ts", "import": "./dist/coolermaster/index.js" diff --git a/scripts/fast-test-timers.mjs b/scripts/fast-test-timers.mjs index 30a93ab2..d69dd2f3 100644 --- a/scripts/fast-test-timers.mjs +++ b/scripts/fast-test-timers.mjs @@ -21,6 +21,8 @@ import { sep } from "node:path"; /** Test files that assert real elapsed timing and must keep real timers. */ const TIMING_ASSERTING = [ + // Delayed acknowledgements must arrive before the driver's reply timeout. + "ajazz/aj200p-hid.test.ts", // Receiver writes must keep a minimum real gap between overlapping calls. "attackshark/hid.test.ts", // Magnetic-button calibration follows live readings across a settle window. diff --git a/src/ajazz/aj200p-products.ts b/src/ajazz/aj200p-products.ts new file mode 100644 index 00000000..e4752e57 --- /dev/null +++ b/src/ajazz/aj200p-products.ts @@ -0,0 +1,113 @@ +/** Protocol capabilities from the AJAZZ 2.2.5 device catalogue, not product-name guesses. */ +export interface Aj200pProduct { + name: string; + sensor: "PAW3311" | "PAW3395" | "PAW3950" | "PAW3955"; + wired: boolean; + verified: boolean; + minDpi: number; + maxDpi: number; + stepDpi: number; + flywheel: boolean; + bleSetting: boolean; + lodCodes: readonly number[]; + wirelessRates: readonly number[]; + wiredRates: readonly number[]; + /** Only MASTER 3.3.13's key-action dispatcher has been examined. */ + hiddenActionsAnalyzed: boolean; +} +const RATE_1K = [125, 250, 500, 1000]; +const RATE_4K = [...RATE_1K, 2000, 4000]; +const RATE_8K = [...RATE_4K, 8000]; +const catalogue = new Map(); +function model( + name: string, + sensor: Aj200pProduct["sensor"], + maxDpi: number, + ids: readonly [number, boolean][], + features: { + flywheel?: boolean; + wirelessRates?: readonly number[]; + lodCodes?: readonly number[]; + hiddenActions?: boolean; + } = {}, +) { + for (const [id, wired] of ids) + catalogue.set(id, { + name: `AJAZZ ${name}`, + sensor, + wired, + verified: id === 0xed1c, + minDpi: sensor === "PAW3955" ? 1 : 50, + maxDpi, + stepDpi: sensor === "PAW3955" ? 1 : 50, + flywheel: features.flywheel ?? false, + bleSetting: sensor !== "PAW3311", + lodCodes: + features.lodCodes ?? + (sensor === "PAW3311" ? [] : sensor === "PAW3395" ? [1, 2] : [0, 1, 2]), + wirelessRates: features.wirelessRates ?? (sensor === "PAW3311" ? RATE_1K : RATE_4K), + wiredRates: sensor === "PAW3311" ? RATE_1K : RATE_8K, + hiddenActionsAnalyzed: features.hiddenActions ?? false, + }); +} +model("AJ200 NL AI MC", "PAW3311", 24000, [ + [0xed03, true], + [0xed05, false], +]); +model("AJ200P NL AI MC", "PAW3311", 26000, [ + [0xed06, true], + [0xed00, false], +]); +model("AJ200 NL AI PRO+", "PAW3395", 32000, [ + [0xed07, true], + [0xed08, false], + [0xfc08, false], +]); +model("AJ200P NL AI ULTRA", "PAW3950", 42000, [ + [0xed09, true], + [0xed0a, false], + [0xfc0a, false], +]); +model( + "AJ200P NL AI ULTRA+", + "PAW3950", + 42000, + [ + [0xed0b, true], + [0xed0c, false], + [0xfc0c, false], + ], + { flywheel: true, wirelessRates: [250, 500, 1000, 2000, 4000, 8000] }, +); +model("AJ200P NL AI ULTRA-3955", "PAW3955", 80000, [ + [0xed1d, true], + [0xed1e, false], +]); +// The nano receiver has a distinct name and advertises 8 kHz in the catalogue. +model("AJ200P NL AI ULTRA", "PAW3955", 80000, [[0xfc1e, false]], { wirelessRates: RATE_8K }); +model( + "AJ200P AI MASTER", + "PAW3955", + 80000, + [ + [0xed1b, true], + [0xed1c, false], + [0xfc1c, false], + ], + { flywheel: true, wirelessRates: RATE_8K, hiddenActions: true }, +); +model( + "AJ200P NL AI S ULTRA", + "PAW3955", + 80000, + [ + [0xed25, true], + [0xed26, false], + [0xfc26, false], + ], + { wirelessRates: RATE_8K }, +); +export const AJ200P_PRODUCTS: ReadonlyMap = catalogue; +export function aj200pSettingsLength(product: Aj200pProduct): number { + return 30 + Number(product.bleSetting) + (product.flywheel ? 8 : 0); +} diff --git a/src/ajazz/aj200p.ts b/src/ajazz/aj200p.ts new file mode 100644 index 00000000..50fc4b63 --- /dev/null +++ b/src/ajazz/aj200p.ts @@ -0,0 +1,375 @@ +/** + * AJ200 / AJ200P AI-family codecs, including MASTER firmware 3.3.13. + * + * This is the 0x363c / 0xffa0 protocol, unrelated to NJ07/NJ08 or GearHub. + * All offsets below are relative to payloads, excluding command/length/status. + * See docs/ajazz-aj200p.md for evidence and verification boundaries. + */ +export const AJ200P_VENDOR_ID = 0x363c; +export const AJ200P_USAGE_PAGE = 0xffa0; +export const AJ200P_USAGE = 2; +export const AJ200P_OUTPUT_REPORT_ID = 0x0b; +export const AJ200P_REPORT_BYTES = 127; +export { AJ200P_PRODUCTS, aj200pSettingsLength, type Aj200pProduct } from "./aj200p-products.js"; +export const AJ200P_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; +const RATE_CODES = [8, 4, 2, 1, 16, 32, 64] as const; +export const AJ200P_SLEEP_SECONDS = [10, 30, 60, 120, 180, 300, 900, 1800] as const; +export const AJ200P_BUTTON_NAMES = [ + "Left", + "Middle", + "Right", + "Backward", + "Forward", + "Bottom", + "Scroll up", + "Scroll down", + "Tilt left", + "Tilt right", +] as const; +/** Wire slot 6 is the vendor's hidden `key7`, not an additional physical AI key. */ +export const AJ200P_BUTTON_SLOTS = [0, 1, 2, 3, 4, 5, 7, 8, 9, 10] as const; +export const AJ200P_COMMAND = { + info: 0x13, + keys: 0x15, + setKeys: 0x16, + settings: 0x17, + setSettings: 0x18, + dpi: 0x19, + setDpi: 0x1a, + profile: 0x21, +} as const; +export type Aj200pCommand = (typeof AJ200P_COMMAND)[keyof typeof AJ200P_COMMAND]; +export type Aj200pBinding = readonly [number, number, number]; +export interface Aj200pAction { + label: string; + binding: Aj200pBinding; +} + +export function aj200pInteger(value: number, min: number, max: number, name: string): number { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${name} must be an integer from ${min} to ${max}.`); + } + return value; +} + +export function aj200pEncode(command: Aj200pCommand, payload: ArrayLike = []): Uint8Array { + const lengths: Record = { + 0x13: [0], + 0x15: [0], + 0x16: [21, 33], + 0x17: [0], + 0x18: [30, 31, 38, 39], + 0x19: [0], + 0x1a: [48, 64], + 0x21: [1], + }; + if (!(command in lengths) || !lengths[command].includes(payload.length)) + throw new Error("Invalid AJ200P command or payload length."); + const body = new Uint8Array(AJ200P_REPORT_BYTES); + body[0] = command; + body[1] = payload.length; + for (let i = 0; i < payload.length; i++) + body[i + 2] = aj200pInteger(payload[i], 0, 255, "Payload byte"); + if (command === AJ200P_COMMAND.profile) aj200pInteger(payload[0], 0, 4, "Profile index"); + return body; +} + +/** Returns null for unrelated reports; malformed matching replies are errors. */ +export function aj200pDecodeReply( + reportId: number, + body: Uint8Array, + command: Aj200pCommand, +): Uint8Array | null { + if ((reportId !== 0x0a && reportId !== 0x0b) || body[0] !== command) return null; + if (body.length < 3) throw new Error("Truncated AJ200P response."); + if (body[2] !== 0) + throw new Error(`AJ200P command 0x${command.toString(16)} failed (status ${body[2]}).`); + // DPI tables count only data bytes; other replies include the status byte. + const length = command === AJ200P_COMMAND.dpi ? body[1] : body[1] - 1; + const required: Partial> = { + 0x13: [54], + 0x15: [21, 33], + 0x17: [30, 31, 38, 39], + 0x19: [48, 64], + }; + if ( + length < 0 || + body.length < 3 + length || + (required[command] !== undefined && !required[command]!.includes(length)) + ) { + throw new Error("Unsupported or truncated AJ200P response layout."); + } + return body.slice(3, 3 + length); +} + +export function aj200pDecodeRate(code: number): number | null { + const index = RATE_CODES.indexOf(code as (typeof RATE_CODES)[number]); + return index < 0 ? null : AJ200P_RATES[index]; +} +export function aj200pEncodeRate(hz: number): number { + const index = AJ200P_RATES.indexOf(hz as (typeof AJ200P_RATES)[number]); + if (index < 0) throw new RangeError("Unsupported AJ200P polling rate."); + return RATE_CODES[index]; +} +export function aj200pColor(data: ArrayLike, offset: number): string { + return "#" + [0, 1, 2].map((n) => data[offset + n].toString(16).padStart(2, "0")).join(""); +} +export function aj200pColorBytes(color: string): Uint8Array { + if (!/^#[0-9a-f]{6}$/i.test(color)) + throw new RangeError("Expected an RGB colour in #rrggbb format."); + return Uint8Array.from( + [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)), + ); +} +export function aj200pVersion(lo: number, hi: number): string { + return `${hi}.${lo >> 4}.${lo & 15}`; +} +export function aj200pDecodeInfo(data: Uint8Array) { + if (data.length !== 54) throw new Error("Unsupported AJ200P identity layout."); + aj200pInteger(data[17], 0, 4, "Active profile"); + const wireless = data[16] === 1; + return { + profile: data[17] + 1, + wireless, + // Do not expose the radio address or other identifying bytes. + receiverVersion: aj200pVersion(data[7], data[8]), + mouseVersion: aj200pVersion(data[9], data[10]), + battery: data[14] <= 100 ? data[14] : null, + charging: !wireless && data[13] === 1 && data[14] < 100, + }; +} + +export interface Aj200pSettings { + raw: Uint8Array; + wirelessRate: number | null; + wiredRate: number | null; + count: number; + active: number; + lod: number; + debounce: number; + motionSync: boolean; + angleSnapping: boolean; + ripple: number; + performanceMode: boolean | null; + sleepSeconds: number; + deepSleepSeconds: number; + color: string; + effect: number; + brightness: number; + speed: number; +} +export function aj200pDecodeSettings(data: Uint8Array): Aj200pSettings { + if (![30, 31, 38, 39].includes(data.length)) + throw new Error("Unsupported AJ200P settings layout."); + aj200pInteger(data[2], 1, 8, "DPI stage count"); + aj200pInteger(data[3], 0, data[2] - 1, "Active DPI stage"); + return { + raw: data.slice(), + wirelessRate: aj200pDecodeRate(data[0]), + wiredRate: aj200pDecodeRate(data[1]), + count: data[2], + active: data[3], + lod: data[6], + debounce: data[7], + motionSync: data[8] === 1, + angleSnapping: data[10] === 1, + ripple: data[11], + // The wire flag enables power saving, the inverse of performance mode. + performanceMode: data[15] === 0 ? true : data[15] === 1 ? false : null, + sleepSeconds: data[25] * 10, + deepSleepSeconds: data[26] * 10, + color: aj200pColor(data, 21), + effect: data[27], + brightness: data[28], + speed: data[29], + }; +} +export interface Aj200pDpiStage { + x: number; + y: number; + color: string; +} +export function aj200pDecodeDpi(data: Uint8Array): Aj200pDpiStage[] { + if (data.length !== 64 && data.length !== 48) throw new Error("Unsupported AJ200P DPI layout."); + const stride = data.length / 8; + return Array.from({ length: 8 }, (_, stage) => { + const offset = stage * stride; + if (stride === 6) + return { + x: (data[offset] + ((data[offset + 2] >> 6) << 8) + 1) * 50, + y: (data[offset + 1] + (((data[offset + 2] >> 2) & 3) << 8) + 1) * 50, + color: aj200pColor(data, offset + 3), + }; + return { + x: ((data[offset] & 15) << 16) | data[offset + 1] | (data[offset + 2] << 8), + y: ((data[offset] >> 4) << 16) | data[offset + 3] | (data[offset + 4] << 8), + color: aj200pColor(data, offset + 5), + }; + }); +} +/** Patch only the requested axes/colour; even inactive slots stay intact. */ +export function aj200pPatchDpi( + data: Uint8Array, + stage: number, + patch: Partial, +): Uint8Array { + const current = aj200pDecodeDpi(data); + aj200pInteger(stage, 0, 7, "DPI stage"); + const next = data.slice(); + const stride = data.length / 8; + const offset = stage * stride; + if (patch.x !== undefined || patch.y !== undefined) { + const x = patch.x ?? current[stage].x; + const y = patch.y ?? current[stage].y; + aj200pInteger(x, stride === 8 ? 1 : 50, stride === 8 ? 80000 : 51200, "X DPI"); + aj200pInteger(y, stride === 8 ? 1 : 50, stride === 8 ? 80000 : 51200, "Y DPI"); + if (stride === 6) { + if (x % 50 !== 0 || y % 50 !== 0) throw new RangeError("This sensor uses 50-DPI steps."); + const xc = x / 50 - 1, + yc = y / 50 - 1; + next[offset] = xc & 255; + next[offset + 1] = yc & 255; + next[offset + 2] = (next[offset + 2] & 0x33) | ((xc >> 8) << 6) | ((yc >> 8) << 2); + } else { + next[offset] = ((y >> 16) << 4) | (x >> 16); + next[offset + 1] = x & 255; + next[offset + 2] = (x >> 8) & 255; + next[offset + 3] = y & 255; + next[offset + 4] = (y >> 8) & 255; + } + } + if (patch.color !== undefined) next.set(aj200pColorBytes(patch.color), offset + stride - 3); + return next; +} + +export function aj200pDecodeKeys(data: Uint8Array): Aj200pBinding[] { + if (data.length !== 33 && data.length !== 21) + throw new Error("Unsupported AJ200P key-map layout."); + return Array.from( + { length: data.length / 3 }, + (_, i) => [data[i * 3], data[i * 3 + 1], data[i * 3 + 2]] as const, + ); +} + +const actions: Aj200pAction[] = []; +const add = (label: string, type: number, arg1 = 0, arg2 = 0) => + actions.push({ label, binding: [type, arg1, arg2] }); +add("Disabled", 0); +for (const [label, mask] of [ + ["Left click", 1], + ["Right click", 2], + ["Middle click", 4], + ["Backward", 8], + ["Forward", 16], +] as const) + add(label, 1, mask); +add("DPI loop", 2, 1); +add("DPI+", 2, 2); +add("DPI-", 2, 3); +add("Tilt left", 3, 1); +add("Tilt right", 3, 2); +add("Scroll up", 9, 1); +add("Scroll down", 9, 2); +add("Cycle profiles", 8, 255); +for (let i = 0; i < 5; i++) add(`Profile ${i + 1}`, 8, i); +// Firmware-confirmed actions; physical invocation evidence is documented separately. +add("Cycle polling rate", 7); +for (const hz of AJ200P_RATES) add(`Polling rate ${hz} Hz`, 7, aj200pEncodeRate(hz)); +for (const [label, usage] of [ + ["Mute", 0xe2], + ["Volume up", 0xe9], + ["Volume down", 0xea], + ["Play / pause", 0xcd], + ["Next track", 0xb5], + ["Previous track", 0xb6], + ["Stop", 0xb7], + ["Browser back", 0x224], + ["Browser forward", 0x225], + ["Browser home", 0x223], + ["Brightness up", 0x6f], + ["Brightness down", 0x70], + ["Media player", 0x183], + ["Mail", 0x18a], + ["Search", 0x221], + ["Refresh", 0x227], + ["Browser stop", 0x226], + ["Calculator", 0x192], + ["My Computer", 0x194], +] as const) + add(label, 10, usage & 255, usage >> 8); +for (let i = 0; i < 26; i++) add(String.fromCharCode(65 + i), 11, 4 + i); +for (let i = 0; i < 10; i++) add(String((i + 1) % 10), 11, 30 + i); +for (let i = 0; i < 12; i++) add(`F${i + 1}`, 11, 58 + i); +for (const [label, usage] of [ + ["Enter", 40], + ["Escape", 41], + ["Backspace", 42], + ["Tab", 43], + ["Space", 44], + ["-", 45], + ["=", 46], + ["[", 47], + ["]", 48], + ["\\", 49], + [";", 51], + ["'", 52], + ["`", 53], + [",", 54], + [".", 55], + ["/", 56], + ["Caps Lock", 57], + ["Print Screen", 70], + ["Scroll Lock", 71], + ["Pause", 72], + ["Insert", 73], + ["Home", 74], + ["Page Up", 75], + ["Delete", 76], + ["End", 77], + ["Page Down", 78], + ["Arrow right", 79], + ["Arrow left", 80], + ["Arrow down", 81], + ["Arrow up", 82], + ["Num Lock", 83], + ["Numpad /", 84], + ["Numpad *", 85], + ["Numpad -", 86], + ["Numpad +", 87], + ["Numpad 1", 89], + ["Numpad 2", 90], + ["Numpad 3", 91], + ["Numpad 4", 92], + ["Numpad 5", 93], + ["Numpad 6", 94], + ["Numpad 7", 95], + ["Numpad 8", 96], + ["Numpad 9", 97], + ["Numpad 0", 98], + ["Numpad .", 99], +] as const) + add(label, 11, usage); +for (const [i, label] of [ + "Left Ctrl", + "Left Shift", + "Left Alt", + "Left Windows", + "Right Ctrl", + "Right Shift", + "Right Alt", + "Right Windows", +].entries()) + add(label, 11, 0xe0 + i, 8); +export const AJ200P_ACTIONS: readonly Aj200pAction[] = actions; +export function aj200pFindAction(label: string): Aj200pBinding | null { + return AJ200P_ACTIONS.find((action) => action.label === label)?.binding ?? null; +} +export function aj200pBindingLabel(binding: Aj200pBinding): string { + const action = AJ200P_ACTIONS.find((candidate) => + candidate.binding.every((byte, i) => byte === binding[i]), + ); + if (action) return action.label; + if (binding[0] === 5) return "Custom shortcut"; + if (binding[0] === 6) return `Macro ${binding[1] + 1}`; + return `Custom (${binding.join(",")})`; +} diff --git a/src/drivers/ajazz/aj200p-hid.test.ts b/src/drivers/ajazz/aj200p-hid.test.ts new file mode 100644 index 00000000..cb4499b5 --- /dev/null +++ b/src/drivers/ajazz/aj200p-hid.test.ts @@ -0,0 +1,566 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + AJ200P_ACTIONS, + AJ200P_PRODUCTS, + AJ200P_RATES, + AJ200P_SLEEP_SECONDS, + aj200pBindingLabel, + aj200pColorBytes, + aj200pDecodeDpi, + aj200pDecodeInfo, + aj200pDecodeKeys, + aj200pDecodeRate, + aj200pDecodeReply, + aj200pDecodeSettings, + aj200pEncode, + aj200pEncodeRate, + aj200pFindAction, + aj200pPatchDpi, + aj200pSettingsLength, +} from "../../ajazz/aj200p.js"; +import { AjazzAj200pHidClient } from "./aj200p-hid.js"; +import { createSupportedClient, deviceBrand } from "../registry.ts"; +import { SUPPORTED_HID_FILTERS } from "../vendors.ts"; + +// Configuration-only payloads observed on firmware 3.3.13. These contain no +// serial number, radio address, host key events or other identifying data. +const SETTINGS = Uint8Array.from([ + 1, 32, 7, 3, 0, 0, 0, 1, 1, 6, 0, 2, 2, 0, 18, 1, 0, 0, 0, 0, 0, 255, 255, 255, 2, 18, 180, 2, + 255, 3, 0, 7, 1, 16, 5, 120, 0, 25, 10, +]); +const DPI = Uint8Array.from([ + 0, 144, 1, 144, 1, 255, 255, 255, 0, 32, 3, 32, 3, 255, 255, 255, 0, 176, 4, 176, 4, 255, 255, + 255, 0, 108, 7, 108, 7, 255, 255, 255, 0, 96, 9, 96, 9, 255, 255, 255, 0, 128, 12, 128, 12, 255, + 255, 255, 17, 128, 56, 128, 56, 255, 255, 255, 0, 52, 8, 52, 8, 255, 255, 255, +]); +const KEYS = Uint8Array.from([ + 1, 1, 0, 1, 4, 0, 1, 2, 0, 1, 8, 0, 1, 16, 0, 8, 255, 0, 0, 0, 0, 9, 1, 0, 9, 2, 0, 3, 1, 0, 3, 2, + 0, +]); +function info(profile = 0): Uint8Array { + const data = new Uint8Array(54); + data.set([0x3d, 3, 0x3d, 3], 7); + data[14] = 88; + data[16] = 1; + data[17] = profile; + return data; +} +function response(command: number, payload = new Uint8Array(), status = 0): Uint8Array { + const data = new Uint8Array(127); + data[0] = command; + data[1] = payload.length + (command === 0x19 ? 0 : 1); + data[2] = status; + data.set(payload, 3); + return data; +} + +describe("AJ200 AI-family codecs", () => { + it("frames only configuration commands with their exact lengths", () => { + assert.deepEqual([...aj200pEncode(0x21, [4]).slice(0, 4)], [0x21, 1, 4, 0]); + assert.equal(aj200pEncode(0x13).length, 127); + assert.equal(aj200pEncode(0x1a, DPI)[1], 64); + assert.throws(() => aj200pEncode(0x18, SETTINGS.slice(0, 29))); + assert.throws(() => aj200pEncode(0x21, [5])); + assert.throws(() => aj200pEncode(0x21, [-1])); + assert.throws(() => aj200pEncode(0x16, Array(33).fill(256))); + assert.throws(() => aj200pEncode(0x30 as never)); + assert.throws(() => aj200pEncode(0xef as never)); + }); + it("handles the DPI length exception and DataView-sized replies", () => { + assert.deepEqual(aj200pDecodeReply(10, response(0x17, SETTINGS), 0x17), SETTINGS); + assert.deepEqual(aj200pDecodeReply(11, response(0x19, DPI), 0x19), DPI); + assert.equal(aj200pDecodeReply(1, response(0x17, SETTINGS), 0x17), null); + assert.equal(aj200pDecodeReply(10, response(0x15, KEYS), 0x17), null); + assert.throws( + () => aj200pDecodeReply(10, response(0x17, SETTINGS).slice(0, 41), 0x17), + /truncated/, + ); + assert.throws(() => aj200pDecodeReply(10, response(0x19, DPI.slice(0, 63)), 0x19), /layout/); + assert.throws( + () => aj200pDecodeReply(10, response(0x16, new Uint8Array(), 255), 0x16), + /status 255/, + ); + assert.throws(() => aj200pDecodeReply(10, Uint8Array.of(0x15), 0x15), /Truncated/); + assert.throws(() => aj200pDecodeReply(10, response(0x17, new Uint8Array(29)), 0x17), /layout/); + }); + it("decodes identity without exposing radio identifiers", () => { + assert.deepEqual(aj200pDecodeInfo(info()), { + profile: 1, + wireless: true, + receiverVersion: "3.3.13", + mouseVersion: "3.3.13", + battery: 88, + charging: false, + }); + const data = info(4); + data[14] = 255; + assert.equal(aj200pDecodeInfo(data).battery, null); + assert.equal(aj200pDecodeInfo(data).profile, 5); + data[17] = 5; + assert.throws(() => aj200pDecodeInfo(data)); + }); + it("reads all 39 setting bytes while keeping non-boolean ripple and flywheel fields", () => { + const config = aj200pDecodeSettings(SETTINGS); + assert.equal(config.count, 7); + assert.equal(config.active, 3); + assert.equal(config.wirelessRate, 1000); + assert.equal(config.wiredRate, 4000); + assert.equal(config.ripple, 2); + assert.equal(config.sleepSeconds, 180); + assert.equal(config.deepSleepSeconds, 1800); + assert.equal(config.performanceMode, false); + const unknown = SETTINGS.slice(); + unknown[15] = 2; + assert.equal(aj200pDecodeSettings(unknown).performanceMode, null); + assert.deepEqual(config.raw, SETTINGS); + assert.throws(() => aj200pDecodeSettings(new Uint8Array(39))); + }); + it("round-trips every rate and rejects unsupported values", () => { + for (const hz of AJ200P_RATES) assert.equal(aj200pDecodeRate(aj200pEncodeRate(hz)), hz); + assert.equal(aj200pDecodeRate(0), null); + assert.throws(() => aj200pEncodeRate(750)); + }); + it("decodes 20-bit independent DPI axes and patches only one slot", () => { + assert.deepEqual( + aj200pDecodeDpi(DPI).map((stage) => stage.x), + [400, 800, 1200, 1900, 2400, 3200, 80000, 2100], + ); + const updated = aj200pPatchDpi(DPI, 6, { x: 1, y: 79999, color: "#1234ab" }); + assert.deepEqual(aj200pDecodeDpi(updated)[6], { x: 1, y: 79999, color: "#1234ab" }); + assert.deepEqual(updated.slice(0, 48), DPI.slice(0, 48)); + assert.deepEqual(updated.slice(56), DPI.slice(56)); + assert.deepEqual(aj200pPatchDpi(DPI, 6, { x: 80000 }), DPI); + assert.deepEqual(aj200pDecodeDpi(aj200pPatchDpi(DPI, 0, { x: 65536 }))[0], { + x: 65536, + y: 400, + color: "#ffffff", + }); + for (const value of [0, 80001, 1.5, NaN]) + assert.throws(() => aj200pPatchDpi(DPI, 0, { x: value })); + assert.throws(() => aj200pPatchDpi(DPI, 8, { x: 800 })); + assert.throws(() => aj200pColorBytes("#f00")); + assert.deepEqual(DPI.slice(0, 8), Uint8Array.of(0, 144, 1, 144, 1, 255, 255, 255)); + }); + it("provides unique action names/encodings and preserves opaque assignments", () => { + assert.equal(AJ200P_ACTIONS.length, new Set(AJ200P_ACTIONS.map((a) => a.label)).size); + assert.equal( + AJ200P_ACTIONS.length, + new Set(AJ200P_ACTIONS.map((a) => a.binding.join(","))).size, + ); + assert.deepEqual(aj200pFindAction("Cycle profiles"), [8, 255, 0]); + assert.deepEqual(aj200pFindAction("Profile 5"), [8, 4, 0]); + assert.deepEqual(aj200pFindAction("Polling rate 8000 Hz"), [7, 64, 0]); + assert.deepEqual(aj200pFindAction("Left Ctrl"), [11, 0xe0, 8]); + assert.deepEqual(aj200pFindAction("W"), [11, 26, 0]); + assert.deepEqual(aj200pFindAction("Volume up"), [10, 0xe9, 0]); + assert.equal(aj200pBindingLabel([12, 9, 0]), "Custom (12,9,0)"); + assert.equal(aj200pBindingLabel([6, 2, 1]), "Macro 3"); + assert.equal(aj200pFindAction("Unknown"), null); + assert.equal(aj200pDecodeKeys(KEYS).length, 11); + assert.throws(() => aj200pDecodeKeys(KEYS.slice(0, 32))); + assert.equal( + AJ200P_ACTIONS.some((a) => a.binding[0] === 12 || a.binding[0] === 13), + false, + ); + }); + it("includes the vendor's consumer/system and distinct numpad actions", () => { + for (const [label, low, high] of [ + ["Brightness up", 0x6f, 0], + ["Brightness down", 0x70, 0], + ["Media player", 0x83, 1], + ["Mail", 0x8a, 1], + ["Search", 0x21, 2], + ["Refresh", 0x27, 2], + ["Browser stop", 0x26, 2], + ["Calculator", 0x92, 1], + ["My Computer", 0x94, 1], + ] as const) + assert.deepEqual(aj200pFindAction(label), [10, low, high]); + const numpad = [ + ["Num Lock", 83], + ["Numpad /", 84], + ["Numpad *", 85], + ["Numpad -", 86], + ["Numpad +", 87], + ["Numpad 1", 89], + ["Numpad 2", 90], + ["Numpad 3", 91], + ["Numpad 4", 92], + ["Numpad 5", 93], + ["Numpad 6", 94], + ["Numpad 7", 95], + ["Numpad 8", 96], + ["Numpad 9", 97], + ["Numpad 0", 98], + ["Numpad .", 99], + ] as const; + for (const [label, usage] of numpad) assert.deepEqual(aj200pFindAction(label), [11, usage, 0]); + assert.notDeepEqual(aj200pFindAction("Numpad 1"), aj200pFindAction("1")); + assert.equal(new Set(AJ200P_ACTIONS.map((action) => action.label)).size, AJ200P_ACTIONS.length); + for (const action of AJ200P_ACTIONS) + assert.equal(aj200pBindingLabel(action.binding), action.label); + }); +}); + +const LEGACY_DPI = Uint8Array.from([ + 7, 7, 51, 255, 0, 0, 15, 15, 51, 0, 255, 0, 23, 23, 51, 0, 0, 255, 31, 31, 51, 255, 255, 0, 47, + 47, 51, 255, 0, 255, 63, 63, 51, 0, 255, 255, 127, 127, 51, 255, 255, 255, 255, 255, 51, 255, 255, + 255, +]); +interface MockOptions { + ignoreWrites?: boolean; + ignoreDeepSleep?: boolean; + rejectWrites?: boolean; + silent?: boolean; + delayedAck?: boolean; + productId?: number; +} +function fakeMouse(options: MockOptions = {}) { + const productId = options.productId ?? 0xed1c; + const product = AJ200P_PRODUCTS.get(productId)!; + const banks = Array.from({ length: 5 }, () => ({ + settings: SETTINGS.slice(0, aj200pSettingsLength(product)), + dpi: product.sensor === "PAW3955" ? DPI.slice() : LEGACY_DPI.slice(), + keys: KEYS.slice(0, product.flywheel ? 33 : 21), + })); + for (const bank of banks) bank.settings[1] = 1; + if (productId === 0xed1c) for (const bank of banks) bank.settings[1] = SETTINGS[1]; + const state = { profile: 0, wireless: !product.wired, beforeCommand: (_command: number) => {} }; + const listeners = new Set<(event: HIDInputReportEvent) => void>(); + const commands: number[] = []; + let awaitingAck = false; + const emit = (data: Uint8Array, reportId = 10) => { + // Nonzero DataView offset catches decoders that use the entire buffer. + const storage = new Uint8Array(data.length + 9); + storage.set(data, 5); + const event = { + reportId, + data: new DataView(storage.buffer, 5, data.length), + } as HIDInputReportEvent; + for (const fn of [...listeners]) fn(event); + }; + const device = { + vendorId: 0x363c, + productId, + opened: false, + collections: [{ usagePage: 0xffa0, usage: 2, children: [] }], + open: async () => { + device.opened = true; + }, + close: async () => { + device.opened = false; + }, + addEventListener: (_type: string, fn: (event: HIDInputReportEvent) => void) => { + listeners.add(fn); + }, + removeEventListener: (_type: string, fn: (event: HIDInputReportEvent) => void) => { + listeners.delete(fn); + }, + sendReport: async (reportId: number, source: BufferSource) => { + assert.equal(reportId, 11); + const body = new Uint8Array(source as ArrayBuffer); + assert.equal(body.length, 127); + const command = body[0]; + commands.push(command); + state.beforeCommand(command); + assert.equal(awaitingAck, false, "a read must not overtake a write acknowledgement"); + if (options.silent) return; + emit(response(0x12)); // unrelated unsolicited notification + const bank = banks[state.profile]; + if (command === 0x13) { + const data = info(state.profile); + data[16] = Number(state.wireless); + emit(response(command, data)); + } else if (command === 0x17) emit(response(command, bank.settings)); + else if (command === 0x19) emit(response(command, bank.dpi), 11); + else if (command === 0x15) emit(response(command, bank.keys)); + else { + const acknowledge = () => { + awaitingAck = false; + if (!options.ignoreWrites && !options.rejectWrites) { + if (command === 0x21) state.profile = body[2]; + else if (command === 0x18) { + const deepSleep = bank.settings[26]; + bank.settings.set(body.slice(2, 2 + body[1])); + if (options.ignoreDeepSleep) bank.settings[26] = deepSleep; + } else if (command === 0x1a) bank.dpi.set(body.slice(2, 2 + body[1])); + else if (command === 0x16) bank.keys.set(body.slice(2, 2 + body[1])); + else assert.fail("Unexpected command"); + } + emit(response(command, new Uint8Array(), options.rejectWrites ? 255 : 0)); + }; + if (options.delayedAck) { + awaitingAck = true; + setTimeout(acknowledge, 1); + } else acknowledge(); + } + }, + }; + const client = new AjazzAj200pHidClient(device as unknown as HIDDevice, { + replyTimeoutMs: 500, + settleAfterWriteMs: 0, + }); + return { client, device: device as unknown as HIDDevice, state, banks, commands, listeners }; +} + +describe("AjazzAj200pHidClient", () => { + it("claims only exact product ids and configuration collections, and is registered", () => { + const { device } = fakeMouse(); + assert.ok(AjazzAj200pHidClient.isSupported(device)); + assert.ok(createSupportedClient(device) instanceof AjazzAj200pHidClient); + assert.equal(deviceBrand(createSupportedClient(device)!), "AJAZZ"); + for (const patch of [{ vendorId: 0x1234 }, { productId: 0xffff }, { collections: [] }]) + assert.equal(AjazzAj200pHidClient.isSupported({ ...device, ...patch }), false); + for (const productId of AJ200P_PRODUCTS.keys()) + assert.ok( + SUPPORTED_HID_FILTERS.some( + (f) => + f.vendorId === device.vendorId && + f.productId === productId && + f.usagePage === 0xffa0 && + f.usage === 2, + ), + ); + }); + it("reads the active bank without switching or writing any profile", async () => { + const { client, commands, listeners } = fakeMouse(); + const status = await client.readStatus(); + assert.equal(status.name, "AJAZZ AJ200P AI MASTER"); + assert.equal(status.activeProfile, 1); + assert.equal(status.profileCount, 5); + assert.equal(status.batteryPercent, 88); + assert.equal(status.dpi, 1900); + assert.equal(status.dpiY, 1900); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.buttonMappings?.Bottom, "Cycle profiles"); + assert.equal(Object.keys(status.buttonMappings!).length, 10); + assert.equal(status.buttonMappings?.AI, undefined); + assert.equal(status.lighting?.color, "#ffffff"); + assert.equal(status.lighting?.mode, "Breathing single"); + assert.equal(status.dpiStageColors?.[0], "#ffffff"); + assert.equal(status.rippleControl, undefined); + assert.equal(status.performanceMode, false); + assert.deepEqual(status.firmware, ["Mouse 3.3.13", "Receiver 3.3.13"]); + assert.deepEqual(commands, [0x13, 0x17, 0x19, 0x15, 0x13]); + assert.equal(listeners.size, 0); + }); + it("serializes complete transactions, preserves unknown bytes, and waits for acknowledgements", async () => { + const { client, banks } = fakeMouse({ delayedAck: true }); + await client.readStatus(); + await Promise.all([ + client.setDebounceTime(5), + client.setMotionSync(false), + client.setPollingRate(2000), + ]); + const expected = SETTINGS.slice(); + expected[7] = 5; + expected[8] = 0; + expected[0] = 16; + assert.deepEqual(banks[0].settings, expected); + for (const bank of banks.slice(1)) assert.deepEqual(bank.settings, SETTINGS); + }); + it("keeps key7 hidden and retains its bytes when editing physical buttons or the wheel", async () => { + const { client, banks } = fakeMouse(); + banks[0].keys.set([6, 3, 2], 18); + banks[0].keys.set([12, 9, 0], 27); + const before = banks[0].keys.slice(); + await client.readStatus(); + await client.setButtonMapping("Bottom", "Profile 5"); + const expected = before.slice(); + expected.set([8, 4, 0], 15); + assert.deepEqual(banks[0].keys, expected); + assert.equal((await client.readStatus()).buttonMappings?.AI, undefined); + await assert.rejects(client.setButtonMapping("AI", "W"), /Unknown/); + await client.setButtonMapping("Scroll up", "W"); + assert.deepEqual([...banks[0].keys.slice(21, 24)], [11, 26, 0]); + assert.deepEqual([...banks[0].keys.slice(18, 21)], [6, 3, 2]); + await assert.rejects(client.setButtonMapping("Left", "Disabled"), /left-click/); + await client.setButtonMapping("Forward", "Left click"); + await client.setButtonMapping("Left", "Disabled"); + }); + it("writes independent axes, stage colours/count and active stage", async () => { + const { client, banks } = fakeMouse(); + await client.readStatus(); + await client.setDpiStageAxes(0, 65536, 80000); + await client.setDpiStageColor(0, "#006400"); + assert.deepEqual(aj200pDecodeDpi(banks[0].dpi)[0], { x: 65536, y: 80000, color: "#006400" }); + assert.deepEqual(banks[0].dpi.slice(8), DPI.slice(8)); + await client.setDpiStageCount(2); + assert.equal(banks[0].settings[2], 2); + assert.equal(banks[0].settings[3], 1); + await client.setDpi(801); + assert.deepEqual(aj200pDecodeDpi(banks[0].dpi)[1], { x: 801, y: 801, color: "#ffffff" }); + await client.setDpiStageCount(8); + await client.setActiveDpiStage(7); + assert.equal(banks[0].settings[3], 7); + await assert.rejects(client.setActiveDpiStage(8)); + }); + it("keeps DPI theme colour distinct from stage colours and retains rounded brightness", async () => { + const { client, banks } = fakeMouse(); + banks[0].settings[28] = 127; + const lighting = (await client.readStatus()).lighting!; + await client.setLighting({ ...lighting, color: "#ff00ff", mode: "Static" }); + assert.deepEqual(banks[0].settings.slice(21, 24), Uint8Array.of(255, 0, 255)); + assert.equal(banks[0].settings[28], 127); + assert.deepEqual(banks[0].dpi, DPI); + await client.setLighting({ ...lighting, brightness: 20, speed: 5 }); + assert.equal(banks[0].settings[28], 51); + assert.equal(banks[0].settings[29], 5); + }); + it("switches and edits the selected bank only", async () => { + const { client, state, banks } = fakeMouse(); + await client.readStatus(); + await client.setProfile(5); + await client.setDebounceTime(4); + assert.equal(state.profile, 4); + assert.equal(banks[4].settings[7], 4); + assert.deepEqual(banks[0].settings, SETTINGS); + await assert.rejects(client.setProfile(0)); + await assert.rejects(client.setProfile(6)); + }); + it("refuses stale-bank writes and a bank change inside a transaction", async () => { + const { client, state, commands } = fakeMouse(); + await client.readStatus(); + state.profile = 1; + await assert.rejects(client.setDebounceTime(5), /profile changed/); + assert.equal(commands.includes(0x18), false); + await client.readStatus(); + state.beforeCommand = (command) => { + if (command === 0x17) state.profile = 2; + }; + await assert.rejects(client.setDebounceTime(5), /profile changed/); + assert.equal(commands.includes(0x18), false); + }); + it("detects ignored or rejected writes and leaves the queue usable", async () => { + const mock = fakeMouse({ ignoreWrites: true }); + await mock.client.readStatus(); + await assert.rejects(mock.client.setDebounceTime(5), /read-back differed/); + assert.equal((await mock.client.readStatus()).debounceMs, 1); + const rejected = fakeMouse({ rejectWrites: true }); + await assert.rejects(rejected.client.setDebounceTime(5), /status 255/); + assert.equal(rejected.listeners.size, 0); + }); + it("writes both sleep timers together, even when the normal timeout is unchanged", async () => { + const { client, banks, commands } = fakeMouse(); + const expected = SETTINGS.slice(); + await client.setSleepTimeout(180); + expected[26] = 24; + assert.deepEqual(banks[0].settings, expected); + assert.equal(commands.filter((command) => command === 0x18).length, 1); + for (const seconds of AJ200P_SLEEP_SECONDS) { + await client.setSleepTimeout(seconds); + expected[25] = seconds / 10; + expected[26] = (seconds + 60) / 10; + assert.deepEqual(banks[0].settings, expected); + assert.equal((await client.readStatus()).sleepTimeout, seconds); + } + for (const bank of banks.slice(1)) assert.deepEqual(bank.settings, SETTINGS); + }); + it("rejects a sleep write if only the normal timer was accepted", async () => { + const { client, banks } = fakeMouse({ ignoreDeepSleep: true }); + await assert.rejects(client.setSleepTimeout(10), /read-back differed/); + assert.equal(banks[0].settings[25], 1); + assert.equal(banks[0].settings[26], SETTINGS[26]); + }); + it("inverts the power-saving flag and refuses an unknown sensor mode", async () => { + const { client, banks } = fakeMouse(); + const expected = SETTINGS.slice(); + expected[15] = 0; + assert.equal(await client.setPerformanceMode(true), true); + assert.deepEqual(banks[0].settings, expected); + assert.equal((await client.readStatus()).performanceMode, true); + assert.equal(await client.setPerformanceMode(false), false); + assert.deepEqual(banks[0].settings, SETTINGS); + assert.equal((await client.readStatus()).performanceMode, false); + banks[0].settings[15] = 2; + assert.equal((await client.readStatus()).performanceMode, null); + await assert.rejects(client.setPerformanceMode(true), /Unknown/); + assert.equal(banks[0].settings[15], 2); + }); + it("offers and writes the full 0–20 ms debounce range without changing other bytes", async () => { + const { client, banks } = fakeMouse(); + assert.deepEqual( + client.getDebounceOptions(), + Array.from({ length: 21 }, (_, i) => i), + ); + for (const ms of [0, 20]) { + await client.setDebounceTime(ms); + const expected = SETTINGS.slice(); + expected[7] = ms; + assert.deepEqual(banks[0].settings, expected); + assert.equal((await client.readStatus()).debounceMs, ms); + } + }); + it("rejects invalid settings before sending writes", async () => { + const { client, commands } = fakeMouse(); + for (const run of [ + () => client.setDpi(0), + () => client.setPollingRate(750), + () => client.setDebounceTime(-1), + () => client.setDebounceTime(21), + () => client.setSleepTimeout(0), + () => client.setSleepTimeout(2550), + () => client.setPerformanceMode(1 as never), + () => client.setButtonMapping("No button", "W"), + ]) + await assert.rejects(run); + assert.equal(commands.length, 0); + }); + it("cleans listeners on timeout and cancellation", async () => { + const { client, listeners } = fakeMouse({ silent: true }); + await assert.rejects(client.readStatus(), /timed out/); + assert.equal(listeners.size, 0); + const pending = assert.rejects(client.readStatus(), /closed/); + await new Promise((resolve) => setTimeout(resolve, 1)); + await client.close(); + await pending; + assert.equal(listeners.size, 0); + }); + it("decodes and patches the legacy 6-byte format without clearing reserved bits", () => { + assert.deepEqual( + aj200pDecodeDpi(LEGACY_DPI).map((stage) => stage.x), + [400, 800, 1200, 1600, 2400, 3200, 6400, 12800], + ); + const changed = aj200pPatchDpi(LEGACY_DPI, 0, { x: 42000, y: 50, color: "#123456" }); + assert.deepEqual([...changed.slice(0, 6)], [71, 0, 243, 18, 52, 86]); + assert.deepEqual(aj200pDecodeDpi(changed)[0], { x: 42000, y: 50, color: "#123456" }); + assert.deepEqual(changed.slice(6), LEGACY_DPI.slice(6)); + assert.throws(() => aj200pPatchDpi(LEGACY_DPI, 0, { x: 401 }), /50-DPI/); + }); + it("covers every catalogued interface with model-specific layouts and limits", async () => { + assert.equal(AJ200P_PRODUCTS.size, 22); + assert.deepEqual( + [...AJ200P_PRODUCTS].filter(([, p]) => p.verified).map(([id]) => id), + [0xed1c], + ); + for (const [productId, product] of AJ200P_PRODUCTS) { + const { client, banks, device } = fakeMouse({ productId }); + assert.ok(AjazzAj200pHidClient.isSupported(device)); + const status = await client.readStatus(); + assert.equal(status.name, product.name); + assert.equal(Object.keys(status.buttonMappings!).length, product.flywheel ? 10 : 6); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, product.maxDpi); + assert.equal(status.buttonOptions?.includes("Cycle profiles"), true); + assert.equal(Boolean(status.ui?.statusNote), !product.hiddenActionsAnalyzed); + await client.setDpiStageAxes(0, product.maxDpi, product.minDpi); + assert.equal(aj200pDecodeDpi(banks[0].dpi)[0].x, product.maxDpi); + await client.setButtonMapping("Forward", "W"); + assert.deepEqual([...banks[0].keys.slice(12, 15)], [11, 26, 0]); + const unsupportedRate = product.wired + ? 16000 + : product.wirelessRates.includes(8000) + ? 16000 + : 8000; + await assert.rejects(client.setPollingRate(unsupportedRate)); + await assert.rejects(client.setDpi(product.maxDpi + product.stepDpi)); + if (product.lodCodes.length === 0) await assert.rejects(client.setLiftOffDistance("Low")); + if (!product.flywheel) await assert.rejects(client.setButtonMapping("Scroll up", "W")); + await client.setButtonMapping("Bottom", "Cycle profiles"); + await client.setPerformanceMode(true); + assert.equal(banks[0].settings[15], 0); + await client.setSleepTimeout(30); + assert.deepEqual([...banks[0].settings.slice(25, 27)], [3, 9]); + } + }); +}); diff --git a/src/drivers/ajazz/aj200p-hid.ts b/src/drivers/ajazz/aj200p-hid.ts new file mode 100644 index 00000000..78d1b0ee --- /dev/null +++ b/src/drivers/ajazz/aj200p-hid.ts @@ -0,0 +1,534 @@ +import type { MouseLighting, MouseStatus } from "../mouse-types.js"; +import { + AJ200P_ACTIONS, + AJ200P_BUTTON_NAMES, + AJ200P_BUTTON_SLOTS, + AJ200P_COMMAND, + AJ200P_OUTPUT_REPORT_ID, + AJ200P_PRODUCTS, + AJ200P_SLEEP_SECONDS, + AJ200P_USAGE, + AJ200P_USAGE_PAGE, + AJ200P_VENDOR_ID, + aj200pBindingLabel, + aj200pColorBytes, + aj200pDecodeDpi, + aj200pDecodeInfo, + aj200pDecodeKeys, + aj200pDecodeReply, + aj200pDecodeSettings, + aj200pDecodeRate, + aj200pEncode, + aj200pEncodeRate, + aj200pFindAction, + aj200pInteger, + aj200pPatchDpi, + aj200pSettingsLength, + type Aj200pCommand, + type Aj200pSettings, +} from "../../ajazz/aj200p.js"; + +export interface AjazzAj200pOptions { + replyTimeoutMs?: number; + settleAfterWriteMs?: number; +} +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const equal = (a: Uint8Array, b: Uint8Array) => + a.length === b.length && a.every((value, i) => value === b[i]); +const LODS = ["Low", "Medium", "High"] as const; +const MODES = ["Off", "Static", "Breathing single"] as const; + +/** + * Native WebHID, with whole read/modify/write transactions serialized. + * Writes await their acknowledgement BEFORE issuing a read-back request. + * Unknown bytes and unrelated profile settings are always retained. + */ +export class AjazzAj200pHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private readonly replyTimeoutMs: number; + private readonly settleAfterWriteMs: number; + private cancelPending: (() => void) | null = null; + private epoch = 0; + private knownProfile: number | null = null; + private wireless: boolean; + + constructor(device: HIDDevice, options: AjazzAj200pOptions = {}) { + this.device = device; + if (!AJ200P_PRODUCTS.has(device.productId)) + throw new RangeError("Unknown AJ200 AI product id."); + this.wireless = !this.product.wired; + this.replyTimeoutMs = options.replyTimeoutMs ?? 2000; + this.settleAfterWriteMs = options.settleAfterWriteMs ?? 250; + } + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== AJ200P_VENDOR_ID || !AJ200P_PRODUCTS.has(device.productId)) + return false; + const find = (collections: readonly HIDCollectionInfo[]): boolean => + collections.some( + (collection) => + (collection.usagePage === AJ200P_USAGE_PAGE && collection.usage === AJ200P_USAGE) || + find(collection.children ?? []), + ); + return find(device.collections); + } + private get product() { + return AJ200P_PRODUCTS.get(this.device.productId)!; + } + private get buttonNames() { + return AJ200P_BUTTON_NAMES.slice(0, this.product.flywheel ? 10 : 6); + } + get supportedPollingRates(): number[] { + return [...(this.wireless ? this.product.wirelessRates : this.product.wiredRates)]; + } + getDpiOptions(): number[] { + const { minDpi, maxDpi, stepDpi } = this.product; + return Array.from({ length: (maxDpi - minDpi) / stepDpi + 1 }, (_, i) => minDpi + i * stepDpi); + } + private validateDpi(dpi: number): void { + aj200pInteger(dpi, this.product.minDpi, this.product.maxDpi, "DPI"); + if (dpi % this.product.stepDpi !== 0) + throw new RangeError(`This sensor uses ${this.product.stepDpi}-DPI steps.`); + } + getSleepOptions(): number[] { + return [...AJ200P_SLEEP_SECONDS]; + } + getDebounceOptions(): number[] { + return Array.from({ length: 21 }, (_, i) => i); + } + getDebounceMaxMs(): number { + return 20; + } + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + async close(): Promise { + this.epoch++; + this.cancelPending?.(); + await this.queue.catch(() => {}); + this.knownProfile = null; + if (this.device.opened) await this.device.close(); + } + private transaction(operation: () => Promise): Promise { + const epoch = this.epoch; + const task = this.queue.then(async () => { + if (this.epoch !== epoch) throw new Error("The AJ200P connection was closed."); + await this.open(); + return operation(); + }); + this.queue = task.catch(() => {}); + return task; + } + private exchange(command: Aj200pCommand, payload: ArrayLike = []): Promise { + const body = aj200pEncode(command, payload); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + this.cancelPending = null; + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const listener = (event: HIDInputReportEvent) => { + if (settled) return; + try { + const view = event.data; + const response = aj200pDecodeReply( + event.reportId, + new Uint8Array(view.buffer, view.byteOffset, view.byteLength), + command, + ); + if (response === null) return; + settled = true; + cleanup(); + resolve(response); + } catch (error) { + fail(error); + } + }; + const timer = setTimeout( + () => + fail( + new Error( + `AJ200P command 0x${command.toString(16)} timed out. Wake the mouse and close other configurators.`, + ), + ), + this.replyTimeoutMs, + ); + this.cancelPending = () => fail(new Error("The AJ200P connection was closed.")); + this.device.addEventListener("inputreport", listener); + try { + this.device.sendReport(AJ200P_OUTPUT_REPORT_ID, Uint8Array.from(body).buffer).catch(fail); + } catch (error) { + fail(error); + } + }); + } + private async info() { + const info = aj200pDecodeInfo(await this.exchange(AJ200P_COMMAND.info)); + this.wireless = info.wireless; + return info; + } + private async settings() { + const data = await this.exchange(AJ200P_COMMAND.settings); + if (data.length !== aj200pSettingsLength(this.product)) + throw new Error("The settings layout does not match this AJ200 AI model."); + return aj200pDecodeSettings(data); + } + private async dpiTable() { + const data = await this.exchange(AJ200P_COMMAND.dpi); + if (data.length !== (this.product.sensor === "PAW3955" ? 64 : 48)) + throw new Error("The DPI layout does not match this AJ200 AI sensor."); + return data; + } + private async keyTable() { + const data = await this.exchange(AJ200P_COMMAND.keys); + aj200pDecodeKeys(data); + if (this.product.flywheel && data.length !== 33) + throw new Error("The AJ200 AI flywheel key map is incomplete."); + return data; + } + private actions() { + return AJ200P_ACTIONS.filter((action) => { + const type = action.binding[0]; + return ( + type !== 7 || + action.binding[1] === 0 || + this.supportedPollingRates.includes(aj200pDecodeRate(action.binding[1]) ?? -1) + ); + }); + } + private async beginEdit(): Promise { + const info = await this.info(); + if (this.knownProfile !== null && info.profile !== this.knownProfile) { + throw new Error("The onboard profile changed. Refresh the mouse before applying edits."); + } + return info.profile; + } + private async checkProfile(profile: number): Promise { + if ((await this.info()).profile !== profile) + throw new Error("The onboard profile changed during the operation. Refresh the mouse."); + } + private async writeBlock( + command: Aj200pCommand, + read: Aj200pCommand, + data: Uint8Array, + profile: number, + ): Promise { + await this.checkProfile(profile); + await this.exchange(command, data); + await pause(this.settleAfterWriteMs); + await this.checkProfile(profile); + const actual = await this.exchange(read); + if (!equal(actual, data)) + throw new Error("The AJ200P did not keep the new setting; read-back differed."); + await this.checkProfile(profile); + } + private updateSettings( + patch: (raw: Uint8Array, current: Aj200pSettings, wireless: boolean) => void, + ): Promise { + return this.transaction(async () => { + const profile = await this.beginEdit(); + const current = await this.settings(); + const next = current.raw.slice(); + patch(next, current, (await this.info()).wireless); + aj200pDecodeSettings(next); + if (!equal(next, current.raw)) + await this.writeBlock(AJ200P_COMMAND.setSettings, AJ200P_COMMAND.settings, next, profile); + else await this.checkProfile(profile); + }); + } + + async readStatus(): Promise { + return this.transaction(async () => { + // A bottom-button profile change can arrive between reads. Do not combine + // a key map from one bank with the DPI/settings of another. + for (let attempt = 0; attempt < 2; attempt++) { + const info = await this.info(); + const config = await this.settings(); + const dpi = aj200pDecodeDpi(await this.dpiTable()).slice(0, config.count); + const keys = aj200pDecodeKeys(await this.keyTable()); + if ((await this.info()).profile !== info.profile) continue; + for (const stage of dpi) { + this.validateDpi(stage.x); + this.validateDpi(stage.y); + } + this.knownProfile = info.profile; + const lighting = this.lighting(config); + return { + brand: "AJAZZ", + name: this.product.name, + ui: { + family: "ajazz-aj200p", + settingsReady: true, + valuesVerified: true, + showAdvancedSection: true, + hideSignalCard: true, + hideUnsupportedPollingRates: true, + hideRippleControl: true, + hideAdvancedLighting: true, + sleepNote: "Saving this timeout also sets deep sleep to one minute later.", + statusNote: + this.product.hiddenActionsAnalyzed && info.mouseVersion === "3.3.13" + ? undefined + : "Profile and polling-rate button actions are experimental on this model and firmware; hardware behavior is unverified.", + dpiStageEditor: { + maxStages: 8, + countEditable: true, + minDpi: this.product.minDpi, + maxDpi: this.product.maxDpi, + stepDpi: this.product.stepDpi, + }, + }, + batteryPercent: info.battery, + batteryState: + info.battery === null + ? "Unknown" + : info.battery === 100 + ? "Full" + : info.charging + ? "Charging" + : "Discharging", + connectionType: info.wireless ? "Wireless" : "Wired", + connectionDetail: info.wireless ? "2.4 GHz receiver" : "Wired USB", + firmware: [ + `Mouse ${info.mouseVersion}`, + ...(AJ200P_PRODUCTS.get(this.device.productId)?.wired + ? [] + : [`Receiver ${info.receiverVersion}`]), + ], + deviceMode: "Onboard", + activeProfile: info.profile, + profileCount: 5, + dpi: dpi[config.active].x, + dpiY: dpi[config.active].y, + supportsSeparateDpiAxes: true, + dpiStages: dpi.map((stage) => stage.x), + dpiStagesY: dpi.map((stage) => stage.y), + dpiStageColors: dpi.map((stage) => stage.color), + activeDpiStage: config.active, + pollingRateHz: (info.wireless ? config.wirelessRate : config.wiredRate) ?? 0, + supportedPollingRates: this.supportedPollingRates, + liftOffDistance: this.product.lodCodes.includes(config.lod) ? LODS[config.lod] : null, + supportedLiftOffDistances: this.product.lodCodes.map((code) => LODS[code]), + debounceMs: config.debounce, + motionSync: config.motionSync, + angleSnapping: config.angleSnapping, + performanceMode: config.performanceMode, + sleepTimeout: config.sleepSeconds, + buttonMappings: Object.fromEntries( + this.buttonNames.map((name, i) => [ + name, + aj200pBindingLabel(keys[AJ200P_BUTTON_SLOTS[i]]), + ]), + ), + buttonOptions: this.actions().map((action) => action.label), + lighting, + }; + } + throw new Error("The onboard profile kept changing while reading. Refresh the mouse."); + }); + } + private lighting(config: Aj200pSettings): MouseLighting { + return { + zone: "DPI indicator", + modes: [...MODES], + mode: MODES[config.effect] ?? null, + color: config.color, + color2: null, + colorModes: ["Static", "Breathing single"], + dualColorModes: [], + reactiveModes: ["Breathing single"], + speeds: [1, 2, 3, 4, 5], + speed: config.speed, + brightness: Math.round((config.brightness * 100) / 255), + brightnessLevels: [0, 20, 40, 60, 80, 100], + }; + } + async setProfile(profile: number): Promise { + aj200pInteger(profile, 1, 5, "Onboard profile"); + return this.transaction(async () => { + await this.exchange(AJ200P_COMMAND.profile, [profile - 1]); + await pause(this.settleAfterWriteMs); + await this.checkProfile(profile); + this.knownProfile = profile; + return profile; + }); + } + async setButtonMapping(button: string, action: string): Promise { + const index = (this.buttonNames as readonly string[]).indexOf(button); + const binding = aj200pFindAction(action); + if (index < 0 || !binding) throw new RangeError("Unknown AJ200P button or action."); + return this.transaction(async () => { + const profile = await this.beginEdit(); + await this.info(); + if (!this.actions().some((candidate) => candidate.label === action)) + throw new Error( + "This polling-rate action exceeds the current connection's supported rates.", + ); + const keys = await this.keyTable(); + const next = keys.slice(); + next.set(binding, AJ200P_BUTTON_SLOTS[index] * 3); + // Keep at least one primary click available, allowing deliberate swaps. + const decoded = aj200pDecodeKeys(next); + if ( + !this.buttonNames.some( + (_name, i) => + decoded[AJ200P_BUTTON_SLOTS[i]][0] === 1 && + (decoded[AJ200P_BUTTON_SLOTS[i]][1] & 1) !== 0, + ) + ) + throw new Error("Keep a left-click action on at least one button."); + if (!equal(next, keys)) + await this.writeBlock(AJ200P_COMMAND.setKeys, AJ200P_COMMAND.keys, next, profile); + }); + } + async setPollingRate(hz: number): Promise { + const code = aj200pEncodeRate(hz); + await this.updateSettings((raw, _config, wireless) => { + if (!(wireless ? this.product.wirelessRates : this.product.wiredRates).includes(hz)) + throw new RangeError("This model does not support that polling rate on this connection."); + raw[wireless ? 0 : 1] = code; + }); + return hz; + } + async setActiveDpiStage(stage: number): Promise { + await this.updateSettings((raw, config) => { + raw[3] = aj200pInteger(stage, 0, config.count - 1, "DPI stage"); + }); + return stage; + } + async setDpiStageCount(count: number): Promise { + aj200pInteger(count, 1, 8, "DPI stage count"); + return this.transaction(async () => { + const profile = await this.beginEdit(); + const config = await this.settings(); + // Inactive slots are retained, but a blank slot cannot be enabled. + const data = await this.dpiTable(); + const table = aj200pDecodeDpi(data); + let initialized = data; + for (let stage = 0; stage < count; stage++) { + try { + this.validateDpi(table[stage].x); + this.validateDpi(table[stage].y); + } catch (error) { + if (stage < config.count) throw error; + initialized = aj200pPatchDpi(initialized, stage, table[config.count - 1]); + } + } + if (!equal(initialized, data)) + await this.writeBlock(AJ200P_COMMAND.setDpi, AJ200P_COMMAND.dpi, initialized, profile); + const next = config.raw.slice(); + next[2] = count; + next[3] = Math.min(config.active, count - 1); + if (!equal(next, config.raw)) + await this.writeBlock(AJ200P_COMMAND.setSettings, AJ200P_COMMAND.settings, next, profile); + return count; + }); + } + private editDpi( + stage: number | null, + patch: { x?: number; y?: number; color?: string }, + axesTogether = false, + ): Promise { + if (patch.x !== undefined) this.validateDpi(patch.x); + if (patch.y !== undefined) this.validateDpi(patch.y); + if (patch.color !== undefined) aj200pColorBytes(patch.color); + return this.transaction(async () => { + const profile = await this.beginEdit(); + const config = await this.settings(); + const index = stage ?? config.active; + aj200pInteger(index, 0, config.count - 1, "DPI stage"); + const data = await this.dpiTable(); + const next = aj200pPatchDpi(data, index, axesTogether ? { x: patch.x, y: patch.x } : patch); + if (!equal(next, data)) + await this.writeBlock(AJ200P_COMMAND.setDpi, AJ200P_COMMAND.dpi, next, profile); + }); + } + async setDpi(dpi: number): Promise { + await this.editDpi(null, { x: dpi }, true); + return dpi; + } + async setDpiStageValue(stage: number, dpi: number): Promise { + await this.editDpi(stage, { x: dpi }, true); + return dpi; + } + async setDpiStageAxes(stage: number, x: number, y: number): Promise { + await this.editDpi(stage, { x, y }); + } + async setDpiStageColor(stage: number, color: string): Promise { + await this.editDpi(stage, { color }); + } + async setLiftOffDistance(lod: NonNullable): Promise { + const code = LODS.indexOf(lod); + if (code < 0 || !this.product.lodCodes.includes(code)) + throw new RangeError("Unsupported AJ200P lift-off distance."); + await this.updateSettings((raw) => { + raw[6] = code; + }); + } + async setDebounceTime(ms: number): Promise { + aj200pInteger(ms, 0, 20, "Debounce time"); + await this.updateSettings((raw) => { + raw[7] = ms; + }); + return ms; + } + async setMotionSync(enabled: boolean): Promise { + if (typeof enabled !== "boolean") throw new TypeError("Motion Sync must be a boolean."); + await this.updateSettings((raw) => { + raw[8] = Number(enabled); + }); + return enabled; + } + async setAngleSnapping(enabled: boolean): Promise { + if (typeof enabled !== "boolean") throw new TypeError("Angle snapping must be a boolean."); + await this.updateSettings((raw) => { + raw[10] = Number(enabled); + }); + return enabled; + } + async setPerformanceMode(enabled: boolean): Promise { + if (typeof enabled !== "boolean") throw new TypeError("Performance mode must be a boolean."); + await this.updateSettings((raw, config) => { + if (config.performanceMode === null) throw new Error("Unknown AJ200P sensor power mode."); + raw[15] = Number(!enabled); + }); + return enabled; + } + async setSleepTimeout(seconds: number): Promise { + if (!(AJ200P_SLEEP_SECONDS as readonly number[]).includes(seconds)) + throw new RangeError("Unsupported AJ200P sleep timeout."); + await this.updateSettings((raw) => { + raw[25] = seconds / 10; + raw[26] = (seconds + 60) / 10; + }); + return seconds; + } + async setLighting(lighting: MouseLighting): Promise { + if (lighting.zone !== "DPI indicator" || lighting.mode === null) + throw new RangeError("Unsupported AJ200P lighting zone or mode."); + const mode = (MODES as readonly string[]).indexOf(lighting.mode); + if (mode < 0) throw new RangeError("Unsupported AJ200P lighting effect."); + if (lighting.color !== null) aj200pColorBytes(lighting.color); + if (lighting.brightness != null) aj200pInteger(lighting.brightness, 0, 100, "Brightness"); + if (lighting.speed !== null) aj200pInteger(lighting.speed, 1, 5, "Lighting speed"); + await this.updateSettings((raw, current) => { + raw[27] = mode; + if (lighting.color !== null) raw.set(aj200pColorBytes(lighting.color), 21); + // A rounded UI percentage must not silently change the raw brightness + // when only the colour/effect was edited. + if ( + lighting.brightness != null && + lighting.brightness !== Math.round((current.brightness * 100) / 255) + ) + raw[28] = Math.round((lighting.brightness * 255) / 100); + if (lighting.speed !== null) raw[29] = lighting.speed; + }); + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ffcc6d06..00f889b6 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -42,6 +42,8 @@ export interface MouseUiHints { * invisible without this. Set it only when a card in there will show. */ showAdvancedSection?: boolean; + /** Keep RGB controls in the Lighting tab without duplicating them under Advanced. */ + hideAdvancedLighting?: boolean; /** Always show battery column (even wired with null %). */ forceShowBattery?: boolean; /** Group the device's battery, DPI-indicator, and sleep controls on Overview. */ @@ -53,6 +55,8 @@ export interface MouseUiHints { statusNote?: string; /** Override the polling-rate footnote. */ pollingNote?: string; + /** Explain coupled sleep settings when applying the timeout also changes another timer. */ + sleepNote?: string; /** Sidebar name before first status read. */ defaultDisplayName?: string; /** diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index e7384d0f..f3894187 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -92,10 +92,11 @@ import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; import { AjazzHidClient } from "./ajazz/hid.ts"; +import { AjazzAj200pHidClient } from "./ajazz/aj200p-hid.js"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAj200pHidClient | AjazzAk820HidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -213,6 +214,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ // Shares VIDs 0xA8A4/0xA8A5 and the 0xFF01:0x10 collection with K-snake; the // two stay disjoint by product id (K-snake owns 0x2255). { brand: "AJAZZ", supports: (device) => AjazzHidClient.isSupported(device), create: (device) => new AjazzHidClient(device), score: () => 7 }, + { brand: "AJAZZ", supports: (device) => AjazzAj200pHidClient.isSupported(device), create: (device) => new AjazzAj200pHidClient(device), score: () => 7 }, { brand: "Microsoft", supports: (device) => MicrosoftHidClient.isSupported(device), create: (device) => new MicrosoftHidClient(device), score: () => 5 }, // Shares vendor id 0x093a with Glorious (see vendors.ts), but claims only // its own two product ids, so the two drivers never contend for a device. diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 3387e201..dbc10f9d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -145,8 +145,10 @@ import { COOLERMASTER_VENDOR_ID, } from "@openmouse/protocol/coolermaster"; import { AJAZZ_AK820_WIRED_PID } from "../ajazz/ak820.ts"; +import { AJ200P_PRODUCTS, AJ200P_USAGE, AJ200P_USAGE_PAGE, AJ200P_VENDOR_ID } from "../ajazz/aj200p.js"; export const VENDOR_ID = { + ajazzAj200p: AJ200P_VENDOR_ID, coolermaster: COOLERMASTER_VENDOR_ID, ajazz: 0x1a2c, vaxee: VAXEE_VENDOR_ID, @@ -869,6 +871,7 @@ export const VAXEE_HID_FILTERS: HIDDeviceFilter[] = VAXEE_PRODUCT_IDS.map((produ })); export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ + ...[...AJ200P_PRODUCTS.keys()].map((productId) => ({ vendorId: AJ200P_VENDOR_ID, productId, usagePage: AJ200P_USAGE_PAGE, usage: AJ200P_USAGE })), ...VAXEE_HID_FILTERS, ...ASUS_HID_FILTERS, ...DAREU_HID_FILTERS, diff --git a/src/index.ts b/src/index.ts index 869b64cb..aff4f897 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,3 +39,4 @@ export * as rapoo from "./rapoo/index.js"; export * as fater from "./fater/index.js"; export * as coolermaster from "./coolermaster/index.js"; export * as ajazz from "./ajazz/index.js"; +export * as ajazzAj200p from "./ajazz/aj200p.js";