diff --git a/docs/noir-m2-nex-testing.md b/docs/noir-m2-nex-testing.md new file mode 100644 index 0000000..b227e92 --- /dev/null +++ b/docs/noir-m2-nex-testing.md @@ -0,0 +1,72 @@ +# Noir Gear M2-NEX integration note + +This note records the evidence and remaining checks for the M2-NEX support +implemented on top of the shared K-snake control protocol. It is intentionally +separate from the K-snake X11 entry because the transport is shared but the +retail identity and exposed feature set are not. + +## Identity and connection paths + +The M2-NEX reports the following WebHID control collection: + +| Path | VID | PID | Usage page | Usage | +| --- | ---: | ---: | ---: | ---: | +| USB | `0xA8A4` | `0x2255` | `0xFF01` | `0x10` | +| 2.4 GHz receiver | `0xA8A5` | `0x2255` | `0xFF01` | `0x10` | + +The product descriptor reports `M2-NEX`. The driver uses that descriptor to +label the device as `Noir Gear` while keeping the shared K-snake transport +matcher unchanged. If the descriptor is absent or reports another model, the +device remains identified as K-snake X11 rather than being guessed as M2-NEX. + +Bluetooth is not claimed by this integration. Neither the vendor configurator +nor the observed M2-NEX control collection provides a verified Bluetooth path. + +## Observed hardware evidence + +The local integration session exercised an owned retail M2-NEX and its 2.4 GHz +receiver. The observed firmware string was `2.1.7`. Read-only status, battery, +configuration, DPI stages, active DPI stage, polling rate, and the button map +were decoded successfully. DPI and polling writes were followed by a read-back +check on the receiver path. + +The vendor configuration layout observed on the device is: + +- six fixed DPI stages: `800`, `1200`, `1600`, `3200`, `5000`, `12000`; +- polling rates: `125`, `250`, `500`, and `1000 Hz`; +- auto-sleep choices from one minute through one hour; +- forward/reverse scroll direction; +- seven user-facing button slots, with an additional fixed wire slot kept + opaque by the driver; +- 32 macro slots backed by a 4096-byte macro area. + +## Deliberate safety boundaries + +- The device returned `0xFF` for the lift-off field during the M2-NEX capture. + The driver treats that value as unknown and does not expose or write LOD. +- Some M2-NEX descriptors do not advertise the optional macro-read report. + The application therefore starts with a blank local macro table and writes a + complete table on save instead of pretending that an onboard read succeeded. +- Profiles in the OpenMouse application are browser-local configuration slots. + They are not advertised as hardware profile banks and are applied explicitly + by the user. +- The protocol can frame the vendor lighting modes, but the M2-NEX application + surface currently hides lighting until the command is independently verified + on the target hardware. + +## Before opening the pull requests + +A maintainer should repeat and record the following on both USB and 2.4 GHz: + +1. connect, read status, and reconnect; +2. change one DPI stage and the active stage, then confirm after reconnect; +3. change polling rate, sleep timeout, and scroll direction, then confirm after + reconnect; +4. remap one button and restore its default function; +5. record a short macro, save it, assign it to a button, and confirm the + behavior after reconnect; +6. confirm that Bluetooth is correctly left unsupported. + +Only the hardware-tested paths should be marked verified in the driver or in +the pull-request description. Do not add vendor binaries, firmware, captures +with identifiers, or the local vendor artwork to the protocol pull request. diff --git a/src/drivers/ksnake/hid.test.ts b/src/drivers/ksnake/hid.test.ts index 47515bd..8829190 100644 --- a/src/drivers/ksnake/hid.test.ts +++ b/src/drivers/ksnake/hid.test.ts @@ -2,23 +2,38 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { KSNAKE_PRODUCT_ID, + KSNAKE_MACRO_REPORT_ID, KSNAKE_USAGE, KSNAKE_USAGE_PAGE, ksnakeDecodeBattery, ksnakeDecodeConfig, + ksnakeDecodeLightMode, ksnakeDecodeKeys, + ksnakeDecodeMacroChunk, ksnakeDecodePollingRate, ksnakeDecodeVersion, ksnakeEncodePollingRate, + ksnakeEncodeSetLightMode, ksnakeEncodeSetConfig, ksnakeEncodeSetKeys, + ksnakeEncodeMacroChunk, + ksnakeEncodeMacroCommit, ksnakeGetBatteryRequest, ksnakeGetConfigRequest, ksnakeGetKeysRequest, + ksnakeGetMacroChunkRequest, ksnakeGetVersionRequest, + ksnakeFindButtonAction, + ksnakeBindingLabel, + ksnakeEncodeMacroData, + ksnakeDecodeMacroData, + ksnakeIsKnownKeyType, ksnakeIsValidDpi, + ksnakeKeysLookPlausible, + isNoirM2NexDevice, } from "../../ksnake/index.js"; import { KsnakeHidClient } from "./hid.ts"; +import { createSupportedClient, deviceBrand } from "../registry.ts"; function fakeDevice(overrides?: Partial): HIDDevice { return { @@ -85,6 +100,15 @@ describe("ksnake codec", () => { } }); + it("frames the vendor lighting modes", () => { + assert.equal(ksnakeDecodeLightMode(6), "Breathing Loop"); + assert.equal(ksnakeDecodeLightMode(99), null); + assert.deepEqual( + [...ksnakeEncodeSetLightMode(4).slice(0, 11)], + [0x55, 0x21, 0, 0, 3, 0, 0, 0, 0, 0, 4], + ); + }); + it("encodes setConfig with vendor layout", () => { const req = ksnakeEncodeSetConfig({ lightMode: 2, @@ -111,21 +135,36 @@ describe("ksnake codec", () => { describe("KsnakeHidClient", () => { it("matches the X11 control collection", () => { assert.equal(KsnakeHidClient.isSupported(fakeDevice()), true); + assert.equal(KsnakeHidClient.isSupported(fakeDevice({ vendorId: 0xa8a4, productName: "M2-NEX" })), true); assert.equal(KsnakeHidClient.isSupported(fakeDevice({ vendorId: 0x046d })), false); assert.equal(KsnakeHidClient.isSupported(fakeDevice({ productId: 0x1234 })), false); }); + it("recognizes the M2-NEX retail identity without changing the shared transport", () => { + assert.equal(isNoirM2NexDevice(fakeDevice({ productName: "M2-NEX" })), true); + assert.equal(isNoirM2NexDevice(fakeDevice({ productName: "K-snake X11" })), false); + const client = createSupportedClient(fakeDevice({ vendorId: 0xa8a4, productName: "M2-NEX" })); + assert.ok(client instanceof KsnakeHidClient); + assert.equal(deviceBrand(client), "Noir Gear"); + }); + it("rejects unsupported polling rates without touching HID", async () => { const client = new KsnakeHidClient(fakeDevice()); await assert.rejects(() => client.setPollingRate(9999), /does not support/); }); }); -type FakeListener = (event: { data: DataView }) => void; +type FakeListener = (event: { data: DataView; reportId?: number }) => void; -function configReply(stages: number[], reportRate: number, dpiIndex: number, lodValue = 1): Uint8Array { +function configReply( + stages: number[], + reportRate: number, + dpiIndex: number, + lodValue = 1, + options: { lightMode?: number; scrollFlag?: number; sleepLight?: number; flags?: number } = {}, +): Uint8Array { const reply = new Uint8Array(64); - reply[9] = 2; + reply[9] = options.lightMode ?? 2; reply[10] = reportRate + 1; reply[11] = 6; reply[12] = dpiIndex + 1; @@ -133,13 +172,13 @@ function configReply(stages: number[], reportRate: number, dpiIndex: number, lod reply[13 + i * 2] = stage & 0xff; reply[14 + i * 2] = (stage >> 8) & 0xff; }); - reply[48] = 0; + reply[48] = options.scrollFlag ?? 0; reply[49] = lodValue; reply[50] = 53; reply[51] = 2; - reply[52] = 10; + reply[52] = options.sleepLight ?? 10; reply[53] = 0; - reply[55] = 0x11; + reply[55] = options.flags ?? 0x11; return reply; } @@ -154,7 +193,11 @@ class FakeKsnakeDevice { reportRate = 3; dpiIndex = 2; lodValue = 1; - /** 7 key slots, mirroring a retail dump (slot 4 = macro reference). */ + lightMode = 2; + scrollFlag = 0; + sleepLight = 10; + flags = 0x11; + /** 8 wire key slots, mirroring a retail dump (slot 4 = macro reference). */ keys = [ { type: 32, code1: 1, code2: 0, code3: 0 }, { type: 32, code1: 2, code2: 0, code3: 0 }, @@ -163,6 +206,7 @@ class FakeKsnakeDevice { { type: 112, code1: 0, code2: 1, code3: 3 }, { type: 33, code1: 85, code2: 0, code3: 0 }, { type: 33, code1: 56, code2: 1, code3: 0 }, + { type: 33, code1: 56, code2: 255, code3: 0 }, ]; /** Upcoming replies to swallow (simulates a sleeping dongle). */ dropReplies = 0; @@ -174,6 +218,7 @@ class FakeKsnakeDevice { badKeysOnce = false; /** Next keys reply is plausible-but-wrong once (simulates a crossed report). */ garbageKeysOnce = false; + macro = Uint8Array.from({ length: 4096 }, (_, index) => index & 0xff); sent: number[] = []; private listeners = new Map>(); @@ -198,8 +243,19 @@ class FakeKsnakeDevice { this.listeners.get(type)?.delete(listener); } - async sendReport(_reportId: number, payload: ArrayBuffer): Promise { + async sendReport(reportId: number, payload: ArrayBuffer): Promise { const body = new Uint8Array(payload); + if (reportId === KSNAKE_MACRO_REPORT_ID && body[0] === 0x0c) { + const length = body[1]; + const offset = body[2] | (body[3] << 8); + const reply = new Uint8Array(64); + reply.set(this.macro.slice(offset, offset + length), 8); + queueMicrotask(() => { + const data = new DataView(reply.buffer, reply.byteOffset, reply.byteLength); + this.listeners.get("inputreport")?.forEach((listener) => listener({ data, reportId })); + }); + return; + } this.sent.push(body[1]); let reply: Uint8Array; if (body[1] === 0x03) { @@ -218,7 +274,7 @@ class FakeKsnakeDevice { } else if (body[1] === 0x08) { reply = new Uint8Array(64); if (this.garbageKeysOnce) { - for (let i = 0; i < 7; i++) { + for (let i = 0; i < 8; i++) { reply[8 + i * 4] = 99; reply[9 + i * 4] = i; } @@ -233,7 +289,7 @@ class FakeKsnakeDevice { this.badKeysOnce = false; this.garbageKeysOnce = false; } else if (body[1] === 0x09) { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 8; i++) { this.keys[i] = { type: body[8 + i * 4], code1: body[9 + i * 4], code2: body[10 + i * 4], code3: body[11 + i * 4] }; } reply = new Uint8Array(64); @@ -243,6 +299,20 @@ class FakeKsnakeDevice { reply[10 + i * 4] = key.code2; reply[11 + i * 4] = key.code3; }); + } else if (body[1] === 0x21) { + this.lightMode = body[10]; + reply = new Uint8Array(64); + reply[1] = 0x21; + reply[10] = this.lightMode; + } else if (body[1] === 0x0d) { + const length = body[4]; + const offset = body[5] | (body[6] << 8); + this.macro.set(body.slice(8, 8 + length), offset); + reply = new Uint8Array(64); + reply[1] = 0x0d; + } else if (body[1] === 0x10) { + reply = new Uint8Array(64); + reply[1] = 0x10; } else { if (body[1] === 0x0f) { for (let i = 0; i < 6; i++) { @@ -251,8 +321,17 @@ class FakeKsnakeDevice { this.reportRate = body[10] - 1; this.dpiIndex = body[12] - 1; this.lodValue = body[49]; + this.lightMode = body[9]; + this.scrollFlag = body[48]; + this.sleepLight = body[52]; + this.flags = body[54]; } - reply = configReply(this.stages, this.reportRate, this.dpiIndex, this.lodValue); + reply = configReply(this.stages, this.reportRate, this.dpiIndex, this.lodValue, { + lightMode: this.lightMode, + scrollFlag: this.scrollFlag, + sleepLight: this.sleepLight, + flags: this.flags, + }); } queueMicrotask(() => { if (this.dropReplies > 0) { @@ -260,7 +339,7 @@ class FakeKsnakeDevice { return; } const data = new DataView(reply.buffer, reply.byteOffset, reply.byteLength); - this.listeners.get("inputreport")?.forEach((listener) => listener({ data })); + this.listeners.get("inputreport")?.forEach((listener) => listener({ data, reportId })); }); } } @@ -299,6 +378,44 @@ describe("KsnakeHidClient writes", () => { assert.equal(device.reportRate, 2); }); + it("exposes and writes the vendor sleep timeout choices", async () => { + const device = new FakeKsnakeDevice(); + const client = fastClient(device); + + assert.deepEqual(client.getSleepOptions(), [60, 180, 300, 600, 1200, 1800, 3600]); + assert.equal((await client.readStatus()).sleepTimeout, 600); + assert.equal(await client.setSleepTimeout(180), 180); + assert.equal(device.sleepLight, 3); + assert.equal((await client.readStatus()).sleepTimeout, 180); + }); + + it("exposes and writes the vendor scroll direction and lighting mode", async () => { + const device = new FakeKsnakeDevice(); + const client = fastClient(device); + const status = await client.readStatus(); + assert.equal(status.scrollDirection, "Forward"); + assert.equal(status.lighting?.mode, "Neon"); + assert.equal(await client.setScrollDirection("Reverse"), "Reverse"); + assert.equal(device.scrollFlag, 1); + const lighting = await client.setLighting({ + zone: "Mouse", + modes: ["Off", "Neon"], + mode: "Off", + color: null, + color2: null, + colorModes: [], + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + }); + assert.equal(lighting.mode, "Off"); + assert.equal(device.lightMode, 0); + const after = await client.readStatus(); + assert.equal(after.scrollDirection, "Reverse"); + assert.equal(after.lighting?.mode, "Off"); + }); + it("switches the active DPI stage and confirms it", async () => { const device = new FakeKsnakeDevice(); assert.equal(await fastClient(device).setActiveDpiStage(4), 4); @@ -319,6 +436,39 @@ describe("KsnakeHidClient writes", () => { assert.equal(device.stages[0], 600); }); + it("reads the optional macro store through report 6", async () => { + const device = new FakeKsnakeDevice(); + const data = await fastClient(device).getMacroData(); + assert.deepEqual(data, device.macro); + }); + + it("reads and writes decoded onboard macro profiles", async () => { + const device = new FakeKsnakeDevice(); + const client = fastClient(device); + const profiles = [ + { steps: [ + { type: 2 as const, action: 1 as const, delayMs: 12, code: 4 }, + { type: 2 as const, action: 2 as const, delayMs: 34, code: 4 }, + ] }, + { steps: [{ type: 3 as const, action: 1 as const, delayMs: 2, code: 1 }] }, + ]; + + await client.setMacros(profiles); + const decoded = await client.getMacros(); + assert.deepEqual(decoded.slice(0, 2), profiles); + assert.equal(decoded.slice(2).every((profile) => profile.steps.length === 0), true); + }); + + it("uploads macro chunks and waits for vendor acknowledgements", async () => { + const device = new FakeKsnakeDevice(); + const data = Uint8Array.from({ length: 60 }, (_, index) => (255 - index) & 0xff); + + await fastClient(device).setMacroData(data); + + assert.deepEqual([...device.macro.slice(0, data.length)], [...data]); + assert.deepEqual(device.sent.filter((command) => command === 0x0d || command === 0x10), [0x0d, 0x0d, 0x10]); + }); + it("retries status reads that fail validation", async () => { const device = new FakeKsnakeDevice(); device.badVersionOnce = true; @@ -336,16 +486,26 @@ describe("KsnakeHidClient writes", () => { assert.equal(device.lodValue, 2); }); + it("does not write lift-off when the firmware reports an unknown value", async () => { + const device = new FakeKsnakeDevice(); + device.lodValue = 0xff; + await assert.rejects( + () => fastClient(device).setLiftOffDistance("Low"), + /did not report a readable lift-off/, + ); + assert.ok(!device.sent.includes(0x0f)); + }); + it("rejects the unsupported medium lift-off distance", async () => { const device = new FakeKsnakeDevice(); await assert.rejects(() => fastClient(device).setLiftOffDistance("Medium"), /does not support a medium/); }); - it("decodes the 7 button slots from a keys reply", () => { + it("decodes all 8 wire key slots from a keys reply", () => { const reply = new Uint8Array(64); const slots = [ [32, 1, 0, 0], [32, 2, 0, 0], [32, 4, 0, 0], [32, 8, 0, 0], - [112, 0, 1, 3], [33, 85, 0, 0], [33, 56, 1, 0], + [112, 0, 1, 3], [33, 85, 0, 0], [33, 56, 1, 0], [33, 56, 255, 0], ]; slots.forEach(([type, c1, c2, c3], i) => { reply[8 + i * 4] = type; @@ -357,6 +517,11 @@ describe("KsnakeHidClient writes", () => { assert.equal(ksnakeDecodeKeys(new Uint8Array(10)), null); }); + it("rejects the M2-NEX all-0xff key-map sentinel", () => { + const unavailable = Array.from({ length: 8 }, () => ({ type: 0xff, code1: 0xff, code2: 0xff, code3: 0xff })); + assert.equal(ksnakeKeysLookPlausible(unavailable), false); + }); + it("encodes setKeys with the vendor layout and fixed tail", () => { const req = ksnakeEncodeSetKeys([ { type: 32, code1: 1, code2: 0, code3: 0 }, @@ -368,13 +533,65 @@ describe("KsnakeHidClient writes", () => { ]); assert.deepEqual([...req.slice(0, 5)], [0x55, 0x09, 0xa5, 0x22, 0x20]); // Wire offsets (vendor t[9..] minus the t[0] report-id placeholder): - // slot 0 type at data[8], slot 5 at data[28..31], scroll tail at data[32..39]. + // slot 0 type at data[8], slot 5 at data[28..31], wheel slots at data[32..39]. assert.deepEqual([...req.slice(8, 12)], [32, 1, 0, 0]); assert.deepEqual([...req.slice(28, 32)], [33, 85, 0, 0]); assert.deepEqual([...req.slice(32, 40)], [33, 56, 1, 0, 33, 56, 255, 0]); assert.deepEqual([...req.slice(40)], new Array(24).fill(0)); }); + it("keeps the verified vendor-control bindings byte-for-byte", () => { + assert.equal(ksnakeIsKnownKeyType(16), true); + assert.deepEqual(ksnakeFindButtonAction("Escape"), { type: 16, code1: 0, code2: 41, code3: 0 }); + assert.deepEqual(ksnakeFindButtonAction("DPI +"), { type: 240, code1: 1, code2: 1, code3: 0 }); + assert.deepEqual(ksnakeFindButtonAction("DPI -"), { type: 240, code1: 1, code2: 2, code3: 0 }); + assert.deepEqual(ksnakeFindButtonAction("Report Rate +"), { type: 240, code1: 2, code2: 1, code3: 0 }); + assert.deepEqual(ksnakeFindButtonAction("Web refresh"), { type: 48, code1: 39, code2: 2, code3: 0 }); + assert.deepEqual(ksnakeFindButtonAction("Macro 4"), { type: 112, code1: 3, code2: 0, code3: 0 }); + assert.equal(ksnakeBindingLabel({ type: 112, code1: 3, code2: 1, code3: 3 }), "Macro 4"); + }); + + it("frames and round-trips the vendor macro memory format", () => { + const profiles = [ + { + steps: [ + { type: 2 as const, action: 1 as const, delayMs: 10, code: 4 }, + { type: 2 as const, action: 2 as const, delayMs: 25, code: 4 }, + ], + }, + { steps: [] }, + { + steps: [{ type: 3 as const, action: 1 as const, delayMs: 2, code: 1 }], + }, + ]; + const data = ksnakeEncodeMacroData(profiles); + assert.equal(data.length, 68 + 12); + assert.deepEqual([...data.slice(0, 8)], [68, 0, 64, 0, 76, 0, 64, 0]); + assert.deepEqual([...data.slice(64, 72)], [0, 0, 0x80, 0, 10, 0, 0x42, 4]); + assert.deepEqual([...data.slice(72, 80)], [25, 0, 0x82, 4, 2, 0, 0xc3, 1]); + assert.deepEqual(ksnakeDecodeMacroData(data)?.slice(0, 3), profiles); + + const modifier = ksnakeEncodeMacroData([{ + steps: [{ type: 1 as const, action: 1 as const, delayMs: 5, code: 2 }], + }]); + assert.deepEqual([...modifier.slice(68, 72)], [5, 0, 0xc1, 2]); + assert.deepEqual(ksnakeDecodeMacroData(modifier)?.[0], { + steps: [{ type: 1, action: 1, delayMs: 5, code: 2 }], + }); + + const request = ksnakeGetMacroChunkRequest(56, 12); + assert.equal(request.reportId, KSNAKE_MACRO_REPORT_ID); + assert.deepEqual([...request.body.slice(0, 4)], [0x0c, 12, 56, 0]); + const reply = new Uint8Array(64); + reply.set(data.slice(56, 68), 8); + assert.deepEqual([...ksnakeDecodeMacroChunk(reply, 12)!], [...data.slice(56, 68)]); + + const chunk = ksnakeEncodeMacroChunk(64, data.slice(64, 76)); + assert.deepEqual([...chunk.slice(0, 8)], [0x55, 0x0d, 0, 0, 12, 64, 0, 0]); + assert.deepEqual([...chunk.slice(8, 20)], [...data.slice(64, 76)]); + assert.deepEqual([...ksnakeEncodeMacroCommit().slice(0, 9)], [0x55, 0x10, 0xa5, 0x22, 0, 0, 0, 0, 5]); + }); + it("writes a button map and confirms it", async () => { const device = new FakeKsnakeDevice(); device.keys = [ @@ -385,8 +602,9 @@ describe("KsnakeHidClient writes", () => { { type: 32, code1: 16, code2: 0, code3: 0 }, { type: 33, code1: 85, code2: 0, code3: 0 }, { type: 33, code1: 56, code2: 1, code3: 0 }, + { type: 33, code1: 56, code2: 255, code3: 0 }, ]; - const next = device.keys.slice(0, 6).map((key) => ({ ...key })); + const next = device.keys.map((key) => ({ ...key })); next[4] = { type: 48, code1: 233, code2: 0, code3: 0 }; // Forward -> Volume+ const returned = await fastClient(device).setKeys(next); assert.deepEqual(returned, next); @@ -394,11 +612,20 @@ describe("KsnakeHidClient writes", () => { assert.ok(device.sent.includes(0x09)); }); - it("refuses to overwrite macro bindings", async () => { + it("preserves macro bindings while remapping another button", async () => { const device = new FakeKsnakeDevice(); - const next = device.keys.slice(0, 6).map((key) => ({ ...key })); - await assert.rejects(() => fastClient(device).setKeys(next), /not remappable/); - assert.ok(!device.sent.includes(0x09)); + await fastClient(device).setButtonMapping("Backward", "Volume +"); + assert.deepEqual(device.keys[3], { type: 48, code1: 233, code2: 0, code3: 0 }); + assert.deepEqual(device.keys[4], { type: 112, code1: 0, code2: 1, code3: 3 }); + assert.ok(device.sent.includes(0x09)); + }); + + it("remaps a wheel slot without touching the fixed DPI or opposite wheel slot", async () => { + const device = new FakeKsnakeDevice(); + await fastClient(device).setButtonMapping("Scroll up", "DPI +"); + assert.deepEqual(device.keys[5], { type: 33, code1: 85, code2: 0, code3: 0 }); + assert.deepEqual(device.keys[6], { type: 240, code1: 1, code2: 1, code3: 0 }); + assert.deepEqual(device.keys[7], { type: 33, code1: 56, code2: 255, code3: 0 }); }); it("skips stray zeroed reports when reading the button map", async () => { @@ -423,11 +650,59 @@ describe("KsnakeHidClient writes", () => { Middle: "Middle click", Backward: "Backward", // The fake ships a macro reference here: opaque slots surface as-is. - Forward: "Custom (112,0,1,3)", - DPI: "DPI loop", + Forward: "Macro 1", + "Scroll up": "Scroll up", + "Scroll down": "Scroll down", }); assert.ok(status.buttonOptions?.includes("Backward")); assert.ok(status.buttonOptions?.includes("DPI loop")); + assert.ok(status.buttonOptions?.includes("DPI +")); + assert.ok(status.buttonOptions?.includes("Escape")); + }); + + it("identifies M2-NEX as Noir Gear, exposes verified lift-off, and hides its unreadable key map", async () => { + const device = new FakeKsnakeDevice(); + device.vendorId = 0xa8a4; + device.productName = "M2-NEX"; + // M2-NEX replies with lodValue 1 (Low); the adjacent tail bytes are the + // 0xff sentinels. Keep the fixture aligned with the live receiver dump. + device.lodValue = 1; + device.keys = Array.from({ length: 8 }, () => ({ type: 0xff, code1: 0xff, code2: 0xff, code3: 0xff })); + const status = await fastClient(device).readStatus(); + assert.equal(status.brand, "Noir Gear"); + assert.equal(status.name, "M2-NEX"); + assert.equal(status.connectionDetail, "Wired USB"); + assert.equal(status.liftOffDistance, "Low"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); + assert.equal(status.buttonMappings, undefined); + assert.equal(status.ui?.showAdvancedSection, false); + assert.deepEqual(status.firmware, ["M2-NEX 2.1.7"]); + + const receiver = new FakeKsnakeDevice(); + receiver.productName = "M2-NEX"; + receiver.lodValue = 1; + receiver.keys = Array.from({ length: 8 }, () => ({ type: 0xff, code1: 0xff, code2: 0xff, code3: 0xff })); + const wirelessStatus = await fastClient(receiver).readStatus(); + assert.equal(wirelessStatus.brand, "Noir Gear"); + assert.equal(wirelessStatus.connectionDetail, "2.4 GHz receiver"); + }); + + it("opens the shared Buttons tab when M2-NEX returns a readable key map", async () => { + const device = new FakeKsnakeDevice(); + device.productName = "M2-NEX"; + device.keys = [ + { type: 32, code1: 1, code2: 0, code3: 0 }, + { type: 32, code1: 2, code2: 0, code3: 0 }, + { type: 32, code1: 4, code2: 0, code3: 0 }, + { type: 32, code1: 8, code2: 0, code3: 0 }, + { type: 32, code1: 16, code2: 0, code3: 0 }, + { type: 33, code1: 85, code2: 0, code3: 0 }, + { type: 33, code1: 56, code2: 1, code3: 0 }, + { type: 33, code1: 56, code2: 255, code3: 0 }, + ]; + const status = await fastClient(device).readStatus(); + assert.equal(status.ui?.showAdvancedSection, true); + assert.equal(status.buttonMappings?.Forward, "Forward"); }); it("remaps one button by label through setButtonMapping", async () => { @@ -440,6 +715,7 @@ describe("KsnakeHidClient writes", () => { { type: 32, code1: 16, code2: 0, code3: 0 }, { type: 33, code1: 85, code2: 0, code3: 0 }, { type: 33, code1: 56, code2: 1, code3: 0 }, + { type: 33, code1: 56, code2: 255, code3: 0 }, ]; await fastClient(device).setButtonMapping("Forward", "Backward"); assert.deepEqual(device.keys[4], { type: 32, code1: 8, code2: 0, code3: 0 }); diff --git a/src/drivers/ksnake/hid.ts b/src/drivers/ksnake/hid.ts index 898b2e7..4e0bbcd 100644 --- a/src/drivers/ksnake/hid.ts +++ b/src/drivers/ksnake/hid.ts @@ -1,34 +1,52 @@ -import type { MouseStatus } from "../mouse-types.ts"; +import type { MouseLighting, MouseStatus } from "../mouse-types.ts"; import { KSNAKE_BUTTON_ACTIONS, KSNAKE_BUTTON_NAMES, + KSNAKE_BUTTON_WIRE_INDICES, + KSNAKE_MACRO_BYTES, + KSNAKE_MACRO_CHUNK_BYTES, + KSNAKE_MACRO_REPORT_ID, + KSNAKE_LIGHT_MODE_LABELS, KSNAKE_PRODUCT_ID, KSNAKE_POLLING_RATES, + KSNAKE_SLEEP_OPTIONS, KSNAKE_REPORT_ID, KSNAKE_USAGE, KSNAKE_USAGE_PAGE, KSNAKE_USB_VENDOR_ID, + NOIR_M2_NEX_BRAND, + NOIR_M2_NEX_MODEL, ksnakeDecodeBattery, ksnakeDecodeConfig, + ksnakeDecodeLightMode, ksnakeDecodeKeys, + ksnakeDecodeMacroChunk, + ksnakeDecodeMacroData, ksnakeDecodeLiftOff, ksnakeDecodePollingRate, ksnakeDecodeVersion, ksnakeEncodeLiftOff, + ksnakeEncodeLightMode, ksnakeEncodePollingRate, + ksnakeEncodeSetLightMode, ksnakeEncodeSetConfig, ksnakeEncodeSetKeys, + ksnakeEncodeMacroChunk, + ksnakeEncodeMacroCommit, + ksnakeEncodeMacroData, ksnakeGetBatteryRequest, ksnakeGetConfigRequest, ksnakeGetKeysRequest, + ksnakeGetMacroChunkRequest, ksnakeGetVersionRequest, ksnakeBindingLabel, ksnakeFindButtonAction, - ksnakeIsKnownKeyType, ksnakeIsValidDpi, ksnakeKeysLookPlausible, + isNoirM2NexDevice, type KsnakeConfig, type KsnakeKeyBinding, + type KsnakeMacroProfile, } from "../../ksnake/index.js"; import { VENDOR_ID } from "../vendors.ts"; @@ -101,6 +119,10 @@ export class KsnakeHidClient { return options; } + getSleepOptions(): number[] { + return [...KSNAKE_SLEEP_OPTIONS]; + } + async open(): Promise { if (!this.device.opened) await this.device.open(); } @@ -115,30 +137,57 @@ export class KsnakeHidClient { return started; } - private async exchange(body: Uint8Array): Promise { + private async exchange(body: Uint8Array, reportId = KSNAKE_REPORT_ID): Promise { const timeoutMs = this.replyTimeoutMs; - return this.run(async () => { - await this.open(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.device.removeEventListener("inputreport", listener); - reject(new KsnakeTimeoutError()); - }, timeoutMs); - const listener = (event: HIDInputReportEvent): void => { - clearTimeout(timer); - this.device.removeEventListener("inputreport", listener); - resolve(copyDataView(event.data)); - }; - this.device.addEventListener("inputreport", listener); - this.device.sendReport(KSNAKE_REPORT_ID, new Uint8Array(body.slice(0, 64)).buffer).catch((error: unknown) => { - clearTimeout(timer); - this.device.removeEventListener("inputreport", listener); - reject(error); - }); + return this.run(() => this.exchangeNow(body, reportId, timeoutMs)); + } + + private async exchangeNow(body: Uint8Array, reportId: number, timeoutMs = this.replyTimeoutMs): Promise { + await this.open(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.device.removeEventListener("inputreport", listener); + reject(new KsnakeTimeoutError()); + }, timeoutMs); + const listener = (event: HIDInputReportEvent): void => { + // Older test doubles omit reportId; a real WebHID event always has it. + // Filtering real events matters for macro reads because report 6 shares + // this collection with the normal report-0 command stream. + if (typeof event.reportId === "number" && event.reportId !== reportId) return; + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + resolve(copyDataView(event.data)); + }; + this.device.addEventListener("inputreport", listener); + this.device.sendReport(reportId, new Uint8Array(body.slice(0, 64)).buffer).catch((error: unknown) => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + reject(error); }); }); } + /** + * Some K-snake descriptors expose the optional macro read channel as HID + * report 6, while the M2-NEX descriptor only advertises output report 0. + * Use the descriptor instead of asking WebHID to send an undeclared report. + * Test doubles without collections retain the original report-6 behavior. + */ + private macroReadReportId(): number { + const collections = (this.device as unknown as { + collections?: Array<{ outputReports?: Array<{ reportId: number }> }>; + }).collections; + if (!collections) return KSNAKE_MACRO_REPORT_ID; + const outputReports = collections.flatMap((collection) => collection.outputReports ?? []); + // Minimal test doubles expose collections but omit the report descriptors. + // Keep the generic K-snake report-6 behavior for those; a real M2-NEX + // descriptor has outputReports: [{ reportId: 0 }] and therefore selects 0. + if (outputReports.length === 0) return KSNAKE_MACRO_REPORT_ID; + return outputReports.some((report) => report.reportId === KSNAKE_MACRO_REPORT_ID) + ? KSNAKE_MACRO_REPORT_ID + : KSNAKE_REPORT_ID; + } + /** Same as exchange, but resends on timeout (a sleeping dongle often drops the first). Non-timeout errors throw immediately. SETs are idempotent full-config writes, so resending is safe. */ private async exchangeRetrying(body: Uint8Array, attempts: number): Promise { let lastError: unknown = null; @@ -206,15 +255,27 @@ export class KsnakeHidClient { const activeStage = config ? Math.min(Math.max(config.dpiIndex, 0), Math.max(stages.length - 1, 0)) : 0; const dpi = stages[activeStage] ?? 1600; const pollingRateHz = config ? (ksnakeDecodePollingRate(config.reportRate) ?? 1000) : 1000; + const isNoirM2Nex = isNoirM2NexDevice(this.device); + const brand: MouseStatus["brand"] = isNoirM2Nex ? NOIR_M2_NEX_BRAND : "K-snake"; + const model = this.device.productName || (isNoirM2Nex ? NOIR_M2_NEX_MODEL : "K-snake X11"); + const lightingMode = config ? ksnakeDecodeLightMode(config.lightMode) : null; + const liftOffDistance = config ? ksnakeDecodeLiftOff(config.lodValue) : null; + const supportedLiftOffDistances: NonNullable = liftOffDistance + ? ["Low", "High"] + : []; return { - brand: "K-snake", - name: this.device.productName || "K-snake X11", + brand, + name: model, ui: { family: "ksnake", settingsReady: config !== null, + // K-snake has no generic advanced cards, but its readable key map + // belongs in the shared Buttons tab. Keep the tab hidden when the + // dongle only returns the erased 0xff sentinel. + showAdvancedSection: keys !== null, hideUnsupportedPollingRates: true, hideProcessingCard: true, - defaultDisplayName: this.device.productName || "K-snake X11", + defaultDisplayName: model, dpiStageEditor: { maxStages: 6, countEditable: false, @@ -233,21 +294,42 @@ export class KsnakeHidClient { activeProfile: null, connectionType: this.device.vendorId === KSNAKE_USB_VENDOR_ID ? "Wired" : "Wireless", connectionDetail: this.device.vendorId === KSNAKE_USB_VENDOR_ID ? "Wired USB" : "2.4 GHz receiver", - liftOffDistance: config ? ksnakeDecodeLiftOff(config.lodValue) : null, - supportedLiftOffDistances: ["Low", "High"], + liftOffDistance, + supportedLiftOffDistances, + sleepTimeout: config && config.sleepLight > 0 ? config.sleepLight * 60 : null, + scrollDirection: config ? (config.scrollFlag === 1 ? "Reverse" : "Forward") : null, + lighting: lightingMode ? { + zone: "Mouse", + modes: [...KSNAKE_LIGHT_MODE_LABELS], + mode: lightingMode, + color: null, + color2: null, + colorModes: [], + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + } : undefined, // Generic remap interface: the shared ButtonMappingCard renders these - // with no brand-specific code. Opaque slots surface as "Custom (…)" and - // stay selectable-visible; setButtonMapping refuses to rewrite them. + // with no brand-specific code. The fixed DPI slot is deliberately not + // exposed as a user-remappable control; opaque bindings surface as + // "Custom (…)" and are preserved during a different slot's write. buttonMappings: keys ? Object.fromEntries( - keys.slice(0, KSNAKE_BUTTON_NAMES.length).map((binding, index) => [ - KSNAKE_BUTTON_NAMES[index] as string, - ksnakeBindingLabel(binding) ?? `Custom (${binding.type},${binding.code1},${binding.code2},${binding.code3})`, - ]), + KSNAKE_BUTTON_NAMES.map((name, index) => { + const binding = keys[KSNAKE_BUTTON_WIRE_INDICES[index]]; + return [ + name, + binding + ? ksnakeBindingLabel(binding) + ?? `Custom (${binding.type},${binding.code1},${binding.code2},${binding.code3})` + : "Unknown", + ] as const; + }), ) : undefined, buttonOptions: KSNAKE_BUTTON_ACTIONS.map((action) => action.label), - firmware: version ? [`X11 ${version}`] : ["K-snake X11"], + firmware: version ? [(isNoirM2Nex ? NOIR_M2_NEX_MODEL : "X11") + " " + version] : [model], }; } @@ -306,7 +388,7 @@ export class KsnakeHidClient { return dpi; } - /** Read-only dump of the 7 button slots (GET_KEYS, reply[8..35]). */ + /** Read-only dump of all 8 key slots (GET_KEYS, reply[8..39]). */ async getKeys(): Promise { // Consensus, not first-plausible: a crossed report from another command // can decode to plausible-but-wrong slots, and two strays never agree. @@ -322,21 +404,75 @@ export class KsnakeHidClient { } /** - * Remap slots 0-5 (SET_KEYS). Only catalog types (mouse/special/media) are - * accepted — macro references and other opaque bindings are rejected rather - * than risk bricking them. Confirms by reading the map back. + * Experimental legacy read of the 4096-byte macro store. The live M2-NEX + * receiver exposes no reliable read response on its browser HID interface, + * so the OpenMouse editor intentionally does not call this method. + */ + async getMacroData(): Promise { + return this.run(async () => { + const data = new Uint8Array(KSNAKE_MACRO_BYTES); + const reportId = this.macroReadReportId(); + for (let offset = 0; offset < KSNAKE_MACRO_BYTES; offset += KSNAKE_MACRO_CHUNK_BYTES) { + const length = Math.min(KSNAKE_MACRO_CHUNK_BYTES, KSNAKE_MACRO_BYTES - offset); + const request = ksnakeGetMacroChunkRequest(offset, length); + const reply = await this.exchangeNow(request.body, reportId); + const chunk = ksnakeDecodeMacroChunk(reply, length); + if (!chunk) throw new Error(`The mouse returned an invalid macro chunk at offset ${offset}.`); + data.set(chunk, offset); + } + return data; + }); + } + + /** Experimental legacy decode of the vendor's 32 onboard macro slots. */ + async getMacros(): Promise { + const profiles = ksnakeDecodeMacroData(await this.getMacroData()); + if (!profiles) throw new Error("The mouse returned an invalid macro store."); + return profiles; + } + + /** + * Upload a raw vendor macro image and commit it. The shared OpenMouse UI does + * not call this yet, but keeping the transport here makes future macro + * editing possible without touching the already-verified key-map path. + * The vendor panel waits for an input response after every chunk and after + * the commit. Those replies confirm transport only; this firmware does not + * expose a byte-for-byte macro read-back on the live 2.4G receiver. + */ + async setMacroData(data: Uint8Array): Promise { + if (data.length > KSNAKE_MACRO_BYTES) { + throw new RangeError(`Macro data cannot exceed ${KSNAKE_MACRO_BYTES} bytes.`); + } + await this.run(async () => { + for (let offset = 0; offset < data.length; offset += KSNAKE_MACRO_CHUNK_BYTES) { + const chunk = data.slice(offset, Math.min(offset + KSNAKE_MACRO_CHUNK_BYTES, data.length)); + await this.exchangeNow(ksnakeEncodeMacroChunk(offset, chunk), KSNAKE_REPORT_ID); + } + await this.exchangeNow(ksnakeEncodeMacroCommit(), KSNAKE_REPORT_ID); + }); + } + + /** Encode and commit all onboard macro slots in one vendor transaction. */ + async setMacros(profiles: readonly KsnakeMacroProfile[]): Promise { + await this.setMacroData(ksnakeEncodeMacroData(profiles)); + } + + /** + * Write all 8 key slots (SET_KEYS). The fixed DPI slot must remain intact; + * opaque macro/custom slots are preserved rather than guessed or rebuilt. + * Confirms by reading the map back. */ async setKeys(keys: readonly KsnakeKeyBinding[]): Promise { - const slots = [...keys].slice(0, 6); - if (slots.length !== 6) throw new Error(`Exactly 6 button bindings are required (got ${keys.length}).`); + const slots = [...keys].slice(0, 8); + if (slots.length !== 8) throw new Error(`Exactly 8 key bindings are required (got ${keys.length}).`); for (const [index, key] of slots.entries()) { const bytes = [key.type, key.code1, key.code2, key.code3]; if (!bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) { throw new Error(`Button ${index + 1}: binding bytes must be 0-255.`); } - if (!ksnakeIsKnownKeyType(key.type)) { - throw new Error(`Button ${index + 1}: type ${key.type} is not remappable (macro/custom bindings are preserved, not rewritten).`); - } + } + if (!equalBinding(slots[5], { type: 33, code1: 85, code2: 0, code3: 0 })) { + throw new Error("The fixed DPI button binding may not be changed."); } await this.exchangeRetrying(ksnakeEncodeSetKeys(slots), 2); await sleep(this.settleAfterWriteMs); @@ -360,10 +496,10 @@ export class KsnakeHidClient { /** * Remap one button by display label (generic `setButtonMapping` interface). - * SET_KEYS always carries all six remappable slots, so the current map is - * re-read and the single slot replaced — confirmed by setKeys' read-back. - * Slot "Left" is locked (the vendor panel refuses drops there too); opaque - * slots elsewhere abort the write rather than risk bricking macros. + * SET_KEYS always carries all eight key slots, so the current map is + * re-read and the single user-visible slot replaced — confirmed by + * setKeys' read-back. Slot "Left" is locked (the vendor panel refuses + * drops there too). */ async setButtonMapping(button: string, actionLabel: string): Promise { const index = KSNAKE_BUTTON_NAMES.indexOf(button as (typeof KSNAKE_BUTTON_NAMES)[number]); @@ -373,8 +509,8 @@ export class KsnakeHidClient { if (!binding) throw new Error(`Unknown button action "${actionLabel}".`); const current = await this.getKeys(); if (!current) throw new Error("Could not read the current button map from the mouse."); - const slots = current.slice(0, KSNAKE_BUTTON_NAMES.length).map((slot) => ({ ...slot })); - slots[index] = binding; + const slots = current.map((slot) => ({ ...slot })); + slots[KSNAKE_BUTTON_WIRE_INDICES[index]] = binding; await this.setKeys(slots); } @@ -386,6 +522,9 @@ export class KsnakeHidClient { const raw = await this.exchangeRetrying(ksnakeGetConfigRequest(), 3).catch(() => null); const config = raw ? ksnakeDecodeConfig(raw) : null; if (!config) throw new Error("Could not read the current config from the mouse."); + if (ksnakeDecodeLiftOff(config.lodValue) === null) { + throw new Error("The mouse did not report a readable lift-off setting; changing it is disabled."); + } await this.exchangeRetrying(ksnakeEncodeSetConfig({ ...config, lodValue: encoded }), 2); await sleep(this.settleAfterWriteMs); const confirmed = await this.readBackConfig(3); @@ -407,6 +546,62 @@ export class KsnakeHidClient { if (back !== rate) throw new Error(`The mouse kept ${back ?? "?"} Hz instead of ${rate} Hz.`); return rate; } + + async setSleepTimeout(seconds: number): Promise { + if (!this.getSleepOptions().includes(seconds)) { + throw new Error(`This mouse does not support a ${seconds}-second sleep timeout.`); + } + const raw = await this.exchangeRetrying(ksnakeGetConfigRequest(), 3).catch(() => null); + const config = raw ? ksnakeDecodeConfig(raw) : null; + if (!config) throw new Error("Could not read the current config from the mouse."); + const sleepLight = seconds / 60; + await this.exchangeRetrying(ksnakeEncodeSetConfig({ ...config, sleepLight }), 2); + await sleep(this.settleAfterWriteMs); + const confirmed = await this.readBackConfig(3); + if (confirmed?.sleepLight !== sleepLight) { + throw new Error(`The mouse kept a ${confirmed?.sleepLight ?? "?"}-minute sleep timeout instead of ${sleepLight}.`); + } + return seconds; + } + + /** Sets the stored wheel direction and confirms the config byte. */ + async setScrollDirection(direction: NonNullable): Promise> { + if (direction !== "Forward" && direction !== "Reverse") { + throw new Error(`Scroll direction must be Forward or Reverse (got ${direction}).`); + } + const raw = await this.exchangeRetrying(ksnakeGetConfigRequest(), 3).catch(() => null); + const config = raw ? ksnakeDecodeConfig(raw) : null; + if (!config) throw new Error("Could not read the current config from the mouse."); + const scrollFlag = direction === "Reverse" ? 1 : 0; + await this.exchangeRetrying(ksnakeEncodeSetConfig({ ...config, scrollFlag }), 2); + await sleep(this.settleAfterWriteMs); + const confirmed = await this.readBackConfig(3); + const confirmedDirection = confirmed?.scrollFlag === 1 ? "Reverse" : confirmed?.scrollFlag === 0 ? "Forward" : null; + if (confirmedDirection !== direction) { + throw new Error(`The mouse kept ${confirmedDirection ?? "?"} scroll direction instead of ${direction}.`); + } + return direction; + } + + /** Sets the vendor lighting effect, then persists and confirms it. */ + async setLighting(lighting: MouseLighting): Promise { + if (!lighting.mode) throw new Error("A lighting mode is required."); + const lightMode = ksnakeEncodeLightMode(lighting.mode); + if (lightMode === null) throw new Error(`Unsupported K-snake lighting mode: ${lighting.mode}.`); + // The vendor panel uses this immediate command for the LED controller and + // then keeps the same value in the full config block for the next connect. + await this.exchangeRetrying(ksnakeEncodeSetLightMode(lightMode), 2); + const raw = await this.exchangeRetrying(ksnakeGetConfigRequest(), 3).catch(() => null); + const config = raw ? ksnakeDecodeConfig(raw) : null; + if (!config) throw new Error("Could not read the current config from the mouse."); + await this.exchangeRetrying(ksnakeEncodeSetConfig({ ...config, lightMode }), 2); + await sleep(this.settleAfterWriteMs); + const confirmed = await this.readBackConfig(3); + if (confirmed?.lightMode !== lightMode) { + throw new Error(`The mouse kept lighting mode ${confirmed?.lightMode ?? "?"} instead of ${lightMode}.`); + } + return { ...lighting, mode: ksnakeDecodeLightMode(lightMode)! }; + } } function copyDataView(view: DataView): Uint8Array { diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ea78f98..8ad915e 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -125,7 +125,12 @@ export type MouseLightingMode = | "Reactive" | "Breathing random" | "Breathing single" - | "Breathing dual"; + | "Breathing dual" + | "Neon" + | "Cycling Flash" + | "YOYO Ball" + | "Single Flash" + | "Breathing Loop"; export interface AtkStoredButton { id: "left" | "right" | "middle" | "back" | "forward" | "bottom"; @@ -148,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" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI"; + 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"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; @@ -408,5 +413,7 @@ export interface MouseStatus { lighting?: MouseLighting; /** Independently addressable lighting zones. `lighting` remains the first zone for compatibility. */ lightingZones?: MouseLighting[]; + /** Device-side scroll direction, when the firmware stores it. */ + scrollDirection?: "Forward" | "Reverse" | null; firmware: string[]; } diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index b18abc1..754495d 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -57,6 +57,7 @@ import { MchoseHidClient } from "./mchose/hid.ts"; import { MchoseDockHidClient } from "./mchose/dock-hid.ts"; import { MchoseA5ProMaxHidClient } from "./mchose/a5-gen1-hid.ts"; import { KsnakeHidClient } from "./ksnake/hid.ts"; +import { isNoirM2NexDevice } from "../ksnake/index.ts"; import { MicrosoftHidClient } from "./microsoft/hid.ts"; import { MotospeedHidClient } from "./motospeed/hid.ts"; import { DareuHidClient } from "./dareu/hid.ts"; @@ -179,5 +180,6 @@ export function deviceBrand(client: SupportedClient): string { if (client instanceof AtkHidClient) return client.deviceBrand(); if (client instanceof DeluxHidClient) return client.deviceBrand(); if (client instanceof AttackSharkHidClient) return client.deviceBrand(); + if (client instanceof KsnakeHidClient && isNoirM2NexDevice(client.device)) return "Noir Gear"; return driverFor(client.device)?.brand ?? "Unknown"; } diff --git a/src/ksnake/index.ts b/src/ksnake/index.ts index de0b23b..9091cfd 100644 --- a/src/ksnake/index.ts +++ b/src/ksnake/index.ts @@ -21,6 +21,19 @@ export const KSNAKE_PRODUCT_ID = 0x2255; export const KSNAKE_USAGE_PAGE = 0xff01; export const KSNAKE_USAGE = 0x10; +/** + * Noir's M2-NEX is an OEM rebrand of the same control protocol used by the + * K-snake X11. The USB and 2.4 GHz transports keep the same PID, so the + * product descriptor is the only safe way to give the UI the retail identity. + */ +export const NOIR_M2_NEX_BRAND = "Noir Gear" as const; +export const NOIR_M2_NEX_MODEL = "M2-NEX" as const; + +export function isNoirM2NexDevice(device: { productName?: string | null }): boolean { + const compactName = (device.productName ?? "").trim().toLowerCase().replace(/[\s_-]+/g, ""); + return compactName === "m2nex"; +} + export const KSNAKE_REPORT_ID = 0x00; export const KSNAKE_MAGIC = 0x55; export const KSNAKE_REPORT_SIZE = 64; @@ -56,6 +69,15 @@ const CMD = { GET_BATTERY: 0x30, } as const; +/** The vendor stores two 2 KiB macro profiles in one 4096-byte address space. */ +export const KSNAKE_MACRO_REPORT_ID = 0x06; +export const KSNAKE_MACRO_SLOT_COUNT = 32; +export const KSNAKE_MACRO_BYTES = 4096; +export const KSNAKE_MACRO_CHUNK_BYTES = 56; +export const KSNAKE_MACRO_POINTER_BYTES = KSNAKE_MACRO_SLOT_COUNT * 2; +/** Pointer table (64 bytes) plus the vendor's four-byte profile marker. */ +export const KSNAKE_MACRO_HEADER_BYTES = KSNAKE_MACRO_POINTER_BYTES + 4; + const GET_CONFIG_TAIL = [0xa5, 0x0b, 0x2f, 0x01, 0x01, 0x00, 0x00, 0x00] as const; const SET_CONFIG_HEAD = [0xae, 0x0a, 0x2f, 0x01, 0x01, 0x00, 0x00] as const; const GET_BATTERY_TAIL = [0xa5, 0x0b, 0x2e, 0x01, 0x01, 0x00, 0x00, 0x00] as const; @@ -69,6 +91,32 @@ const GET_BATTERY_TAIL = [0xa5, 0x0b, 0x2e, 0x01, 0x01, 0x00, 0x00, 0x00] as con */ export const KSNAKE_POLLING_RATES = [125, 250, 500, 1000] as const; +/** Auto-sleep choices exposed by the M2-NEX vendor panel, in seconds. */ +export const KSNAKE_SLEEP_OPTIONS = [60, 180, 300, 600, 1200, 1800, 3600] as const; + +/** Lighting effects exposed by the M2-NEX vendor panel (value -> label). */ +export const KSNAKE_LIGHT_MODE_LABELS = [ + "Off", + "Wave", + "Neon", + "Cycling Flash", + "YOYO Ball", + "Single Flash", + "Breathing Loop", +] as const; +export type KsnakeLightMode = typeof KSNAKE_LIGHT_MODE_LABELS[number]; + +export function ksnakeDecodeLightMode(value: number): KsnakeLightMode | null { + return Number.isInteger(value) && value >= 0 && value < KSNAKE_LIGHT_MODE_LABELS.length + ? KSNAKE_LIGHT_MODE_LABELS[value] + : null; +} + +export function ksnakeEncodeLightMode(mode: string): number | null { + const value = KSNAKE_LIGHT_MODE_LABELS.indexOf(mode as KsnakeLightMode); + return value < 0 ? null : value; +} + export function ksnakeEncodePollingRate(hz: number): number | null { const index = (KSNAKE_POLLING_RATES as readonly number[]).indexOf(hz); return index === -1 ? null : index; @@ -83,8 +131,10 @@ export function ksnakeDecodePollingRate(index: number): number | null { * default 1, likely 1mm/2mm on the PAW3311); they map to the Low/High stops. * Medium is not offered by the hardware. */ -export function ksnakeDecodeLiftOff(value: number): "Low" | "High" { - return value === 2 ? "High" : "Low"; +export function ksnakeDecodeLiftOff(value: number): "Low" | "High" | null { + if (value === 1) return "Low"; + if (value === 2) return "High"; + return null; } export function ksnakeEncodeLiftOff(level: string): number | null { @@ -163,6 +213,18 @@ export function ksnakeGetBatteryRequest(): Uint8Array { return buf; } +/** Immediate lighting-effect command used by the vendor panel. */ +export function ksnakeEncodeSetLightMode(mode: number): Uint8Array { + if (!Number.isInteger(mode) || mode < 0 || mode >= KSNAKE_LIGHT_MODE_LABELS.length) { + throw new RangeError(`Lighting mode must be between 0 and ${KSNAKE_LIGHT_MODE_LABELS.length - 1}.`); + } + const buf = new Uint8Array(KSNAKE_REPORT_SIZE); + // The vendor request includes report id 0 before this body. After WebHID + // strips it, the mode lands at body[10] (vendor array index 11). + buf.set([KSNAKE_MAGIC, CMD.SET_LIGHT, 0, 0, 3, 0, 0, 0, 0, 0, mode], 0); + return buf; +} + export function ksnakeDecodeBattery(reply: Uint8Array): { percent: number; charging: number } | null { if (reply.length < 10) return null; return { percent: reply[8] & 0xff, charging: reply[9] & 0xff }; @@ -242,21 +304,33 @@ export function ksnakeEncodeSetConfig(config: KsnakeConfig): Uint8Array { return buf; } -/** Physical button order for the 6 remappable slots. Labels follow the factory - * functions; the side button ships as Forward (user-renameable to Macro1). */ -export const KSNAKE_BUTTON_NAMES = ["Left", "Right", "Middle", "Backward", "Forward", "DPI"] as const; +/** + * User-visible physical controls in the order used by the shared OpenMouse + * button card. The DPI button is intentionally absent: the vendor firmware + * exposes it as a fixed slot between Forward/Backward and the wheel actions. + */ +export const KSNAKE_BUTTON_NAMES = ["Left", "Right", "Middle", "Forward", "Backward", "Scroll up", "Scroll down"] as const; + +/** Wire slots for the user-visible controls. Slot 5 is the fixed DPI button. */ +export const KSNAKE_BUTTON_WIRE_INDICES = [0, 1, 2, 4, 3, 6, 7] as const; + +const KSNAKE_FIXED_DPI_BINDING = { type: 33, code1: 85, code2: 0, code3: 0 } as const; /** Button function types from the vendor key catalog. */ export const KSNAKE_KEY_TYPE = { + keyboard: 16, mouse: 32, special: 33, media: 48, + macro: 112, + control: 240, } as const; /** One button slot: type 32 = mouse button (code1 = HID bitmask, 0 = disabled), - * 33 = special (DPI loop [85,0,0], scroll [56,1/255]), 48 = consumer/media - * (code1 = consumer usage). Macro references (e.g. type 112) are preserved - * opaquely — the catalog cannot rebuild them. */ + * 16 = keyboard usage (code1 = modifier bits, code2 = HID usage), 33 = + * special (DPI loop [85,0,0], scroll [56,1/255]), 48 = consumer/media + * (code1 = consumer usage), 112 = a macro slot (code1 = 0..31), and 240 = the + * vendor's DPI/report-rate controls. */ export interface KsnakeKeyBinding { type: number; code1: number; @@ -265,7 +339,7 @@ export interface KsnakeKeyBinding { } export function ksnakeIsKnownKeyType(type: number): boolean { - return type === KSNAKE_KEY_TYPE.mouse || type === KSNAKE_KEY_TYPE.special || type === KSNAKE_KEY_TYPE.media; + return type === KSNAKE_KEY_TYPE.keyboard || type === KSNAKE_KEY_TYPE.mouse || type === KSNAKE_KEY_TYPE.special || type === KSNAKE_KEY_TYPE.media || type === KSNAKE_KEY_TYPE.macro || type === KSNAKE_KEY_TYPE.control; } /** Remappable actions from the vendor key catalog (display label + bytes). */ @@ -276,6 +350,37 @@ export const KSNAKE_BUTTON_ACTIONS: ReadonlyArray<{ code2: number; code3: number; }> = [ + // Keyboard usages follow the vendor catalog's type-16 encoding. The + // modifier byte is zero for these single-key actions; custom combinations + // remain lossless when they are read back as opaque bindings. + { label: "Escape", type: 16, code1: 0, code2: 41, code3: 0 }, + { label: "Tab", type: 16, code1: 0, code2: 43, code3: 0 }, + { label: "Caps Lock", type: 16, code1: 0, code2: 57, code3: 0 }, + { label: "Enter", type: 16, code1: 0, code2: 40, code3: 0 }, + { label: "Space", type: 16, code1: 0, code2: 44, code3: 0 }, + { label: "Backspace", type: 16, code1: 0, code2: 42, code3: 0 }, + { label: "Insert", type: 16, code1: 0, code2: 73, code3: 0 }, + { label: "Delete", type: 16, code1: 0, code2: 76, code3: 0 }, + { label: "Home", type: 16, code1: 0, code2: 74, code3: 0 }, + { label: "End", type: 16, code1: 0, code2: 77, code3: 0 }, + { label: "Page Up", type: 16, code1: 0, code2: 75, code3: 0 }, + { label: "Page Down", type: 16, code1: 0, code2: 78, code3: 0 }, + { label: "Left Arrow", type: 16, code1: 0, code2: 80, code3: 0 }, + { label: "Right Arrow", type: 16, code1: 0, code2: 79, code3: 0 }, + { label: "Up Arrow", type: 16, code1: 0, code2: 82, code3: 0 }, + { label: "Down Arrow", type: 16, code1: 0, code2: 81, code3: 0 }, + { label: "F1", type: 16, code1: 0, code2: 58, code3: 0 }, + { label: "F2", type: 16, code1: 0, code2: 59, code3: 0 }, + { label: "F3", type: 16, code1: 0, code2: 60, code3: 0 }, + { label: "F4", type: 16, code1: 0, code2: 61, code3: 0 }, + { label: "F5", type: 16, code1: 0, code2: 62, code3: 0 }, + { label: "F6", type: 16, code1: 0, code2: 63, code3: 0 }, + { label: "F7", type: 16, code1: 0, code2: 64, code3: 0 }, + { label: "F8", type: 16, code1: 0, code2: 65, code3: 0 }, + { label: "F9", type: 16, code1: 0, code2: 66, code3: 0 }, + { label: "F10", type: 16, code1: 0, code2: 67, code3: 0 }, + { label: "F11", type: 16, code1: 0, code2: 68, code3: 0 }, + { label: "F12", type: 16, code1: 0, code2: 69, code3: 0 }, { label: "Left click", type: 32, code1: 1, code2: 0, code3: 0 }, { label: "Right click", type: 32, code1: 2, code2: 0, code3: 0 }, { label: "Middle click", type: 32, code1: 4, code2: 0, code3: 0 }, @@ -283,18 +388,53 @@ export const KSNAKE_BUTTON_ACTIONS: ReadonlyArray<{ { label: "Forward", type: 32, code1: 16, code2: 0, code3: 0 }, { label: "Disabled", type: 32, code1: 0, code2: 0, code3: 0 }, { label: "DPI loop", type: 33, code1: 85, code2: 0, code3: 0 }, + // These vendor-control bindings were captured from the installed M2-NEX + // application and read back from the live 2.4 GHz receiver. + { label: "DPI +", type: 240, code1: 1, code2: 1, code3: 0 }, + { label: "DPI -", type: 240, code1: 1, code2: 2, code3: 0 }, + { label: "Report Rate +", type: 240, code1: 2, code2: 1, code3: 0 }, { label: "Scroll up", type: 33, code1: 56, code2: 1, code3: 0 }, { label: "Scroll down", type: 33, code1: 56, code2: 255, code3: 0 }, { label: "Volume +", type: 48, code1: 233, code2: 0, code3: 0 }, { label: "Volume −", type: 48, code1: 234, code2: 0, code3: 0 }, { label: "Mute", type: 48, code1: 226, code2: 0, code3: 0 }, { label: "Play/Pause", type: 48, code1: 205, code2: 0, code3: 0 }, + { label: "Stop", type: 48, code1: 183, code2: 0, code3: 0 }, { label: "Prev track", type: 48, code1: 182, code2: 0, code3: 0 }, { label: "Next track", type: 48, code1: 181, code2: 0, code3: 0 }, + { label: "Multimedia", type: 48, code1: 131, code2: 1, code3: 0 }, + { label: "Homepage", type: 48, code1: 35, code2: 2, code3: 0 }, + { label: "Web refresh", type: 48, code1: 39, code2: 2, code3: 0 }, + { label: "Web stop", type: 48, code1: 38, code2: 2, code3: 0 }, + { label: "Web forward", type: 48, code1: 37, code2: 2, code3: 0 }, + { label: "Web backward", type: 48, code1: 36, code2: 2, code3: 0 }, + { label: "Web favorites", type: 48, code1: 42, code2: 2, code3: 0 }, + { label: "Web search", type: 48, code1: 33, code2: 2, code3: 0 }, + { label: "Calculator", type: 48, code1: 146, code2: 1, code3: 0 }, + { label: "My Computer", type: 48, code1: 148, code2: 1, code3: 0 }, + { label: "Mail", type: 48, code1: 138, code2: 1, code3: 0 }, + // Macro contents live in a separate 4096-byte store. These assignments are + // safe to expose independently: selecting one only changes the pointer on + // the button and leaves the macro buffer untouched. + ...Array.from({ length: KSNAKE_MACRO_SLOT_COUNT }, (_, slot) => ({ + label: `Macro ${slot + 1}`, + type: KSNAKE_KEY_TYPE.macro, + code1: slot, + code2: 0, + code3: 0, + })), ]; /** Display label for a binding, or null when the catalog cannot name it. */ export function ksnakeBindingLabel(binding: KsnakeKeyBinding): string | null { + if ( + binding.type === KSNAKE_KEY_TYPE.macro + && Number.isInteger(binding.code1) + && binding.code1 >= 0 + && binding.code1 < KSNAKE_MACRO_SLOT_COUNT + ) { + return `Macro ${binding.code1 + 1}`; + } return KSNAKE_BUTTON_ACTIONS.find( (action) => action.type === binding.type && @@ -310,14 +450,188 @@ export function ksnakeFindButtonAction(label: string): KsnakeKeyBinding | null { return action ? { type: action.type, code1: action.code1, code2: action.code2, code3: action.code3 } : null; } +export interface KsnakeMacroChunkRequest { + reportId: number; + body: Uint8Array; +} + +export interface KsnakeMacroStep { + /** Vendor macro type: 1 = keyboard modifier, 2 = keyboard, 3 = mouse. */ + type: 1 | 2 | 3; + /** Vendor action: 1 = press, 2 = release. */ + action: 1 | 2; + delayMs: number; + code: number; +} + +export interface KsnakeMacroProfile { + steps: readonly KsnakeMacroStep[]; +} + +function assertMacroOffset(offset: number): void { + if (!Number.isInteger(offset) || offset < 0 || offset >= KSNAKE_MACRO_BYTES) { + throw new RangeError(`Macro offset must be between 0 and ${KSNAKE_MACRO_BYTES - 1} (got ${offset}).`); + } +} + +function assertMacroChunk(offset: number, length: number): void { + assertMacroOffset(offset); + if (!Number.isInteger(length) || length < 1 || length > KSNAKE_MACRO_CHUNK_BYTES) { + throw new RangeError(`Macro chunks must contain 1-${KSNAKE_MACRO_CHUNK_BYTES} bytes (got ${length}).`); + } + if (offset + length > KSNAKE_MACRO_BYTES) { + throw new RangeError(`Macro chunk ends beyond ${KSNAKE_MACRO_BYTES} bytes.`); + } +} + +function writeLe16(target: Uint8Array, offset: number, value: number): void { + target[offset] = value & 0xff; + target[offset + 1] = (value >> 8) & 0xff; +} + +/** Build the vendor report-6 read request for one macro-memory chunk. */ +export function ksnakeGetMacroChunkRequest(offset: number, length: number): KsnakeMacroChunkRequest { + assertMacroChunk(offset, length); + const body = new Uint8Array(KSNAKE_REPORT_SIZE); + body[0] = 0x0c; + body[1] = length; + writeLe16(body, 2, offset); + return { reportId: KSNAKE_MACRO_REPORT_ID, body }; +} + +/** Decode the 8-byte vendor macro-read header and return the requested data. */ +export function ksnakeDecodeMacroChunk(reply: Uint8Array, length: number): Uint8Array | null { + if (!Number.isInteger(length) || length < 1 || length > KSNAKE_MACRO_CHUNK_BYTES) return null; + if (reply.length < 8 + length) return null; + return reply.slice(8, 8 + length); +} + +/** Build one report-0 macro-memory write chunk. */ +export function ksnakeEncodeMacroChunk(offset: number, data: Uint8Array): Uint8Array { + assertMacroChunk(offset, data.length); + const body = new Uint8Array(KSNAKE_REPORT_SIZE); + body.set([KSNAKE_MAGIC, 0x0d, 0x00, 0x00, data.length], 0); + writeLe16(body, 5, offset); + body[7] = 0; + body.set(data, 8); + return body; +} + +/** Final report-0 command that commits the previously written macro chunks. */ +export function ksnakeEncodeMacroCommit(): Uint8Array { + const body = new Uint8Array(KSNAKE_REPORT_SIZE); + body.set([KSNAKE_MAGIC, 0x10, 0xa5, 0x22, 0x00, 0x00, 0x00, 0x00, 0x05], 0); + return body; +} + +/** Vendor report-6 command for clearing the macro memory. */ +export function ksnakeResetMacroRequest(): KsnakeMacroChunkRequest { + const body = new Uint8Array(KSNAKE_REPORT_SIZE); + body.set([0x0f, 0x04], 0); + return { reportId: KSNAKE_MACRO_REPORT_ID, body }; +} + +function macroStepFlags(step: KsnakeMacroStep, last: boolean): number { + if (![1, 2, 3].includes(step.type)) throw new RangeError(`Unsupported macro step type ${step.type}.`); + if (![1, 2].includes(step.action)) throw new RangeError(`Unsupported macro action ${step.action}.`); + if (!Number.isInteger(step.delayMs) || step.delayMs < 0 || step.delayMs > 0xffff) { + throw new RangeError(`Macro delay must be an integer between 0 and 65535 ms (got ${step.delayMs}).`); + } + if (!Number.isInteger(step.code) || step.code < 0 || step.code > 0xff) { + throw new RangeError(`Macro code must be an integer between 0 and 255 (got ${step.code}).`); + } + let flags = step.action === 1 ? 0x40 : 0; + // The official configurator uses modifier=1, keyboard=2, mouse=3 in the + // low three bits of every four-byte action record. + flags |= step.type; + if (last) flags |= 0x80; + return flags; +} + +/** + * Encode the vendor's pointer table plus macro action records. Empty slots are + * omitted exactly like the official app; the result is ready to upload in + * 56-byte chunks with `ksnakeEncodeMacroChunk`. + */ +export function ksnakeEncodeMacroData(profiles: readonly KsnakeMacroProfile[]): Uint8Array { + if (profiles.length > KSNAKE_MACRO_SLOT_COUNT) { + throw new RangeError(`The mouse has only ${KSNAKE_MACRO_SLOT_COUNT} macro slots.`); + } + const data = new Uint8Array(KSNAKE_MACRO_BYTES); + let cursor = KSNAKE_MACRO_HEADER_BYTES; + // The official M2-NEX configurator writes 0x0040 for an empty slot and a + // four-byte marker immediately after the pointer table. Without these + // bytes the firmware accepts the packet but ignores the macro table. + for (let slot = 0; slot < KSNAKE_MACRO_SLOT_COUNT; slot++) { + writeLe16(data, slot * 2, KSNAKE_MACRO_POINTER_BYTES); + } + data.set([0x00, 0x00, 0x80, 0x00], KSNAKE_MACRO_POINTER_BYTES); + profiles.forEach((profile, slot) => { + if (profile.steps.length === 0) return; + if (profile.steps.length > Math.floor((KSNAKE_MACRO_BYTES - cursor) / 4)) { + throw new RangeError(`Macro ${slot + 1} is too long for the device memory.`); + } + writeLe16(data, slot * 2, cursor); + profile.steps.forEach((step, index) => { + const record = cursor + index * 4; + // Firmware treats a zero delay as an invalid/empty record. The vendor + // configurator normalizes an editor value of 0 ms to its minimum 2 ms. + writeLe16(data, record, step.delayMs === 0 ? 2 : step.delayMs); + data[record + 2] = macroStepFlags(step, index === profile.steps.length - 1); + data[record + 3] = step.code; + }); + cursor += profile.steps.length * 4; + }); + return data.slice(0, cursor); +} + +/** Decode known vendor macro records from a complete macro-memory image. */ +export function ksnakeDecodeMacroData(data: Uint8Array): KsnakeMacroProfile[] | null { + if (data.length < KSNAKE_MACRO_HEADER_BYTES || data.length > KSNAKE_MACRO_BYTES) return null; + const profiles: KsnakeMacroProfile[] = Array.from({ length: KSNAKE_MACRO_SLOT_COUNT }, () => ({ steps: [] })); + for (let slot = 0; slot < KSNAKE_MACRO_SLOT_COUNT; slot++) { + const start = le16(data[slot * 2], data[slot * 2 + 1]); + // 0 is accepted for old captures; the M2-NEX configurator uses the + // pointer-table end (0x0040) for an empty slot. + if (start === 0 || start === KSNAKE_MACRO_POINTER_BYTES) continue; + if (start < KSNAKE_MACRO_HEADER_BYTES || start + 4 > data.length) return null; + const steps: KsnakeMacroStep[] = []; + let cursor = start; + let terminated = false; + while (cursor + 4 <= data.length) { + const flags = data[cursor + 2]; + const wireType = flags & 0x07; + const type = wireType === 0x01 ? 1 : wireType === 0x02 ? 2 : wireType === 0x03 ? 3 : null; + if (type === null) return null; + const action = (flags & 0x40) !== 0 ? 1 : 2; + steps.push({ + type, + action, + delayMs: le16(data[cursor], data[cursor + 1]), + code: data[cursor + 3], + }); + cursor += 4; + if ((flags & 0x80) !== 0) { + terminated = true; + break; + } + } + if (!terminated) return null; + profiles[slot] = { steps }; + } + return profiles; +} + /** * Plausibility gate for decoded key maps. `exchange()` resolves with the next * input report, so a stray report from another command can land here; those - * decode to zeroed/garbage slot types. Real maps always carry nonzero types - * (32/33/48 catalog, plus opaque refs like macro 112). + * decode to zeroed/garbage slot types. Real maps always carry nonzero, + * non-0xff types (32/33/48/240 catalog, plus opaque refs like macro 112). + * Some M2-NEX firmware paths answer with an all-0xff sentinel instead of a + * readable map; that must not become a fake remap UI. */ export function ksnakeKeysLookPlausible(keys: readonly KsnakeKeyBinding[]): boolean { - return keys.length === 7 && keys.every((key) => key.type !== 0); + return keys.length === 8 && keys.every((key) => key.type !== 0 && key.type !== 0xff); } /** GET_KEYS request tail observed in vendor JS: [0x55, 0x08, 0xA5, 0x0B, 0x20]. */ @@ -332,15 +646,15 @@ export function ksnakeGetKeysRequest(): Uint8Array { } /** - * Decode a getKeys reply: 7 slots of 4 bytes at reply[8..35] (the vendor - * slices 8). Slot 6 is a fixed scroll-up entry; slots 0-5 are remappable. - * Verified against a retail dongle (factory map decodes to - * Left/Right/Middle/Backward + DPI loop in order). + * Decode a getKeys reply: 8 slots of 4 bytes at reply[8..39] (the vendor + * slices 8). Slot 5 is the fixed DPI button; slots 0-4, 6 and 7 are the + * user-visible controls. Verified against the installed M2-NEX application + * and its live 2.4 GHz receiver. */ export function ksnakeDecodeKeys(reply: Uint8Array): KsnakeKeyBinding[] | null { - if (reply.length < 36) return null; + if (reply.length < 40) return null; const bindings: KsnakeKeyBinding[] = []; - for (let i = 0; i < 7; i++) { + for (let i = 0; i < 8; i++) { bindings.push({ type: reply[8 + i * 4], code1: reply[9 + i * 4], @@ -353,8 +667,10 @@ export function ksnakeDecodeKeys(reply: Uint8Array): KsnakeKeyBinding[] | null { /** * Encode a setKeys request, mirroring vendor `setMouseKeys()`: head - * [0x55, 0x09, 0xA5, 0x22, 0x20], slots 0-5 at body[9..32], fixed tail - * [33,56,1,0, 33,56,255,0] at body[33..40]. + * [0x55, 0x09, 0xA5, 0x22, 0x20], followed by all eight four-byte slots at + * body[8..39]. For compatibility, a seven-entry logical map is also accepted + * and placed into the seven user-visible wire slots while retaining the fixed + * DPI binding in slot 5. */ export function ksnakeEncodeSetKeys(keys: readonly KsnakeKeyBinding[]): Uint8Array { const buf = new Uint8Array(KSNAKE_REPORT_SIZE); @@ -363,20 +679,29 @@ export function ksnakeEncodeSetKeys(keys: readonly KsnakeKeyBinding[]): Uint8Arr buf[2] = 0xa5; buf[3] = 0x22; buf[4] = 0x20; - const slots = [...keys].slice(0, 6); - while (slots.length < 6) slots.push({ type: 32, code1: 0, code2: 0, code3: 0 }); + let slots: KsnakeKeyBinding[]; + if (keys.length >= 8) { + slots = [...keys].slice(0, 8).map((key) => ({ ...key })); + } else if (keys.length === 7) { + slots = Array.from({ length: 8 }, () => ({ type: 32, code1: 0, code2: 0, code3: 0 })); + slots[5] = { ...KSNAKE_FIXED_DPI_BINDING }; + keys.forEach((key, index) => { + slots[KSNAKE_BUTTON_WIRE_INDICES[index]] = { ...key }; + }); + } else { + // Legacy callers supplied the first six raw slots; retain that encoding + // while always restoring the two fixed wheel entries at the end. + slots = [...keys].slice(0, 6).map((key) => ({ ...key })); + while (slots.length < 6) slots.push({ type: 32, code1: 0, code2: 0, code3: 0 }); + slots.push({ type: 33, code1: 56, code2: 1, code3: 0 }, { type: 33, code1: 56, code2: 255, code3: 0 }); + } // Wire offsets, NOT vendor-JS t[] indices: t[0] is the report id, so the - // request slots at t[9..32] land at data[8..31]. (buf[9..] bricked buttons.) - for (let n = 0; n < 6; n++) { - const key = slots[n]; + // request slots at t[9..40] land at data[8..39]. + slots.forEach((key, n) => { buf[8 + n * 4] = key.type & 0xff; buf[9 + n * 4] = key.code1 & 0xff; buf[10 + n * 4] = key.code2 & 0xff; buf[11 + n * 4] = key.code3 & 0xff; - } - const tail = [33, 56, 1, 0, 33, 56, 255, 0]; - tail.forEach((b, i) => { - buf[32 + i] = b; }); return buf; }