From 023bb31f63a4d07bcdc0db9822613f2fbacda0b0 Mon Sep 17 00:00:00 2001 From: Kori Francis Date: Sun, 6 Sep 2026 16:16:34 -0400 Subject: [PATCH 1/4] =?UTF-8?q?corsair:=20phase=202=20=E2=80=94=20live=20D?= =?UTF-8?q?PI=20stage,=20lift-off,=20and=20angle-snap=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the NIGHTSWORD RGB driver from read-only into a settings client. Every write addresses the live profile (byte 3 = 0), gets no reply, and is confirmed by the matching GET, following the ckb-next layout that the write probe in captures/corsair-nightsword verified on fw 3.41. Setters, matching the shared stage-editor contract: - setDpi(x, y?) rewrites whichever slot is selected, reading its colour first so the RGB-required stage write does not clear it. - setDpiStageValue / setDpiStageColor / setActiveDpiStage index the numbered stages (slots d1–d5); the Sniper slot d0 is never touched. A stage whose Y already differs keeps it. - setDpiStageCount seeds never-populated slots from the last enabled stage before enabling them, preserves the Sniper bit in the mask, and moves the selection back inside the enabled range when shrinking. - setLiftOffDistance maps Low/Medium/High onto the raw 1–5 scale (1/3/5 on write; 1–2/3/4–5 on read) until iCUE's Surface Calibration labels are captured against it. - setAngleSnapping sends ckb-next's trailing 0x05, which the mouse accepts with or without. Status now reports dpiStageColors, liftOffDistance and the three supported stops, settingsReady/valuesVerified true, a dpiStageEditor hint (5 stages, 100–18,000 in 50-DPI steps to keep the option list usable), and showAdvancedSection so the processing card appears. Polling rate stays read-only because the device re-enumerates on that write. The identity-only fallback is unchanged. The driver test's fake device is now stateful: GETs answer from the iCUE-loaded fixture state big-endian, SETs mutate it little-endian and leave the feature buffer alone, so every setter is checked against both the bytes on the wire and the read-back. Co-Authored-By: Claude Fable 5.1 --- src/corsair/index.test.ts | 18 ++ src/corsair/index.ts | 24 +++ src/drivers/corsair/hid.test.ts | 285 ++++++++++++++++++++++++++++---- src/drivers/corsair/hid.ts | 248 +++++++++++++++++++++++++-- 4 files changed, 521 insertions(+), 54 deletions(-) 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..663a29b 100644 --- a/src/corsair/index.ts +++ b/src/corsair/index.ts @@ -225,6 +225,30 @@ 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). The + * three-stop names map onto that scale until iCUE's Surface Calibration labels + * have been captured against 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..2e40e9a 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,49 @@ 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) { + 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 +124,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 +144,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 +192,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 +205,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 +232,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 +240,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 +277,157 @@ 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 grows by seeding empty slots and keeps the Sniper bit", async () => { + const { device, sent, state } = fakeDevice(); + assert.equal(await new CorsairHidClient(device).setDpiStageCount(5), 5); + const writes = sets(sent); + // Slots 4 and 5 were empty: seeded from slot 3 (5700, cyan) before the mask write. + assert.deepEqual(writes[0], [0x07, 0x13, 0xd4, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); + assert.deepEqual(writes[1], [0x07, 0x13, 0xd5, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); + assert.deepEqual(writes[2], [0x07, 0x13, 0x05, 0x00, 0x3f, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(state.mask, 0x3f); + assert.deepEqual(state.slots[5], { 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..08b6627 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,122 @@ 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. A slot that was never + * populated (0 DPI) is seeded from the last enabled stage first, 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; + 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); + } + 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}.`); + // 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 +260,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 +278,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 +309,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 +366,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 +376,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 +417,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, From 729fda42c318072f001148109377e0e258c366ae Mon Sep 17 00:00:00 2001 From: Kori Francis Date: Sun, 6 Sep 2026 16:25:39 -0400 Subject: [PATCH 2/4] corsair: enable the stage mask before seeding a newly added slot The first phase-2 hardware run failed "Set DPI stage count to 4" with "kept 0 DPI on stage 4": the driver wrote slot d4 while the mask still had it disabled, and the mouse dropped the write. Write the mask first, confirm it, then seed any newly enabled slot that holds 0 DPI. The test fake now drops writes to disabled slots so the old order fails there too, and the run's other findings (a stale reply caught by the echo check, iCUE not reading live state back) are recorded in PROTOCOL.md. Co-Authored-By: Claude Fable 5.1 --- captures/corsair-nightsword/PROTOCOL.md | 7 +++++++ src/drivers/corsair/hid.test.ts | 28 ++++++++++++++++++++----- src/drivers/corsair/hid.ts | 20 +++++++++++------- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/captures/corsair-nightsword/PROTOCOL.md b/captures/corsair-nightsword/PROTOCOL.md index 80ecda0..3abbafc 100644 --- a/captures/corsair-nightsword/PROTOCOL.md +++ b/captures/corsair-nightsword/PROTOCOL.md @@ -126,6 +126,13 @@ 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 diff --git a/src/drivers/corsair/hid.test.ts b/src/drivers/corsair/hid.test.ts index 2e40e9a..8c37538 100644 --- a/src/drivers/corsair/hid.test.ts +++ b/src/drivers/corsair/hid.test.ts @@ -100,6 +100,9 @@ function fakeDevice(options: FakeOptions = {}) { 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), @@ -351,18 +354,33 @@ test("setActiveDpiStage selects the numbered slot and confirms", async () => { await assert.rejects(new CorsairHidClient(stuck.device).setActiveDpiStage(0), /stayed on DPI slot 2/); }); -test("setDpiStageCount grows by seeding empty slots and keeps the Sniper bit", async () => { +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); - // Slots 4 and 5 were empty: seeded from slot 3 (5700, cyan) before the mask write. - assert.deepEqual(writes[0], [0x07, 0x13, 0xd4, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); - assert.deepEqual(writes[1], [0x07, 0x13, 0xd5, 0x00, 0x00, 0x44, 0x16, 0x44, 0x16, 0x00, 0xbf, 0xff]); - assert.deepEqual(writes[2], [0x07, 0x13, 0x05, 0x00, 0x3f, 0, 0, 0, 0, 0, 0, 0]); + // 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; diff --git a/src/drivers/corsair/hid.ts b/src/drivers/corsair/hid.ts index 08b6627..cd20be8 100644 --- a/src/drivers/corsair/hid.ts +++ b/src/drivers/corsair/hid.ts @@ -157,8 +157,10 @@ export class CorsairHidClient { } /** - * Enables slots d1..d`count` and disables the rest. A slot that was never - * populated (0 DPI) is seeded from the last enabled stage first, since an + * 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 { @@ -172,6 +174,14 @@ export class CorsairHidClient { 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))); @@ -179,12 +189,6 @@ export class CorsairHidClient { const seed = template ?? { x: 800, y: 800, rgb: [0x00, 0xbf, 0xff] as CorsairRgb }; await this.writeSlot(slot, seed.x, seed.y, seed.rgb); } - 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}.`); // Keep the selection inside the enabled range. if (state.current.stage > count) await this.selectSlot(count); return enabled; From 3b57d5e741f9d6b387e270cee1f16e5edd2649b4 Mon Sep 17 00:00:00 2001 From: Kori Francis Date: Sun, 6 Sep 2026 21:30:09 -0400 Subject: [PATCH 3/4] corsair: state the lift-off mapping as the driver's own, not pending iCUE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iCUE has no manual lift-off control for the NIGHTSWORD, only the spiral surface-calibration pass, so there are no vendor labels to map the raw 1–5 byte against. The scale is ckb-next's slider range; Low/Medium/High = 1/3/5 is this driver's definition. Comment and notes only. Co-Authored-By: Claude Fable 5.1 --- captures/corsair-nightsword/PROTOCOL.md | 2 +- src/corsair/index.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/captures/corsair-nightsword/PROTOCOL.md b/captures/corsair-nightsword/PROTOCOL.md index 3abbafc..9d0780c 100644 --- a/captures/corsair-nightsword/PROTOCOL.md +++ b/captures/corsair-nightsword/PROTOCOL.md @@ -138,7 +138,7 @@ Run from the OpenMouse origin in Chrome 152 against the usage‑4 interface (`wr ## 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. Still open: whether the calibration pass writes `MOUSE_LIFT` or a separate field. 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.ts b/src/corsair/index.ts index 663a29b..dc4cb4d 100644 --- a/src/corsair/index.ts +++ b/src/corsair/index.ts @@ -234,10 +234,12 @@ export function corsairParseRgbHex(color: string): CorsairRgb { } /** - * Lift-off height is a raw 1–5 byte (every value accepted on fw 3.41). The - * three-stop names map onto that scale until iCUE's Surface Calibration labels - * have been captured against it: Low = 1, Medium = 3, High = 5 on write; - * 1–2 → Low, 3 → Medium, 4–5 → High on read. + * 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; From bd75e4e7bd91e8b02db92f7cba1187b8a0fc44a9 Mon Sep 17 00:00:00 2001 From: Kori Francis Date: Sun, 6 Sep 2026 21:44:14 -0400 Subject: [PATCH 4/4] corsair: note that iCUE's surface calibration never touches MOUSE_LIFT Polled 0e 13 03 00 twice a second through a complete spiral calibration pass in iCUE (fw 3.41); the lift byte stayed at 5 throughout. Surface tuning is therefore a separate command, still to be captured. Co-Authored-By: Claude Fable 5.1 --- captures/corsair-nightsword/PROTOCOL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/captures/corsair-nightsword/PROTOCOL.md b/captures/corsair-nightsword/PROTOCOL.md index 9d0780c..cff7b78 100644 --- a/captures/corsair-nightsword/PROTOCOL.md +++ b/captures/corsair-nightsword/PROTOCOL.md @@ -138,7 +138,7 @@ Run from the OpenMouse origin in Chrome 152 against the usage‑4 interface (`wr ## Still to confirm 1. ~~Whether `07 13 02 00 ` switches stage live~~ — confirmed 2026‑09‑06. -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. Still open: whether the calibration pass writes `MOUSE_LIFT` or a separate field. +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).