Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion captures/corsair-nightsword/PROTOCOL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <n>` 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).
Expand Down
18 changes: 18 additions & 0 deletions src/corsair/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -12,6 +13,8 @@ import {
corsairEncode as encode,
corsairFormatVersion,
corsairIsEcho,
corsairLiftName,
corsairParseRgbHex,
corsairRgbHex,
} from "./index.ts";

Expand DownExpand Up@@ -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");
}
26 changes: 26 additions & 0 deletions src/corsair/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<CorsairLiftName, number>> = { 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}.`);
Expand Down
Loading