From b53844afead8820180170cdd8573b5077f695eec Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Wed, 7 Oct 2026 16:44:42 +0800 Subject: [PATCH] feat(ajazz): driver for the NJ07 and NJ08 family from the vendor web panel Requested for the NJ07 MC in Discord ticket 0156. The vendor's WebHID panel (nacodex.yjx2012.com, "V1" device class) speaks the K-snake command family with a different frame: output report 0xF0 with 63 body bytes, replies that echo the command, and different header bytes. Product ids 0x2157, 0x2158, 0x2167, 0x2168, 0x2177 and 0x2178 on VID 0xA8A4 (wired) and 0xA8A5 (dongle). 0x2255 stays with the K-snake driver. No capture exists; every byte comes from the panel's JavaScript. Covers identity, battery, firmware, DPI stages, polling rate, sleep timer and scroll direction. Every write reads the config block back and fails if the mouse kept the old value. The wake/move-light byte is carried opaque because the panel decodes it with a nibble bug. Buttons, macros, NJ08 lighting and wake mode are left for after a hardware test. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + docs/ajazz-nj07.md | 64 +++++++ package.json | 4 + src/ajazz/index.ts | 229 +++++++++++++++++++++++ src/drivers/ajazz/hid.test.ts | 314 +++++++++++++++++++++++++++++++ src/drivers/ajazz/hid.ts | 335 ++++++++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/registry.ts | 6 +- src/drivers/vendors.ts | 18 ++ src/index.ts | 1 + tsconfig.json | 3 +- 11 files changed, 974 insertions(+), 3 deletions(-) create mode 100644 docs/ajazz-nj07.md create mode 100644 src/ajazz/index.ts create mode 100644 src/drivers/ajazz/hid.test.ts create mode 100644 src/drivers/ajazz/hid.ts diff --git a/README.md b/README.md index 3af5fa1..b964e7f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ checklist. | Brand | Import | | --- | --- | +| AJAZZ | `@openmouse/protocol/ajazz` | | ATK | `@openmouse/protocol/atk` | | Corsair | `@openmouse/protocol/corsair` | | Dareu | `@openmouse/protocol/dareu` | diff --git a/docs/ajazz-nj07.md b/docs/ajazz-nj07.md new file mode 100644 index 0000000..d6bef9d --- /dev/null +++ b/docs/ajazz-nj07.md @@ -0,0 +1,64 @@ +# AJAZZ NJ07 / NJ08 family + +Requested in the OpenMouse Discord (ticket 0156) for the NJ07 MC. No capture +of any of these mice exists. The driver was written from the JavaScript of the +vendor's WebHID panel, https://nacodex.yjx2012.com/ (device class "V1", +Next.js chunk 515 plus the `/detail` route), and has not run on hardware. + +## Which mice + +The panel requests these product ids on VID `0xA8A4` (wired) and `0xA8A5` +(2.4 GHz dongle), usage page `0xFF01`, usage `0x10`: + +- `0x2157` NJ07 and `0x2158` NJ08, panel DPI range 50 to 12800 +- `0x2167` NJ07 MC and `0x2168` NJ08 MC, panel DPI range 50 to 24000 +- `0x2177` NJ07 MC PRO and `0x2178` NJ08 MC PRO, panel DPI range 50 to 24000 + +Left out on purpose: `0x2255` (NJ07/NJ08 V2 and Ultra, but also the K-snake +X11, which the K-snake driver owns; telling them apart needs a descriptor +dump) and `0x2216` (VIDs `0xA8A7`/`0xA8A9`), which the panel requests but no +model in its device table uses. + +## Frame + +Same command family as the K-snake X11, different framing: + +- Output report `0xF0`, 63 bytes. The reply is an input report whose first + data byte echoes the command; the mouse also pushes DPI (`0xFA`), battery + (`0x30`) and status (`0xED`) reports unprompted. +- `0x04` version, `0x30` battery, `0x0E` read config, `0x0F` write config. +- Read config is `0E 01 0B 2E`, write config starts `0F 01 0A 2F`. Offsets in + `src/ajazz/index.ts` are body offsets (the report id is not counted). +- The config block holds light mode, polling index (0 to 3 for 125, 250, 500, + 1000 Hz), six little-endian DPI stages, active stage, scroll direction, + lift-off, sensor flags, key debounce, sleep minutes (0 = never), a high + speed flag and one wake/move-light byte. +- A write replaces the whole block, so every setter reads the block, patches + one field, writes, then reads back and fails if the mouse kept the old value. + +## One deliberate difference from the panel + +The panel writes `wakeup << 4 | move_light` but reads both fields back from +the high nibble, so a save flips the move-light bit. The driver keeps that +byte opaque and writes back exactly what it read. + +## What the driver does + +Identity, battery, firmware, DPI stages (read, edit one stage, pick the active +stage), polling rate (125, 500, 1000 Hz, the panel's per-model list), sleep +timer and scroll direction. + +Not done: buttons and macros (same slot layout as the K-snake, report `0x08` +and `0x09`), lighting (the panel only shows it for the NJ08 family, modes 0 +to 6), wake mode, line correction, lift-off, debounce. The panel has no +controls for the last two. + +## To test + +1. Connect in Chrome through control.openmouse.app, wired and on the dongle. +2. Status shows the model name, DPI stages, polling rate, sleep timer and + battery. If settings stay hidden, the read did not echo: send the console + log. +3. Change DPI on one stage, the polling rate, the sleep timer and the scroll + direction. Each write reads back and errors if the mouse kept the old value. +4. Confirm the change in the vendor panel (nacodex.yjx2012.com) or by feel. diff --git a/package.json b/package.json index 2489f55..30769cf 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,10 @@ "types": "./dist/fater/index.d.ts", "import": "./dist/fater/index.js" }, + "./ajazz": { + "types": "./dist/ajazz/index.d.ts", + "import": "./dist/ajazz/index.js" + }, "./coolermaster": { "types": "./dist/coolermaster/index.d.ts", "import": "./dist/coolermaster/index.js" diff --git a/src/ajazz/index.ts b/src/ajazz/index.ts new file mode 100644 index 0000000..5059676 --- /dev/null +++ b/src/ajazz/index.ts @@ -0,0 +1,229 @@ +/** + * AJAZZ NJ07 / NJ08 configuration protocol ("V1" device class of the vendor + * WebHID panel at https://nacodex.yjx2012.com/, Next.js chunk 515). + * + * No hardware capture exists. Every byte here was read off that panel's + * JavaScript, so the driver is unverified until an owner confirms it. + * + * Transport: output report 0xF0 carrying 63 bytes, answered by an input + * report whose first data byte echoes the command. The control collection is + * usage page 0xFF01, usage 0x10, and the panel's device picker requests + * [{ vendorId: 0xA8A4, productId, usagePage: 0xFF01, usage: 0x10 }, + * { vendorId: 0xA8A5, productId, usagePage: 0xFF01, usage: 0x10 }] + * for each product id below. 0xA8A4 is the wired mouse, 0xA8A5 the 2.4 GHz + * dongle. + * + * This is the same command family as the K-snake X11 (`../ksnake`), with a + * different first byte and different header bytes. All offsets below are in + * "body coordinates": index 0 of the 63 bytes WebHID sends, which is also + * index 0 of the data WebHID hands back (the report id is stripped). + * + * This module is transport-independent; `src/drivers/ajazz/hid.ts` does the + * WebHID exchange. + */ + +export const AJAZZ_USB_VENDOR_ID = 0xa8a4; +export const AJAZZ_DONGLE_VENDOR_ID = 0xa8a5; +export const AJAZZ_USAGE_PAGE = 0xff01; +export const AJAZZ_USAGE = 0x10; +export const AJAZZ_REPORT_ID = 0xf0; +export const AJAZZ_BODY_BYTES = 63; + +export interface AjazzProduct { + model: string; + /** Top of the vendor panel's DPI slider for this model. */ + maxDpi: number; +} + +/** + * Product ids the vendor panel requests, with the DPI range of each model's + * panel config (`mouse_info_nj07*.json`, `mouse_info_nj08*.json`). The panel + * also requests 0x2255 (shared with the K-snake X11, so owned by that driver) + * and 0x2216 on VIDs 0xA8A7/0xA8A9; those need a descriptor to tell apart. + */ +export const AJAZZ_PRODUCTS: ReadonlyMap = new Map([ + [0x2157, { model: "NJ07", maxDpi: 12800 }], + [0x2158, { model: "NJ08", maxDpi: 12800 }], + [0x2167, { model: "NJ07 MC", maxDpi: 24000 }], + [0x2168, { model: "NJ08 MC", maxDpi: 24000 }], + [0x2177, { model: "NJ07 MC PRO", maxDpi: 24000 }], + [0x2178, { model: "NJ08 MC PRO", maxDpi: 24000 }], +]); + +export const AJAZZ_DPI_MIN = 50; +export const AJAZZ_DPI_STEP = 50; +export const AJAZZ_DPI_STAGES = 6; + +export function ajazzIsValidDpi(dpi: number, maxDpi: number): boolean { + return Number.isInteger(dpi) && dpi >= AJAZZ_DPI_MIN && dpi <= maxDpi; +} + +/** Polling rate by 0-based index, as the config block stores it. */ +export const AJAZZ_POLLING_RATES = [125, 250, 500, 1000] as const; + +/** The panel's per-model config lists 125, 500 and 1000 Hz on USB and 2.4G. */ +export const AJAZZ_OFFERED_POLLING_RATES = [125, 500, 1000] as const; + +export function ajazzEncodePollingRate(hz: number): number | null { + const index = (AJAZZ_POLLING_RATES as readonly number[]).indexOf(hz); + return index === -1 ? null : index; +} + +export function ajazzDecodePollingRate(index: number): number | null { + return index >= 0 && index < AJAZZ_POLLING_RATES.length ? AJAZZ_POLLING_RATES[index] : null; +} + +/** Auto-sleep choices offered in the UI, in seconds. The panel allows 1 to 100 minutes. */ +export const AJAZZ_SLEEP_OPTIONS = [60, 180, 300, 600, 1200, 1800, 3600] as const; + +/** The panel's sleep slider runs 0 to 100 minutes and 0 means never sleep. */ +export const AJAZZ_SLEEP_MAX_MINUTES = 100; + +export function ajazzIsValidSleepSeconds(seconds: number): boolean { + return ( + Number.isInteger(seconds) && + seconds >= 60 && + seconds % 60 === 0 && + seconds / 60 <= AJAZZ_SLEEP_MAX_MINUTES + ); +} + +const CMD = { + GET_VERSION: 0x04, + GET_CONFIG: 0x0e, + SET_CONFIG: 0x0f, + GET_BATTERY: 0x30, +} as const; + +export const AJAZZ_CMD = CMD; + +/** Config block as the panel's `getMouseConfigInfo()` / `setMouseConfigData()` see it. */ +export interface AjazzConfig { + lightMode: number; + /** 0-based index into AJAZZ_POLLING_RATES */ + reportRate: number; + /** Number of enabled DPI stages (the panel's `dpi_count` / `dpi_flag`). */ + dpiCount: number; + /** 0-based active DPI stage */ + dpiIndex: number; + /** Six little-endian uint16 DPI stages */ + stages: number[]; + /** 0 forward, 1 reverse */ + scrollFlag: number; + lodValue: number; + /** Bit 0 is the panel's "line correction" (angle snapping). */ + sensorFlag: number; + keyRespond: number; + /** Auto-sleep in minutes, 0 = never */ + sleepMinutes: number; + highspeedMode: number; + /** + * The panel writes `wakeup << 4 | moveLight` here but reads both fields back + * from the high nibble, so it is carried as one opaque byte. + */ + wakeByte: number; +} + +/** What the panel substitutes when the config block reads back blank or erased. */ +export const AJAZZ_BLANK_CONFIG: Readonly = { + lightMode: 0, + reportRate: 3, + dpiCount: 6, + dpiIndex: 2, + stages: [800, 1600, 2400, 3200, 5000, 12000], + scrollFlag: 0, + lodValue: 1, + sensorFlag: 53, + keyRespond: 8, + sleepMinutes: 10, + highspeedMode: 0, + wakeByte: 0x10, +}; + +function body(...head: number[]): Uint8Array { + const out = new Uint8Array(AJAZZ_BODY_BYTES); + out.set(head); + return out; +} + +function le16(lo: number, hi: number): number { + return ((hi & 0xff) << 8) | (lo & 0xff); +} + +export function ajazzGetVersionRequest(): Uint8Array { + return body(CMD.GET_VERSION, 0x01); +} + +export function ajazzGetBatteryRequest(): Uint8Array { + return body(CMD.GET_BATTERY, 0x01); +} + +export function ajazzGetConfigRequest(): Uint8Array { + return body(CMD.GET_CONFIG, 0x01, 0x0b, 0x2e); +} + +/** + * The panel keeps the last five printable characters of the reply after the + * two header bytes ("x.y.z"); the rest of the report is padding. + */ +export function ajazzDecodeVersion(reply: Uint8Array): string | null { + if (reply[0] !== CMD.GET_VERSION) return null; + const printable = [...reply.slice(2)].filter((b) => b >= 32 && b <= 126); + return printable.length ? String.fromCharCode(...printable.slice(-5)) : null; +} + +/** Battery percent at byte 7 and a charge flag at byte 8 (nonzero while charging). */ +export function ajazzDecodeBattery(reply: Uint8Array): { percent: number; charging: boolean } | null { + if (reply[0] !== CMD.GET_BATTERY || reply.length < 9) return null; + return { percent: reply[7], charging: reply[8] !== 0 }; +} + +/** + * Decode a GET_CONFIG reply. Like the panel, an all-zero or all-0xFF run at + * bytes 13 to 15 means the flash was never written and the defaults apply. + */ +export function ajazzDecodeConfig(reply: Uint8Array): AjazzConfig | null { + if (reply[0] !== CMD.GET_CONFIG || reply.length < 54) return null; + const run = [reply[13], reply[14], reply[15]]; + if (run.every((b) => b === 0) || run.every((b) => b === 0xff)) { + return { ...AJAZZ_BLANK_CONFIG, stages: [...AJAZZ_BLANK_CONFIG.stages] }; + } + return { + lightMode: reply[8], + reportRate: reply[9] - 1, + dpiCount: reply[10], + dpiIndex: Math.max(reply[11] - 1, 0), + stages: Array.from({ length: AJAZZ_DPI_STAGES }, (_, i) => le16(reply[12 + i * 2], reply[13 + i * 2])), + scrollFlag: reply[47], + lodValue: reply[48], + sensorFlag: reply[49], + keyRespond: reply[50], + sleepMinutes: reply[51], + highspeedMode: reply[52], + wakeByte: reply[53], + }; +} + +/** + * SET_CONFIG always rewrites the whole block; bytes 4 to 7 and 24 to 46 stay + * zero exactly as the panel sends them. + */ +export function ajazzEncodeSetConfig(config: AjazzConfig): Uint8Array { + const out = body(CMD.SET_CONFIG, 0x01, 0x0a, 0x2f); + out[8] = config.lightMode; + out[9] = config.reportRate + 1; + out[10] = config.dpiCount; + out[11] = config.dpiIndex + 1; + config.stages.slice(0, AJAZZ_DPI_STAGES).forEach((dpi, i) => { + out[12 + i * 2] = dpi & 0xff; + out[13 + i * 2] = (dpi >> 8) & 0xff; + }); + out[47] = config.scrollFlag; + out[48] = config.lodValue; + out[49] = config.sensorFlag; + out[50] = config.keyRespond; + out[51] = config.sleepMinutes; + out[52] = config.highspeedMode; + out[53] = config.wakeByte; + return out; +} diff --git a/src/drivers/ajazz/hid.test.ts b/src/drivers/ajazz/hid.test.ts new file mode 100644 index 0000000..653391b --- /dev/null +++ b/src/drivers/ajazz/hid.test.ts @@ -0,0 +1,314 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + AJAZZ_BLANK_CONFIG, + AJAZZ_DONGLE_VENDOR_ID, + AJAZZ_PRODUCTS, + AJAZZ_REPORT_ID, + AJAZZ_USAGE, + AJAZZ_USAGE_PAGE, + AJAZZ_USB_VENDOR_ID, + ajazzDecodeBattery, + ajazzDecodeConfig, + ajazzDecodePollingRate, + ajazzDecodeVersion, + ajazzEncodePollingRate, + ajazzEncodeSetConfig, + ajazzGetBatteryRequest, + ajazzGetConfigRequest, + ajazzGetVersionRequest, + ajazzIsValidDpi, + ajazzIsValidSleepSeconds, + type AjazzConfig, +} from "../../ajazz/index.ts"; +import { AjazzHidClient } from "./hid.ts"; +import { createSupportedClient, deviceBrand } from "../registry.ts"; + +const NJ07_MC = 0x2167; + +/** The NJ07 MC factory config from the vendor panel's mouse_info_nj07mc.json. */ +const FACTORY: AjazzConfig = { + lightMode: 0, + reportRate: 3, + dpiCount: 6, + dpiIndex: 1, + stages: [400, 800, 1200, 1600, 2400, 3200], + scrollFlag: 0, + lodValue: 1, + sensorFlag: 0, + keyRespond: 8, + sleepMinutes: 5, + highspeedMode: 0, + wakeByte: 0x10, +}; + +/** + * What the vendor panel's `setMouseConfigData(FACTORY)` puts on the wire, + * written out by hand from its t[] assignments (t[0] is the report id 0xF0, so + * t[n] is body[n - 1]). + */ +const FACTORY_SET_BODY = (() => { + const body = new Uint8Array(63); + body.set([0x0f, 0x01, 0x0a, 0x2f]); + body[9] = 4; // report_rate 3 + 1 + body[10] = 6; // dpi_count + body[11] = 2; // dpi_index 1 + 1 + body.set([0x90, 0x01, 0x20, 0x03, 0xb0, 0x04, 0x40, 0x06, 0x60, 0x09, 0x80, 0x0c], 12); + body[48] = 1; // lod_value + body[50] = 8; // key_respond + body[51] = 5; // sleep_light + body[53] = 0x10; // wakeup << 4 | move_light + return body; +})(); + +function reply(command: number, fill: (data: Uint8Array) => void = () => {}): Uint8Array { + const data = new Uint8Array(63); + data[0] = command; + fill(data); + return data; +} + +describe("ajazz codec", () => { + it("frames the three read requests as 63-byte bodies", () => { + assert.deepEqual([...ajazzGetVersionRequest().slice(0, 3)], [0x04, 0x01, 0x00]); + assert.deepEqual([...ajazzGetBatteryRequest().slice(0, 3)], [0x30, 0x01, 0x00]); + assert.deepEqual([...ajazzGetConfigRequest().slice(0, 5)], [0x0e, 0x01, 0x0b, 0x2e, 0x00]); + for (const req of [ajazzGetVersionRequest(), ajazzGetBatteryRequest(), ajazzGetConfigRequest()]) { + assert.equal(req.length, 63); + } + }); + + it("encodes the factory config exactly as the vendor panel does", () => { + assert.deepEqual([...ajazzEncodeSetConfig(FACTORY)], [...FACTORY_SET_BODY]); + }); + + it("decodes a config reply back to the config that produced it", () => { + const wire = Uint8Array.from(FACTORY_SET_BODY); + wire[0] = 0x0e; + assert.deepEqual(ajazzDecodeConfig(wire), FACTORY); + }); + + it("keeps the wake byte whole instead of splitting its nibbles", () => { + const wire = Uint8Array.from(FACTORY_SET_BODY); + wire[0] = 0x0e; + wire[53] = 0x01; // click wake, move light on: the panel would read both as 0 + const config = ajazzDecodeConfig(wire)!; + assert.equal(config.wakeByte, 0x01); + assert.equal(ajazzEncodeSetConfig(config)[53], 0x01); + }); + + it("falls back to the vendor defaults on a blank or erased block", () => { + for (const fill of [0x00, 0xff]) { + const wire = new Uint8Array(63).fill(fill); + wire[0] = 0x0e; + assert.deepEqual(ajazzDecodeConfig(wire), AJAZZ_BLANK_CONFIG); + } + }); + + it("rejects a reply that is short or answers another command", () => { + assert.equal(ajazzDecodeConfig(new Uint8Array(40)), null); + assert.equal(ajazzDecodeConfig(reply(0x30)), null); + assert.equal(ajazzDecodeBattery(reply(0x0e)), null); + assert.equal(ajazzDecodeVersion(reply(0x0e)), null); + }); + + it("decodes battery and the trailing version characters", () => { + assert.deepEqual( + ajazzDecodeBattery(reply(0x30, (d) => { d[7] = 87; d[8] = 1; })), + { percent: 87, charging: true }, + ); + const ascii = (text: string) => (d: Uint8Array) => d.set([...text].map((c) => c.charCodeAt(0)), 2); + assert.equal(ajazzDecodeVersion(reply(0x04, ascii("V1.0.5"))), "1.0.5"); + assert.equal(ajazzDecodeVersion(reply(0x04)), null); + }); + + it("maps polling rate indexes and validates DPI and sleep", () => { + assert.equal(ajazzEncodePollingRate(1000), 3); + assert.equal(ajazzEncodePollingRate(750), null); + assert.equal(ajazzDecodePollingRate(0), 125); + assert.equal(ajazzDecodePollingRate(4), null); + assert.equal(ajazzIsValidDpi(50, 24000), true); + assert.equal(ajazzIsValidDpi(49, 24000), false); + assert.equal(ajazzIsValidDpi(24050, 24000), false); + assert.equal(ajazzIsValidDpi(800.5, 24000), false); + assert.equal(ajazzIsValidSleepSeconds(300), true); + assert.equal(ajazzIsValidSleepSeconds(90), false); + assert.equal(ajazzIsValidSleepSeconds(0), false); + assert.equal(ajazzIsValidSleepSeconds(6060), false); + }); +}); + +interface MouseOptions { + vendorId?: number; + productId?: number; + /** Never answer anything. */ + silent?: boolean; + /** Acknowledge SET_CONFIG but keep the old block. */ + ignoreWrites?: boolean; + /** Push a status report before every answer, like the mouse does on DPI changes. */ + noisy?: boolean; +} + +/** A mock mouse that answers the vendor frames from a stored config block. */ +function fakeMouse(options: MouseOptions = {}) { + const block = Uint8Array.from(FACTORY_SET_BODY); + const sent: Uint8Array[] = []; + const listeners = new Set<(event: HIDInputReportEvent) => void>(); + const emit = (data: Uint8Array): void => { + const event = { reportId: AJAZZ_REPORT_ID, data: new DataView(data.buffer.slice(0)) }; + for (const listener of [...listeners]) listener(event as unknown as HIDInputReportEvent); + }; + const device = { + vendorId: options.vendorId ?? AJAZZ_DONGLE_VENDOR_ID, + productId: options.productId ?? NJ07_MC, + productName: "2.4G Wireless Mouse", + opened: false, + collections: [{ usagePage: AJAZZ_USAGE_PAGE, usage: AJAZZ_USAGE, children: [] }], + open: async () => { device.opened = true; }, + close: async () => { device.opened = false; }, + addEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => { listeners.add(fn); }, + removeEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => { listeners.delete(fn); }, + sendReport: async (reportId: number, body: BufferSource) => { + assert.equal(reportId, AJAZZ_REPORT_ID); + const request = Uint8Array.from(body as Uint8Array); + assert.equal(request.length, 63); + sent.push(request); + if (options.silent) return; + if (options.noisy) emit(reply(0xfa, (d) => { d[8] = 3; d[9] = 4; })); + switch (request[0]) { + case 0x0e: { + const wire = Uint8Array.from(block); + wire[0] = 0x0e; + emit(wire); + break; + } + case 0x0f: + if (!options.ignoreWrites) block.set(request.slice(8, 54), 8); + emit(reply(0x0f)); + break; + case 0x30: + emit(reply(0x30, (d) => { d[7] = 64; d[8] = 0; })); + break; + case 0x04: + emit(reply(0x04, (d) => d.set([...("V1.0.5")].map((c) => c.charCodeAt(0)), 2))); + break; + } + }, + }; + const client = new AjazzHidClient(device as unknown as HIDDevice, { replyTimeoutMs: 20, settleAfterWriteMs: 0 }); + return { device: device as unknown as HIDDevice, client, block, sent }; +} + +describe("AjazzHidClient", () => { + it("claims only the catalogue ids on the two vendor ids and the 0xFF01:0x10 collection", () => { + const { device } = fakeMouse(); + assert.equal(AjazzHidClient.isSupported(device), true); + for (const patch of [{ vendorId: 0x1234 }, { productId: 0x2255 }, { collections: [] }]) { + assert.equal(AjazzHidClient.isSupported({ ...device, ...patch } as unknown as HIDDevice), false); + } + assert.equal(AjazzHidClient.isSupported({ ...device, vendorId: AJAZZ_USB_VENDOR_ID } as unknown as HIDDevice), true); + assert.deepEqual([...AJAZZ_PRODUCTS.keys()].sort(), [0x2157, 0x2158, 0x2167, 0x2168, 0x2177, 0x2178]); + }); + + it("is picked by the registry and named AJAZZ, leaving K-snake's id alone", () => { + const { device } = fakeMouse(); + const client = createSupportedClient(device); + assert.ok(client instanceof AjazzHidClient); + assert.equal(deviceBrand(client), "AJAZZ"); + const ksnake = { ...device, productId: 0x2255 } as unknown as HIDDevice; + assert.notEqual(createSupportedClient(ksnake)?.constructor.name, "AjazzHidClient"); + }); + + it("reads identity, battery, DPI stages, polling, sleep and scroll direction", async () => { + const { client } = fakeMouse(); + const status = await client.readStatus(); + assert.equal(status.brand, "AJAZZ"); + assert.equal(status.name, "AJAZZ NJ07 MC"); + assert.equal(status.ui?.settingsReady, true); + assert.deepEqual(status.dpiStages, [400, 800, 1200, 1600, 2400, 3200]); + assert.equal(status.activeDpiStage, 1); + assert.equal(status.dpi, 800); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 500, 1000]); + assert.equal(status.sleepTimeout, 300); + assert.equal(status.scrollDirection, "Forward"); + assert.equal(status.batteryPercent, 64); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.connectionType, "Wireless"); + assert.deepEqual(status.firmware, ["NJ07 MC 1.0.5"]); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 24000); + }); + + it("reports a wired mouse as wired", async () => { + const { client } = fakeMouse({ vendorId: AJAZZ_USB_VENDOR_ID }); + assert.equal((await client.readStatus()).connectionType, "Wired"); + }); + + it("falls back to identity only when the mouse stays silent", async () => { + const { client } = fakeMouse({ silent: true }); + const status = await client.readStatus(); + assert.equal(status.name, "AJAZZ NJ07 MC"); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.dpi, 0); + assert.equal(status.batteryPercent, null); + assert.deepEqual(status.firmware, ["NJ07 MC"]); + }); + + it("matches replies by command, so unsolicited reports are ignored", async () => { + const { client } = fakeMouse({ noisy: true }); + const status = await client.readStatus(); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.batteryPercent, 64); + }); + + it("changes one DPI stage and leaves the rest of the block alone", async () => { + const { client, block } = fakeMouse(); + assert.equal(await client.setDpiStageValue(2, 1500), 1500); + assert.deepEqual(ajazzDecodeConfig(Uint8Array.from([0x0e, ...block.slice(1)])), { + ...FACTORY, + stages: [400, 800, 1500, 1600, 2400, 3200], + }); + }); + + it("changes the active stage's DPI with setDpi", async () => { + const { client, block } = fakeMouse(); + await client.setDpi(900); + assert.equal(ajazzDecodeConfig(Uint8Array.from([0x0e, ...block.slice(1)]))!.stages[1], 900); + }); + + it("switches stage, polling rate, sleep and scroll direction", async () => { + const { client, block } = fakeMouse(); + await client.setActiveDpiStage(4); + await client.setPollingRate(500); + await client.setSleepTimeout(600); + await client.setScrollDirection("Reverse"); + assert.deepEqual(ajazzDecodeConfig(Uint8Array.from([0x0e, ...block.slice(1)])), { + ...FACTORY, + dpiIndex: 4, + reportRate: 2, + sleepMinutes: 10, + scrollFlag: 1, + }); + }); + + it("refuses values the mouse or UI cannot take", async () => { + const { client, sent } = fakeMouse(); + await assert.rejects(client.setPollingRate(250), /does not support 250 Hz/); + await assert.rejects(client.setDpiStageValue(0, 24050), /between 50 and 24000/); + await assert.rejects(client.setDpiStageValue(6, 800), /stage must be between 1 and 6/); + await assert.rejects(client.setActiveDpiStage(6), /stage must be between 1 and 6/); + await assert.rejects(client.setSleepTimeout(90), /does not support a 90-second/); + assert.equal(sent.some((r) => r[0] === 0x0f), false); + }); + + it("fails loudly when the mouse acknowledges a write but keeps the old value", async () => { + const { client } = fakeMouse({ ignoreWrites: true }); + await assert.rejects(client.setPollingRate(125), /kept 1000 Hz instead of 125 Hz/); + }); + + it("does not write when the current config cannot be read", async () => { + const { client, sent } = fakeMouse({ silent: true }); + await assert.rejects(client.setPollingRate(500), /Could not read the current config/); + assert.equal(sent.some((r) => r[0] === 0x0f), false); + }); +}); diff --git a/src/drivers/ajazz/hid.ts b/src/drivers/ajazz/hid.ts new file mode 100644 index 0000000..24063ee --- /dev/null +++ b/src/drivers/ajazz/hid.ts @@ -0,0 +1,335 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + AJAZZ_DONGLE_VENDOR_ID, + AJAZZ_OFFERED_POLLING_RATES, + AJAZZ_PRODUCTS, + AJAZZ_REPORT_ID, + AJAZZ_SLEEP_OPTIONS, + AJAZZ_USAGE, + AJAZZ_USAGE_PAGE, + AJAZZ_USB_VENDOR_ID, + AJAZZ_DPI_MIN, + AJAZZ_DPI_STEP, + AJAZZ_DPI_STAGES, + ajazzDecodeBattery, + ajazzDecodeConfig, + ajazzDecodePollingRate, + ajazzDecodeVersion, + ajazzEncodePollingRate, + ajazzEncodeSetConfig, + ajazzGetBatteryRequest, + ajazzGetConfigRequest, + ajazzGetVersionRequest, + ajazzIsValidDpi, + ajazzIsValidSleepSeconds, + type AjazzConfig, +} from "../../ajazz/index.ts"; + +const REPLY_TIMEOUT_MS = 800; +/** Pause after a SET before reading back, so the mouse can commit to flash. */ +const SETTLE_AFTER_WRITE_MS = 250; + +/** Rejected when a command gets no matching input report within the timeout. */ +export class AjazzTimeoutError extends Error { + constructor() { + super("The mouse did not answer. It may be asleep or out of range."); + this.name = "AjazzTimeoutError"; + } +} + +export interface AjazzClientOptions { + replyTimeoutMs?: number; + settleAfterWriteMs?: number; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function copyDataView(view: DataView): Uint8Array { + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); +} + +/** + * AJAZZ NJ07 / NJ08 vendor HID control (see `@openmouse/protocol/ajazz`). + * + * Transport: output report 0xF0 (63 bytes); the answer is the next input + * report that echoes the command byte. The mouse also pushes DPI, battery and + * status reports unprompted, so replies are matched by command rather than by + * arrival order. A tiny queue serializes calls like the vendor panel does. + * + * Evidence: the vendor panel's JavaScript only. Nothing here has been run + * against hardware, so every write reads the config back and fails loudly + * when the mouse kept something else. + */ +export class AjazzHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private readonly replyTimeoutMs: number; + private readonly settleAfterWriteMs: number; + /** Last config that decoded cleanly, so a failed poll does not blank the UI. */ + private lastGoodConfig: AjazzConfig | null = null; + + constructor(device: HIDDevice, options: AjazzClientOptions = {}) { + this.device = device; + this.replyTimeoutMs = options.replyTimeoutMs ?? REPLY_TIMEOUT_MS; + this.settleAfterWriteMs = options.settleAfterWriteMs ?? SETTLE_AFTER_WRITE_MS; + } + + static isSupported(device: HIDDevice): boolean { + if (!AJAZZ_PRODUCTS.has(device.productId)) return false; + if (device.vendorId !== AJAZZ_USB_VENDOR_ID && device.vendorId !== AJAZZ_DONGLE_VENDOR_ID) return false; + const search = (list: readonly HIDCollectionInfo[]): boolean => + list.some((c) => (c.usagePage === AJAZZ_USAGE_PAGE && c.usage === AJAZZ_USAGE) || search(c.children)); + return search(device.collections); + } + + private get product() { + return AJAZZ_PRODUCTS.get(this.device.productId); + } + + get supportedPollingRates(): number[] { + return [...AJAZZ_OFFERED_POLLING_RATES]; + } + + getDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = AJAZZ_DPI_MIN; dpi <= (this.product?.maxDpi ?? 0); dpi += AJAZZ_DPI_STEP) options.push(dpi); + return options; + } + + getSleepOptions(): number[] { + return [...AJAZZ_SLEEP_OPTIONS]; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + /** Send one command and resolve with the input report that echoes it. */ + private exchange(request: Uint8Array): Promise { + const command = request[0]; + const started = this.queue.then(async () => { + await this.open(); + return new Promise((resolve, reject) => { + const done = (): void => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + }; + const timer = setTimeout(() => { + done(); + reject(new AjazzTimeoutError()); + }, this.replyTimeoutMs); + const listener = (event: HIDInputReportEvent): void => { + const data = copyDataView(event.data); + if (data[0] !== command) return; + done(); + resolve(data); + }; + this.device.addEventListener("inputreport", listener); + this.device.sendReport(AJAZZ_REPORT_ID, request.slice()).catch((error: unknown) => { + done(); + reject(error); + }); + }); + }); + this.queue = started.catch(() => undefined); + return started; + } + + /** Retry on timeout only; a sleeping dongle often drops the first command. */ + private async exchangeRetrying(request: Uint8Array, attempts: number): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await this.exchange(request); + } catch (error) { + if (!(error instanceof AjazzTimeoutError)) throw error; + lastError = error; + } + } + throw lastError; + } + + private async readConfig(attempts = 3): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + const reply = await this.exchange(ajazzGetConfigRequest()).catch(() => null); + const config = reply ? ajazzDecodeConfig(reply) : null; + if (config && configLooksValid(config)) return (this.lastGoodConfig = config); + } + return null; + } + + async readStatus(): Promise { + await this.open(); + const product = this.product; + const model = product?.model ?? "NJ07"; + const wired = this.device.vendorId === AJAZZ_USB_VENDOR_ID; + const config = (await this.readConfig(2)) ?? this.lastGoodConfig; + const battery = await this.exchange(ajazzGetBatteryRequest()).then(ajazzDecodeBattery, () => null); + const version = await this.exchange(ajazzGetVersionRequest()).then(ajazzDecodeVersion, () => null); + const stages = config?.stages.slice(0, config.dpiCount) ?? []; + const activeStage = config ? Math.min(config.dpiIndex, stages.length - 1) : 0; + return { + brand: "AJAZZ", + name: `AJAZZ ${model}`, + ui: { + family: "ajazz", + settingsReady: config !== null, + valuesVerified: config !== null, + showAdvancedSection: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + hideSignalCard: true, + defaultDisplayName: `AJAZZ ${model}`, + dpiStageEditor: { + maxStages: AJAZZ_DPI_STAGES, + countEditable: false, + minDpi: AJAZZ_DPI_MIN, + maxDpi: product?.maxDpi ?? 12800, + stepDpi: AJAZZ_DPI_STEP, + }, + }, + batteryPercent: battery ? Math.min(battery.percent, 100) : null, + batteryState: battery ? (battery.charging ? "Charging" : "Discharging") : "Unknown", + dpi: config ? stages[activeStage] : 0, + dpiStages: stages.length ? stages : undefined, + activeDpiStage: stages.length ? activeStage : undefined, + pollingRateHz: config ? (ajazzDecodePollingRate(config.reportRate) ?? 0) : 0, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + liftOffDistance: null, + connectionType: wired ? "Wired" : "Wireless", + connectionDetail: wired ? "Wired USB" : "2.4 GHz receiver", + sleepTimeout: config && config.sleepMinutes > 0 ? config.sleepMinutes * 60 : null, + scrollDirection: config ? (config.scrollFlag === 1 ? "Reverse" : "Forward") : null, + firmware: version ? [`${model} ${version}`] : [model], + }; + } + + /** + * SET_CONFIG rewrites the whole block, so every setter reads the current + * block, patches one field, writes it and reads it back. The panel tolerates + * a silent SET (it only waits 500 ms for an echo), so a missing echo is not + * an error here either; the read-back is the check. + */ + private async updateConfig( + patch: Partial, + kept: (confirmed: AjazzConfig) => boolean, + describe: (confirmed: AjazzConfig) => string, + wanted: string, + ): Promise { + const current = await this.readConfig(); + if (!current) throw new Error("Could not read the current config from the mouse."); + await this.exchangeRetrying(ajazzEncodeSetConfig({ ...current, ...patch }), 2).catch((error: unknown) => { + if (!(error instanceof AjazzTimeoutError)) throw error; + }); + await sleep(this.settleAfterWriteMs); + const confirmed = await this.readConfig(); + if (!confirmed || !kept(confirmed)) { + throw new Error(`The mouse kept ${confirmed ? describe(confirmed) : "an unreadable config"} instead of ${wanted}.`); + } + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + const maxDpi = this.product?.maxDpi ?? 12800; + if (!ajazzIsValidDpi(dpi, maxDpi)) { + throw new Error(`DPI must be a whole number between ${AJAZZ_DPI_MIN} and ${maxDpi} (got ${dpi}).`); + } + const current = await this.readConfig(); + if (!current) throw new Error("Could not read the current config from the mouse."); + if (!Number.isInteger(stage) || stage < 0 || stage >= current.dpiCount) { + throw new Error(`DPI stage must be between 1 and ${current.dpiCount}.`); + } + const stages = [...current.stages]; + stages[stage] = dpi; + await this.updateConfig( + { stages }, + (c) => c.stages[stage] === dpi, + (c) => `${c.stages[stage]} DPI`, + `${dpi} DPI`, + ); + return dpi; + } + + async setDpi(dpi: number): Promise { + const current = await this.readConfig(); + if (!current) throw new Error("Could not read the current config from the mouse."); + return this.setDpiStageValue(current.dpiIndex, dpi); + } + + async setActiveDpiStage(stage: number): Promise { + const current = await this.readConfig(); + if (!current) throw new Error("Could not read the current config from the mouse."); + if (!Number.isInteger(stage) || stage < 0 || stage >= current.dpiCount) { + throw new Error(`DPI stage must be between 1 and ${current.dpiCount}.`); + } + await this.updateConfig( + { dpiIndex: stage }, + (c) => c.dpiIndex === stage, + (c) => `DPI stage ${c.dpiIndex + 1}`, + `DPI stage ${stage + 1}`, + ); + return stage; + } + + async setPollingRate(rate: number): Promise { + const index = (AJAZZ_OFFERED_POLLING_RATES as readonly number[]).includes(rate) + ? ajazzEncodePollingRate(rate) + : null; + if (index === null) { + throw new Error(`This mouse does not support ${rate} Hz.`); + } + await this.updateConfig( + { reportRate: index }, + (c) => c.reportRate === index, + (c) => `${ajazzDecodePollingRate(c.reportRate) ?? "?"} Hz`, + `${rate} Hz`, + ); + return rate; + } + + async setSleepTimeout(seconds: number): Promise { + if (!ajazzIsValidSleepSeconds(seconds)) { + throw new Error(`This mouse does not support a ${seconds}-second sleep timeout.`); + } + const minutes = seconds / 60; + await this.updateConfig( + { sleepMinutes: minutes }, + (c) => c.sleepMinutes === minutes, + (c) => `a ${c.sleepMinutes}-minute sleep timeout`, + `${minutes} minutes`, + ); + return seconds; + } + + async setScrollDirection( + direction: NonNullable, + ): Promise> { + if (direction !== "Forward" && direction !== "Reverse") { + throw new Error(`Scroll direction must be Forward or Reverse (got ${direction}).`); + } + const scrollFlag = direction === "Reverse" ? 1 : 0; + await this.updateConfig( + { scrollFlag }, + (c) => c.scrollFlag === scrollFlag, + (c) => `${c.scrollFlag === 1 ? "Reverse" : "Forward"} scroll direction`, + direction, + ); + return direction; + } +} + +/** Rejects a decode that cannot be a real block, such as a crossed or half-filled report. */ +function configLooksValid(config: AjazzConfig): boolean { + return ( + ajazzDecodePollingRate(config.reportRate) !== null && + config.dpiCount >= 1 && + config.dpiCount <= AJAZZ_DPI_STAGES && + config.dpiIndex < config.dpiCount + ); +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ddc7bce..d27a09d 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -191,7 +191,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 578f135..43657ca 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -74,9 +74,10 @@ import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; +import { AjazzHidClient } from "./ajazz/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | 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 | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | 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 | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient; export interface DeviceDriver { brand: string; @@ -172,6 +173,9 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ // two matchers are kept disjoint by product id. { brand: "MCHOSE", supports: (device) => MchoseV3HidClient.isSupported(device), create: (device) => new MchoseV3HidClient(device), score: () => 7 }, { brand: "K-snake", supports: (device) => KsnakeHidClient.isSupported(device), create: (device) => new KsnakeHidClient(device), score: () => 5 }, + // 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: "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 17c9faa..89d8b44 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -125,6 +125,13 @@ import { FATER_PRODUCT_IDS, FATER_VENDOR_ID, } from "@openmouse/protocol/fater"; +import { + AJAZZ_DONGLE_VENDOR_ID, + AJAZZ_PRODUCTS, + AJAZZ_USAGE, + AJAZZ_USAGE_PAGE, + AJAZZ_USB_VENDOR_ID, +} from "@openmouse/protocol/ajazz"; import { MOTOSPEED_PRODUCTS, MOTOSPEED_USAGE_PAGE, MOTOSPEED_VENDOR_ID } from "@openmouse/protocol/motospeed"; import { COOLERMASTER_USAGE, @@ -222,6 +229,16 @@ export const DAREU_HID_FILTERS: HIDDeviceFilter[] = [...DAREU_PRODUCT_IDS].map(( usage: DAREU_COMMAND_USAGE, })); +/** NJ07 / NJ08 control collection, on the wired VID and on the 2.4 GHz dongle VID. */ +export const AJAZZ_HID_FILTERS: HIDDeviceFilter[] = [...AJAZZ_PRODUCTS.keys()].flatMap((productId) => + [AJAZZ_USB_VENDOR_ID, AJAZZ_DONGLE_VENDOR_ID].map((vendorId) => ({ + vendorId, + productId, + usagePage: AJAZZ_USAGE_PAGE, + usage: AJAZZ_USAGE, + })), +); + /** Holtek vendor collection on the MCR-9000B's interface 2, as reported by its owner. */ export const FATER_HID_FILTERS: HIDDeviceFilter[] = [...FATER_PRODUCT_IDS].map((productId) => ({ vendorId: FATER_VENDOR_ID, @@ -868,6 +885,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ // both the wired USB VID and the 2.4 GHz dongle VID. { vendorId: VENDOR_ID.ksnakeUsb, productId: 0x2255, usagePage: 0xff01, usage: 0x10 }, { vendorId: VENDOR_ID.ksnakeDongle, productId: 0x2255, usagePage: 0xff01, usage: 0x10 }, + ...AJAZZ_HID_FILTERS, ...MICROSOFT_HID_FILTERS, ...INCOTT_HID_FILTERS, ...HYPERX_HID_FILTERS, diff --git a/src/index.ts b/src/index.ts index df3399e..49e6056 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,3 +36,4 @@ export * as motospeed from "./motospeed/index.js"; 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"; diff --git a/tsconfig.json b/tsconfig.json index 00c126e..5e1ca06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,7 +50,8 @@ "@openmouse/protocol/bytech": ["./src/bytech/index.ts"], "@openmouse/protocol/rapoo": ["./src/rapoo/index.ts"], "@openmouse/protocol/fater": ["./src/fater/index.ts"], - "@openmouse/protocol/coolermaster": ["./src/coolermaster/index.ts"] + "@openmouse/protocol/coolermaster": ["./src/coolermaster/index.ts"], + "@openmouse/protocol/ajazz": ["./src/ajazz/index.ts"] } }, "include": ["src/**/*"],