diff --git a/captures/coolermaster-mm711/README.md b/captures/coolermaster-mm711/README.md new file mode 100644 index 0000000..f2a685e --- /dev/null +++ b/captures/coolermaster-mm711/README.md @@ -0,0 +1,55 @@ +# Cooler Master MM711 captures + +Hardware reference material for the Cooler Master MM711 wired gaming mouse in +`src/coolermaster/` and `src/drivers/coolermaster/`. + +Captured directly from a physical Cooler Master MM711 on Windows (hidraw / Win32 HID handle) +on 2026-10-02: +- Vendor ID: `0x2516` (Cooler Master Technology Inc.) +- Product ID: `0x0101` (MasterMouse MM711) +- Interface: `MI_01` (Usage Page `0xFF00`, Usage `0x0001`) +- Report ID: `0x00` +- Report Length: 65 bytes (1 byte Report ID `0x00` + 64-byte payload) + +## Files + +| File | What it is | +|---|---| +| `handshake.hex` | Init / wakeup sequence `0x41 0x80` and the device's echo reply. | +| `get-dpi-level.hex` | Read active DPI stage mapping (`0x52 0x9B`), stage order and active slot. | +| `get-performance.hex` | Read performance block (`0x52 0x40`), returning 7 DPI presets for X and Y, angle snapping, lift-off distance, angle tuning, and sensor threshold parameters. | +| `get-polling.hex` | Read polling rate (`0x52 0xF0`), returning `0x01` (1000 Hz). | +| `get-debounce.hex` | Read button response / debounce time (`0x52 0x10`), returning `0x05` (5 ms). | +| `set-polling.hex` | Write polling rate command (`0x51 0xF0`) and read confirmation echo. | + +## Protocol Summary + +- **Handshake / Wakeup**: `0x41, 0x80` +- **Read Prefix**: `0x52` ('R') + - `0x52, 0x40`: Performance / DPI table + - `0x52, 0x9B`: Active DPI level + - `0x52, 0xF0`: Polling rate + - `0x52, 0x10`: Button response time (debounce) +- **Write Prefix**: `0x51` ('Q') + - `0x51, 0x40`: Performance / DPI settings + - `0x51, 0x9B`: Active DPI level table + - `0x51, 0xF0`: Polling rate + - `0x51, 0x10`: Button response time (debounce) +- **DPI Encoding**: + - `DPI = (raw_byte + 1) * 100` (from 100 to 16,000 DPI in 100 DPI steps) + - Raw value for 400 DPI = `0x03`, 800 DPI = `0x07`, 1200 DPI = `0x0B`, 1600 DPI = `0x0F`, 3200 DPI = `0x1F`, 6400 DPI = `0x3F`, 16000 DPI = `0x9F` +- **Polling Rate Codes**: + - `1` = 1000 Hz (1 ms) + - `2` = 500 Hz (2 ms) + - `4` = 250 Hz (4 ms) + - `8` = 125 Hz (8 ms) +- **Lift-off Distance (LOD)**: + - `0` (bit mask `(byte & 6) != 6`) = Low LOD (~2 mm) + - `1` (bit mask `(byte & 6) == 6`) = High LOD (~3 mm) +- **Angle Snapping**: + - `0` = Disabled + - `1` = Enabled +- **Angle Tuning**: + - Signed byte in degrees (-30 to +30) +- **Debounce Time**: + - Integer in milliseconds (e.g. 4 ms to 32 ms) diff --git a/captures/coolermaster-mm711/get-debounce.hex b/captures/coolermaster-mm711/get-debounce.hex new file mode 100644 index 0000000..e0ec049 --- /dev/null +++ b/captures/coolermaster-mm711/get-debounce.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Get Debounce (Button Response Time) +# Out: 0x52, 0x10 +52100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: Debounce = 5 ms (byte 13 = 0x05) +52100000000000000000000000050000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/captures/coolermaster-mm711/get-dpi-level.hex b/captures/coolermaster-mm711/get-dpi-level.hex new file mode 100644 index 0000000..893d893 --- /dev/null +++ b/captures/coolermaster-mm711/get-dpi-level.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Get DPI Level +# Out: 0x52, 0x9B +529b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: Active level = 1 (stage 1 = 800 DPI), order: 01 03 02 04 05 06 +529b0000000103020405060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/captures/coolermaster-mm711/get-performance.hex b/captures/coolermaster-mm711/get-performance.hex new file mode 100644 index 0000000..2392ba6 --- /dev/null +++ b/captures/coolermaster-mm711/get-performance.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Get Performance +# Out: 0x52, 0x40 +52400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: 7 DPI stages (400, 800, 1600, 1200, 3200, 6400, 16000), LOD Low (0x02), Angle Snap Off (0x00) +52400000010303070f0b1f3f9f03070f0b1f3f9f0000000000000000020a06000000000000000000000000000000000000000000000000000000000000000000 diff --git a/captures/coolermaster-mm711/get-polling.hex b/captures/coolermaster-mm711/get-polling.hex new file mode 100644 index 0000000..ffb2632 --- /dev/null +++ b/captures/coolermaster-mm711/get-polling.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Get Polling Rate +# Out: 0x52, 0xF0 +52f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: Polling rate = 1 (1000 Hz) +52f00000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/captures/coolermaster-mm711/handshake.hex b/captures/coolermaster-mm711/handshake.hex new file mode 100644 index 0000000..e00d37a --- /dev/null +++ b/captures/coolermaster-mm711/handshake.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Handshake (64-byte payload) +# Out: 0x41, 0x80 +41800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: Echo 0x41, 0x80 +41800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/captures/coolermaster-mm711/set-polling.hex b/captures/coolermaster-mm711/set-polling.hex new file mode 100644 index 0000000..c5871e0 --- /dev/null +++ b/captures/coolermaster-mm711/set-polling.hex @@ -0,0 +1,5 @@ +# Cooler Master MM711 Set Polling Rate +# Out: 0x51, 0xF0, 0x00, 0x00, 0x02 (Set to 500 Hz) +51f00000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +# In: Echo confirmation +51f00000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/package.json b/package.json index 6ca6e18..8bc8c13 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,10 @@ "./fater": { "types": "./dist/fater/index.d.ts", "import": "./dist/fater/index.js" + }, + "./coolermaster": { + "types": "./dist/coolermaster/index.d.ts", + "import": "./dist/coolermaster/index.js" } }, "scripts": { diff --git a/src/coolermaster/index.test.ts b/src/coolermaster/index.test.ts new file mode 100644 index 0000000..6ab9d53 --- /dev/null +++ b/src/coolermaster/index.test.ts @@ -0,0 +1,335 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COOLERMASTER_CMD_HANDSHAKE_1, + COOLERMASTER_CMD_HANDSHAKE_2, + COOLERMASTER_CMD_READ, + COOLERMASTER_CMD_WRITE, + COOLERMASTER_DPI_MAX, + COOLERMASTER_DPI_MIN, + COOLERMASTER_DPI_STAGE_COUNT, + COOLERMASTER_MM711_PRODUCT_ID, + COOLERMASTER_PAYLOAD_SIZE, + COOLERMASTER_POLLING_RATES, + COOLERMASTER_PRODUCT_NAMES, + COOLERMASTER_REPORT_ID, + COOLERMASTER_REPORT_SIZE, + COOLERMASTER_SUB_CUSTOM_EFFECT, + COOLERMASTER_SUB_DEBOUNCE, + COOLERMASTER_SUB_DPI_LEVEL, + COOLERMASTER_SUB_EFFECT_MODE, + COOLERMASTER_SUB_GENERAL_EFFECT, + COOLERMASTER_SUB_PERFORMANCE, + COOLERMASTER_SUB_POLLING, + COOLERMASTER_USAGE, + COOLERMASTER_USAGE_PAGE, + COOLERMASTER_VENDOR_ID, + COOLERMASTER_LIGHTING_MODE_STATIC, + COOLERMASTER_LIGHTING_MODE_BREATH, + COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE, + COOLERMASTER_LIGHTING_MODE_CUSTOM, + COOLERMASTER_LIGHTING_MODE_OFF, + coolermasterDecodeBreathingSpeed, + coolermasterDecodeColorCycleSpeed, + coolermasterDecodeCustomEffect, + coolermasterDecodeDebounce, + coolermasterDecodeDpi, + coolermasterDecodeDpiLevel, + coolermasterDecodeEffectMode, + coolermasterDecodeGeneralEffect, + coolermasterDecodePerformance, + coolermasterDecodePollingRate, + coolermasterDecodePollingRateCode, + coolermasterEncodeDebounce, + coolermasterEncodeDpi, + coolermasterEncodeGetCustomEffect, + coolermasterEncodeGetDebounce, + coolermasterEncodeGetDpiLevel, + coolermasterEncodeGetEffectMode, + coolermasterEncodeGetGeneralEffect, + coolermasterEncodeGetPerformance, + coolermasterEncodeGetPollingRate, + coolermasterEncodeHandshake, + coolermasterEncodePollingCode, + coolermasterEncodeSetCustomEffect, + coolermasterEncodeSetDebounce, + coolermasterEncodeSetEffectMode, + coolermasterEncodeSetGeneralEffect, + coolermasterEncodeSetPerformance, + coolermasterEncodeSetPollingRate, + coolermasterParseHexColor, + coolermasterToHexColor, +} from "./index.ts"; + +function fromHex(text: string): Uint8Array { + const clean = text.replace(/#.*$/gm, "").trim().replace(/\s+/g, ""); + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < clean.length; i += 2) { + bytes[i / 2] = Number.parseInt(clean.slice(i, i + 2), 16); + } + return bytes; +} + +// Captured fixtures from physical MM711 +const FIXTURE_HANDSHAKE = fromHex( + "41800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +); +const FIXTURE_GET_POLLING_WITH_REPORT_ID = fromHex( + "0052f00000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +); +const FIXTURE_GET_PERFORMANCE_WITH_REPORT_ID = fromHex( + "0052400000010303070f0b1f3f9f03070f0b1f3f9f0000000000000000020a06000000000000000000000000000000000000000000000000000000000000000000", +); +const FIXTURE_GET_DEBOUNCE_WITH_REPORT_ID = fromHex( + "0052100000000000000000000005000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +); +const FIXTURE_GET_DPI_LEVEL_WITH_REPORT_ID = fromHex( + "00529b0000000103020405060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +); + +test("Cooler Master constants match hardware and MasterPlus specifications", () => { + assert.equal(COOLERMASTER_VENDOR_ID, 0x2516); + assert.equal(COOLERMASTER_MM711_PRODUCT_ID, 0x0101); + assert.equal(COOLERMASTER_USAGE_PAGE, 0xff00); + assert.equal(COOLERMASTER_USAGE, 0x0001); + assert.equal(COOLERMASTER_REPORT_ID, 0x00); + assert.equal(COOLERMASTER_PAYLOAD_SIZE, 64); + assert.equal(COOLERMASTER_REPORT_SIZE, 65); + assert.equal(COOLERMASTER_PRODUCT_NAMES.get(0x0101), "Cooler Master MM711"); + assert.deepEqual(COOLERMASTER_POLLING_RATES, [125, 250, 500, 1000]); +}); + +test("DPI encoding and decoding follows (code + 1) * 100 formula", () => { + assert.equal(coolermasterEncodeDpi(100), 0x00); + assert.equal(coolermasterEncodeDpi(400), 0x03); + assert.equal(coolermasterEncodeDpi(800), 0x07); + assert.equal(coolermasterEncodeDpi(1200), 0x0b); + assert.equal(coolermasterEncodeDpi(1600), 0x0f); + assert.equal(coolermasterEncodeDpi(3200), 0x1f); + assert.equal(coolermasterEncodeDpi(6400), 0x3f); + assert.equal(coolermasterEncodeDpi(16000), 0x9f); + + assert.equal(coolermasterDecodeDpi(0x00), 100); + assert.equal(coolermasterDecodeDpi(0x03), 400); + assert.equal(coolermasterDecodeDpi(0x07), 800); + assert.equal(coolermasterDecodeDpi(0x0b), 1200); + assert.equal(coolermasterDecodeDpi(0x0f), 1600); + assert.equal(coolermasterDecodeDpi(0x1f), 3200); + assert.equal(coolermasterDecodeDpi(0x3f), 6400); + assert.equal(coolermasterDecodeDpi(0x9f), 16000); + + // Clamping + assert.equal(coolermasterEncodeDpi(50), 0x00); + assert.equal(coolermasterEncodeDpi(20000), 0x9f); + assert.throws(() => coolermasterEncodeDpi(Number.NaN)); +}); + +test("polling rate codes map to 1000, 500, 250, and 125 Hz", () => { + assert.equal(coolermasterEncodePollingCode(1000), 1); + assert.equal(coolermasterEncodePollingCode(500), 2); + assert.equal(coolermasterEncodePollingCode(250), 4); + assert.equal(coolermasterEncodePollingCode(125), 8); + assert.throws(() => coolermasterEncodePollingCode(2000)); + + assert.equal(coolermasterDecodePollingRateCode(1), 1000); + assert.equal(coolermasterDecodePollingRateCode(2), 500); + assert.equal(coolermasterDecodePollingRateCode(4), 250); + assert.equal(coolermasterDecodePollingRateCode(8), 125); + assert.throws(() => coolermasterDecodePollingRateCode(3)); +}); + +test("handshake packet builds correctly", () => { + const packet = coolermasterEncodeHandshake(); + assert.equal(packet.length, COOLERMASTER_PAYLOAD_SIZE); + assert.equal(packet[0], COOLERMASTER_CMD_HANDSHAKE_1); + assert.equal(packet[1], COOLERMASTER_CMD_HANDSHAKE_2); + assert.deepEqual(packet, FIXTURE_HANDSHAKE); +}); + +test("polling rate read and write commands match protocol", () => { + const getReq = coolermasterEncodeGetPollingRate(); + assert.equal(getReq[0], COOLERMASTER_CMD_READ); + assert.equal(getReq[1], COOLERMASTER_SUB_POLLING); + + const setReq = coolermasterEncodeSetPollingRate(500); + assert.equal(setReq[0], COOLERMASTER_CMD_WRITE); + assert.equal(setReq[1], COOLERMASTER_SUB_POLLING); + assert.equal(setReq[4], 2); + + // Decode from fixture with report ID + assert.equal(coolermasterDecodePollingRate(FIXTURE_GET_POLLING_WITH_REPORT_ID), 1000); + + // Decode from payload without report ID + const payloadOnly = FIXTURE_GET_POLLING_WITH_REPORT_ID.subarray(1); + assert.equal(coolermasterDecodePollingRate(payloadOnly), 1000); +}); + +test("performance packet decodes correctly from captured MM711 fixture", () => { + const perf = coolermasterDecodePerformance(FIXTURE_GET_PERFORMANCE_WITH_REPORT_ID); + + assert.equal(perf.activeDpiStage, 1); + assert.equal(perf.currentDpi, 800); + assert.equal(perf.currentDpiY, 800); + assert.deepEqual(perf.dpiStages, [400, 800, 1600, 1200, 3200, 6400, 16000]); + assert.deepEqual(perf.dpiStagesY, [400, 800, 1600, 1200, 3200, 6400, 16000]); + assert.equal(perf.angleTuning, 0); + assert.equal(perf.angleSnapping, false); + assert.equal(perf.liftOffDistance, "Low"); + assert.equal(perf.pixelThreshold, 10); + assert.equal(perf.minSqRun, 6); +}); + +test("set performance packet updates active stage, stages, and LOD", () => { + const base = FIXTURE_GET_PERFORMANCE_WITH_REPORT_ID.subarray(1); + const updated = coolermasterEncodeSetPerformance( + { + activeDpiStage: 0, + dpiStages: [800, 1600, 2400, 3200, 4800, 6400, 12000], + liftOffDistance: "High", + angleSnapping: true, + angleTuning: 5, + }, + base, + ); + + assert.equal(updated[0], COOLERMASTER_CMD_WRITE); + assert.equal(updated[1], COOLERMASTER_SUB_PERFORMANCE); + assert.equal(updated[4], 0); // active stage + assert.equal(coolermasterDecodeDpi(updated[6]!), 800); + assert.equal(coolermasterDecodeDpi(updated[7]!), 1600); + assert.equal(coolermasterDecodeDpi(updated[12]!), 12000); + assert.equal(updated[27], 5); // angle tuning + assert.equal((updated[28]! & 0x01) === 0x01, true); // angle snap on + assert.equal((updated[28]! & 0x06) === 0x06, true); // high LOD +}); + +test("debounce read and write packets decode and encode correctly", () => { + const getReq = coolermasterEncodeGetDebounce(); + assert.equal(getReq[0], COOLERMASTER_CMD_READ); + assert.equal(getReq[1], COOLERMASTER_SUB_DEBOUNCE); + + assert.equal(coolermasterDecodeDebounce(FIXTURE_GET_DEBOUNCE_WITH_REPORT_ID), 5); + + const setReq = coolermasterEncodeSetDebounce(12); + assert.equal(setReq[0], COOLERMASTER_CMD_WRITE); + assert.equal(setReq[1], COOLERMASTER_SUB_DEBOUNCE); + assert.equal(setReq[12], 12); + assert.equal(setReq[16], 12); + + assert.throws(() => coolermasterEncodeSetDebounce(0)); + assert.throws(() => coolermasterEncodeSetDebounce(50)); +}); + +test("DPI level packet decodes correctly", () => { + const req = coolermasterEncodeGetDpiLevel(); + assert.equal(req[0], COOLERMASTER_CMD_READ); + assert.equal(req[1], COOLERMASTER_SUB_DPI_LEVEL); + + const level = coolermasterDecodeDpiLevel(FIXTURE_GET_DPI_LEVEL_WITH_REPORT_ID); + assert.equal(level.activeDpiStage, 1); + assert.deepEqual(level.stageOrder, [3, 2, 4, 5, 6, 0, 0]); +}); + +test("lighting effect mode encodes and decodes correctly", () => { + const getReq = coolermasterEncodeGetEffectMode(); + assert.equal(getReq[0], COOLERMASTER_CMD_READ); + assert.equal(getReq[1], COOLERMASTER_SUB_EFFECT_MODE); + + const setReq = coolermasterEncodeSetEffectMode(COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE); + assert.equal(setReq[0], COOLERMASTER_CMD_WRITE); + assert.equal(setReq[1], COOLERMASTER_SUB_EFFECT_MODE); + assert.equal(setReq[4], 0x02); + + const reply = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + reply[0] = COOLERMASTER_CMD_READ; + reply[1] = COOLERMASTER_SUB_EFFECT_MODE; + reply[4] = COOLERMASTER_LIGHTING_MODE_CUSTOM; + + assert.equal(coolermasterDecodeEffectMode(reply), 0xb0); +}); + +test("lighting general effect encodes and decodes correctly", () => { + const getReq = coolermasterEncodeGetGeneralEffect(COOLERMASTER_LIGHTING_MODE_BREATH); + assert.equal(getReq[0], COOLERMASTER_CMD_READ); + assert.equal(getReq[1], COOLERMASTER_SUB_GENERAL_EFFECT); + assert.equal(getReq[4], 0x01); + + const setReq = coolermasterEncodeSetGeneralEffect({ + modeId: COOLERMASTER_LIGHTING_MODE_BREATH, + speedCode: 49, // level 3 + random: false, + brightness: 200, + color: { r: 0, g: 255, b: 0 }, + }); + assert.equal(setReq[0], COOLERMASTER_CMD_WRITE); + assert.equal(setReq[1], COOLERMASTER_SUB_GENERAL_EFFECT); + assert.equal(setReq[4], 0x01); + assert.equal(setReq[5], 49); + assert.equal(setReq[6], 0x20); // random false for breathing + assert.equal(setReq[7], 0xff); + assert.equal(setReq[8], 0xff); + assert.equal(setReq[9], 200); + assert.equal(setReq[10], 0); + assert.equal(setReq[11], 255); + assert.equal(setReq[12], 0); + + // Decode + const decoded = coolermasterDecodeGeneralEffect(setReq); + assert.equal(decoded.modeId, 0x01); + assert.equal(decoded.speedCode, 49); + assert.equal(decoded.random, false); + assert.equal(decoded.brightness, 200); + assert.deepEqual(decoded.color, { r: 0, g: 255, b: 0 }); + + // Breathing random flag + const randomSet = coolermasterEncodeSetGeneralEffect({ + modeId: COOLERMASTER_LIGHTING_MODE_BREATH, + random: true, + }); + assert.equal(randomSet[6], 0xa0); + assert.equal(coolermasterDecodeGeneralEffect(randomSet).random, true); +}); + +test("custom lighting per-zone encodes and decodes correctly", () => { + const getReq = coolermasterEncodeGetCustomEffect(); + assert.equal(getReq[0], COOLERMASTER_CMD_READ); + assert.equal(getReq[1], COOLERMASTER_SUB_CUSTOM_EFFECT); + + const setReq = coolermasterEncodeSetCustomEffect( + { r: 255, g: 0, b: 128 }, + { r: 0, g: 200, b: 255 }, + ); + assert.equal(setReq[0], COOLERMASTER_CMD_WRITE); + assert.equal(setReq[1], COOLERMASTER_SUB_CUSTOM_EFFECT); + assert.equal(setReq[4], 255); + assert.equal(setReq[5], 0); + assert.equal(setReq[6], 128); + assert.equal(setReq[7], 0); + assert.equal(setReq[8], 200); + assert.equal(setReq[9], 255); + + const decoded = coolermasterDecodeCustomEffect(setReq); + assert.deepEqual(decoded.wheel, { r: 255, g: 0, b: 128 }); + assert.deepEqual(decoded.logo, { r: 0, g: 200, b: 255 }); +}); + +test("speed decoders and hex color helpers work correctly", () => { + assert.equal(coolermasterDecodeBreathingSpeed(60), 1); + assert.equal(coolermasterDecodeBreathingSpeed(55), 2); + assert.equal(coolermasterDecodeBreathingSpeed(49), 3); + assert.equal(coolermasterDecodeBreathingSpeed(44), 4); + assert.equal(coolermasterDecodeBreathingSpeed(38), 5); + // Nearest approximation + assert.equal(coolermasterDecodeBreathingSpeed(56), 2); + + assert.equal(coolermasterDecodeColorCycleSpeed(50), 1); + assert.equal(coolermasterDecodeColorCycleSpeed(45), 2); + assert.equal(coolermasterDecodeColorCycleSpeed(40), 3); + assert.equal(coolermasterDecodeColorCycleSpeed(35), 4); + assert.equal(coolermasterDecodeColorCycleSpeed(30), 5); + + assert.deepEqual(coolermasterParseHexColor("#ff8800"), { r: 255, g: 136, b: 0 }); + assert.equal(coolermasterToHexColor({ r: 255, g: 136, b: 0 }), "#ff8800"); +}); + diff --git a/src/coolermaster/index.ts b/src/coolermaster/index.ts new file mode 100644 index 0000000..135e7bc --- /dev/null +++ b/src/coolermaster/index.ts @@ -0,0 +1,630 @@ +/** + * Cooler Master HID protocol codec. + * + * Reverse-engineered from Cooler Master MasterPlus+ v1.9.6 (CMUOT.dll/pdb, + * MM711PerformanceWid.dll/pdb, CfgFile.dll/pdb) and verified against a physical + * Cooler Master MM711 wired gaming mouse (VID 0x2516, PID 0x0101, MI_01). + * + * All control traffic uses raw 64-byte HID output/input reports on report ID 0x00 + * (total 65 bytes on the wire including Report ID 0x00). + * + * Reference captures and ground truth fixtures: + * `captures/coolermaster-mm711/` + */ + +export const COOLERMASTER_VENDOR_ID = 0x2516; +export const COOLERMASTER_MM711_PRODUCT_ID = 0x0101; + +export const COOLERMASTER_USAGE_PAGE = 0xff00; +export const COOLERMASTER_USAGE = 0x0001; +export const COOLERMASTER_REPORT_ID = 0x00; +export const COOLERMASTER_PAYLOAD_SIZE = 64; +export const COOLERMASTER_REPORT_SIZE = 65; + +export const COOLERMASTER_PRODUCT_IDS = [ + COOLERMASTER_MM711_PRODUCT_ID, +] as const; + +export const COOLERMASTER_PRODUCT_NAMES: ReadonlyMap = new Map([ + [COOLERMASTER_MM711_PRODUCT_ID, "Cooler Master MM711"], +]); + +export const COOLERMASTER_POLLING_RATES: readonly number[] = [125, 250, 500, 1000]; + +export const COOLERMASTER_DPI_MIN = 100; +export const COOLERMASTER_DPI_MAX = 16000; +export const COOLERMASTER_DPI_STEP = 100; +export const COOLERMASTER_DPI_STAGE_COUNT = 7; +export const COOLERMASTER_DPI_OPTIONS: readonly number[] = Array.from( + { length: 160 }, + (_, i) => (i + 1) * 100, +); + +// Protocol command bytes +export const COOLERMASTER_CMD_HANDSHAKE_1 = 0x41; +export const COOLERMASTER_CMD_HANDSHAKE_2 = 0x80; +export const COOLERMASTER_CMD_READ = 0x52; +export const COOLERMASTER_CMD_WRITE = 0x51; + +export const COOLERMASTER_SUB_PERFORMANCE = 0x40; +export const COOLERMASTER_SUB_DPI_LEVEL = 0x9b; +export const COOLERMASTER_SUB_POLLING = 0xf0; +export const COOLERMASTER_SUB_DEBOUNCE = 0x10; +export const COOLERMASTER_SUB_EFFECT_MODE = 0x28; +export const COOLERMASTER_SUB_GENERAL_EFFECT = 0x2b; +export const COOLERMASTER_SUB_CUSTOM_EFFECT = 0xa8; + +// Lighting modes +export const COOLERMASTER_LIGHTING_MODE_STATIC = 0x00; +export const COOLERMASTER_LIGHTING_MODE_BREATH = 0x01; +export const COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE = 0x02; +export const COOLERMASTER_LIGHTING_MODE_INDICATOR = 0x04; +export const COOLERMASTER_LIGHTING_MODE_CUSTOM = 0xb0; +export const COOLERMASTER_LIGHTING_MODE_OFF = 0xfe; + +export const COOLERMASTER_LIGHTING_ZONES = ["Scroll wheel", "Logo"] as const; +export type CoolerMasterLightingZone = (typeof COOLERMASTER_LIGHTING_ZONES)[number]; + +export interface CoolerMasterRgbColor { + r: number; + g: number; + b: number; +} + +export const COOLERMASTER_BREATHING_SPEED_MAP: Readonly> = { + 1: 60, // 0x3c + 2: 55, // 0x37 + 3: 49, // 0x31 + 4: 44, // 0x2c + 5: 38, // 0x26 +}; + +export const COOLERMASTER_COLOR_CYCLE_SPEED_MAP: Readonly> = { + 1: 50, // 0x32 + 2: 45, // 0x2d + 3: 40, // 0x28 + 4: 35, // 0x23 + 5: 30, // 0x1e +}; + +/** Polling rate code mapping (matches CMUOT toInnerPollingRate / bInterval ms). */ +export const COOLERMASTER_POLLING_RATE_MAP: Readonly> = { + 1000: 1, // 1 ms + 500: 2, // 2 ms + 250: 4, // 4 ms + 125: 8, // 8 ms +}; + +export const COOLERMASTER_POLLING_CODE_MAP: Readonly> = { + 1: 1000, + 2: 500, + 4: 250, + 8: 125, +}; + +export function coolermasterEncodePollingCode(hz: number): number { + const code = COOLERMASTER_POLLING_RATE_MAP[hz]; + if (code === undefined) { + throw new RangeError(`Unsupported Cooler Master polling rate: ${hz} Hz`); + } + return code; +} + +export function coolermasterDecodePollingRateCode(code: number): number { + const hz = COOLERMASTER_POLLING_CODE_MAP[code]; + if (hz === undefined) { + throw new RangeError(`Unknown Cooler Master polling rate code: 0x${code.toString(16)}`); + } + return hz; +} + +/** + * Encodes a DPI value into the raw hardware byte code: + * formula from CMUOT: (raw_code + 1) * 100 = DPI => raw_code = Math.round(dpi / 100) - 1. + */ +export function coolermasterEncodeDpi(dpi: number): number { + if (!Number.isFinite(dpi)) { + throw new RangeError("DPI must be a finite number"); + } + const clamped = Math.max(COOLERMASTER_DPI_MIN, Math.min(COOLERMASTER_DPI_MAX, dpi)); + const rounded = Math.round(clamped / COOLERMASTER_DPI_STEP) * COOLERMASTER_DPI_STEP; + return (rounded / 100) - 1; +} + +/** + * Decodes a raw hardware byte code to DPI: (raw_code + 1) * 100. + */ +export function coolermasterDecodeDpi(code: number): number { + return (code + 1) * 100; +} + +function normalizePayload(source: Uint8Array | DataView): Uint8Array { + const bytes = source instanceof Uint8Array + ? source + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + + if (bytes.length === COOLERMASTER_REPORT_SIZE && bytes[0] === COOLERMASTER_REPORT_ID) { + return bytes.subarray(1); + } + if (bytes.length === COOLERMASTER_PAYLOAD_SIZE) { + return bytes; + } + throw new RangeError( + `Invalid Cooler Master report length: expected 64 or 65 bytes, got ${bytes.length}`, + ); +} + +/** Builds the 64-byte handshake request [0x41, 0x80, ...0x00]. */ +export function coolermasterEncodeHandshake(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_HANDSHAKE_1; + buf[1] = COOLERMASTER_CMD_HANDSHAKE_2; + return buf; +} + +/** Builds the 64-byte get polling rate request [0x52, 0xF0, ...0x00]. */ +export function coolermasterEncodeGetPollingRate(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_POLLING; + return buf; +} + +/** Builds the 64-byte set polling rate command [0x51, 0xF0, 0x00, 0x00, code, ...0x00]. */ +export function coolermasterEncodeSetPollingRate(hz: number): Uint8Array { + const code = coolermasterEncodePollingCode(hz); + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_POLLING; + buf[2] = 0x00; + buf[3] = 0x00; + buf[4] = code; + return buf; +} + +/** Decodes the polling rate response [0x52, 0xF0, 0x00, 0x00, code, ...]. */ +export function coolermasterDecodePollingRate(source: Uint8Array | DataView): number { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_POLLING + ) { + throw new Error( + `Invalid Cooler Master polling reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + return coolermasterDecodePollingRateCode(payload[4]!); +} + +/** Builds the 64-byte get DPI level request [0x52, 0x9B, ...0x00]. */ +export function coolermasterEncodeGetDpiLevel(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_DPI_LEVEL; + return buf; +} + +export interface CoolerMasterDpiLevel { + activeDpiStage: number; + stageOrder: number[]; +} + +/** Decodes DPI level reply [0x52, 0x9B, 0x00, 0x00, 0x00, activeStage, ...order]. */ +export function coolermasterDecodeDpiLevel(source: Uint8Array | DataView): CoolerMasterDpiLevel { + const payload = normalizePayload(source); + if (payload[0] !== COOLERMASTER_CMD_READ || payload[1] !== COOLERMASTER_SUB_DPI_LEVEL) { + throw new Error("Invalid Cooler Master DPI level reply."); + } + const activeDpiStage = payload[5]!; + const stageOrder: number[] = []; + for (let i = 6; i < 13; i++) { + stageOrder.push(payload[i]!); + } + return { activeDpiStage, stageOrder }; +} + +/** Builds the 64-byte get performance request [0x52, 0x40, ...0x00]. */ +export function coolermasterEncodeGetPerformance(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_PERFORMANCE; + return buf; +} + +export interface CoolerMasterPerformance { + activeDpiStage: number; + stageCount: number; + dpiStages: number[]; + dpiStagesY: number[]; + currentDpi: number; + currentDpiY: number; + angleTuning: number; + angleSnapping: boolean; + liftOffDistance: "Low" | "High"; + pixelThreshold: number; + minSqRun: number; + rawPayload: Uint8Array; +} + +/** + * Decodes the 64-byte performance block returned by 0x52 0x40. + */ +export function coolermasterDecodePerformance(source: Uint8Array | DataView): CoolerMasterPerformance { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_PERFORMANCE + ) { + throw new Error( + `Invalid Cooler Master performance reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + + const activeDpiStage = payload[4]!; + const stageCount = payload[5]!; + + const dpiStages: number[] = []; + for (let i = 0; i < COOLERMASTER_DPI_STAGE_COUNT; i++) { + dpiStages.push(coolermasterDecodeDpi(payload[6 + i]!)); + } + + const dpiStagesY: number[] = []; + for (let i = 0; i < COOLERMASTER_DPI_STAGE_COUNT; i++) { + dpiStagesY.push(coolermasterDecodeDpi(payload[13 + i]!)); + } + + const currentDpi = dpiStages[activeDpiStage] ?? dpiStages[0]!; + const currentDpiY = dpiStagesY[activeDpiStage] ?? dpiStagesY[0]!; + + // Angle tuning is signed int8 at offset 27 (-30 to +30 degrees) + const rawAngleTune = payload[27]!; + const angleTuning = (rawAngleTune << 24) >> 24; + + // Offset 28: bit 0 is angle snapping (0=off, 1=on) + // bits 1..2: if (byte & 6) == 6 => High LOD, else Low LOD + const flags = payload[28]!; + const angleSnapping = (flags & 0x01) === 0x01; + const liftOffDistance: "Low" | "High" = (flags & 0x06) === 0x06 ? "High" : "Low"; + + const pixelThreshold = payload[29]!; + const minSqRun = payload[30]!; + + return { + activeDpiStage, + stageCount, + dpiStages, + dpiStagesY, + currentDpi, + currentDpiY, + angleTuning, + angleSnapping, + liftOffDistance, + pixelThreshold, + minSqRun, + rawPayload: new Uint8Array(payload), + }; +} + +export interface CoolerMasterPerformanceUpdate { + activeDpiStage?: number; + stageCount?: number; + dpiStages?: readonly number[]; + dpiStagesY?: readonly number[]; + angleTuning?: number; + angleSnapping?: boolean; + liftOffDistance?: "Low" | "High"; +} + +/** + * Builds the 64-byte write performance packet (0x51 0x40 ...), preserving + * existing values from baseFrame or defaults when not explicitly modified. + */ +export function coolermasterEncodeSetPerformance( + update: CoolerMasterPerformanceUpdate, + baseFrame?: Uint8Array, +): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + if (baseFrame) { + const base = normalizePayload(baseFrame); + buf.set(base); + } + + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_PERFORMANCE; + + if (update.activeDpiStage !== undefined) { + if (update.activeDpiStage < 0 || update.activeDpiStage >= COOLERMASTER_DPI_STAGE_COUNT) { + throw new RangeError(`Active DPI stage must be 0-${COOLERMASTER_DPI_STAGE_COUNT - 1}`); + } + buf[4] = update.activeDpiStage; + } + + if (update.stageCount !== undefined) { + buf[5] = update.stageCount; + } else if (!baseFrame) { + buf[5] = COOLERMASTER_DPI_STAGE_COUNT; + } + + if (update.dpiStages) { + for (let i = 0; i < update.dpiStages.length && i < COOLERMASTER_DPI_STAGE_COUNT; i++) { + buf[6 + i] = coolermasterEncodeDpi(update.dpiStages[i]!); + } + } + + if (update.dpiStagesY) { + for (let i = 0; i < update.dpiStagesY.length && i < COOLERMASTER_DPI_STAGE_COUNT; i++) { + buf[13 + i] = coolermasterEncodeDpi(update.dpiStagesY[i]!); + } + } else if (update.dpiStages && !baseFrame) { + for (let i = 0; i < update.dpiStages.length && i < COOLERMASTER_DPI_STAGE_COUNT; i++) { + buf[13 + i] = coolermasterEncodeDpi(update.dpiStages[i]!); + } + } + + if (update.angleTuning !== undefined) { + const clamped = Math.max(-30, Math.min(30, update.angleTuning)); + buf[27] = clamped & 0xff; + } + + let currentFlags = buf[28]!; + if (update.angleSnapping !== undefined) { + currentFlags = update.angleSnapping ? (currentFlags | 0x01) : (currentFlags & ~0x01); + } + if (update.liftOffDistance !== undefined) { + if (update.liftOffDistance === "High") { + currentFlags = (currentFlags & ~0x06) | 0x06; + } else { + currentFlags = (currentFlags & ~0x06) | 0x02; + } + } + buf[28] = currentFlags; + + if (!baseFrame) { + buf[29] = 0x0a; // default pixel threshold 10 + buf[30] = 0x06; // default min_sq_run 6 + } + + return buf; +} + +/** Builds the 64-byte get debounce request [0x52, 0x10, ...0x00]. */ +export function coolermasterEncodeGetDebounce(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_DEBOUNCE; + return buf; +} + +/** Decodes debounce ms from [0x52, 0x10, ...]. Offset 12 carries debounce ms. */ +export function coolermasterDecodeDebounce(source: Uint8Array | DataView): number { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_DEBOUNCE + ) { + throw new Error( + `Invalid Cooler Master debounce reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + return payload[12]!; +} + +/** Builds the 64-byte set debounce command [0x51, 0x10, ...]. */ +export function coolermasterEncodeSetDebounce(debounceMs: number, baseFrame?: Uint8Array): Uint8Array { + if (!Number.isInteger(debounceMs) || debounceMs < 1 || debounceMs > 32) { + throw new RangeError("Cooler Master debounce time must be an integer between 1 and 32 ms."); + } + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + if (baseFrame) { + buf.set(normalizePayload(baseFrame)); + } + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_DEBOUNCE; + buf[12] = debounceMs; + buf[16] = debounceMs; + return buf; +} + +export function coolermasterDecodeBreathingSpeed(wireVal: number): number { + let bestLevel = 3; + let bestDiff = Infinity; + for (const [lvlStr, val] of Object.entries(COOLERMASTER_BREATHING_SPEED_MAP)) { + const diff = Math.abs(val - wireVal); + if (diff < bestDiff) { + bestDiff = diff; + bestLevel = Number(lvlStr); + } + } + return bestLevel; +} + +export function coolermasterDecodeColorCycleSpeed(wireVal: number): number { + let bestLevel = 3; + let bestDiff = Infinity; + for (const [lvlStr, val] of Object.entries(COOLERMASTER_COLOR_CYCLE_SPEED_MAP)) { + const diff = Math.abs(val - wireVal); + if (diff < bestDiff) { + bestDiff = diff; + bestLevel = Number(lvlStr); + } + } + return bestLevel; +} + +/** Parses #rrggbb into RGB components (defaulting to 255, 0, 0). */ +export function coolermasterParseHexColor(color: string | null | undefined): CoolerMasterRgbColor { + if (!color || !color.startsWith("#")) { + return { r: 255, g: 0, b: 0 }; + } + const clean = color.slice(1); + if (clean.length === 6) { + return { + r: Number.parseInt(clean.slice(0, 2), 16) || 0, + g: Number.parseInt(clean.slice(2, 4), 16) || 0, + b: Number.parseInt(clean.slice(4, 6), 16) || 0, + }; + } + return { r: 255, g: 0, b: 0 }; +} + +/** Formats RGB components into #rrggbb. */ +export function coolermasterToHexColor(rgb: CoolerMasterRgbColor): string { + const hexByte = (n: number) => + Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0"); + return `#${hexByte(rgb.r)}${hexByte(rgb.g)}${hexByte(rgb.b)}`; +} + +/** Builds the 64-byte get effect mode request [0x52, 0x28, ...0x00]. */ +export function coolermasterEncodeGetEffectMode(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_EFFECT_MODE; + return buf; +} + +/** Decodes active effect mode ID from [0x52, 0x28, ...]. Offset 4 carries mode ID. */ +export function coolermasterDecodeEffectMode(source: Uint8Array | DataView): number { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_EFFECT_MODE + ) { + throw new Error( + `Invalid Cooler Master effect mode reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + return payload[4]!; +} + +/** Builds the 64-byte set effect mode command [0x51, 0x28, 0x00, 0x00, modeId, ...]. */ +export function coolermasterEncodeSetEffectMode(modeId: number): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_EFFECT_MODE; + buf[4] = modeId & 0xff; + return buf; +} + +/** Builds the 64-byte get general effect request [0x52, 0x2b, 0x00, 0x00, modeId, ...]. */ +export function coolermasterEncodeGetGeneralEffect(modeId: number): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_GENERAL_EFFECT; + buf[4] = modeId & 0xff; + return buf; +} + +export interface CoolerMasterGeneralEffect { + modeId: number; + speedCode: number; + random: boolean; + brightness: number; + color: CoolerMasterRgbColor; +} + +/** Decodes general effect parameters from [0x52, 0x2b, ...]. */ +export function coolermasterDecodeGeneralEffect( + source: Uint8Array | DataView, +): CoolerMasterGeneralEffect { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_GENERAL_EFFECT + ) { + throw new Error( + `Invalid Cooler Master general effect reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + return { + modeId: payload[4]!, + speedCode: payload[5]!, + random: (payload[6]! & 0x80) !== 0, + brightness: payload[9]!, + color: { + r: payload[10]!, + g: payload[11]!, + b: payload[12]!, + }, + }; +} + +/** Builds the 64-byte set general effect command [0x51, 0x2b, ...]. */ +export function coolermasterEncodeSetGeneralEffect(config: { + modeId: number; + speedCode?: number; + random?: boolean; + brightness?: number; + color?: CoolerMasterRgbColor; +}): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_GENERAL_EFFECT; + buf[4] = config.modeId & 0xff; + buf[5] = (config.speedCode ?? 0) & 0xff; + buf[6] = config.random ? 0xa0 : config.modeId === COOLERMASTER_LIGHTING_MODE_BREATH ? 0x20 : 0x00; + buf[7] = 0xff; + buf[8] = 0xff; + buf[9] = (config.brightness ?? 255) & 0xff; + buf[10] = (config.color?.r ?? 255) & 0xff; + buf[11] = (config.color?.g ?? 0) & 0xff; + buf[12] = (config.color?.b ?? 0) & 0xff; + return buf; +} + +/** Builds the 64-byte get custom effect request [0x52, 0xa8, ...0x00]. */ +export function coolermasterEncodeGetCustomEffect(): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_READ; + buf[1] = COOLERMASTER_SUB_CUSTOM_EFFECT; + return buf; +} + +export interface CoolerMasterCustomEffect { + wheel: CoolerMasterRgbColor; + logo: CoolerMasterRgbColor; +} + +/** Decodes custom per-zone RGB colors from [0x52, 0xa8, ...]. */ +export function coolermasterDecodeCustomEffect( + source: Uint8Array | DataView, +): CoolerMasterCustomEffect { + const payload = normalizePayload(source); + if ( + (payload[0] !== COOLERMASTER_CMD_READ && payload[0] !== COOLERMASTER_CMD_WRITE) || + payload[1] !== COOLERMASTER_SUB_CUSTOM_EFFECT + ) { + throw new Error( + `Invalid Cooler Master custom effect reply: [0x${payload[0]?.toString(16)}, 0x${payload[1]?.toString(16)}]`, + ); + } + return { + wheel: { + r: payload[4]!, + g: payload[5]!, + b: payload[6]!, + }, + logo: { + r: payload[7]!, + g: payload[8]!, + b: payload[9]!, + }, + }; +} + +/** Builds the 64-byte set custom effect command [0x51, 0xa8, 0x00, 0x00, R0, G0, B0, R1, G1, B1, ...]. */ +export function coolermasterEncodeSetCustomEffect( + wheel: CoolerMasterRgbColor, + logo: CoolerMasterRgbColor, +): Uint8Array { + const buf = new Uint8Array(COOLERMASTER_PAYLOAD_SIZE); + buf[0] = COOLERMASTER_CMD_WRITE; + buf[1] = COOLERMASTER_SUB_CUSTOM_EFFECT; + buf[4] = wheel.r & 0xff; + buf[5] = wheel.g & 0xff; + buf[6] = wheel.b & 0xff; + buf[7] = logo.r & 0xff; + buf[8] = logo.g & 0xff; + buf[9] = logo.b & 0xff; + return buf; +} + diff --git a/src/drivers/coolermaster/hid.test.ts b/src/drivers/coolermaster/hid.test.ts new file mode 100644 index 0000000..bccb81c --- /dev/null +++ b/src/drivers/coolermaster/hid.test.ts @@ -0,0 +1,446 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COOLERMASTER_MM711_PRODUCT_ID, + COOLERMASTER_REPORT_ID, + COOLERMASTER_USAGE, + COOLERMASTER_USAGE_PAGE, + COOLERMASTER_VENDOR_ID, + coolermasterDecodeDpi, + coolermasterEncodeDpi, +} from "@openmouse/protocol/coolermaster"; +import { CoolerMasterHidClient } from "./hid.ts"; +import { clientSupportScore, createSupportedClient, deviceBrand } from "../registry.ts"; + +function fromHex(text: string): Uint8Array { + const clean = text.replace(/#.*$/gm, "").trim().replace(/\s+/g, ""); + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < clean.length; i += 2) { + bytes[i / 2] = Number.parseInt(clean.slice(i, i + 2), 16); + } + return bytes; +} + +class FakeCoolerMasterDevice { + vendorId = COOLERMASTER_VENDOR_ID; + productId = COOLERMASTER_MM711_PRODUCT_ID; + productName = "Cooler Master MM711"; + opened = false; + collections: HIDCollectionInfo[] = [ + { + usagePage: COOLERMASTER_USAGE_PAGE, + usage: COOLERMASTER_USAGE, + children: [], + featureReports: [], + inputReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + outputReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + }, + ]; + + // Internal state modeled after captured MM711 hardware responses + private activeStage = 1; + private stageCount = 7; + private dpiStages = [400, 800, 1600, 1200, 3200, 6400, 16000]; + private pollingCode = 1; // 1000 Hz + private debounceMs = 5; + private angleTuning = 0; + private angleSnapping = false; + private liftOffDistance: "Low" | "High" = "Low"; + private lightingMode = 2; // Color cycle + private lightingSpeed = 40; + private lightingBrightness = 255; + private lightingRandom = false; + private lightingColor = { r: 255, g: 0, b: 0 }; + private customWheel = { r: 255, g: 0, b: 0 }; + private customLogo = { r: 0, g: 255, b: 0 }; + + private listeners = new Set<(event: HIDInputReportEvent) => void>(); + readonly sentReports: Array<{ reportId: number; data: Uint8Array }> = []; + + async open(): Promise { + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + addEventListener(type: string, listener: (event: HIDInputReportEvent) => void): void { + if (type === "inputreport") this.listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: HIDInputReportEvent) => void): void { + if (type === "inputreport") this.listeners.delete(listener); + } + + async sendReport(reportId: number, data: BufferSource): Promise { + const frame = new Uint8Array(data as ArrayBufferLike); + this.sentReports.push({ reportId, data: new Uint8Array(frame) }); + + const reply = new Uint8Array(64); + + if (frame[0] === 0x41 && frame[1] === 0x80) { + // Handshake echo + reply[0] = 0x41; + reply[1] = 0x80; + } else if (frame[0] === 0x52 && frame[1] === 0x40) { + // Get Performance + reply[0] = 0x52; + reply[1] = 0x40; + reply[4] = this.activeStage; + reply[5] = this.stageCount; + for (let i = 0; i < 7; i++) { + reply[6 + i] = coolermasterEncodeDpi(this.dpiStages[i]!); + reply[13 + i] = coolermasterEncodeDpi(this.dpiStages[i]!); + } + reply[27] = this.angleTuning & 0xff; + reply[28] = (this.angleSnapping ? 0x01 : 0x00) | (this.liftOffDistance === "High" ? 0x06 : 0x02); + reply[29] = 10; + reply[30] = 6; + } else if (frame[0] === 0x51 && frame[1] === 0x40) { + // Set Performance + reply.set(frame); + this.activeStage = frame[4]!; + if (frame[5]! > 0) this.stageCount = frame[5]!; + for (let i = 0; i < 7; i++) { + this.dpiStages[i] = coolermasterDecodeDpi(frame[6 + i]!); + } + this.angleTuning = (frame[27]! << 24) >> 24; + this.angleSnapping = (frame[28]! & 0x01) === 0x01; + this.liftOffDistance = (frame[28]! & 0x06) === 0x06 ? "High" : "Low"; + } else if (frame[0] === 0x52 && frame[1] === 0xf0) { + // Get Polling Rate + reply[0] = 0x52; + reply[1] = 0xf0; + reply[4] = this.pollingCode; + } else if (frame[0] === 0x51 && frame[1] === 0xf0) { + // Set Polling Rate + this.pollingCode = frame[4]!; + reply.set(frame); + } else if (frame[0] === 0x52 && frame[1] === 0x10) { + // Get Debounce + reply[0] = 0x52; + reply[1] = 0x10; + reply[12] = this.debounceMs; + } else if (frame[0] === 0x51 && frame[1] === 0x10) { + // Set Debounce + this.debounceMs = frame[12]!; + reply.set(frame); + } else if (frame[0] === 0x52 && frame[1] === 0x28) { + // Get Effect Mode + reply[0] = 0x52; + reply[1] = 0x28; + reply[4] = this.lightingMode; + } else if (frame[0] === 0x51 && frame[1] === 0x28) { + // Set Effect Mode + this.lightingMode = frame[4]!; + reply.set(frame); + } else if (frame[0] === 0x52 && frame[1] === 0xa8) { + // Get Custom Effect + reply[0] = 0x52; + reply[1] = 0xa8; + reply[4] = this.customWheel.r; + reply[5] = this.customWheel.g; + reply[6] = this.customWheel.b; + reply[7] = this.customLogo.r; + reply[8] = this.customLogo.g; + reply[9] = this.customLogo.b; + } else if (frame[0] === 0x51 && frame[1] === 0xa8) { + // Set Custom Effect + this.customWheel = { r: frame[4]!, g: frame[5]!, b: frame[6]! }; + this.customLogo = { r: frame[7]!, g: frame[8]!, b: frame[9]! }; + reply.set(frame); + } else if (frame[0] === 0x52 && frame[1] === 0x2b) { + // Get General Effect + reply[0] = 0x52; + reply[1] = 0x2b; + reply[4] = this.lightingMode; + reply[5] = this.lightingSpeed; + reply[6] = this.lightingRandom ? 0xa0 : 0x20; + reply[7] = 0xff; + reply[8] = 0xff; + reply[9] = this.lightingBrightness; + reply[10] = this.lightingColor.r; + reply[11] = this.lightingColor.g; + reply[12] = this.lightingColor.b; + } else if (frame[0] === 0x51 && frame[1] === 0x2b) { + // Set General Effect + this.lightingSpeed = frame[5]!; + this.lightingRandom = (frame[6]! & 0x80) !== 0; + this.lightingBrightness = frame[9]!; + this.lightingColor = { r: frame[10]!, g: frame[11]!, b: frame[12]! }; + reply.set(frame); + } + + queueMicrotask(() => { + const event = { + reportId: 0, + data: new DataView(reply.buffer, reply.byteOffset, reply.byteLength), + device: this as unknown as HIDDevice, + } as unknown as HIDInputReportEvent; + for (const listener of this.listeners) { + listener(event); + } + }); + } +} + +test("CoolerMasterHidClient.isSupported detects matching MM711 device", () => { + const fake = new FakeCoolerMasterDevice(); + assert.equal(CoolerMasterHidClient.isSupported(fake as unknown as HIDDevice), true); + + const wrongVid = new FakeCoolerMasterDevice(); + wrongVid.vendorId = 0x1234; + assert.equal(CoolerMasterHidClient.isSupported(wrongVid as unknown as HIDDevice), false); + + const wrongPid = new FakeCoolerMasterDevice(); + wrongPid.productId = 0x9999; + assert.equal(CoolerMasterHidClient.isSupported(wrongPid as unknown as HIDDevice), false); + + const wrongCollection = new FakeCoolerMasterDevice(); + wrongCollection.collections = [ + { + usagePage: 0x0001, + usage: 0x0002, + children: [], + featureReports: [], + inputReports: [], + outputReports: [], + }, + ]; + assert.equal(CoolerMasterHidClient.isSupported(wrongCollection as unknown as HIDDevice), false); +}); + +test("CoolerMasterHidClient reads complete status from device", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const status = await client.readStatus(); + assert.equal(status.brand, "Cooler Master"); + assert.equal(status.name, "Cooler Master MM711"); + assert.equal(status.dpi, 800); + assert.equal(status.dpiY, 800); + assert.equal(status.activeDpiStage, 1); + assert.deepEqual(status.dpiStages, [400, 800, 1600, 1200, 3200, 6400, 16000]); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.debounceMs, 5); + assert.equal(status.liftOffDistance, "Low"); + assert.equal(status.angleSnapping, false); + assert.equal(status.angleTuning, 0); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.batteryPercent, null); + assert.equal(status.ui?.valuesVerified, true); + assert.equal(status.ui?.settingsReady, true); + assert.deepEqual(status.ui?.dpiStageEditor, { + maxStages: 7, + countEditable: true, + minDpi: 100, + maxDpi: 16000, + stepDpi: 100, + }); + assert.ok(status.lighting); + assert.equal(status.lighting.zone, "Scroll wheel"); + assert.equal(status.lighting.mode, "Cycling"); + assert.equal(status.lightingZones?.length, 2); + assert.equal(status.lightingZones[0]?.zone, "Scroll wheel"); + assert.equal(status.lightingZones[1]?.zone, "Logo"); +}); + +test("CoolerMasterHidClient sets active DPI stage and stage values", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + // Set active stage to 0 (400 DPI) + const activeStage = await client.setActiveDpiStage(0); + assert.equal(activeStage, 0); + + // Set active DPI to 1200 + const newDpi = await client.setDpi(1200); + assert.equal(newDpi, 1200); + + // Set stage 3 value to 2000 + const stageVal = await client.setDpiStageValue(3, 2000); + assert.equal(stageVal, 2000); + + const status = await client.readStatus(); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.dpi, 1200); + assert.equal(status.dpiStages?.[3], 2000); +}); + +test("CoolerMasterHidClient sets DPI stage count", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const count3 = await client.setDpiStageCount(3); + assert.equal(count3, 3); + + const status = await client.readStatus(); + assert.equal(status.dpiStages?.length, 3); + assert.deepEqual(status.dpiStages, [400, 800, 1600]); + + await assert.rejects(() => client.setDpiStageCount(0), RangeError); + await assert.rejects(() => client.setDpiStageCount(8), RangeError); +}); + +test("CoolerMasterHidClient sets polling rate", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const rate500 = await client.setPollingRate(500); + assert.equal(rate500, 500); + + const rate250 = await client.setPollingRate(250); + assert.equal(rate250, 250); + + const rate125 = await client.setPollingRate(125); + assert.equal(rate125, 125); + + const rate1000 = await client.setPollingRate(1000); + assert.equal(rate1000, 1000); + + await assert.rejects(() => client.setPollingRate(2000), /RangeError/); +}); + +test("CoolerMasterHidClient sets debounce time", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + assert.equal(client.getDebounceMaxMs(), 32); + assert.equal(client.getDebounceOptions().length, 29); + assert.equal(client.getDebounceOptions()[0], 4); + assert.equal(client.getDebounceOptions().at(-1), 32); + + const debounce = await client.setDebounceTime(12); + assert.equal(debounce, 12); + + await assert.rejects(() => client.setDebounceTime(0), /RangeError/); + await assert.rejects(() => client.setDebounceTime(40), /RangeError/); +}); + +test("CoolerMasterHidClient sets LOD, angle snapping, and angle tuning", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const lodHigh = await client.setLiftOffDistance("High"); + assert.equal(lodHigh, "High"); + + const snapOn = await client.setAngleSnapping(true); + assert.equal(snapOn, true); + + const tune = await client.setAngleTuning(5); + assert.equal(tune, 5); + + const status = await client.readStatus(); + assert.equal(status.liftOffDistance, "High"); + assert.equal(status.angleSnapping, true); + assert.equal(status.angleTuning, 5); +}); + +test("Cooler Master device is registered and resolved in the driver registry", () => { + const fake = new FakeCoolerMasterDevice(); + const client = createSupportedClient(fake as unknown as HIDDevice); + assert.ok(client instanceof CoolerMasterHidClient); + assert.equal(deviceBrand(client), "Cooler Master"); + assert.equal(clientSupportScore(fake as unknown as HIDDevice), 7); +}); + +test("CoolerMasterHidClient sets static lighting per zone", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const status = await client.readStatus(); + const wheelZone = status.lightingZones![0]!; + const logoZone = status.lightingZones![1]!; + + // Set Scroll wheel to blue + const updatedWheel = await client.setLighting({ + ...wheelZone, + mode: "Static", + color: "#0000ff", + }); + assert.equal(updatedWheel.zone, "Scroll wheel"); + assert.equal(updatedWheel.mode, "Static"); + assert.equal(updatedWheel.color, "#0000ff"); + + // Set Logo to magenta + const updatedLogo = await client.setLighting({ + ...logoZone, + mode: "Static", + color: "#ff00ff", + }); + assert.equal(updatedLogo.zone, "Logo"); + assert.equal(updatedLogo.mode, "Static"); + assert.equal(updatedLogo.color, "#ff00ff"); + + // Re-read status and verify both zones kept their colors + const recheck = await client.readStatus(); + assert.equal(recheck.lightingZones![0]!.color, "#0000ff"); + assert.equal(recheck.lightingZones![1]!.color, "#ff00ff"); +}); + +test("CoolerMasterHidClient sets breathing and color cycle lighting", async () => { + const fake = new FakeCoolerMasterDevice(); + const client = new CoolerMasterHidClient(fake as unknown as HIDDevice); + + const status = await client.readStatus(); + const zone = status.lightingZones![0]!; + + // Breathing single color + const breath = await client.setLighting({ + ...zone, + mode: "Breathing single", + color: "#00ff00", + speed: 4, + brightness: 80, + }); + assert.equal(breath.mode, "Breathing single"); + assert.equal(breath.color, "#00ff00"); + assert.equal(breath.speed, 4); + + // Breathing random colors + const breathRandom = await client.setLighting({ + ...zone, + mode: "Breathing random", + speed: 2, + }); + assert.equal(breathRandom.mode, "Breathing random"); + assert.equal(breathRandom.color, null); + assert.equal(breathRandom.speed, 2); + + // Color cycle (Cycling) + const cycle = await client.setLighting({ + ...zone, + mode: "Cycling", + speed: 5, + brightness: 100, + }); + assert.equal(cycle.mode, "Cycling"); + assert.equal(cycle.speed, 5); + assert.equal(cycle.color, null); + + // Off + const off = await client.setLighting({ + ...zone, + mode: "Off", + }); + assert.equal(off.mode, "Off"); + assert.equal(off.brightness, 0); + + // Validation + await assert.rejects( + () => client.setLighting({ ...zone, zone: "Underglow" }), + /Unknown Cooler Master lighting zone/, + ); + await assert.rejects( + () => client.setLighting({ ...zone, mode: null }), + /Choose an RGB effect first/, + ); + await assert.rejects( + () => client.setLighting({ ...zone, mode: "Wave" as any }), + /Unsupported Cooler Master lighting mode/, + ); +}); + + diff --git a/src/drivers/coolermaster/hid.ts b/src/drivers/coolermaster/hid.ts new file mode 100644 index 0000000..a1942a7 --- /dev/null +++ b/src/drivers/coolermaster/hid.ts @@ -0,0 +1,633 @@ +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts"; +import { + COOLERMASTER_BREATHING_SPEED_MAP, + COOLERMASTER_CMD_HANDSHAKE_1, + COOLERMASTER_CMD_HANDSHAKE_2, + COOLERMASTER_COLOR_CYCLE_SPEED_MAP, + COOLERMASTER_DPI_MAX, + COOLERMASTER_DPI_MIN, + COOLERMASTER_DPI_OPTIONS, + COOLERMASTER_DPI_STAGE_COUNT, + COOLERMASTER_DPI_STEP, + COOLERMASTER_LIGHTING_MODE_BREATH, + COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE, + COOLERMASTER_LIGHTING_MODE_CUSTOM, + COOLERMASTER_LIGHTING_MODE_OFF, + COOLERMASTER_LIGHTING_MODE_STATIC, + COOLERMASTER_LIGHTING_ZONES, + COOLERMASTER_POLLING_RATES, + COOLERMASTER_PRODUCT_IDS, + COOLERMASTER_PRODUCT_NAMES, + COOLERMASTER_REPORT_ID, + COOLERMASTER_USAGE, + COOLERMASTER_USAGE_PAGE, + COOLERMASTER_VENDOR_ID, + coolermasterDecodeBreathingSpeed, + coolermasterDecodeColorCycleSpeed, + coolermasterDecodeCustomEffect, + coolermasterDecodeDebounce, + coolermasterDecodeEffectMode, + coolermasterDecodeGeneralEffect, + coolermasterDecodePerformance, + coolermasterDecodePollingRate, + coolermasterEncodeGetCustomEffect, + coolermasterEncodeGetDebounce, + coolermasterEncodeGetEffectMode, + coolermasterEncodeGetGeneralEffect, + coolermasterEncodeGetPerformance, + coolermasterEncodeGetPollingRate, + coolermasterEncodeHandshake, + coolermasterEncodeSetCustomEffect, + coolermasterEncodeSetDebounce, + coolermasterEncodeSetEffectMode, + coolermasterEncodeSetGeneralEffect, + coolermasterEncodeSetPerformance, + coolermasterEncodeSetPollingRate, + coolermasterParseHexColor, + coolermasterToHexColor, + type CoolerMasterCustomEffect, + type CoolerMasterGeneralEffect, + type CoolerMasterLightingZone, + type CoolerMasterPerformance, +} from "@openmouse/protocol/coolermaster"; + +const MM711_LIGHTING_MODES: readonly MouseLightingMode[] = [ + "Static", + "Breathing single", + "Breathing random", + "Cycling", + "Off", +]; + +const MM711_COLOR_MODES: readonly MouseLightingMode[] = ["Static", "Breathing single"]; +const MM711_SPEED_MODES: readonly MouseLightingMode[] = ["Breathing single", "Breathing random", "Cycling"]; +const MM711_SPEEDS: readonly number[] = [1, 2, 3, 4, 5]; +const MM711_BRIGHTNESS_LEVELS: readonly number[] = [0, 25, 50, 75, 100]; + +const RESPONSE_TIMEOUT_MS = 1000; + +function hasControlCollection(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === COOLERMASTER_USAGE_PAGE && collection.usage === COOLERMASTER_USAGE) + || (collection.children && hasControlCollection(collection.children))); +} + +function matchesReply(request: Uint8Array, reply: Uint8Array): boolean { + if (reply.length < 2 || request.length < 2) return false; + if (request[0] === COOLERMASTER_CMD_HANDSHAKE_1 && request[1] === COOLERMASTER_CMD_HANDSHAKE_2) { + return reply[0] === COOLERMASTER_CMD_HANDSHAKE_1 && reply[1] === COOLERMASTER_CMD_HANDSHAKE_2; + } + return reply[1] === request[1]; +} + +/** + * WebHID driver for Cooler Master mice (MM711 and compatible models). + */ +export class CoolerMasterHidClient { + readonly device: HIDDevice; + private waiter: { + request: Uint8Array; + resolve: (reply: Uint8Array) => void; + reject: (error: Error) => void; + timer: ReturnType; + } | null = null; + private queue: Promise = Promise.resolve(); + private lastPerformance: CoolerMasterPerformance | null = null; + private lastCustomLighting: CoolerMasterCustomEffect = { + wheel: { r: 255, g: 0, b: 0 }, + logo: { r: 255, g: 0, b: 0 }, + }; + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (event.reportId !== COOLERMASTER_REPORT_ID) return; + let reply = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + if (reply.length === 65 && reply[0] === COOLERMASTER_REPORT_ID) { + reply = reply.subarray(1); + } + const waiter = this.waiter; + if (!waiter || !matchesReply(waiter.request, reply)) return; + clearTimeout(waiter.timer); + this.waiter = null; + waiter.resolve(reply); + }; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === COOLERMASTER_VENDOR_ID + && (COOLERMASTER_PRODUCT_IDS as readonly number[]).includes(device.productId) + && hasControlCollection(device.collections); + } + + get pollIntervalMs(): number { + return 30_000; + } + + getDpiOptions(): number[] { + return [...COOLERMASTER_DPI_OPTIONS]; + } + + getSupportedPollingRates(): number[] { + return [...COOLERMASTER_POLLING_RATES]; + } + + getDebounceOptions(): number[] { + return Array.from({ length: 29 }, (_, i) => i + 4); + } + + getDebounceMaxMs(): number { + return 32; + } + + async open(): Promise { + if (!CoolerMasterHidClient.isSupported(this.device)) { + throw new Error("Cooler Master configuration collection is unavailable."); + } + if (!this.device.opened) await this.device.open(); + this.device.removeEventListener("inputreport", this.onInputReport); + this.device.addEventListener("inputreport", this.onInputReport); + } + + async close(): Promise { + this.device.removeEventListener("inputreport", this.onInputReport); + this.failWaiter(new Error("The Cooler Master device was closed.")); + if (this.device.opened) await this.device.close(); + } + + async startNotifications(_onChange?: () => void): Promise { + return false; + } + + async readStatus(): Promise { + return await this.serialized(async () => { + await this.open(); + // Step 1: Handshake + await this.exchange(coolermasterEncodeHandshake()).catch(() => undefined); + + // Step 2: Read performance block (DPI stages, active stage, LOD, angle snap, angle tune) + const perfReply = await this.exchange(coolermasterEncodeGetPerformance()); + const perf = coolermasterDecodePerformance(perfReply); + this.lastPerformance = perf; + + // Step 3: Read polling rate + const pollingReply = await this.exchange(coolermasterEncodeGetPollingRate()); + const pollingRate = coolermasterDecodePollingRate(pollingReply); + + // Step 4: Read debounce time + let debounceMs: number | null = null; + try { + const debounceReply = await this.exchange(coolermasterEncodeGetDebounce()); + debounceMs = coolermasterDecodeDebounce(debounceReply); + } catch { + debounceMs = null; + } + + // Step 5: Read lighting + let lightingZones: MouseLighting[] | undefined; + try { + lightingZones = await this.readLighting(); + } catch { + lightingZones = undefined; + } + + const name = COOLERMASTER_PRODUCT_NAMES.get(this.device.productId) ?? "Cooler Master MM711"; + + return { + brand: "Cooler Master", + name, + ui: { + family: "cooler master", + defaultDisplayName: name, + valuesVerified: true, + settingsReady: true, + showAdvancedSection: true, + singleAxisStages: true, + hideProcessingCard: false, + hideRippleControl: true, + hideMotionSync: true, + dpiStageEditor: { + maxStages: COOLERMASTER_DPI_STAGE_COUNT, + countEditable: true, + minDpi: COOLERMASTER_DPI_MIN, + maxDpi: COOLERMASTER_DPI_MAX, + stepDpi: COOLERMASTER_DPI_STEP, + }, + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: perf.currentDpi, + dpiY: perf.currentDpiY, + supportsSeparateDpiAxes: true, + dpiStages: perf.dpiStages.slice(0, perf.stageCount), + activeDpiStage: perf.activeDpiStage, + pollingRateHz: pollingRate, + supportedPollingRates: [...COOLERMASTER_POLLING_RATES], + debounceMs, + liftOffDistance: perf.liftOffDistance, + supportedLiftOffDistances: ["Low", "High"], + angleSnapping: perf.angleSnapping, + angleTuning: perf.angleTuning, + activeProfile: 1, + connectionType: "Wired", + firmware: [], + lighting: lightingZones?.[0], + lightingZones, + }; + }); + } + + async setDpi(dpi: number): Promise { + if (!Number.isFinite(dpi) || dpi < COOLERMASTER_DPI_MIN || dpi > COOLERMASTER_DPI_MAX) { + throw new RangeError( + `Cooler Master DPI must be between ${COOLERMASTER_DPI_MIN} and ${COOLERMASTER_DPI_MAX}.`, + ); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const updatedStages = [...perf.dpiStages]; + updatedStages[perf.activeDpiStage] = dpi; + const writePacket = coolermasterEncodeSetPerformance( + { + activeDpiStage: perf.activeDpiStage, + dpiStages: updatedStages, + dpiStagesY: updatedStages, + }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.currentDpi; + }); + } + + async setActiveDpiStage(stage: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= COOLERMASTER_DPI_STAGE_COUNT) { + throw new RangeError( + `Cooler Master active DPI stage must be 0-${COOLERMASTER_DPI_STAGE_COUNT - 1}.`, + ); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const writePacket = coolermasterEncodeSetPerformance( + { activeDpiStage: stage }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.activeDpiStage; + }); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= COOLERMASTER_DPI_STAGE_COUNT) { + throw new RangeError( + `Cooler Master DPI stage must be 0-${COOLERMASTER_DPI_STAGE_COUNT - 1}.`, + ); + } + if (!Number.isFinite(dpi) || dpi < COOLERMASTER_DPI_MIN || dpi > COOLERMASTER_DPI_MAX) { + throw new RangeError( + `Cooler Master DPI must be between ${COOLERMASTER_DPI_MIN} and ${COOLERMASTER_DPI_MAX}.`, + ); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const updatedStages = [...perf.dpiStages]; + updatedStages[stage] = dpi; + const writePacket = coolermasterEncodeSetPerformance( + { + dpiStages: updatedStages, + dpiStagesY: updatedStages, + }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.dpiStages[stage]!; + }); + } + + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > COOLERMASTER_DPI_STAGE_COUNT) { + throw new RangeError( + `Cooler Master DPI stage count must be between 1 and ${COOLERMASTER_DPI_STAGE_COUNT}.`, + ); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const clampedActive = Math.min(perf.activeDpiStage, count - 1); + const writePacket = coolermasterEncodeSetPerformance( + { stageCount: count, activeDpiStage: clampedActive }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.stageCount; + }); + } + + async setPollingRate(hz: number): Promise { + if (!(COOLERMASTER_POLLING_RATES as readonly number[]).includes(hz)) { + throw new RangeError( + `Cooler Master polling rate must be one of: ${COOLERMASTER_POLLING_RATES.join(", ")} Hz.`, + ); + } + return await this.serialized(async () => { + await this.open(); + const writePacket = coolermasterEncodeSetPollingRate(hz); + const echo = await this.exchange(writePacket); + return coolermasterDecodePollingRate(echo); + }); + } + + async setDebounceTime(ms: number): Promise { + if (!Number.isInteger(ms) || ms < 1 || ms > 32) { + throw new RangeError("Cooler Master debounce time must be an integer between 1 and 32 ms."); + } + return await this.serialized(async () => { + await this.open(); + const writePacket = coolermasterEncodeSetDebounce(ms); + const echo = await this.exchange(writePacket); + return coolermasterDecodeDebounce(echo); + }); + } + + async setLiftOffDistance(lod: "Low" | "Medium" | "High"): Promise<"Low" | "High"> { + if (lod !== "Low" && lod !== "High") { + throw new RangeError("Cooler Master lift-off distance must be 'Low' or 'High'."); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const writePacket = coolermasterEncodeSetPerformance( + { liftOffDistance: lod }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.liftOffDistance; + }); + } + + async setAngleSnapping(enabled: boolean): Promise { + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const writePacket = coolermasterEncodeSetPerformance( + { angleSnapping: enabled }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.angleSnapping; + }); + } + + async setAngleTuning(degrees: number): Promise { + if (!Number.isInteger(degrees) || degrees < -30 || degrees > 30) { + throw new RangeError("Cooler Master angle tuning must be an integer between -30 and +30 degrees."); + } + return await this.serialized(async () => { + const perf = await this.ensurePerformance(); + const writePacket = coolermasterEncodeSetPerformance( + { angleTuning: degrees }, + perf.rawPayload, + ); + const echo = await this.exchange(writePacket); + const recheck = coolermasterDecodePerformance(echo); + this.lastPerformance = recheck; + return recheck.angleTuning; + }); + } + + async setLighting(lighting: MouseLighting): Promise { + const zoneIndex = COOLERMASTER_LIGHTING_ZONES.indexOf( + lighting.zone as CoolerMasterLightingZone, + ); + if (zoneIndex < 0) { + throw new Error(`Unknown Cooler Master lighting zone: ${lighting.zone}`); + } + if (!lighting.mode) { + throw new Error("Choose an RGB effect first."); + } + if (!MM711_LIGHTING_MODES.includes(lighting.mode)) { + throw new Error(`Unsupported Cooler Master lighting mode: ${lighting.mode}`); + } + + return await this.serialized(async () => { + await this.open(); + + if (lighting.mode === "Off") { + await this.exchange(coolermasterEncodeSetEffectMode(COOLERMASTER_LIGHTING_MODE_OFF)); + } else if (lighting.mode === "Cycling") { + const speedLevel = Math.max(1, Math.min(5, lighting.speed ?? 3)); + const speedCode = COOLERMASTER_COLOR_CYCLE_SPEED_MAP[speedLevel] ?? 40; + const brightness = Math.max( + 0, + Math.min(127, Math.round(((lighting.brightness ?? 100) * 127) / 100)), + ); + await this.exchange( + coolermasterEncodeSetGeneralEffect({ + modeId: COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE, + speedCode, + brightness, + color: { r: 255, g: 255, b: 255 }, + }), + ); + await this.exchange( + coolermasterEncodeSetEffectMode(COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE), + ); + } else if ( + lighting.mode === "Breathing single" || + lighting.mode === "Breathing random" + ) { + const isRandom = lighting.mode === "Breathing random"; + const speedLevel = Math.max(1, Math.min(5, lighting.speed ?? 3)); + const speedCode = COOLERMASTER_BREATHING_SPEED_MAP[speedLevel] ?? 49; + const brightness = Math.max( + 0, + Math.min(255, Math.round(((lighting.brightness ?? 100) * 255) / 100)), + ); + const color = isRandom + ? { r: 255, g: 0, b: 0 } + : coolermasterParseHexColor(lighting.color); + await this.exchange( + coolermasterEncodeSetGeneralEffect({ + modeId: COOLERMASTER_LIGHTING_MODE_BREATH, + speedCode, + random: isRandom, + brightness, + color, + }), + ); + await this.exchange( + coolermasterEncodeSetEffectMode(COOLERMASTER_LIGHTING_MODE_BREATH), + ); + } else if (lighting.mode === "Static") { + const newColor = coolermasterParseHexColor(lighting.color); + if (lighting.zone === "Scroll wheel") { + this.lastCustomLighting.wheel = newColor; + } else { + this.lastCustomLighting.logo = newColor; + } + await this.exchange( + coolermasterEncodeSetCustomEffect( + this.lastCustomLighting.wheel, + this.lastCustomLighting.logo, + ), + ); + await this.exchange( + coolermasterEncodeSetEffectMode(COOLERMASTER_LIGHTING_MODE_CUSTOM), + ); + } + + const refreshed = await this.readLighting(); + return refreshed[zoneIndex]!; + }); + } + + private async readLighting(): Promise { + let modeId = COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE; + try { + const modeReply = await this.exchange(coolermasterEncodeGetEffectMode()); + modeId = coolermasterDecodeEffectMode(modeReply); + } catch { + // Keep default + } + + try { + const customReply = await this.exchange(coolermasterEncodeGetCustomEffect()); + this.lastCustomLighting = coolermasterDecodeCustomEffect(customReply); + } catch { + // Keep cached + } + + let general: CoolerMasterGeneralEffect = { + modeId, + speedCode: 40, + random: false, + brightness: 255, + color: { r: 255, g: 0, b: 0 }, + }; + + if ( + modeId === COOLERMASTER_LIGHTING_MODE_STATIC || + modeId === COOLERMASTER_LIGHTING_MODE_BREATH || + modeId === COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE + ) { + try { + const generalReply = await this.exchange(coolermasterEncodeGetGeneralEffect(modeId)); + general = coolermasterDecodeGeneralEffect(generalReply); + } catch { + // Keep fallback + } + } + + const { wheel, logo } = this.lastCustomLighting; + + let activeMode: MouseLightingMode = "Cycling"; + let speed: number | null = null; + let brightness = 100; + + if (modeId === COOLERMASTER_LIGHTING_MODE_OFF) { + activeMode = "Off"; + brightness = 0; + } else if (modeId === COOLERMASTER_LIGHTING_MODE_COLOR_CYCLE) { + activeMode = "Cycling"; + speed = coolermasterDecodeColorCycleSpeed(general.speedCode); + brightness = Math.round((general.brightness * 100) / 127); + } else if (modeId === COOLERMASTER_LIGHTING_MODE_BREATH) { + activeMode = general.random ? "Breathing random" : "Breathing single"; + speed = coolermasterDecodeBreathingSpeed(general.speedCode); + brightness = Math.round((general.brightness * 100) / 255); + } else if (modeId === COOLERMASTER_LIGHTING_MODE_CUSTOM) { + activeMode = "Static"; + brightness = 100; + } else if (modeId === COOLERMASTER_LIGHTING_MODE_STATIC) { + activeMode = "Static"; + brightness = Math.round((general.brightness * 100) / 255); + } + + return COOLERMASTER_LIGHTING_ZONES.map((zone) => { + let color: string | null = null; + if (activeMode === "Static") { + color = zone === "Scroll wheel" ? coolermasterToHexColor(wheel) : coolermasterToHexColor(logo); + } else if (activeMode === "Breathing single") { + color = coolermasterToHexColor(general.color); + } + + return { + zone, + modes: MM711_LIGHTING_MODES, + mode: activeMode, + color, + color2: null, + colorModes: MM711_COLOR_MODES, + dualColorModes: [], + reactiveModes: MM711_SPEED_MODES, + speeds: MM711_SPEEDS, + speed, + brightness: Math.max(0, Math.min(100, brightness)), + brightnessLevels: MM711_BRIGHTNESS_LEVELS, + }; + }); + } + + private async ensurePerformance(): Promise { + await this.open(); + if (this.lastPerformance) return this.lastPerformance; + const reply = await this.exchange(coolermasterEncodeGetPerformance()); + this.lastPerformance = coolermasterDecodePerformance(reply); + return this.lastPerformance; + } + + private async exchange(request: Uint8Array): Promise { + await this.open(); + if (this.waiter) { + throw new Error("Another Cooler Master request is already in progress."); + } + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.waiter?.resolve === resolve) this.waiter = null; + reject( + new Error( + `The Cooler Master mouse did not answer command [0x${request[0]?.toString(16)}, 0x${request[1]?.toString(16)}].`, + ), + ); + }, RESPONSE_TIMEOUT_MS); + this.waiter = { request, resolve, reject, timer }; + }); + + try { + await this.device.sendReport(COOLERMASTER_REPORT_ID, new Uint8Array(request)); + } catch (error) { + this.failWaiter(error instanceof Error ? error : new Error(String(error))); + } + + return await response; + } + + private serialized(operation: () => Promise): Promise { + const run = this.queue.then(operation, operation); + this.queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private failWaiter(error: Error): void { + const waiter = this.waiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.waiter = null; + waiter.reject(error); + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index f123919..9ccba98 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -153,7 +153,7 @@ export interface AtkReceiverInfo { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80e7f71..24e4c71 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -71,9 +71,10 @@ import { MchoseV3HidClient } from "./mchose/v3-hid.ts"; import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/hid.ts"; +import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient; export interface DeviceDriver { brand: string; @@ -174,6 +175,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ // collection, neither of which another driver claims, so the two product ids // can be the whole matcher. { brand: "Rapoo", supports: (device) => RapooHidClient.isSupported(device), create: (device) => new RapooHidClient(device), score: () => 7 }, + { brand: "Cooler Master", supports: (device) => CoolerMasterHidClient.isSupported(device), create: (device) => new CoolerMasterHidClient(device), score: () => 7 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 50fc7a9..eb002e9 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -117,8 +117,14 @@ import { FATER_VENDOR_ID, } from "@openmouse/protocol/fater"; import { MOTOSPEED_PRODUCTS, MOTOSPEED_USAGE_PAGE, MOTOSPEED_VENDOR_ID } from "@openmouse/protocol/motospeed"; +import { + COOLERMASTER_USAGE, + COOLERMASTER_USAGE_PAGE, + COOLERMASTER_VENDOR_ID, +} from "@openmouse/protocol/coolermaster"; export const VENDOR_ID = { + coolermaster: COOLERMASTER_VENDOR_ID, vaxee: VAXEE_VENDOR_ID, asus: ASUS_VENDOR_ID, ryunix: RYUNIX_VENDOR_ID, @@ -837,4 +843,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...HYPERX_HID_FILTERS, ...RYUNIX_HID_FILTERS, ...RAPOO_HID_FILTERS, + { vendorId: VENDOR_ID.coolermaster, usagePage: COOLERMASTER_USAGE_PAGE, usage: COOLERMASTER_USAGE }, ]; diff --git a/src/index.ts b/src/index.ts index ae47b94..df3399e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,3 +35,4 @@ export * as bytech from "./bytech/index.js"; export * as motospeed from "./motospeed/index.js"; export * as rapoo from "./rapoo/index.js"; export * as fater from "./fater/index.js"; +export * as coolermaster from "./coolermaster/index.js"; diff --git a/tsconfig.json b/tsconfig.json index d8ca76f..00c126e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,7 +49,8 @@ "@openmouse/protocol/delux": ["./src/delux/index.ts"], "@openmouse/protocol/bytech": ["./src/bytech/index.ts"], "@openmouse/protocol/rapoo": ["./src/rapoo/index.ts"], - "@openmouse/protocol/fater": ["./src/fater/index.ts"] + "@openmouse/protocol/fater": ["./src/fater/index.ts"], + "@openmouse/protocol/coolermaster": ["./src/coolermaster/index.ts"] } }, "include": ["src/**/*"],