From 127af57bbb1d3d49d79aa503fb9058ff4e535358 Mon Sep 17 00:00:00 2001 From: happyme531 <2302004040@qq.com> Date: Tue, 20 Oct 2026 23:39:51 +0800 Subject: [PATCH 1/3] feat: add AJAZZ AJ200 AI family driver --- docs/ajazz-aj200p.md | 79 +++++++ package.json | 4 + src/ajazz/aj200p-products.ts | 50 ++++ src/ajazz/aj200p.ts | 217 +++++++++++++++++ src/drivers/ajazz/aj200p-hid.test.ts | 303 ++++++++++++++++++++++++ src/drivers/ajazz/aj200p-hid.ts | 334 +++++++++++++++++++++++++++ src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 2 + src/index.ts | 1 + 9 files changed, 993 insertions(+), 1 deletion(-) create mode 100644 docs/ajazz-aj200p.md create mode 100644 src/ajazz/aj200p-products.ts create mode 100644 src/ajazz/aj200p.ts create mode 100644 src/drivers/ajazz/aj200p-hid.test.ts create mode 100644 src/drivers/ajazz/aj200p-hid.ts diff --git a/docs/ajazz-aj200p.md b/docs/ajazz-aj200p.md new file mode 100644 index 00000000..d12c02f8 --- /dev/null +++ b/docs/ajazz-aj200p.md @@ -0,0 +1,79 @@ +# 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. + +## Supported settings + +- Five firmware-managed onboard banks; edit the active bank after selecting it. +- Seven button entries; ULTRA+ and MASTER add four wheel/tilt entries. +- Mouse buttons, keyboard keys/modifiers, consumer controls, 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, debounce, + Motion Sync, angle snapping and normal sleep timeout. +- A separate DPI lighting theme colour, off/static/breathing, brightness and speed. + +Three-state ripple control, deep sleep, 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 read through this driver on Windows, including +identity, battery, all eleven mappings, DPI, lighting and settings. Earlier +hardware configuration experiments verified stored bank cycling and both kinds +of RGB data across all five banks, with the configurator closed. Further write +validation and OpenMouse integration checks are in progress for this draft. 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/src/ajazz/aj200p-products.ts b/src/ajazz/aj200p-products.ts new file mode 100644 index 00000000..0b0207e0 --- /dev/null +++ b/src/ajazz/aj200p-products.ts @@ -0,0 +1,50 @@ +/** 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..dcb009ff --- /dev/null +++ b/src/ajazz/aj200p.ts @@ -0,0 +1,217 @@ +/** + * 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", "AI", + "Scroll up", "Scroll down", "Tilt left", "Tilt right", +] 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]}).`); + // The PAW3955 DPI table uniquely counts only the data, excluding status. + 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; + sleepSeconds: 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], + sleepSeconds: data[25] * 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], +] 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], +] 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..92398581 --- /dev/null +++ b/src/drivers/ajazz/aj200p-hid.test.ts @@ -0,0 +1,303 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + AJ200P_ACTIONS, AJ200P_COMMAND, AJ200P_PRODUCTS, AJ200P_RATES, + 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("AJ200P MASTER 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.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); + }); +}); + +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; 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) bank.settings.set(body.slice(2, 2 + body[1])); + 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: 50, 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, 11); + 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.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("edits all 11 slots while retaining macros and unclassified actions in others", 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); + 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("rejects invalid settings before sending writes", async () => { + const { client, commands } = fakeMouse(); + for (const run of [() => client.setDpi(0), () => client.setPollingRate(750), () => client.setDebounceTime(0), () => client.setSleepTimeout(0), () => 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 ? 11 : 7); + 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"); + } + }); +}); diff --git a/src/drivers/ajazz/aj200p-hid.ts b/src/drivers/ajazz/aj200p-hid.ts new file mode 100644 index 00000000..5a1744cc --- /dev/null +++ b/src/drivers/ajazz/aj200p-hid.ts @@ -0,0 +1,334 @@ +import type { MouseLighting, MouseStatus } from "../mouse-types.js"; +import { + AJ200P_ACTIONS, AJ200P_BUTTON_NAMES, 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 ? 11 : 7); } + 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: 20 }, (_, i) => i + 1); } + 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, + 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, + sleepTimeout: config.sleepSeconds, + buttonMappings: Object.fromEntries(this.buttonNames.map((name, i) => [name, aj200pBindingLabel(keys[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, index * 3); + // Keep at least one primary click available, allowing deliberate swaps. + if (!aj200pDecodeKeys(next).some((key) => key[0] === 1 && (key[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, 1, 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 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; }); 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/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..771f1138 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -145,6 +145,7 @@ 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 = { coolermaster: COOLERMASTER_VENDOR_ID, @@ -869,6 +870,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"; From 893f925619f56894a5457744e6a2d6e350f38e17 Mon Sep 17 00:00:00 2001 From: happyme531 <2302004040@qq.com> Date: Tue, 20 Oct 2026 23:40:13 +0800 Subject: [PATCH 2/3] test: include AJ200 vendor in registry coverage --- src/drivers/vendors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 771f1138..dbc10f9d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -148,6 +148,7 @@ 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, From 1d8406fdd5335d1fb7f6c8c2f349d75431c29d43 Mon Sep 17 00:00:00 2001 From: happyme531 <2302004040@qq.com> Date: Wed, 21 Oct 2026 00:37:59 +0800 Subject: [PATCH 3/3] feat: complete AJ200 AI onboard configuration Preserve reserved key7 while exposing physical wheel controls, complete consumer and numpad assignments, add sensor performance mode and 0 ms debounce, and couple normal/deep sleep timers with full read-back verification. Document packet layouts and hardware verification limits, format the family codecs and driver, and add optional lighting/sleep UI hints. Package checks and 2668 protocol tests pass; OpenMouse build, 405 tests and size check pass against the local package. --- docs/ajazz-aj200p.md | 93 +++++- scripts/fast-test-timers.mjs | 2 + src/ajazz/aj200p-products.ts | 103 +++++-- src/ajazz/aj200p.ts | 274 +++++++++++++---- src/drivers/ajazz/aj200p-hid.test.ts | 427 ++++++++++++++++++++++----- src/drivers/ajazz/aj200p-hid.ts | 362 ++++++++++++++++++----- src/drivers/mouse-types.ts | 4 + 7 files changed, 1014 insertions(+), 251 deletions(-) diff --git a/docs/ajazz-aj200p.md b/docs/ajazz-aj200p.md index d12c02f8..272fcba0 100644 --- a/docs/ajazz-aj200p.md +++ b/docs/ajazz-aj200p.md @@ -26,20 +26,71 @@ 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. -- Seven button entries; ULTRA+ and MASTER add four wheel/tilt entries. -- Mouse buttons, keyboard keys/modifiers, consumer controls, DPI actions and +- 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, debounce, - Motion Sync, angle snapping and normal sleep timeout. +- 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, deep sleep, BLE options and flywheel tuning are +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. @@ -72,8 +123,30 @@ 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 read through this driver on Windows, including -identity, battery, all eleven mappings, DPI, lighting and settings. Earlier -hardware configuration experiments verified stored bank cycling and both kinds -of RGB data across all five banks, with the configurator closed. Further write -validation and OpenMouse integration checks are in progress for this draft. +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/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 index 0b0207e0..e4752e57 100644 --- a/src/ajazz/aj200p-products.ts +++ b/src/ajazz/aj200p-products.ts @@ -20,30 +20,93 @@ 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, + name: string, + sensor: Aj200pProduct["sensor"], + maxDpi: number, ids: readonly [number, boolean][], - features: { flywheel?: boolean; wirelessRates?: readonly number[]; lodCodes?: readonly number[]; hiddenActions?: 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, - }); + 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]]); +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 }); +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 index dcb009ff..50fc4b63 100644 --- a/src/ajazz/aj200p.ts +++ b/src/ajazz/aj200p.ts @@ -15,16 +15,35 @@ 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", "AI", - "Scroll up", "Scroll down", "Tilt left", "Tilt right", + "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, + 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 Aj200pCommand = (typeof AJ200P_COMMAND)[keyof typeof AJ200P_COMMAND]; export type Aj200pBinding = readonly [number, number, number]; -export interface Aj200pAction { label: string; binding: Aj200pBinding } +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) { @@ -35,37 +54,60 @@ export function aj200pInteger(value: number, min: number, max: number, name: str 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], + 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."); + 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"); + 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 { +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]}).`); - // The PAW3955 DPI table uniquely counts only the data, excluding status. + 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))) { + 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]); + 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]); + 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]; } @@ -73,8 +115,11 @@ 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))); + 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}`; @@ -105,35 +150,56 @@ export interface Aj200pSettings { 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."); + 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], - sleepSeconds: data[25] * 10, color: aj200pColor(data, 21), - effect: data[27], brightness: data[28], speed: data[29], + 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 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), - }; + 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), @@ -142,7 +208,11 @@ export function aj200pDecodeDpi(data: Uint8Array): Aj200pDpiStage[] { }); } /** Patch only the requested axes/colour; even inactive slots stay intact. */ -export function aj200pPatchDpi(data: Uint8Array, stage: number, patch: Partial): Uint8Array { +export function aj200pPatchDpi( + data: Uint8Array, + stage: number, + patch: Partial, +): Uint8Array { const current = aj200pDecodeDpi(data); aj200pInteger(stage, 0, 7, "DPI stage"); const next = data.slice(); @@ -155,15 +225,17 @@ export function aj200pPatchDpi(data: Uint8Array, stage: number, patch: Partial> 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; + 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); @@ -171,45 +243,131 @@ export function aj200pPatchDpi(data: Uint8Array, stage: number, patch: Partial [data[i * 3], data[i * 3 + 1], data[i * 3 + 2]] as const); + 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] }); +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); +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], -] as const) add(label, 10, usage & 255, usage >> 8); + ["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], -] 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); + ["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])); + 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}`; diff --git a/src/drivers/ajazz/aj200p-hid.test.ts b/src/drivers/ajazz/aj200p-hid.test.ts index 92398581..cb4499b5 100644 --- a/src/drivers/ajazz/aj200p-hid.test.ts +++ b/src/drivers/ajazz/aj200p-hid.test.ts @@ -1,10 +1,22 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { - AJ200P_ACTIONS, AJ200P_COMMAND, AJ200P_PRODUCTS, AJ200P_RATES, - aj200pBindingLabel, aj200pColorBytes, aj200pDecodeDpi, aj200pDecodeInfo, - aj200pDecodeKeys, aj200pDecodeRate, aj200pDecodeReply, aj200pDecodeSettings, - aj200pEncode, aj200pEncodeRate, aj200pFindAction, aj200pPatchDpi, + 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"; @@ -13,23 +25,37 @@ 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]); +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; + 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[0] = command; + data[1] = payload.length + (command === 0x19 ? 0 : 1); + data[2] = status; data.set(payload, 3); return data; } -describe("AJ200P MASTER codecs", () => { +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); @@ -46,24 +72,47 @@ describe("AJ200P MASTER codecs", () => { 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(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, 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.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)); + 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.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))); }); @@ -73,42 +122,108 @@ describe("AJ200P MASTER codecs", () => { 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]); + 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.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)); + 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( + 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); + 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; rejectWrites?: boolean; silent?: boolean; delayedAck?: boolean; productId?: number } +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) })); + 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) => {} }; @@ -117,27 +232,47 @@ function fakeMouse(options: MockOptions = {}) { 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; + 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, + 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); }, + 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); + 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)); + 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 { @@ -145,19 +280,27 @@ function fakeMouse(options: MockOptions = {}) { awaitingAck = false; if (!options.ignoreWrites && !options.rejectWrites) { if (command === 0x21) state.profile = body[2]; - else if (command === 0x18) bank.settings.set(body.slice(2, 2 + body[1])); - else if (command === 0x1a) bank.dpi.set(body.slice(2, 2 + body[1])); + 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(); + if (options.delayedAck) { + awaitingAck = true; + setTimeout(acknowledge, 1); + } else acknowledge(); } }, }; - const client = new AjazzAj200pHidClient(device as unknown as HIDDevice, { replyTimeoutMs: 50, settleAfterWriteMs: 0 }); + 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 }; } @@ -167,41 +310,71 @@ describe("AjazzAj200pHidClient", () => { 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)); + 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.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, 11); + 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.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; + 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("edits all 11 slots while retaining macros and unclassified actions in others", async () => { + 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); + 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); + 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"); @@ -214,90 +387,180 @@ describe("AjazzAj200pHidClient", () => { 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); + 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); + 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 { 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.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); + 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); + 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)); + 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 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; }; + 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(); + 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(0), () => client.setSleepTimeout(0), () => client.setButtonMapping("No button", "W")]) await assert.rejects(run); + 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); + 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); + 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]); + 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([...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]); + 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 ? 11 : 7); + 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; + 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 index 5a1744cc..78d1b0ee 100644 --- a/src/drivers/ajazz/aj200p-hid.ts +++ b/src/drivers/ajazz/aj200p-hid.ts @@ -1,10 +1,31 @@ import type { MouseLighting, MouseStatus } from "../mouse-types.js"; import { - AJ200P_ACTIONS, AJ200P_BUTTON_NAMES, 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, + 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 { @@ -12,7 +33,8 @@ export interface AjazzAj200pOptions { 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 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; @@ -33,32 +55,53 @@ export class AjazzAj200pHidClient { constructor(device: HIDDevice, options: AjazzAj200pOptions = {}) { this.device = device; - if (!AJ200P_PRODUCTS.has(device.productId)) throw new RangeError("Unknown AJ200 AI product id."); + 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 ?? [])); + 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 ? 11 : 7); } - get supportedPollingRates(): number[] { return [...(this.wireless ? this.product.wirelessRates : this.product.wiredRates)]; } + 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.`); + 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(); } - getSleepOptions(): number[] { return [...AJ200P_SLEEP_SECONDS]; } - getDebounceOptions(): number[] { return Array.from({ length: 20 }, (_, i) => i + 1); } - getDebounceMaxMs(): number { return 20; } - async open(): Promise { if (!this.device.opened) await this.device.open(); } async close(): Promise { this.epoch++; this.cancelPending?.(); @@ -85,21 +128,45 @@ export class AjazzAj200pHidClient { this.device.removeEventListener("inputreport", listener); this.cancelPending = null; }; - const fail = (error: unknown) => { if (settled) return; settled = true; cleanup(); reject(error); }; + 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); + 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); } + 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); + 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); } + try { + this.device.sendReport(AJ200P_OUTPUT_REPORT_ID, Uint8Array.from(body).buffer).catch(fail); + } catch (error) { + fail(error); + } }); } private async info() { @@ -109,24 +176,31 @@ export class AjazzAj200pHidClient { } 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."); + 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."); + 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."); + 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); + return ( + type !== 7 || + action.binding[1] === 0 || + this.supportedPollingRates.includes(aj200pDecodeRate(action.binding[1]) ?? -1) + ); }); } private async beginEdit(): Promise { @@ -137,25 +211,35 @@ export class AjazzAj200pHidClient { 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."); + 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 { + 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."); + 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 { + 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); + if (!equal(next, current.raw)) + await this.writeBlock(AJ200P_COMMAND.setSettings, AJ200P_COMMAND.settings, next, profile); else await this.checkProfile(profile); }); } @@ -170,37 +254,79 @@ export class AjazzAj200pHidClient { 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); } + 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, + brand: "AJAZZ", + name: this.product.name, ui: { - family: "ajazz-aj200p", settingsReady: true, valuesVerified: true, - showAdvancedSection: true, hideSignalCard: true, hideUnsupportedPollingRates: true, + family: "ajazz-aj200p", + settingsReady: true, + valuesVerified: true, + showAdvancedSection: true, + hideSignalCard: true, + hideUnsupportedPollingRates: true, hideRippleControl: true, - 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 }, + 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", + 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, + 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, + 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, + 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[i])])), + buttonMappings: Object.fromEntries( + this.buttonNames.map((name, i) => [ + name, + aj200pBindingLabel(keys[AJ200P_BUTTON_SLOTS[i]]), + ]), + ), buttonOptions: this.actions().map((action) => action.label), lighting, }; @@ -210,10 +336,18 @@ export class AjazzAj200pHidClient { } 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], + 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 { @@ -233,25 +367,40 @@ export class AjazzAj200pHidClient { 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."); + 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, index * 3); + next.set(binding, AJ200P_BUTTON_SLOTS[index] * 3); // Keep at least one primary click available, allowing deliberate swaps. - if (!aj200pDecodeKeys(next).some((key) => key[0] === 1 && (key[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); + 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."); + 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"); }); + await this.updateSettings((raw, config) => { + raw[3] = aj200pInteger(stage, 0, config.count - 1, "DPI stage"); + }); return stage; } async setDpiStageCount(count: number): Promise { @@ -264,19 +413,29 @@ export class AjazzAj200pHidClient { 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) { + 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); + 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 { + 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); @@ -287,36 +446,73 @@ export class AjazzAj200pHidClient { 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); + 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 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; }); + 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, 1, 20, "Debounce time"); - await this.updateSettings((raw) => { raw[7] = ms; }); return ms; + 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; + 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; + 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; }); return seconds; + 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."); + 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); @@ -327,7 +523,11 @@ export class AjazzAj200pHidClient { 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.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; /**