diff --git a/captures/corsair-nightsword/PROTOCOL.md b/captures/corsair-nightsword/PROTOCOL.md index 80ecda0..cff7b78 100644 --- a/captures/corsair-nightsword/PROTOCOL.md +++ b/captures/corsair-nightsword/PROTOCOL.md @@ -126,12 +126,19 @@ Run from the OpenMouse origin in Chrome 152 against the usage‑4 interface (`wr - `07 13 04 00 01 05` → snap reads `01`; `07 13 04 00 00` (no trailing byte) → reads `00`. The ckb-next trailing `0x05` is harmless and not required. - `07 13 03 00 h` for h = 1…5 → each reads back as written. Mapping of raw height to iCUE's Surface Calibration lift-off labels still to be recorded. +### Phase-2 driver run — 2026‑09‑06, OpenMouse app, iCUE running + +- `07 13 d2 00 …` stage rewrites (400, 1600, 3200 on the selected slot), `07 13 03 00 1|3|5` lift, `07 13 04 00 0|1 05` snap: all took effect and read back. +- **A `MOUSE_DPIPROF` write to a slot that is not enabled in `MOUSE_DPIMASK` is ignored** — writing `07 13 d4 00 00 44 16 44 16 00 bf ff` with mask `0x0f` read back all zeros. Enable the mask bit first, then write the slot. +- One stale reply seen in the wild: a GET for `d3` returned the previous `d2` buffer once; the echo check caught it and the retry succeeded. +- iCUE does not read live state back from the mouse, so its DPI panel keeps showing its own stored profile after an OpenMouse write. It re-pushes that profile on its own triggers (profile switch, reconnect), overwriting live changes. + **Poll-rate caveat:** ckb-next notes the device re-enumerates after `FIELD_POLLRATE`; the driver must handle the WebHID device closing and reconnect. ## Still to confirm 1. ~~Whether `07 13 02 00 ` switches stage live~~ — confirmed 2026‑09‑06. -2. Raw lift height (1–5) → iCUE Surface Calibration lift-off label mapping. +2. ~~Raw lift height (1–5) → iCUE lift-off label mapping~~ — moot: iCUE (checked 2026‑09‑06) has no manual lift-off control for the NIGHTSWORD, only the spiral Surface Calibration pass, so there are no labels. The 1–5 scale is ckb-next's slider range; OpenMouse maps Low/Medium/High to 1/3/5. **iCUE's spiral Surface Calibration does not write `MOUSE_LIFT`**: polled `0e 13 03 00` twice a second through a full pass to 100 % (2026‑09‑06) and the byte never left 5. Surface tuning is a separate command; finding it needs a USBPcap of the calibration pass. 3. ~~`MOUSE_SNAP` trailing `0x05` byte~~ — confirmed optional 2026‑09‑06. 4. Poll-rate write and reconnect behaviour. 5. Profile-ID reply layout (optional). diff --git a/src/corsair/index.test.ts b/src/corsair/index.test.ts index b1d4dd6..53e0f34 100644 --- a/src/corsair/index.test.ts +++ b/src/corsair/index.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { CORSAIR_CONFIG_USAGE, + CORSAIR_LIFT_LEVELS, CORSAIR_NIGHTSWORD_PRODUCT_ID, CORSAIR_PRODUCTS, CORSAIR_USAGE_PAGE, @@ -12,6 +13,8 @@ import { corsairEncode as encode, corsairFormatVersion, corsairIsEcho, + corsairLiftName, + corsairParseRgbHex, corsairRgbHex, } from "./index.ts"; @@ -181,6 +184,21 @@ test("isEcho matches on the first four request bytes only", () => { assert.equal(corsairIsEcho(encode.ident(), new Uint8Array(3)), false); }); +test("parses #rrggbb colours and maps the lift scale to three stops", () => { + assert.deepEqual(corsairParseRgbHex("#00bfff"), [0x00, 0xbf, 0xff]); + assert.deepEqual(corsairParseRgbHex("FF8000"), [0xff, 0x80, 0x00]); + assert.throws(() => corsairParseRgbHex("#fff"), /#rrggbb/); + assert.throws(() => corsairParseRgbHex("red"), /#rrggbb/); + assert.deepEqual(CORSAIR_LIFT_LEVELS, { Low: 1, Medium: 3, High: 5 }); + assert.deepEqual([1, 2, 3, 4, 5].map(corsairLiftName), ["Low", "Low", "Medium", "High", "High"]); + assert.equal(corsairLiftName(0), null); + assert.equal(corsairLiftName(6), null); + // Writing a named stop and reading it back lands on the same name. + for (const name of ["Low", "Medium", "High"] as const) { + assert.equal(corsairLiftName(CORSAIR_LIFT_LEVELS[name]), name); + } +}); + function hex(value: number): string { return value.toString(16).padStart(2, "0"); } diff --git a/src/corsair/index.ts b/src/corsair/index.ts index b7328b5..dc4cb4d 100644 --- a/src/corsair/index.ts +++ b/src/corsair/index.ts @@ -225,6 +225,32 @@ export function corsairRgbHex(rgb: CorsairRgb): string { return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } +/** "#rrggbb" (case-insensitive, hash optional) → [r, g, b]. */ +export function corsairParseRgbHex(color: string): CorsairRgb { + const match = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!match) throw new Error(`Corsair stage colour must be #rrggbb, got "${color}".`); + const value = Number.parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +/** + * Lift-off height is a raw 1–5 byte (every value accepted on fw 3.41). iCUE + * offers no manual lift-off control for this mouse — only its spiral surface + * calibration — so there are no vendor labels to match; the 1–5 scale is the + * one ckb-next exposes as a slider. The three-stop names are this driver's own + * mapping onto it: Low = 1, Medium = 3, High = 5 on write; 1–2 → Low, + * 3 → Medium, 4–5 → High on read. + */ +export const CORSAIR_LIFT_MIN = 1; +export const CORSAIR_LIFT_MAX = 5; +export type CorsairLiftName = "Low" | "Medium" | "High"; +export const CORSAIR_LIFT_LEVELS: Readonly> = { Low: 1, Medium: 3, High: 5 }; + +export function corsairLiftName(raw: number): CorsairLiftName | null { + if (!Number.isInteger(raw) || raw < CORSAIR_LIFT_MIN || raw > CORSAIR_LIFT_MAX) return null; + return raw <= 2 ? "Low" : raw === 3 ? "Medium" : "High"; +} + function stageIndex(stage: number): number { if (!Number.isInteger(stage) || stage < 0 || stage >= CORSAIR_STAGE_COUNT) { throw new Error(`Corsair DPI stage must be 0–${CORSAIR_STAGE_COUNT - 1}.`); diff --git a/src/drivers/corsair/hid.test.ts b/src/drivers/corsair/hid.test.ts index 4347911..8c37538 100644 --- a/src/drivers/corsair/hid.test.ts +++ b/src/drivers/corsair/hid.test.ts @@ -4,41 +4,52 @@ import test from "node:test"; import { CORSAIR_NIGHTSWORD_PRODUCT_ID, CORSAIR_VENDOR_ID } from "../../corsair/index.ts"; import { CorsairHidClient, corsairTransferError } from "./hid.ts"; -function packet(hex: string): Uint8Array { - const bytes = hex.trim().split(/\s+/).map((byte) => Number.parseInt(byte, 16)); +function key(bytes: Uint8Array): string { + return [...bytes.subarray(0, 4)].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +function packet(...bytes: number[]): Uint8Array { const out = new Uint8Array(64); out.set(bytes); return out; } -function key(bytes: Uint8Array): string { - return [...bytes.subarray(0, 4)].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); -} +interface Slot { x: number; y: number; rgb: [number, number, number] } -// Canned GET replies from captures/corsair-nightsword/PROTOCOL.md, keyed by -// the request's first four bytes. The iCUE-loaded live profile. -const REPLIES: ReadonlyMap = new Map([ - ["0e 01 00 00", packet("0e 01 00 00 01 01 00 01 41 03 08 03 1c 1b 5c 1b 01")], - ["0e 13 05 00", packet("0e 13 05 00 0f")], - ["0e 13 02 00", packet("0e 13 02 00 02 09 60 09 60")], - ["0e 13 03 00", packet("0e 13 03 00 05")], - ["0e 13 04 00", packet("0e 13 04 00 00")], - ["0e 13 d0 00", packet("0e 13 d0 00 00 01 90 01 90 ff ff 00")], - ["0e 13 d1 00", packet("0e 13 d1 00 00 03 20 03 20 00 bf ff")], - ["0e 13 d2 00", packet("0e 13 d2 00 00 09 60 09 60 00 bf ff")], - ["0e 13 d3 00", packet("0e 13 d3 00 00 16 44 16 44 00 bf ff")], - ["0e 13 d4 00", packet("0e 13 d4 00")], - ["0e 13 d5 00", packet("0e 13 d5 00")], -]); +/** + * A NIGHTSWORD RGB on fw 3.41 holding the iCUE-loaded live profile from + * captures/corsair-nightsword: Sniper 400 in slot 0, 800 / 2400 / 5700 in + * slots 1–3, slot 2 selected, lift 5, snap off. GETs answer from this state + * (big-endian DPI); SETs mutate it (little-endian DPI) and leave the feature + * buffer untouched, exactly like the hardware. + */ +function initialState() { + return { + mask: 0x0f, + current: 2, + slots: [ + { x: 400, y: 400, rgb: [0xff, 0xff, 0x00] }, + { x: 800, y: 800, rgb: [0x00, 0xbf, 0xff] }, + { x: 2400, y: 2400, rgb: [0x00, 0xbf, 0xff] }, + { x: 5700, y: 5700, rgb: [0x00, 0xbf, 0xff] }, + { x: 0, y: 0, rgb: [0, 0, 0] }, + { x: 0, y: 0, rgb: [0, 0, 0] }, + ] as Slot[], + lift: 5, + snap: 0, + }; +} interface FakeOptions { collections?: HIDCollectionInfo[]; - /** Replace the reply for a request key; `null` makes the device stay silent (stale buffer). */ - overrides?: Map; + /** Requests (by first four bytes) the device stays silent on, leaving the stale buffer. */ + silent?: string[]; /** Throw this from every transfer. */ transferError?: Error; /** Return the previous buffer this many times before the fresh reply. */ staleReads?: number; + /** Drop every SET on the floor, as if the write did not take. */ + ignoreWrites?: boolean; } function collection(usage: number, feature: boolean): HIDCollectionInfo { @@ -54,12 +65,52 @@ function collection(usage: number, feature: boolean): HIDCollectionInfo { } function fakeDevice(options: FakeOptions = {}) { + const state = initialState(); const sent: Array<{ reportId: number; payload: Uint8Array }> = []; const received: number[] = []; let buffer = new Uint8Array(64); let staleLeft = 0; let lastKey = ""; let opened = false; + + const be = (value: number) => [value >> 8, value & 0xff]; + const reply = (request: Uint8Array): Uint8Array | null => { + const [, field, sub] = request; + if (field === 0x01) return packet(0x0e, 0x01, 0x00, 0x00, 1, 1, 0, 1, 0x41, 0x03, 0x08, 0x03, 0x1c, 0x1b, 0x5c, 0x1b, 1); + if (field !== 0x13) return null; + if (sub === 0x05) return packet(0x0e, 0x13, 0x05, 0x00, state.mask); + if (sub === 0x03) return packet(0x0e, 0x13, 0x03, 0x00, state.lift); + if (sub === 0x04) return packet(0x0e, 0x13, 0x04, 0x00, state.snap); + if (sub === 0x02) { + const slot = state.slots[state.current]!; + return packet(0x0e, 0x13, 0x02, 0x00, state.current, ...be(slot.x), ...be(slot.y)); + } + if ((sub! & 0xf0) === 0xd0) { + const slot = state.slots[sub! & 0x0f]!; + return packet(0x0e, 0x13, sub!, 0x00, slot.x !== slot.y ? 1 : 0, ...be(slot.x), ...be(slot.y), ...slot.rgb); + } + return null; + }; + const apply = (request: Uint8Array): void => { + if (options.ignoreWrites) return; + const [, field, sub] = request; + if (field !== 0x13) return; + if (sub === 0x02) state.current = request[4]!; + else if (sub === 0x05) state.mask = request[4]!; + else if (sub === 0x03) state.lift = request[4]!; + else if (sub === 0x04) state.snap = request[4]!; + else if ((sub! & 0xf0) === 0xd0) { + // Hardware ignores a stage write to a slot the mask has not enabled + // (observed on fw 3.41: the read-back stayed zero). + if (!(state.mask & (1 << (sub! & 0x0f)))) return; + state.slots[sub! & 0x0f] = { + x: request[5]! | (request[6]! << 8), + y: request[7]! | (request[8]! << 8), + rgb: [request[9]!, request[10]!, request[11]!], + }; + } + }; + const device = { vendorId: CORSAIR_VENDOR_ID, productId: CORSAIR_NIGHTSWORD_PRODUCT_ID, @@ -76,13 +127,13 @@ function fakeDevice(options: FakeOptions = {}) { const payload = new Uint8Array(view); sent.push({ reportId, payload }); const requestKey = key(payload); - const override = options.overrides?.get(requestKey); - const reply = override === undefined ? REPLIES.get(requestKey) : override; // A fresh request answers stale first; a retry of the same request answers fresh. staleLeft = requestKey === lastKey ? staleLeft : (options.staleReads ?? 0); lastKey = requestKey; - // A GET refreshes the feature buffer; a SET or an unknown request leaves it stale. - if (reply && payload[0] === 0x0e) buffer = reply; + if (payload[0] === 0x07) { apply(payload); return; } + if (options.silent?.includes(requestKey)) return; + const answer = reply(payload); + if (answer) buffer = answer; }, receiveFeatureReport: async (reportId: number) => { if (options.transferError) throw options.transferError; @@ -96,9 +147,12 @@ function fakeDevice(options: FakeOptions = {}) { addEventListener: () => {}, removeEventListener: () => {}, }; - return { device: device as unknown as HIDDevice, sent, received }; + return { device: device as unknown as HIDDevice, sent, received, state }; } +const sets = (sent: Array<{ payload: Uint8Array }>) => + sent.filter(({ payload }) => payload[0] === 0x07).map(({ payload }) => [...payload.subarray(0, 12)]); + test("claims only the usage-4 config collection with a feature report on id 0", () => { assert.equal(CorsairHidClient.isSupported(fakeDevice().device), true); // MI_00's 0xffc2 collection (usage 3, input report 14 only) must be rejected. @@ -141,9 +195,11 @@ test("reads identity, mask, current stage, each enabled stage, lift, and snap in // Slot 0 is the Sniper stage; iCUE's Stage 1–3 are slots 1–3, and the // device's "current stage 2" is iCUE's Stage 2. assert.deepEqual(status.dpiStages, [800, 2400, 5700]); + assert.deepEqual(status.dpiStageColors, ["#00bfff", "#00bfff", "#00bfff"]); assert.equal(status.activeDpiStage, 1); assert.equal(status.angleSnapping, false); - assert.equal(status.liftOffDistance, null); + assert.equal(status.liftOffDistance, "High"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "Medium", "High"]); assert.equal(status.connectionType, "Wired"); assert.deepEqual(status.firmware, [ "Firmware 3.41", @@ -152,19 +208,22 @@ test("reads identity, mask, current stage, each enabled stage, lift, and snap in "DPI stages 1: 800 #00bfff, 2: 2400 #00bfff, 3: 5700 #00bfff", "Lift-off height 5", ]); - assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.settingsReady, true); assert.equal(status.ui?.valuesVerified, true); assert.equal(status.ui?.pollingReadOnly, true); + assert.deepEqual(status.ui?.dpiStageEditor, { maxStages: 5, countEditable: true, minDpi: 100, maxDpi: 18_000, stepDpi: 50 }); assert.equal(status.ui?.defaultDisplayName, "Corsair NIGHTSWORD RGB"); }); -test("exposes no setters and no DPI options", async () => { +test("offers 50-DPI steps from 100 to 18,000 and no polling-rate setter", async () => { const client = new CorsairHidClient(fakeDevice().device); - assert.deepEqual(client.getDpiOptions(), []); + const options = client.getDpiOptions(); + assert.equal(options[0], 100); + assert.equal(options.at(-1), 18_000); + assert.equal(options.length, 359); + assert.ok([400, 800, 2400, 5700, 16_000].every((dpi) => options.includes(dpi))); assert.equal(await client.startNotifications(), false); - for (const name of ["setDpi", "setPollingRate", "setLiftOffDistance", "setAngleSnapping", "setDpiStages"]) { - assert.equal(name in client, false, `${name} must not exist on the read-only client`); - } + assert.equal("setPollingRate" in client, false, "polling-rate writes re-enumerate the device and are not offered yet"); }); test("retries when the feature buffer is still the previous reply", async () => { @@ -176,7 +235,7 @@ test("retries when the feature buffer is still the previous reply", async () => }); test("degrades to identity only when the DPI reads fail", async () => { - const { device, sent } = fakeDevice({ overrides: new Map([["0e 13 02 00", null]]) }); + const { device, sent } = fakeDevice({ silent: ["0e 13 02 00"] }); const status = await new CorsairHidClient(device).readStatus(); assert.equal(status.name, "NIGHTSWORD RGB"); assert.equal(status.pollingRateHz, 1000); @@ -184,15 +243,18 @@ test("degrades to identity only when the DPI reads fail", async () => { assert.deepEqual(status.dpiStages, []); assert.equal(status.activeDpiStage, undefined); assert.equal(status.angleSnapping, null); + assert.equal(status.liftOffDistance, null); assert.deepEqual(status.firmware, ["Firmware 3.41", "Bootloader 3.08"]); + assert.equal(status.ui?.settingsReady, false); assert.equal(status.ui?.valuesVerified, false); + assert.equal(status.ui?.dpiStageEditor, undefined); assert.match(status.ui?.statusNote ?? "", /could not be read/); // Lift and snap are skipped once the DPI block fails, after the retries on the stage read. assert.ok(!sent.some(({ payload }) => key(payload) === "0e 13 03 00")); }); test("a silent identity read fails loudly", async () => { - const { device } = fakeDevice({ overrides: new Map([["0e 01 00 00", null]]) }); + const { device } = fakeDevice({ silent: ["0e 01 00 00"] }); await assert.rejects(new CorsairHidClient(device).readStatus(), /did not answer command 0x01\/0x00/); }); @@ -218,3 +280,172 @@ test("serialises overlapping status reads through one queue", async () => { const keys = sent.map(({ payload }) => key(payload)); assert.deepEqual(keys.slice(0, 9), keys.slice(9)); }); + +// --------------------------------------------------------------------------- +// Writes +// --------------------------------------------------------------------------- + +test("setDpi rewrites the selected slot little-endian and carries its colour back", async () => { + const { device, sent, state } = fakeDevice(); + assert.equal(await new CorsairHidClient(device).setDpi(1600), 1600); + // Slot 2 was selected: read it for RGB, write, read back. DPI LE, colour preserved. + assert.deepEqual(sets(sent), [[0x07, 0x13, 0xd2, 0x00, 0x00, 0x40, 0x06, 0x40, 0x06, 0x00, 0xbf, 0xff]]); + assert.deepEqual(state.slots[2], { x: 1600, y: 1600, rgb: [0x00, 0xbf, 0xff] }); + // Sniper slot untouched. + assert.deepEqual(state.slots[0], { x: 400, y: 400, rgb: [0xff, 0xff, 0x00] }); +}); + +test("setDpi with separate axes sets the independent flag", async () => { + const { device, sent } = fakeDevice(); + await new CorsairHidClient(device).setDpi(1600, 800); + assert.deepEqual(sets(sent)[0], [0x07, 0x13, 0xd2, 0x00, 0x01, 0x40, 0x06, 0x20, 0x03, 0x00, 0xbf, 0xff]); +}); + +test("setDpi rejects values outside 100–18,000 without touching the device", async () => { + const { device, sent } = fakeDevice(); + const client = new CorsairHidClient(device); + await assert.rejects(client.setDpi(50), /100–18,000/); + await assert.rejects(client.setDpi(18_001), /100–18,000/); + await assert.rejects(client.setDpi(1600.5), /100–18,000/); + assert.equal(sent.length, 0); +}); + +test("setDpi fails when the read-back disagrees", async () => { + const { device } = fakeDevice({ ignoreWrites: true }); + await assert.rejects(new CorsairHidClient(device).setDpi(1600), /kept 2,400 DPI on stage 2/); +}); + +test("setDpiStageValue addresses numbered stages, skipping the Sniper slot", async () => { + const { device, sent, state } = fakeDevice(); + const client = new CorsairHidClient(device); + // Stage index 0 = slot 1 (iCUE Stage 1). + assert.equal(await client.setDpiStageValue(0, 1000), 1000); + assert.deepEqual(sets(sent).at(-1), [0x07, 0x13, 0xd1, 0x00, 0x00, 0xe8, 0x03, 0xe8, 0x03, 0x00, 0xbf, 0xff]); + assert.deepEqual(state.slots[1], { x: 1000, y: 1000, rgb: [0x00, 0xbf, 0xff] }); + assert.deepEqual(state.slots[0], { x: 400, y: 400, rgb: [0xff, 0xff, 0x00] }); + await assert.rejects(client.setDpiStageValue(3, 1000), /does not have a DPI stage 4/); +}); + +test("setDpiStageValue keeps an existing separate Y axis", async () => { + const { device, state } = fakeDevice(); + state.slots[3] = { x: 5700, y: 2000, rgb: [1, 2, 3] }; + await new CorsairHidClient(device).setDpiStageValue(2, 6000); + assert.deepEqual(state.slots[3], { x: 6000, y: 2000, rgb: [1, 2, 3] }); +}); + +test("setDpiStageColor rewrites the slot with its current DPI and the new colour", async () => { + const { device, sent, state } = fakeDevice(); + assert.equal(await new CorsairHidClient(device).setDpiStageColor(1, "#FF8000"), "#ff8000"); + assert.deepEqual(sets(sent), [[0x07, 0x13, 0xd2, 0x00, 0x00, 0x60, 0x09, 0x60, 0x09, 0xff, 0x80, 0x00]]); + assert.deepEqual(state.slots[2], { x: 2400, y: 2400, rgb: [0xff, 0x80, 0x00] }); + await assert.rejects(new CorsairHidClient(device).setDpiStageColor(1, "red"), /#rrggbb/); +}); + +test("setActiveDpiStage selects the numbered slot and confirms", async () => { + const { device, sent, state } = fakeDevice(); + const client = new CorsairHidClient(device); + assert.equal(await client.setActiveDpiStage(0), 0); + assert.deepEqual(sets(sent), [[0x07, 0x13, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]]); + assert.equal(state.current, 1); + assert.equal(await client.setActiveDpiStage(2), 2); + assert.equal(state.current, 3); + await assert.rejects(client.setActiveDpiStage(3), /does not have a DPI stage 4/); + const stuck = fakeDevice({ ignoreWrites: true }); + await assert.rejects(new CorsairHidClient(stuck.device).setActiveDpiStage(0), /stayed on DPI slot 2/); +}); + +test("setDpiStageCount enables the mask first, then seeds empty slots, keeping the Sniper bit", async () => { + const { device, sent, state } = fakeDevice(); + assert.equal(await new CorsairHidClient(device).setDpiStageCount(5), 5); + const writes = sets(sent); + // The mask goes first because the mouse drops writes to disabled slots + // (the 2026-09-06 hardware run failed with "kept 0 DPI on stage 4" when the + // seed came first). Slots 4 and 5 were empty: seeded from slot 3 (5700, cyan). + assert.deepEqual(writes[0], [0x07, 0x13, 0x05, 0x00, 0x3f, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(writes[1], [0x07, 0x13, 0xd4, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); + assert.deepEqual(writes[2], [0x07, 0x13, 0xd5, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); + assert.equal(writes.length, 3); + assert.equal(state.mask, 0x3f); + assert.deepEqual(state.slots[4], { x: 5700, y: 5700, rgb: [0x00, 0xbf, 0xff] }); + assert.deepEqual(state.slots[5], { x: 5700, y: 5700, rgb: [0x00, 0xbf, 0xff] }); +}); + +test("setDpiStageCount leaves a re-enabled slot's old value alone", async () => { + const { device, sent, state } = fakeDevice(); + const client = new CorsairHidClient(device); + await client.setDpiStageCount(2); + sent.length = 0; + await client.setDpiStageCount(3); + // Slot 3 still held 5700, so only the mask was written. + assert.deepEqual(sets(sent).map((bytes) => bytes.slice(0, 5)), [[0x07, 0x13, 0x05, 0x00, 0x0f]]); + assert.deepEqual(state.slots[3], { x: 5700, y: 5700, rgb: [0x00, 0xbf, 0xff] }); +}); + +test("setDpiStageCount shrinks, moving the selection inside the enabled range", async () => { + const { device, sent, state } = fakeDevice(); + state.current = 3; + assert.equal(await new CorsairHidClient(device).setDpiStageCount(1), 1); + const writes = sets(sent); + assert.deepEqual(writes[0], [0x07, 0x13, 0x05, 0x00, 0x03, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(writes[1], [0x07, 0x13, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(state.mask, 0x03); + assert.equal(state.current, 1); + // Slot contents are not erased; re-enabling later restores them. + assert.deepEqual(state.slots[3], { x: 5700, y: 5700, rgb: [0x00, 0xbf, 0xff] }); +}); + +test("setDpiStageCount rejects counts outside 1–5", async () => { + const client = new CorsairHidClient(fakeDevice().device); + await assert.rejects(client.setDpiStageCount(0), /between 1 and 5/); + await assert.rejects(client.setDpiStageCount(6), /between 1 and 5/); +}); + +test("setLiftOffDistance maps the three stops onto the 1–5 scale and reads back", async () => { + const { device, sent, state } = fakeDevice(); + const client = new CorsairHidClient(device); + assert.equal(await client.setLiftOffDistance("Low"), "Low"); + assert.equal(state.lift, 1); + assert.equal(await client.setLiftOffDistance("Medium"), "Medium"); + assert.equal(state.lift, 3); + assert.equal(await client.setLiftOffDistance("High"), "High"); + assert.equal(state.lift, 5); + assert.deepEqual(sets(sent).map((bytes) => bytes.slice(0, 5)), [ + [0x07, 0x13, 0x03, 0x00, 1], + [0x07, 0x13, 0x03, 0x00, 3], + [0x07, 0x13, 0x03, 0x00, 5], + ]); + const stuck = fakeDevice({ ignoreWrites: true }); + await assert.rejects(new CorsairHidClient(stuck.device).setLiftOffDistance("Low"), /kept lift-off height 5/); +}); + +test("setAngleSnapping writes with the ckb-next trailing byte and confirms", async () => { + const { device, sent, state } = fakeDevice(); + const client = new CorsairHidClient(device); + assert.equal(await client.setAngleSnapping(true), true); + assert.equal(state.snap, 1); + assert.equal(await client.setAngleSnapping(false), false); + assert.equal(state.snap, 0); + assert.deepEqual(sets(sent).map((bytes) => bytes.slice(0, 6)), [ + [0x07, 0x13, 0x04, 0x00, 1, 0x05], + [0x07, 0x13, 0x04, 0x00, 0, 0x05], + ]); + const stuck = fakeDevice({ ignoreWrites: true }); + await assert.rejects(new CorsairHidClient(stuck.device).setAngleSnapping(true), /kept angle snapping off/); +}); + +test("a status read after writes reflects the new live values", async () => { + const { device } = fakeDevice(); + const client = new CorsairHidClient(device); + await client.setDpiStageValue(1, 3200); + await client.setDpiStageColor(1, "#112233"); + await client.setActiveDpiStage(2); + await client.setLiftOffDistance("Medium"); + await client.setAngleSnapping(true); + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, [800, 3200, 5700]); + assert.deepEqual(status.dpiStageColors, ["#00bfff", "#112233", "#00bfff"]); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.dpi, 5700); + assert.equal(status.liftOffDistance, "Medium"); + assert.equal(status.angleSnapping, true); +}); diff --git a/src/drivers/corsair/hid.ts b/src/drivers/corsair/hid.ts index 8cc118c..cd20be8 100644 --- a/src/drivers/corsair/hid.ts +++ b/src/drivers/corsair/hid.ts @@ -1,6 +1,7 @@ import type { MouseStatus } from "../mouse-types.ts"; import { CORSAIR_CONFIG_USAGE, + CORSAIR_LIFT_LEVELS, CORSAIR_PRODUCTS, CORSAIR_REPORT_ID, CORSAIR_SNIPER_STAGE, @@ -8,33 +9,42 @@ import { CORSAIR_VENDOR_ID, type CorsairDpiStage, type CorsairIdent, + type CorsairLiftName, + type CorsairRgb, corsairDecode, corsairDevice, corsairEnabledStages, corsairEncode, corsairFormatVersion, corsairIsEcho, + corsairLiftName, + corsairParseRgbHex, corsairRgbHex, } from "@openmouse/protocol/corsair"; /** - * Corsair NIGHTSWORD RGB — read-only WebHID client (phase 1). + * Corsair NIGHTSWORD RGB — WebHID client (phase 2: live reads and writes). * * Talks to the config interface (usage page 0xffc2, usage 4) through 64-byte * feature reports on report id 0. Every GET is send → short wait → receive, * and the reply must echo the request's first four bytes; a stale buffer from - * the previous GET fails that check and the request is retried. All traffic + * the previous GET fails that check and the request is retried. A SET gets no + * reply at all, so every setter confirms with the matching GET. All traffic * goes through one queue because the device has a single reply buffer. * - * Reads are best-effort past identity: if the DPI fields cannot be read the - * status still identifies the mouse (`ui.settingsReady = false` either way — - * nothing here writes). Live values are the software profile iCUE would show; - * they reset to the onboard profile on power cycle. + * Slot d0 is iCUE's held-button Sniper stage and is left alone; the stage + * editor works on slots d1–d5, numbered the way iCUE numbers them. Every + * write addresses the live profile (byte 3 = 0), which the mouse forgets on + * a power cycle — the onboard profile needs Corsair's file-based format and is + * out of scope. * - * iCUE's service keeps this interface open too. Reads have been observed to - * coexist with it (shared mode, iCUE actively re-applying its profile). If - * Chrome ever refuses a transfer with a bare `NotAllowedError`, that is mapped - * to a "close iCUE" message; the more common cause of that error is being + * Reads past identity are best-effort: if the DPI fields cannot be read the + * status still identifies the mouse with `ui.settingsReady = false`. + * + * iCUE's service keeps this interface open too. Reads and writes have been + * observed to coexist with it (shared mode, iCUE actively re-applying its + * profile). If Chrome ever refuses a transfer with a bare `NotAllowedError`, + * that is mapped to guidance; the more common cause of that error is being * granted MI_00's usage-3 collection, which `isSupported()` now rejects. */ @@ -43,6 +53,15 @@ const PRODUCT_IDS = new Set(CORSAIR_PRODUCTS.keys()); const REPLY_DELAY_MS = 20; const REQUEST_ATTEMPTS = 3; const SUPPORTED_POLLING_RATES = [1000, 500, 250, 125] as const; +const LIFT_NAMES: readonly CorsairLiftName[] = ["Low", "Medium", "High"]; + +/** + * The sensor accepts any integer DPI, but the shared picker and stage editor + * work from an explicit option list, and 18,000 entries is too many for a + * dropdown. 50-DPI steps cover every preset iCUE ships with. + */ +const DPI_MIN = 100; +const DPI_OPTION_STEP = 50; interface CorsairDpiState { mask: number; @@ -71,8 +90,12 @@ export class CorsairHidClient { get pollIntervalMs(): number { return 30_000; } - /** Read-only: nothing to offer the DPI picker. */ - getDpiOptions(): number[] { return []; } + getDpiOptions(): number[] { + const { dpiMax } = corsairDevice(this.device.productId); + const options: number[] = []; + for (let dpi = DPI_MIN; dpi <= dpiMax; dpi += DPI_OPTION_STEP) options.push(dpi); + return options; + } getSupportedPollingRates(): number[] { return [...SUPPORTED_POLLING_RATES]; } @@ -93,6 +116,126 @@ export class CorsairHidClient { }); } + // --------------------------------------------------------------------------- + // Setters. Each one reads what it needs, writes the live profile, and + // confirms with the matching GET because a SET never answers. + // --------------------------------------------------------------------------- + + /** Rewrites the value of whichever slot is currently selected (Sniper included). */ + async setDpi(dpi: number, dpiY: number = dpi): Promise { + this.assertDpi(dpi); + this.assertDpi(dpiY); + return await this.run(async () => { + await this.open(); + const current = corsairDecode.dpiStage(await this.request(corsairEncode.dpiStage())); + const confirmed = await this.writeSlot(current.stage, dpi, dpiY); + return confirmed.x; + }); + } + + /** `stage` indexes the numbered stages (slots d1+), 0-based, like `dpiStages`. */ + async setDpiStageValue(stage: number, dpi: number): Promise { + this.assertDpi(dpi); + return await this.run(async () => { + await this.open(); + const { slot, entry } = await this.numberedSlot(stage); + // A Y that already differs is a separate-axis setting the shared editor + // cannot show; changing X must not silently flatten it. + const y = entry.y === entry.x ? dpi : entry.y; + return (await this.writeSlot(slot, dpi, y, entry.rgb)).x; + }); + } + + async setDpiStageColor(stage: number, color: string): Promise { + const rgb = corsairParseRgbHex(color); + return await this.run(async () => { + await this.open(); + const { slot, entry } = await this.numberedSlot(stage); + const confirmed = await this.writeSlot(slot, entry.x, entry.y, rgb); + return corsairRgbHex(confirmed.rgb); + }); + } + + /** + * Enables slots d1..d`count` and disables the rest. The mask is written + * first: the mouse ignores a stage write to a slot that is not enabled + * (verified on fw 3.41 — the read-back stays zero). Newly enabled slots that + * hold 0 DPI are then seeded from the last previously enabled stage, since an + * enabled 0-DPI stage would freeze the cursor. The Sniper bit is preserved. + */ + async setDpiStageCount(count: number): Promise { + const { stages } = corsairDevice(this.device.productId); + const maxNumbered = stages - 1; + if (!Number.isInteger(count) || count < 1 || count > maxNumbered) { + throw new Error(`This mouse holds between 1 and ${maxNumbered} DPI stages.`); + } + return await this.run(async () => { + await this.open(); + const state = await this.readDpi(); + const numbered = numberedSlots(state.mask, stages); + const template = numbered.length > 0 ? state.stages.get(numbered[numbered.length - 1]!) : undefined; + + const sniperBit = state.mask & (1 << CORSAIR_SNIPER_STAGE); + const wanted = sniperBit | (((1 << count) - 1) << 1); + await this.send(corsairEncode.setDpiMask(wanted)); + const confirmed = corsairDecode.dpiMask(await this.request(corsairEncode.dpiMask())); + const enabled = numberedSlots(confirmed, stages).length; + if (enabled !== count) throw new Error(`The mouse kept ${enabled} DPI stages instead of ${count}.`); + + for (let slot = 1; slot <= count; slot += 1) { + if (numbered.includes(slot)) continue; + const existing = corsairDecode.stage(await this.request(corsairEncode.stage(slot))); + if (existing.x > 0 && existing.y > 0) continue; + const seed = template ?? { x: 800, y: 800, rgb: [0x00, 0xbf, 0xff] as CorsairRgb }; + await this.writeSlot(slot, seed.x, seed.y, seed.rgb); + } + // Keep the selection inside the enabled range. + if (state.current.stage > count) await this.selectSlot(count); + return enabled; + }); + } + + async setActiveDpiStage(stage: number): Promise { + return await this.run(async () => { + await this.open(); + const state = await this.readDpi(); + const numbered = numberedSlots(state.mask, corsairDevice(this.device.productId).stages); + const slot = numbered[stage]; + if (slot === undefined) throw new Error(`This mouse does not have a DPI stage ${stage + 1}.`); + const confirmed = await this.selectSlot(slot); + return numbered.indexOf(confirmed); + }); + } + + async setLiftOffDistance(value: CorsairLiftName): Promise { + const raw = CORSAIR_LIFT_LEVELS[value]; + if (raw === undefined) throw new Error("Lift-off distance must be Low, Medium, or High."); + return await this.run(async () => { + await this.open(); + await this.send(corsairEncode.setLift(raw)); + const confirmed = corsairDecode.lift(await this.request(corsairEncode.lift())); + const name = corsairLiftName(confirmed); + if (confirmed !== raw || name === null) { + throw new Error(`The mouse kept lift-off height ${confirmed} instead of ${raw}.`); + } + return name; + }); + } + + async setAngleSnapping(enabled: boolean): Promise { + return await this.run(async () => { + await this.open(); + await this.send(corsairEncode.setSnap(enabled)); + const confirmed = corsairDecode.snap(await this.request(corsairEncode.snap())); + if (confirmed !== enabled) throw new Error(`The mouse kept angle snapping ${confirmed ? "on" : "off"}.`); + return confirmed; + }); + } + + // --------------------------------------------------------------------------- + // Reads + // --------------------------------------------------------------------------- + private async readStatusDirect(): Promise { const definition = corsairDevice(this.device.productId); const ident = corsairDecode.ident(await this.request(corsairEncode.ident())); @@ -121,15 +264,17 @@ export class CorsairHidClient { connectionType: "Wired", connectionDetail: "USB", dpiStages: stageList.map((stage) => stage.x), + dpiStageColors: stageList.map((stage) => corsairRgbHex(stage.rgb)), activeDpiStage: activeIndex >= 0 ? activeIndex : undefined, - liftOffDistance: null, + liftOffDistance: lift === null ? null : corsairLiftName(lift), + supportedLiftOffDistances: [...LIFT_NAMES], angleSnapping: snap, motionSync: null, rippleControl: null, firmware: firmwareLines(ident, dpi, enabled, lift), ui: { family: "corsair", - settingsReady: false, + settingsReady: dpi !== null, valuesVerified: dpi !== null, pollingReadOnly: true, hideUnsupportedPollingRates: true, @@ -137,10 +282,21 @@ export class CorsairHidClient { hideRippleControl: true, hideSleepCard: true, hideSignalCard: true, + showAdvancedSection: true, + pollingNote: "Changing the polling rate makes the mouse re-enumerate; use iCUE for that until reconnect handling lands.", statusNote: dpi - ? "Read-only for now: live DPI stages, polling rate, lift-off height, and angle snapping are shown but cannot be changed." + ? "Changes apply to the live profile and are lost when the mouse loses power; the Sniper stage is left untouched." : "Identified the mouse but its DPI settings could not be read. Unplug and reconnect the mouse, then add it again.", defaultDisplayName: displayName, + dpiStageEditor: dpi + ? { + maxStages: definition.stages - 1, + countEditable: true, + minDpi: DPI_MIN, + maxDpi: definition.dpiMax, + stepDpi: DPI_OPTION_STEP, + } + : undefined, }, }; } @@ -157,6 +313,56 @@ export class CorsairHidClient { return { mask, current, stages }; } + /** Resolves a 0-based numbered-stage index to its slot and current contents. */ + private async numberedSlot(stage: number): Promise<{ slot: number; entry: CorsairDpiStage }> { + const mask = corsairDecode.dpiMask(await this.request(corsairEncode.dpiMask())); + const numbered = numberedSlots(mask, corsairDevice(this.device.productId).stages); + const slot = numbered[stage]; + if (slot === undefined) throw new Error(`This mouse does not have a DPI stage ${stage + 1}.`); + const entry = corsairDecode.stage(await this.request(corsairEncode.stage(slot))); + return { slot, entry }; + } + + // --------------------------------------------------------------------------- + // Writes + // --------------------------------------------------------------------------- + + /** + * Writes one slot and reads it back. RGB must always be sent — a stage write + * without it clears the stage colour — so the current colour is read first + * when the caller has not supplied one. + */ + private async writeSlot(slot: number, x: number, y: number, rgb?: CorsairRgb): Promise { + const color = rgb ?? corsairDecode.stage(await this.request(corsairEncode.stage(slot))).rgb; + await this.send(corsairEncode.setStageDpi(slot, x, y, color)); + const confirmed = corsairDecode.stage(await this.request(corsairEncode.stage(slot))); + if (confirmed.x !== x || confirmed.y !== y) { + throw new Error(`The mouse kept ${confirmed.x.toLocaleString()} DPI on stage ${slot} instead of ${x.toLocaleString()}.`); + } + if (confirmed.rgb.some((channel, index) => channel !== color[index])) { + throw new Error(`The mouse kept colour ${corsairRgbHex(confirmed.rgb)} on stage ${slot} instead of ${corsairRgbHex(color)}.`); + } + return confirmed; + } + + private async selectSlot(slot: number): Promise { + await this.send(corsairEncode.setStage(slot)); + const confirmed = corsairDecode.dpiStage(await this.request(corsairEncode.dpiStage())); + if (confirmed.stage !== slot) throw new Error(`The mouse stayed on DPI slot ${confirmed.stage} instead of ${slot}.`); + return confirmed.stage; + } + + private assertDpi(dpi: number): void { + const { dpiMax } = corsairDevice(this.device.productId); + if (!Number.isInteger(dpi) || dpi < DPI_MIN || dpi > dpiMax) { + throw new Error(`Corsair DPI must be ${DPI_MIN}–${dpiMax.toLocaleString()}.`); + } + } + + // --------------------------------------------------------------------------- + // Transport + // --------------------------------------------------------------------------- + /** * One GET exchange: send, wait, receive, and require the echo. Chrome hands * back the feature buffer without a report-id prefix for id 0; a 65-byte @@ -164,8 +370,7 @@ export class CorsairHidClient { */ private async request(packet: Uint8Array): Promise { for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt += 1) { - await this.transfer(() => this.device.sendFeatureReport(CORSAIR_REPORT_ID, buffer(packet))); - await delay(REPLY_DELAY_MS); + await this.send(packet); const view = await this.transfer(() => this.device.receiveFeatureReport(CORSAIR_REPORT_ID)); const reply = stripReportId(new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)), packet); if (corsairIsEcho(packet, reply)) return reply; @@ -175,7 +380,13 @@ export class CorsairHidClient { ); } - /** Wraps a transfer so iCUE holding the interface reads as a fix, not a bare Chrome error. */ + /** A SET (or the first half of a GET): send, then leave the device its settle time. */ + private async send(packet: Uint8Array): Promise { + await this.transfer(() => this.device.sendFeatureReport(CORSAIR_REPORT_ID, buffer(packet))); + await delay(REPLY_DELAY_MS); + } + + /** Wraps a transfer so a refused report reads as a fix, not a bare Chrome error. */ private async transfer(operation: () => Promise): Promise { try { return await operation(); @@ -210,6 +421,11 @@ export function corsairTransferError(error: unknown): Error { ); } +/** Enabled slots other than the Sniper slot, in slot order. */ +function numberedSlots(mask: number, stages: number): number[] { + return corsairEnabledStages(mask, stages).filter((slot) => slot !== CORSAIR_SNIPER_STAGE); +} + function firmwareLines( ident: CorsairIdent, dpi: CorsairDpiState | null,