diff --git a/docs/ajazz-nj07.md b/docs/ajazz-nj07.md index d6bef9da..bda9614a 100644 --- a/docs/ajazz-nj07.md +++ b/docs/ajazz-nj07.md @@ -1,9 +1,16 @@ # 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. +Requested in the OpenMouse Discord (ticket 0156) for the NJ07 MC. 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). + +Hardware evidence (ticket 0156, NJ07 MC PID `0x2167` on VID `0xA8A5`, firmware +`2.0.1`): GET_CONFIG / battery / version replies decode exactly as this +driver does (all-400 DPI stages, stage 0 active, 1000 Hz, 1-minute sleep, +opaque wake byte `0x10`), and a flash write round-trip (800 DPI, 500 Hz, then +restore) passes on-device. Only the key-map path (`0x08`/`0x09`) is still +unverified on hardware; it follows the same panel JS. ## Which mice @@ -27,6 +34,10 @@ Same command family as the K-snake X11, different framing: 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. +- `0x08` read keys, `0x09` write keys: six four-byte slots at reply/body offset + 7, then two fixed wheel slots (scroll up `[33,56,1,0]`, scroll down + `[33,56,255,0]`) on write. Read keys is `08 01 0B 2C`, write keys starts + `09 01 22 30`. - 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, @@ -46,12 +57,19 @@ byte opaque and writes back exactly what it read. 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. +timer (including 0 = never sleep) and scroll direction. Button remapping for +the six buttons (Left locked, Right, Middle, Backward, Forward, DPI) with the +vendor's key catalog, plus factory-map restore; macro (type 112) and modified +keyboard-combo bindings read back as opaque labels and are preserved across +writes. + +Writes are fire-and-forget like the panel (`setMouseConfigData` / +`setMouseKeys` never wait for an echo), then read back with retries: a slow +flash commit no longer fails the setter or stalls it for two reply timeouts. -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. +Not done: macros (upload uses commands `0x0D`/`0x10` plus a report-6 read, same +shape as the K-snake macro store), NJ08 lighting, wake mode, line correction, +lift-off, debounce. The panel has no controls for the last two. ## To test diff --git a/src/ajazz/index.ts b/src/ajazz/index.ts index 5059676f..edbb64e3 100644 --- a/src/ajazz/index.ts +++ b/src/ajazz/index.ts @@ -2,8 +2,10 @@ * 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. + * Config, battery and version frames are confirmed against a real NJ07 MC + * (PID 0x2167, firmware 2.0.1, ticket 0156), including a flash write + * round-trip. The key-map frames below follow the same panel JavaScript and + * are still unverified on hardware. * * Transport: output report 0xF0 carrying 63 bytes, answered by an input * report whose first data byte echoes the command. The control collection is @@ -73,13 +75,14 @@ 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; +/** Auto-sleep choices offered in the UI, in seconds. The panel allows 0 to 100 minutes. */ +export const AJAZZ_SLEEP_OPTIONS = [0, 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 { + if (seconds === 0) return true; // the panel's "never sleep" return ( Number.isInteger(seconds) && seconds >= 60 && @@ -90,6 +93,8 @@ export function ajazzIsValidSleepSeconds(seconds: number): boolean { const CMD = { GET_VERSION: 0x04, + GET_KEYS: 0x08, + SET_KEYS: 0x09, GET_CONFIG: 0x0e, SET_CONFIG: 0x0f, GET_BATTERY: 0x30, @@ -227,3 +232,231 @@ export function ajazzEncodeSetConfig(config: AjazzConfig): Uint8Array { out[53] = config.wakeByte; return out; } + +/** + * Button map ("V1" `getMouseKeys()` / `setMouseKeys()` / `resetAllMouseKeys()`). + * + * GET_KEYS is body `[0x08, 0x01, 0x0B, 0x2C]`; the reply echoes `0x08` and + * carries six four-byte slots at reply[7..30]. SET_KEYS is body + * `[0x09, 0x01, 0x22, 0x30]` with eight slots at body[7..38]: the six + * remappable buttons followed by two fixed wheel entries (scroll up, scroll + * down), exactly as the panel's template sends them. `resetAllMouseKeys()` + * sends the same frame with the six factory defaults up front. + * + * Slot types come from the panel's `keys.json` catalog: 32 = mouse button + * (code1 = HID bitmask, 0 = disabled), 16 = keyboard (code1 = modifier bits, + * code2 = HID usage), 33 = special (DPI loop [85,0,0], scroll [56,1/255]), + * 48 = consumer/media (code1 = consumer usage low byte, code2 = page bits), + * 112 = a macro slot (code1 = macro index). A slot read back as type 0 or + * 0xff means the panel keeps its default for that button. + */ + +/** User-remappable buttons in wire order. */ +export const AJAZZ_BUTTON_NAMES = ["Left", "Right", "Middle", "Backward", "Forward", "DPI"] as const; + +/** Remappable wire slots; slots 6-7 are the fixed wheel entries. */ +export const AJAZZ_KEY_SLOTS = 6; +export const AJAZZ_KEY_WIRE_SLOTS = 8; + +/** Button function types from the vendor key catalog. */ +export const AJAZZ_KEY_TYPE = { + keyboard: 16, + mouse: 32, + special: 33, + media: 48, + macro: 112, +} as const; + +/** One button slot: four raw bytes, carried losslessly. */ +export interface AjazzKeyBinding { + type: number; + code1: number; + code2: number; + code3: number; +} + +export function ajazzIsKnownKeyType(type: number): boolean { + return ( + type === AJAZZ_KEY_TYPE.keyboard || + type === AJAZZ_KEY_TYPE.mouse || + type === AJAZZ_KEY_TYPE.special || + type === AJAZZ_KEY_TYPE.media || + type === AJAZZ_KEY_TYPE.macro + ); +} + +/** Factory button map from every model's `mouse_info_*.json` DefaultProfile. */ +export const AJAZZ_DEFAULT_KEYS: ReadonlyArray = [ + { type: 32, code1: 1, code2: 0, code3: 0 }, + { type: 32, code1: 2, code2: 0, code3: 0 }, + { type: 32, code1: 4, code2: 0, code3: 0 }, + { type: 32, code1: 8, code2: 0, code3: 0 }, + { type: 32, code1: 16, code2: 0, code3: 0 }, + { type: 33, code1: 85, code2: 0, code3: 0 }, +]; + +/** Fixed wheel entries the panel always appends as wire slots 6-7. */ +export const AJAZZ_FIXED_WHEEL_KEYS: ReadonlyArray = [ + { type: 33, code1: 56, code2: 1, code3: 0 }, + { type: 33, code1: 56, code2: 255, code3: 0 }, +]; + +/** GET_KEYS request body observed in vendor JS: [0x08, 0x01, 0x0B, 0x2C]. */ +export function ajazzGetKeysRequest(): Uint8Array { + return body(CMD.GET_KEYS, 0x01, 0x0b, 0x2c); +} + +/** + * Decode a GET_KEYS reply: six slots of four bytes at reply[7..30] (the + * vendor slices 7). Returns null when the reply answers another command. + */ +export function ajazzDecodeKeys(reply: Uint8Array): AjazzKeyBinding[] | null { + if (reply[0] !== CMD.GET_KEYS || reply.length < 7 + AJAZZ_KEY_SLOTS * 4) return null; + const bindings: AjazzKeyBinding[] = []; + for (let i = 0; i < AJAZZ_KEY_SLOTS; i++) { + bindings.push({ + type: reply[7 + i * 4], + code1: reply[8 + i * 4], + code2: reply[9 + i * 4], + code3: reply[10 + i * 4], + }); + } + return bindings; +} + +/** + * Plausibility gate for decoded key maps. The vendor panel treats a slot read + * back as type 0 or 0xff as a default-binding marker (it keeps its own + * default for that button), so markers are accepted per slot. Anything else + * outside the 16/32/33/48/112 catalog is rejected as a crossed or + * half-filled report. + */ +export function ajazzKeysLookPlausible(keys: readonly AjazzKeyBinding[]): boolean { + return ( + keys.length === AJAZZ_KEY_SLOTS && + keys.every((key) => key.type === 0 || key.type === 0xff || ajazzIsKnownKeyType(key.type)) + ); +} + +/** + * Substitute the factory defaults for default-binding markers (type 0/0xff), + * mirroring the vendor panel's merge of a freshly read map over its own + * defaults. Real bindings pass through untouched. + */ +export function ajazzApplyKeyDefaults(keys: readonly AjazzKeyBinding[]): AjazzKeyBinding[] { + return keys.map((key, index) => { + if ((key.type === 0 || key.type === 0xff) && index < AJAZZ_DEFAULT_KEYS.length) { + return { ...AJAZZ_DEFAULT_KEYS[index] }; + } + return { ...key }; + }); +} + +/** + * Encode a SET_KEYS request, mirroring vendor `setMouseKeys()`: head + * [0x09, 0x01, 0x22, 0x30], the six bindings at body[7..30], then the two + * fixed wheel entries at body[31..38]. + */ +export function ajazzEncodeSetKeys(keys: readonly AjazzKeyBinding[]): Uint8Array { + const slots = [...keys].slice(0, AJAZZ_KEY_SLOTS); + while (slots.length < AJAZZ_KEY_SLOTS) slots.push({ type: 32, code1: 0, code2: 0, code3: 0 }); + const out = body(CMD.SET_KEYS, 0x01, 0x22, 0x30); + [...slots, ...AJAZZ_FIXED_WHEEL_KEYS].forEach((key, n) => { + out[7 + n * 4] = key.type & 0xff; + out[8 + n * 4] = key.code1 & 0xff; + out[9 + n * 4] = key.code2 & 0xff; + out[10 + n * 4] = key.code3 & 0xff; + }); + return out; +} + +/** + * Remappable actions from the vendor key catalog (display label + bytes). + * Keyboard usages use the type-16 encoding with a zero modifier byte; combos + * with modifiers still read back losslessly as opaque bindings. Macro slots + * (type 112) are intentionally absent: the panel keeps macros in localStorage + * and uploads them separately, so there is no fixed onboard slot count yet. + */ +export const AJAZZ_BUTTON_ACTIONS: ReadonlyArray<{ + label: string; + type: number; + code1: number; + code2: number; + code3: number; +}> = [ + { label: "Escape", type: 16, code1: 0, code2: 41, code3: 0 }, + { label: "Tab", type: 16, code1: 0, code2: 43, code3: 0 }, + { label: "Caps Lock", type: 16, code1: 0, code2: 57, code3: 0 }, + { label: "Enter", type: 16, code1: 0, code2: 40, code3: 0 }, + { label: "Space", type: 16, code1: 0, code2: 44, code3: 0 }, + { label: "Backspace", type: 16, code1: 0, code2: 42, code3: 0 }, + { label: "Insert", type: 16, code1: 0, code2: 73, code3: 0 }, + { label: "Delete", type: 16, code1: 0, code2: 76, code3: 0 }, + { label: "Home", type: 16, code1: 0, code2: 74, code3: 0 }, + { label: "End", type: 16, code1: 0, code2: 77, code3: 0 }, + { label: "Page Up", type: 16, code1: 0, code2: 75, code3: 0 }, + { label: "Page Down", type: 16, code1: 0, code2: 78, code3: 0 }, + { label: "Left Arrow", type: 16, code1: 0, code2: 80, code3: 0 }, + { label: "Right Arrow", type: 16, code1: 0, code2: 79, code3: 0 }, + { label: "Up Arrow", type: 16, code1: 0, code2: 82, code3: 0 }, + { label: "Down Arrow", type: 16, code1: 0, code2: 81, code3: 0 }, + { label: "F1", type: 16, code1: 0, code2: 58, code3: 0 }, + { label: "F2", type: 16, code1: 0, code2: 59, code3: 0 }, + { label: "F3", type: 16, code1: 0, code2: 60, code3: 0 }, + { label: "F4", type: 16, code1: 0, code2: 61, code3: 0 }, + { label: "F5", type: 16, code1: 0, code2: 62, code3: 0 }, + { label: "F6", type: 16, code1: 0, code2: 63, code3: 0 }, + { label: "F7", type: 16, code1: 0, code2: 64, code3: 0 }, + { label: "F8", type: 16, code1: 0, code2: 65, code3: 0 }, + { label: "F9", type: 16, code1: 0, code2: 66, code3: 0 }, + { label: "F10", type: 16, code1: 0, code2: 67, code3: 0 }, + { label: "F11", type: 16, code1: 0, code2: 68, code3: 0 }, + { label: "F12", type: 16, code1: 0, code2: 69, code3: 0 }, + { label: "Left click", type: 32, code1: 1, code2: 0, code3: 0 }, + { label: "Right click", type: 32, code1: 2, code2: 0, code3: 0 }, + { label: "Middle click", type: 32, code1: 4, code2: 0, code3: 0 }, + { label: "Backward", type: 32, code1: 8, code2: 0, code3: 0 }, + { label: "Forward", type: 32, code1: 16, code2: 0, code3: 0 }, + { label: "Disabled", type: 32, code1: 0, code2: 0, code3: 0 }, + { label: "DPI loop", type: 33, code1: 85, code2: 0, code3: 0 }, + { label: "Scroll up", type: 33, code1: 56, code2: 1, code3: 0 }, + { label: "Scroll down", type: 33, code1: 56, code2: 255, code3: 0 }, + { label: "Volume +", type: 48, code1: 233, code2: 0, code3: 0 }, + { label: "Volume −", type: 48, code1: 234, code2: 0, code3: 0 }, + { label: "Mute", type: 48, code1: 226, code2: 0, code3: 0 }, + { label: "Play/Pause", type: 48, code1: 205, code2: 0, code3: 0 }, + { label: "Stop", type: 48, code1: 183, code2: 0, code3: 0 }, + { label: "Prev track", type: 48, code1: 182, code2: 0, code3: 0 }, + { label: "Next track", type: 48, code1: 181, code2: 0, code3: 0 }, + { label: "Multimedia", type: 48, code1: 131, code2: 1, code3: 0 }, + { label: "Homepage", type: 48, code1: 35, code2: 2, code3: 0 }, + { label: "Web refresh", type: 48, code1: 39, code2: 2, code3: 0 }, + { label: "Web stop", type: 48, code1: 38, code2: 2, code3: 0 }, + { label: "Web forward", type: 48, code1: 37, code2: 2, code3: 0 }, + { label: "Web backward", type: 48, code1: 36, code2: 2, code3: 0 }, + { label: "Web favorites", type: 48, code1: 42, code2: 2, code3: 0 }, + { label: "Web search", type: 48, code1: 33, code2: 2, code3: 0 }, + { label: "Calculator", type: 48, code1: 146, code2: 1, code3: 0 }, + { label: "My Computer", type: 48, code1: 148, code2: 1, code3: 0 }, + { label: "Mail", type: 48, code1: 138, code2: 1, code3: 0 }, +]; + +/** Display label for a binding, or null when the catalog cannot name it. */ +export function ajazzBindingLabel(binding: AjazzKeyBinding): string | null { + if (binding.type === AJAZZ_KEY_TYPE.macro && Number.isInteger(binding.code1) && binding.code1 >= 0) { + return `Macro ${binding.code1 + 1}`; + } + return AJAZZ_BUTTON_ACTIONS.find( + (action) => + action.type === binding.type && + action.code1 === binding.code1 && + action.code2 === binding.code2 && + action.code3 === binding.code3, + )?.label ?? null; +} + +/** Catalog binding for a display label, or null for unknown labels. */ +export function ajazzFindButtonAction(label: string): AjazzKeyBinding | null { + const action = AJAZZ_BUTTON_ACTIONS.find((entry) => entry.label === label); + return action ? { type: action.type, code1: action.code1, code2: action.code2, code3: action.code3 } : null; +} diff --git a/src/drivers/ajazz/hid.test.ts b/src/drivers/ajazz/hid.test.ts index 653391b6..b7f46cb9 100644 --- a/src/drivers/ajazz/hid.test.ts +++ b/src/drivers/ajazz/hid.test.ts @@ -2,23 +2,33 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { AJAZZ_BLANK_CONFIG, + AJAZZ_BUTTON_ACTIONS, + AJAZZ_BUTTON_NAMES, + AJAZZ_DEFAULT_KEYS, AJAZZ_DONGLE_VENDOR_ID, AJAZZ_PRODUCTS, AJAZZ_REPORT_ID, AJAZZ_USAGE, AJAZZ_USAGE_PAGE, AJAZZ_USB_VENDOR_ID, + ajazzBindingLabel, ajazzDecodeBattery, ajazzDecodeConfig, + ajazzDecodeKeys, ajazzDecodePollingRate, ajazzDecodeVersion, + ajazzApplyKeyDefaults, ajazzEncodePollingRate, ajazzEncodeSetConfig, + ajazzEncodeSetKeys, + ajazzFindButtonAction, ajazzGetBatteryRequest, ajazzGetConfigRequest, + ajazzGetKeysRequest, ajazzGetVersionRequest, ajazzIsValidDpi, ajazzIsValidSleepSeconds, + ajazzKeysLookPlausible, type AjazzConfig, } from "../../ajazz/index.ts"; import { AjazzHidClient } from "./hid.ts"; @@ -133,9 +143,49 @@ describe("ajazz codec", () => { 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(0), true); // the panel's "never sleep" assert.equal(ajazzIsValidSleepSeconds(6060), false); }); + + it("frames the key requests and round-trips the six slots", () => { + assert.deepEqual([...ajazzGetKeysRequest().slice(0, 4)], [0x08, 0x01, 0x0b, 0x2c]); + assert.equal(ajazzGetKeysRequest().length, 63); + const wire = ajazzEncodeSetKeys([...AJAZZ_DEFAULT_KEYS]); + assert.deepEqual([...wire.slice(0, 4)], [0x09, 0x01, 0x22, 0x30]); + assert.equal(wire.length, 63); + // Six remappable slots up front, then the fixed wheel entries. + assert.deepEqual([...wire.slice(7, 31)], [32, 1, 0, 0, 32, 2, 0, 0, 32, 4, 0, 0, 32, 8, 0, 0, 32, 16, 0, 0, 33, 85, 0, 0]); + assert.deepEqual([...wire.slice(31, 39)], [33, 56, 1, 0, 33, 56, 255, 0]); + const wireReply = Uint8Array.from(wire); + wireReply[0] = 0x08; + assert.deepEqual(ajazzDecodeKeys(wireReply), [...AJAZZ_DEFAULT_KEYS]); + assert.equal(ajazzKeysLookPlausible(ajazzDecodeKeys(wireReply)!), true); + assert.equal(ajazzDecodeKeys(reply(0x09)), null); + assert.equal(ajazzDecodeKeys(new Uint8Array(20)), null); + // Default-binding markers (type 0/0xff) are plausible per slot and fall + // back to the factory bindings, like the vendor panel's merge. + const marked = ajazzDecodeKeys(wireReply)!.map((key, i) => (i % 2 ? { ...key, type: 0xff } : key)); + assert.equal(ajazzKeysLookPlausible(marked), true); + assert.deepEqual(ajazzApplyKeyDefaults(marked), [...AJAZZ_DEFAULT_KEYS]); + const blank = new Uint8Array(63); + blank[0] = 0x08; + assert.equal(ajazzKeysLookPlausible(ajazzDecodeKeys(blank)!), true); + assert.deepEqual(ajazzApplyKeyDefaults(ajazzDecodeKeys(blank)!), [...AJAZZ_DEFAULT_KEYS]); + // Anything outside the 0/0xff/known-type set is still rejected. + const garbage = ajazzDecodeKeys(wireReply)!.map((key) => ({ ...key, type: 7 })); + assert.equal(ajazzKeysLookPlausible(garbage), false); + }); + + it("names catalog bindings and keeps macros readable", () => { + assert.equal(ajazzBindingLabel({ type: 32, code1: 1, code2: 0, code3: 0 }), "Left click"); + assert.equal(ajazzBindingLabel({ type: 33, code1: 85, code2: 0, code3: 0 }), "DPI loop"); + assert.equal(ajazzBindingLabel({ type: 112, code1: 2, code2: 0, code3: 0 }), "Macro 3"); + assert.equal(ajazzBindingLabel({ type: 16, code1: 3, code2: 4, code3: 0 }), null); + assert.deepEqual(ajazzFindButtonAction("Mute"), { type: 48, code1: 226, code2: 0, code3: 0 }); + assert.equal(ajazzFindButtonAction("Nope"), null); + assert.ok(AJAZZ_BUTTON_ACTIONS.length > 40); + assert.deepEqual([...AJAZZ_BUTTON_NAMES], ["Left", "Right", "Middle", "Backward", "Forward", "DPI"]); + }); }); interface MouseOptions { @@ -152,6 +202,8 @@ interface MouseOptions { /** 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 keyBlock = new Uint8Array(32); + keyBlock.set([32, 1, 0, 0, 32, 2, 0, 0, 32, 4, 0, 0, 32, 8, 0, 0, 32, 16, 0, 0, 33, 85, 0, 0, 33, 56, 1, 0, 33, 56, 255, 0]); const sent: Uint8Array[] = []; const listeners = new Set<(event: HIDInputReportEvent) => void>(); const emit = (data: Uint8Array): void => { @@ -186,6 +238,17 @@ function fakeMouse(options: MouseOptions = {}) { if (!options.ignoreWrites) block.set(request.slice(8, 54), 8); emit(reply(0x0f)); break; + case 0x08: { + const wire = new Uint8Array(63); + wire[0] = 0x08; + wire.set(keyBlock.slice(0, 24), 7); + emit(wire); + break; + } + case 0x09: + if (!options.ignoreWrites) keyBlock.set(request.slice(7, 39), 0); + emit(reply(0x09)); + break; case 0x30: emit(reply(0x30, (d) => { d[7] = 64; d[8] = 0; })); break; @@ -196,7 +259,7 @@ function fakeMouse(options: MouseOptions = {}) { }, }; const client = new AjazzHidClient(device as unknown as HIDDevice, { replyTimeoutMs: 20, settleAfterWriteMs: 0 }); - return { device: device as unknown as HIDDevice, client, block, sent }; + return { device: device as unknown as HIDDevice, client, block, keyBlock, sent }; } describe("AjazzHidClient", () => { @@ -219,7 +282,7 @@ describe("AjazzHidClient", () => { assert.notEqual(createSupportedClient(ksnake)?.constructor.name, "AjazzHidClient"); }); - it("reads identity, battery, DPI stages, polling, sleep and scroll direction", async () => { + it("reads identity, battery, DPI stages, polling, sleep, scroll and buttons", async () => { const { client } = fakeMouse(); const status = await client.readStatus(); assert.equal(status.brand, "AJAZZ"); @@ -237,6 +300,15 @@ describe("AjazzHidClient", () => { assert.equal(status.connectionType, "Wireless"); assert.deepEqual(status.firmware, ["NJ07 MC 1.0.5"]); assert.equal(status.ui?.dpiStageEditor?.maxDpi, 24000); + assert.deepEqual(status.buttonMappings, { + Left: "Left click", + Right: "Right click", + Middle: "Middle click", + Backward: "Backward", + Forward: "Forward", + DPI: "DPI loop", + }); + assert.ok((status.buttonOptions?.length ?? 0) > 40); }); it("reports a wired mouse as wired", async () => { @@ -311,4 +383,42 @@ describe("AjazzHidClient", () => { await assert.rejects(client.setPollingRate(500), /Could not read the current config/); assert.equal(sent.some((r) => r[0] === 0x0f), false); }); + + it("remaps one button and keeps the other slots", async () => { + const { client, keyBlock } = fakeMouse(); + await client.setButtonMapping("Forward", "Mute"); + const wire = new Uint8Array(63); + wire[0] = 0x08; + wire.set(keyBlock.slice(0, 24), 7); + const keys = ajazzDecodeKeys(wire)!; + assert.deepEqual(keys[4], { type: 48, code1: 226, code2: 0, code3: 0 }); + assert.deepEqual(keys[0], { type: 32, code1: 1, code2: 0, code3: 0 }); + // The fixed wheel tail survives every write. + assert.deepEqual([...keyBlock.slice(24, 32)], [33, 56, 1, 0, 33, 56, 255, 0]); + const status = await client.readStatus(); + assert.equal(status.buttonMappings?.["Forward"], "Mute"); + }); + + it("refuses unknown buttons, locked Left and unknown actions", async () => { + const { client, sent } = fakeMouse(); + await assert.rejects(client.setButtonMapping("Wheel", "Mute"), /has no "Wheel" button/); + await assert.rejects(client.setButtonMapping("Left", "Mute"), /fixed and cannot be reassigned/); + await assert.rejects(client.setButtonMapping("Forward", "Turbo"), /Unknown button action/); + assert.equal(sent.some((r) => r[0] === 0x09), false); + }); + + it("fails loudly when the mouse keeps the old button map", async () => { + const { client } = fakeMouse({ ignoreWrites: true }); + await assert.rejects(client.setButtonMapping("Forward", "Mute"), /did not keep the new button map/); + }); + + it("restores the factory button map", async () => { + const { client, keyBlock } = fakeMouse(); + await client.setButtonMapping("Forward", "Mute"); + await client.resetButtonMap(); + const wire = new Uint8Array(63); + wire[0] = 0x08; + wire.set(keyBlock.slice(0, 24), 7); + assert.deepEqual(ajazzDecodeKeys(wire), [...AJAZZ_DEFAULT_KEYS]); + }); }); diff --git a/src/drivers/ajazz/hid.ts b/src/drivers/ajazz/hid.ts index 24063ee3..b3d98a97 100644 --- a/src/drivers/ajazz/hid.ts +++ b/src/drivers/ajazz/hid.ts @@ -1,5 +1,8 @@ import type { MouseStatus } from "../mouse-types.ts"; import { + AJAZZ_BUTTON_ACTIONS, + AJAZZ_BUTTON_NAMES, + AJAZZ_DEFAULT_KEYS, AJAZZ_DONGLE_VENDOR_ID, AJAZZ_OFFERED_POLLING_RATES, AJAZZ_PRODUCTS, @@ -11,23 +14,35 @@ import { AJAZZ_DPI_MIN, AJAZZ_DPI_STEP, AJAZZ_DPI_STAGES, + ajazzBindingLabel, ajazzDecodeBattery, ajazzDecodeConfig, + ajazzDecodeKeys, ajazzDecodePollingRate, ajazzDecodeVersion, ajazzEncodePollingRate, ajazzEncodeSetConfig, + ajazzEncodeSetKeys, + ajazzFindButtonAction, + ajazzApplyKeyDefaults, ajazzGetBatteryRequest, ajazzGetConfigRequest, + ajazzGetKeysRequest, ajazzGetVersionRequest, ajazzIsValidDpi, ajazzIsValidSleepSeconds, + ajazzKeysLookPlausible, type AjazzConfig, + type AjazzKeyBinding, } 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; +/** Outer bound for post-write read-back polling; slow commits get this long. */ +const CONFIRM_TIMEOUT_MS = 2500; +/** Pause between read-back polls. */ +const POLL_INTERVAL_MS = 250; /** Rejected when a command gets no matching input report within the timeout. */ export class AjazzTimeoutError extends Error { @@ -58,9 +73,11 @@ function copyDataView(view: DataView): Uint8Array { * 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. + * Evidence: the vendor panel's JavaScript, plus ticket-0156 hardware replies + * for config, battery and version (NJ07 MC, firmware 2.0.1) and a passing + * flash write round-trip. The key-map path follows the same panel JS and + * awaits a hardware confirmation, so every write still reads back and fails + * loudly when the mouse kept something else. */ export class AjazzHidClient { readonly device: HIDDevice; @@ -141,18 +158,19 @@ export class AjazzHidClient { 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; + /** + * Fire-and-forget write, mirroring the vendor panel: `setMouseConfigData()` + * and `setMouseKeys()` never wait for an echo, they just queue the report. + * The read-back below is the check, so a mouse that answers SET slowly (or + * never) no longer stalls every setter for two full reply timeouts. + */ + private sendOnly(request: Uint8Array): Promise { + const started = this.queue.then(async () => { + await this.open(); + await this.device.sendReport(AJAZZ_REPORT_ID, request.slice()); + }); + this.queue = started.catch(() => undefined); + return started; } private async readConfig(attempts = 3): Promise { @@ -172,6 +190,9 @@ export class AjazzHidClient { 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); + // Sequential on purpose: keys are the least critical read, and a failed + // one must not blank the config the UI already shows. + const keys = await this.getKeys().catch(() => null); const stages = config?.stages.slice(0, config.dpiCount) ?? []; const activeStage = config ? Math.min(config.dpiIndex, stages.length - 1) : 0; return { @@ -181,7 +202,9 @@ export class AjazzHidClient { family: "ajazz", settingsReady: config !== null, valuesVerified: config !== null, - showAdvancedSection: true, + // The readable key map belongs in the shared Buttons tab. Keep the + // tab hidden when the dongle returns no plausible map. + showAdvancedSection: keys !== null, hideUnsupportedPollingRates: true, hideProcessingCard: true, hideSignalCard: true, @@ -208,14 +231,34 @@ export class AjazzHidClient { sleepTimeout: config && config.sleepMinutes > 0 ? config.sleepMinutes * 60 : null, scrollDirection: config ? (config.scrollFlag === 1 ? "Reverse" : "Forward") : null, firmware: version ? [`${model} ${version}`] : [model], + // Generic remap interface: the shared ButtonMappingCard renders these + // with no brand-specific code. Opaque bindings (keyboard combos with + // modifiers, macros) surface as "Custom (…)" and are preserved during a + // different slot's write. + buttonMappings: keys + ? Object.fromEntries( + AJAZZ_BUTTON_NAMES.map((name, index) => { + const binding = keys[index]; + return [ + name, + binding + ? ajazzBindingLabel(binding) + ?? `Custom (${binding.type},${binding.code1},${binding.code2},${binding.code3})` + : "Unknown", + ] as const; + }), + ) + : undefined, + buttonOptions: AJAZZ_BUTTON_ACTIONS.map((action) => action.label), }; } /** * 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. + * block, patches one field, writes it and reads it back. The write itself + * is fire-and-forget like the vendor panel; the read-back is the check. + * Slow flash commits are retried for ~2 s before failing, so a mouse that + * applies the write late no longer reports a false "kept the old value". */ private async updateConfig( patch: Partial, @@ -225,13 +268,34 @@ export class AjazzHidClient { ): 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 this.sendOnly(ajazzEncodeSetConfig({ ...current, ...patch })); + const confirmed = await this.readBackConfigMatching(kept); + if (!confirmed) { + const last = await this.readConfig(1); + throw new Error(`The mouse kept ${last ? describe(last) : "an unreadable config"} instead of ${wanted}.`); + } + } + + /** Poll the config until `kept` passes, the deadline elapses, or reads go + * silent three times running (a dead mouse answers nothing, so further + * attempts only burn reply timeouts). Slow flash commits still get the + * full window; silent mice fail fast. */ + private async readBackConfigMatching(kept: (confirmed: AjazzConfig) => boolean): Promise { + const deadline = Date.now() + CONFIRM_TIMEOUT_MS; + let silent = 0; 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}.`); + for (;;) { + const reply = await this.exchange(ajazzGetConfigRequest()).catch(() => null); + const config = reply ? ajazzDecodeConfig(reply) : null; + if (config && configLooksValid(config)) { + this.lastGoodConfig = config; + if (kept(config)) return config; + silent = 0; + } else if (++silent >= 3) { + return null; + } + if (Date.now() >= deadline) return null; + await sleep(POLL_INTERVAL_MS); } } @@ -322,6 +386,95 @@ export class AjazzHidClient { ); return direction; } + + /** + * Read the six-button map; null when the mouse answers nothing plausible. + * Default-binding markers (type 0/0xff) come back as the factory defaults, + * mirroring the vendor panel's merge. + */ + async getKeys(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const reply = await this.exchange(ajazzGetKeysRequest()).catch(() => null); + const keys = reply ? ajazzDecodeKeys(reply) : null; + if (keys && ajazzKeysLookPlausible(keys)) return ajazzApplyKeyDefaults(keys); + } + return null; + } + + /** + * Read the map once without retries, for deadline-bounded write + * confirmation (retries there would multiply timeouts past the deadline). + */ + private async getKeysOnce(): Promise { + const reply = await this.exchange(ajazzGetKeysRequest()).catch(() => null); + const keys = reply ? ajazzDecodeKeys(reply) : null; + return keys && ajazzKeysLookPlausible(keys) ? ajazzApplyKeyDefaults(keys) : null; + } + + /** + * Write all six key slots (SET_KEYS). The two fixed wheel entries are + * always appended by the codec; opaque macro/combo slots are preserved + * rather than guessed or rebuilt. Confirms by reading the map back, with + * the same slow-flash tolerance as config writes. + */ + async setKeys(keys: readonly AjazzKeyBinding[]): Promise { + const slots = [...keys].slice(0, AJAZZ_DEFAULT_KEYS.length); + if (slots.length !== AJAZZ_DEFAULT_KEYS.length) { + throw new Error(`Exactly ${AJAZZ_DEFAULT_KEYS.length} key bindings are required (got ${keys.length}).`); + } + for (const [index, key] of slots.entries()) { + const bytes = [key.type, key.code1, key.code2, key.code3]; + if (!bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) { + throw new Error(`Button ${index + 1}: binding bytes must be 0-255.`); + } + } + await this.sendOnly(ajazzEncodeSetKeys(slots)); + await sleep(this.settleAfterWriteMs); + // Read-back arrives default-substituted like getKeys(), so compare + // against the same normalization (a marker written as-is reads back + // as its factory binding). + const wanted = ajazzApplyKeyDefaults(slots); + const deadline = Date.now() + CONFIRM_TIMEOUT_MS; + let silent = 0; + for (;;) { + const confirmed = await this.getKeysOnce(); + if (confirmed) { + if (wanted.every((key, index) => equalBinding(confirmed[index], key))) return slots; + silent = 0; + } else if (++silent >= 3) { + break; + } + if (Date.now() >= deadline) break; + await sleep(POLL_INTERVAL_MS); + } + const last = await this.getKeys(); + throw new Error(`The mouse did not keep the new button map (read back ${last ? JSON.stringify(last) : "no readable reply"}).`); + } + + /** + * Remap one button by display label (generic `setButtonMapping` interface). + * SET_KEYS always carries all six key slots, so the current map is + * re-read and the single slot replaced — confirmed by setKeys' read-back. + * Slot "Left" is locked: unbinding the primary button with no other + * recovery path would brick basic use. + */ + async setButtonMapping(button: string, actionLabel: string): Promise { + const index = (AJAZZ_BUTTON_NAMES as readonly string[]).indexOf(button); + if (index < 0) throw new Error(`This mouse has no "${button}" button.`); + if (index === 0) throw new Error("Left Click is fixed and cannot be reassigned."); + const binding = ajazzFindButtonAction(actionLabel); + if (!binding) throw new Error(`Unknown button action "${actionLabel}".`); + const current = await this.getKeys(); + if (!current) throw new Error("Could not read the current button map from the mouse."); + const slots = current.map((slot) => ({ ...slot })); + slots[index] = binding; + await this.setKeys(slots); + } + + /** Restore the six factory bindings from every model's default profile. */ + async resetButtonMap(): Promise { + await this.setKeys(AJAZZ_DEFAULT_KEYS.map((key) => ({ ...key }))); + } } /** Rejects a decode that cannot be a real block, such as a crossed or half-filled report. */ @@ -333,3 +486,7 @@ function configLooksValid(config: AjazzConfig): boolean { config.dpiIndex < config.dpiCount ); } + +function equalBinding(a: AjazzKeyBinding, b: AjazzKeyBinding): boolean { + return a.type === b.type && a.code1 === b.code1 && a.code2 === b.code2 && a.code3 === b.code3; +}