diff --git a/README.md b/README.md index 3af5fa11..b964e7fb 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 00000000..d6bef9da --- /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 2489f551..30769cf7 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 00000000..5059676f --- /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 00000000..653391b6 --- /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 00000000..24063ee3 --- /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 ddc7bcef..d27a09d5 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 578f1357..43657caa 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 17c9faa5..89d8b44c 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 df3399ec..49e6056e 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 00c126ef..5e1ca061 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/**/*"],