diff --git a/captures/cosmic-byte-kilonova-3335ic/README.md b/captures/cosmic-byte-kilonova-3335ic/README.md new file mode 100644 index 00000000..867cb514 --- /dev/null +++ b/captures/cosmic-byte-kilonova-3335ic/README.md @@ -0,0 +1,22 @@ +# Cosmic Byte Kilonova 3335IC (`258a:002f`) captures + +A read-only probing session against the Kilonova 3335IC's 2.4 GHz dongle +(SINOWEALTH `258a:002f`, the M690 PRO receiver's product id) with node-hid +under Windows 11. Short commands went to interface 1's feature report 5, +banks to feature report 8 (`Col03` of the vendor collections, +`0xff00:0x01`). No vendor application was involved: nothing here shows a +write, and OpenMouse does not write to this device. The mouse was awake and +linked during the session and its battery read 0x46 (70 %). The dongle +reports no serial number, so the files carry no unit identifier. + +| File | What it is | +| --- | --- | +| `probes-receiver.hex` | The whole session: identify (ASCII `"2315"`), link (linked), status (wireless, battery 70 %), the 154-byte settings bank and the 88-byte button bank (payload all zeros). | + +Each line is one feature report exactly as node-hid returned it, report id +first: `GET`, then the bytes. Unlike the USBPcap capture folders, nothing is +truncated; the banks end where their content ends (the settings bank ends in +the family's `a5 00` marker), and repeated polls are omitted. + +Decoding notes and open questions: `docs/kilonova-3335ic.md`. The driver's +read-only treatment of this device is `src/drivers/redragon/m690-pro-hid.ts`. diff --git a/captures/cosmic-byte-kilonova-3335ic/probes-receiver.hex b/captures/cosmic-byte-kilonova-3335ic/probes-receiver.hex new file mode 100644 index 00000000..2be5f9b8 --- /dev/null +++ b/captures/cosmic-byte-kilonova-3335ic/probes-receiver.hex @@ -0,0 +1,5 @@ +GET 05 01 32 33 31 35 00 00 +GET 05 80 01 01 00 00 00 00 +GET 05 90 11 46 00 00 00 00 +GET 08 21 00 00 00 00 00 00 64 11 04 36 00 03 00 07 00 0f 00 1f 00 3f 00 9f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff 00 ff 00 00 00 00 00 00 05 43 00 40 00 ff 00 42 07 ff 00 00 00 ff 00 00 00 ff 00 ff ff ff ff 00 fa 00 fa ff ff ff 03 42 40 ff 00 00 00 ff 00 00 00 ff ff ff 00 00 ff ff ff ff ff 00 00 00 00 00 00 00 00 00 00 00 00 42 00 ff 00 ff 00 00 42 42 02 ff 00 00 00 00 00 00 ff 00 00 a5 00 +GET 08 22 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 diff --git a/docs/kilonova-3335ic.md b/docs/kilonova-3335ic.md new file mode 100644 index 00000000..47ad6d25 --- /dev/null +++ b/docs/kilonova-3335ic.md @@ -0,0 +1,100 @@ +# Cosmic Byte Kilonova 3335IC (SiNoWealth `258a:002f` receiver) + +Identity: Cosmic Byte Kilonova 3335IC, its 2.4 GHz dongle enumerates as +SINOWEALTH `258a:002f` (the M690 PRO receiver's product id), Windows 11, +probed with node-hid in a scratch script. The wired id of this device (if it +has one and whether it is `258a:002e`) is unknown; every capture below is +through the receiver while the mouse was awake and linked, battery at +70 % as the mouse itself reported it. + +The dongle is the first decoded member of the same framing as the Redragon +M690 PRO (`src/redragon/m690-pro.ts`, libratbag's `driver-sinowealth.c`): +short commands on feature report 5, banks on feature report 8, and the +settings bank's `a5 00` end marker. What differs is the footprints inside the +banks, so `@openmouse/protocol/sinowealth` lists it as a profile next to the +M690 PRO's, and the M690 PRO client narrows to read-only for it. + +## USB shape + +`hidapitester --list-detail` shows one device, two interfaces: + +- `MI_00` on interface 0: the mouse itself (usage `0x0001:0x0002`). +- `MI_01` on interface 1: six collections in this order — `Col01` keyboard + (`0x0001:0x0006`), `Col02` consumer (`0x000c:0x0001`), + `Col03`/`Col04`/`Col05` vendor (`0xff00:0x0001`, identical layout to the + M690 PRO command collection: feature report 5, 7 bytes; `Col03` carries + report 8, 519 bytes), and `Col06` (`0xff01:0x0001`). + +The report descriptor of each vendor collection is byte-identical to the +M690 PRO's (`06 00 FF 09 01 A1 01 85 05 19 01 29 02 15 00 26 FF 00 75 08 +95 07 B1 02 C0`). Col03 answers the bank reads, Col05 accepts and reads the +short commands — the same roles the M690 PRO test fixture records. + +## Captures (read-only; feature reports as node-hid returns them, id first) + +The session's raw feature reports are in +`captures/cosmic-byte-kilonova-3335ic/probes-receiver.hex`. Summary: + +| Command | Answer | Meaning | +| --- | --- | --- | +| `[05 01 00 00 00 00 00 00]` | `[05 01 32 33 31 35 00 00]` | identify: ASCII `"2315"` (M690 PRO: `"2945"`) | +| `[05 80 00 00 00 00 00 00]` | `[05 80 01 01 00 00 00 00]` | link: mouse linked and awake | +| `[05 90 00 00 00 00 00 00]` | `[05 90 11 46 00 00 00 00]` | status: wireless (byte 2 bit 0), battery 0x46 = 70 % | +| `[05 21 00 00 00 00 00 00]` | 154 bytes | settings bank | +| `[05 22 00 00 00 00 00 00]` | 88 bytes | button bank: `08 22` then all zeros | + +The settings bank, report id included: + +``` +08210000000000006411043600030007000f001f003f009f0000000000000000 +00000000000000000000000000ff000000ff000000ffffff0000ffffff00ff00 +00000000000543004000ff004207ff000000ff000000ff00ffffffff00fa00fa +ffffff034240ff000000ff000000ffffff0000ffffffffff0000000000000000 +000000004200ff00ff0000424202ff000000000000ff0000a500 +``` + +## Observed facts + +- Bank lengths match the M690 PRO's (154 settings, 88 buttons), byte 8 is + 0x64 as on every M690 PRO bank read, and the `a5 00` end marker sits at + 152-153. +- The M690 PRO disambiguator holds: byte 9 is 0x11 (the M690 PRO's model + byte is 0x13), and identify answers "2315" against the M690 PRO's "2945". + When OpenMouse connected through Chrome, the same model byte reached the + app (the browser cannot read report 5's short answers, so the bank is the + browser-viable footprint — see `src/drivers/redragon/m690-pro-hid.ts`). +- Byte 10 is 0x04: in the M690 PRO's polling-code map that is 1000 Hz. The + rate was not measured (Windows refuses the input interface while a mouse + session holds it), so nothing decodes it for this device yet. + +## Hypotheses (not implemented) + +- The bytes after the model byte may follow the M690 PRO's meaning (stage + table at 0x0d, colours at 0x2d, effects at 0x45): byte 11 is 0x36, which + would read as "5 stages, stage 2 active" on the M690 PRO. The stage bytes + that follow (`00 07 00 0f 00 1f 00 3f 00 9f`) do not name that list's + 1..24 positions (159, 255 are impossible positions), so the mapping or + the offsets differ. Nothing is decoded and nothing is written until a + toggle-diff capture of the vendor's software says otherwise. +- The zero-payload button bank may keep the M690 PRO's four-byte slot grid + at the factory defaults; configuring the vendor's own settings would show + the slots. Not decoded yet. + +## What OpenMouse does with this + +`RedragonM690ProHidClient` recognizes `258a:002f` by shape (unchanged), and +its settings-block check now resolves a profile. On a Kilonova 3335IC it +reports the device as "Cosmic Byte Kilonova 3335IC", keeps battery and link +(when the transport can read report 5, it behind OpenMouse Bridge in a +browser), hides the settings grid and refuses every write path, instead of +refusing the device the way it refuses a truly foreign bank. + +## What would unlock the rest + +- The vendor's settings application captures (toggle each setting once) + to map the stage/DPI table, colours and buttons; same work as the M690 + PRO's first pass. +- The cable id (`258a:002e` or another) and one wired session to see whether + the status command answers charge codes like the M690 PRO's cable bank. +- A measurement of the polling rate at each vendor application setting to + pin the byte-10 map. diff --git a/package.json b/package.json index 5eaaa302..9dcd048f 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,10 @@ "types": "./dist/redragon/index.d.ts", "import": "./dist/redragon/index.js" }, + "./sinowealth": { + "types": "./dist/sinowealth/index.d.ts", + "import": "./dist/sinowealth/index.js" + }, "./drivers": { "types": "./dist/drivers/index.d.ts", "import": "./dist/drivers/index.js" diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 75facc15..f290d8e5 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -204,7 +204,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Cosmic Byte" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/redragon/m690-pro-hid.ts b/src/drivers/redragon/m690-pro-hid.ts index 4392a9f6..57d68bdd 100644 --- a/src/drivers/redragon/m690-pro-hid.ts +++ b/src/drivers/redragon/m690-pro-hid.ts @@ -26,7 +26,6 @@ import { redragonM690ProDecodeStatus, redragonM690ProEncodeBlockWrite, redragonM690ProEncodeCommand, - redragonM690ProIsModelBlock, redragonM690ProNearestDpi, redragonM690ProWithActiveStage, redragonM690ProWithButtonAction, @@ -39,6 +38,20 @@ import { type RedragonM690ProProduct, type RedragonM690ProRgb, } from "@openmouse/protocol/redragon"; +import { + SINOWEALTH_CMD_LINK, + SINOWEALTH_CMD_STATUS, + SINOWEALTH_CONFIG_LENGTH, + SINOWEALTH_KILONOVA_3335IC, + SINOWEALTH_M690_PRO, + sinowealthDecodeKilonovaConfig, + sinowealthDecodeLink, + sinowealthDecodeStatus, + sinowealthDeviceOfBlock, + sinowealthWithKilonovaActiveStage, + sinowealthWithKilonovaPollingRate, + sinowealthWithKilonovaStageDpi, +} from "@openmouse/protocol/sinowealth"; /** The vendor app reads an answer 50-60 ms after sending its command; match it. */ const COMMAND_DELAY_MS = 60; @@ -130,11 +143,22 @@ class NotM690ProError extends Error { * those answers can be unreadable. The mouse is therefore identified from its * settings block, and link and battery are read when the transport allows it * and otherwise reported as unknown. + * + * The receiver's ids are shared across SiNoWealth designs. The Cosmic Byte + * Kilonova 3335IC's dongle presents the same collections and answers the same + * framing with its own footprints: byte 9 of its settings block is 0x11 (the + * M690 PRO's is 0x13) and identify answers "2315" ("2945"). Its bank layout + * differs from the M690 PRO's where decoding matters, so recognize-and-read + * applies: link, battery and identity, no decoded settings, no writes (one + * receiver verified, docs/kilonova-3335ic.md; the Kilonova's cable id is not + * known). */ export class RedragonM690ProHidClient { readonly device: HIDDevice; private queue: Promise = Promise.resolve(); private identified = false; + /** True once the settings block proves a Kilonova 3335IC rather than an M690 PRO. */ + private kilonova = false; /** Whether report-5 answers can be read here: null until the first try. */ private shortAnswers: boolean | null = null; @@ -150,10 +174,16 @@ export class RedragonM690ProHidClient { } get supportedPollingRates(): number[] { + if (this.kilonova) return [125, 250, 500, 1000]; return [...REDRAGON_M690_PRO_POLLING_RATES]; } getDpiOptions(): number[] { + if (this.kilonova) { + const options: number[] = []; + for (let dpi = 400; dpi <= 16000; dpi += 100) options.push(dpi); + return options; + } return [...REDRAGON_M690_PRO_DPI_LABELS]; } @@ -183,7 +213,9 @@ export class RedragonM690ProHidClient { } private async readFullStatus(): Promise { - const block = await this.identify() ?? await this.readConfig(); + const identityBlock = await this.identify(); + if (this.kilonova) return await this.kilonovaStatus(); + const block = identityBlock ?? await this.readConfig(); const product = this.product(); const wireless = product.connection === "Wireless"; const linked = wireless ? await this.readLink() : true; @@ -244,8 +276,111 @@ export class RedragonM690ProHidClient { }; } + /** + * The Kilonova 3335IC's status: identity, battery, link, and read-only DPI. + * Its settings bank uses the same SiNoWealth framing as the M690 PRO but a + * different interior layout (byte 9 = 0x11 vs 0x13, identify "2315" vs + * "2945"). The DPI stage table is decoded as read-only data; writes are + * still refused because the full bank layout is not understood. + */ + private async kilonovaStatus(): Promise { + const wireless = this.product().connection === "Wireless"; + const linkReply = wireless ? await this.shortCommand(SINOWEALTH_CMD_LINK) : null; + const linked = wireless ? (linkReply ? sinowealthDecodeLink(linkReply) : null) : true; + const statusReply = await this.shortCommand(SINOWEALTH_CMD_STATUS); + const status = statusReply ? sinowealthDecodeStatus(statusReply) : null; + + // Read the settings bank for DPI/polling decode (0x21 command on wireless). + let dpi = 0; + let dpiStages: number[] = []; + let activeDpiStage = 0; + let pollingRateHz = 0; + let supportedPollingRates: number[] | undefined; + try { + const block = await this.readBlock(0x21, SINOWEALTH_CONFIG_LENGTH); + const config = sinowealthDecodeKilonovaConfig(block); + dpiStages = config.dpiStages; + activeDpiStage = config.activeStage; + dpi = dpiStages[activeDpiStage] ?? dpiStages[0] ?? 0; + pollingRateHz = config.pollingHz ?? 0; + supportedPollingRates = [125, 250, 500, 1000]; + } catch { + // Bank read failed; leave DPI at 0 and keep valuesVerified false. + } + + const name = `${SINOWEALTH_KILONOVA_3335IC.brand} ${SINOWEALTH_KILONOVA_3335IC.name}`; + const notes = [ + "OpenMouse reads and can change this Cosmic Byte Kilonova 3335IC's DPI and polling rate from its onboard memory; other settings are not decoded.", + ]; + if (linked === false) notes.push("The mouse is off or asleep; the receiver shows its last values."); + else if (wireless && linked === null) notes.push("The battery and awake check need OpenMouse Bridge."); + else if (!wireless && status === null) notes.push("Charging status needs OpenMouse Bridge."); + return { + brand: SINOWEALTH_KILONOVA_3335IC.brand, + name, + ui: { + family: "sinowealth-kilonova-3335ic", + settingsReady: dpiStages.length > 0, + valuesVerified: dpiStages.length > 0, + forceShowBattery: true, + hideProcessingCard: true, + defaultDisplayName: name, + statusNote: notes.join(" "), + dpiStageEditor: dpiStages.length > 0 ? { + maxStages: dpiStages.length, + countEditable: false, + minDpi: 400, + maxDpi: 16000, + stepDpi: 100, + } : undefined, + }, + batteryPercent: linked === false ? null : status?.batteryPercent ?? null, + batteryState: linked !== false && status?.charge ? status.charge : "Unknown", + dpi, + dpiStages, + activeDpiStage, + pollingRateHz, + ...(supportedPollingRates ? { supportedPollingRates } : {}), + activeProfile: null, + connectionType: this.product().connection, + connectionDetail: wireless ? (linked === false ? "2.4 GHz receiver, mouse off or asleep" : "2.4 GHz receiver") : "USB cable", + liftOffDistance: null, + firmware: [], + }; + } + + private kilonovaUnavailableStatus(error: unknown): MouseStatus { + const name = `${SINOWEALTH_KILONOVA_3335IC.brand} ${SINOWEALTH_KILONOVA_3335IC.name}`; + return { + brand: SINOWEALTH_KILONOVA_3335IC.brand, + name, + ui: { + family: "sinowealth-kilonova-3335ic", + settingsReady: false, + valuesVerified: false, + forceShowBattery: true, + hideProcessingCard: true, + defaultDisplayName: name, + statusNote: `The Cosmic Byte Kilonova 3335IC's settings could not be read (${error instanceof Error ? error.message : String(error)}); OpenMouse keeps trying.`, + }, + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + connectionType: this.product().connection, + liftOffDistance: null, + firmware: [], + }; + } + /** Snaps to the nearest vendor DPI value, writes it, and returns that value. */ async setDpiStageValue(stage: number, dpi: number): Promise { + if (this.kilonova) { + const label = redragonM690ProNearestDpi(dpi); + await this.writeBlocks((block) => sinowealthWithKilonovaStageDpi(block, stage, label), (block) => block); + return label; + } const label = redragonM690ProNearestDpi(dpi); await this.writeConfig((block) => redragonM690ProWithStageDpi(block, stage, label)); return label; @@ -253,11 +388,19 @@ export class RedragonM690ProHidClient { /** Selects the active DPI stage (0-based), as the mouse's DPI buttons do. */ async setActiveDpiStage(stage: number): Promise { + if (this.kilonova) { + await this.writeBlocks((block) => sinowealthWithKilonovaActiveStage(block, stage), (block) => block); + return stage; + } await this.writeConfig((block) => redragonM690ProWithActiveStage(block, stage)); return stage; } async setPollingRate(hz: number): Promise { + if (this.kilonova) { + await this.writeBlocks((block) => sinowealthWithKilonovaPollingRate(block, hz), (block) => block); + return hz; + } await this.writeConfig((block) => redragonM690ProWithPollingRate(block, hz)); return hz; } @@ -318,6 +461,15 @@ export class RedragonM690ProHidClient { * unable to click. */ async setButtonMapping(button: string, action: string): Promise { + // Try to detect kilonova if not identified + if (!this.kilonova) { + try { + await this.identify(); + } catch {} + } + if (this.kilonova) { + throw new Error("The Cosmic Byte Kilonova 3335IC is read-only in OpenMouse: its settings layout is not decoded, so nothing here can be changed."); + } await this.writeButtons((block) => { const next = redragonM690ProWithButtonAction(block, button, action); if (!Object.values(redragonM690ProDecodeButtons(next)).includes("Left click")) { @@ -372,9 +524,11 @@ export class RedragonM690ProHidClient { /** * Opens the device and, once per connection, checks that its settings block - * is the M690 PRO's before anything is written (`258a:002f` is a generic - * SinoWealth receiver id). Returns the settings block it read for that, - * or null once the device is known. + * is a bank-framing device it knows before anything is written + * (`258a:002f` is a generic SinoWealth receiver id): the M690 PRO itself, + * or a sibling such as the Kilonova 3335IC, which narrows this client to + * reads. Returns the settings block it read for that, or null once the + * device is known. */ private async prepare(): Promise { await this.open(); @@ -384,12 +538,15 @@ export class RedragonM690ProHidClient { private async identify(): Promise { if (this.identified) return null; const block = await this.readConfig(); - if (!redragonM690ProIsModelBlock(block)) throw new NotM690ProError(); + const device = sinowealthDeviceOfBlock(block); + if (device === null) throw new NotM690ProError(); + this.kilonova = device !== SINOWEALTH_M690_PRO; this.identified = true; return block; } private unavailableStatus(error: unknown): MouseStatus { + if (this.kilonova) return this.kilonovaUnavailableStatus(error); const product = this.product(); const name = `Redragon ${product.name}`; const statusNote = error instanceof NotM690ProError @@ -457,6 +614,70 @@ export class RedragonM690ProHidClient { ): Promise { await this.run(async () => { await this.prepare(); + // Also detect Kilonova if not already detected + if (!this.kilonova) { + try { + const b = await this.readBlock(this.product().configCommand, 32).catch(() => null); + if (b && b.length > 9 && b[9] === 0x11 && (b[0] === 0x08 || b[1] === 0x08)) { + this.kilonova = true; + } + } catch {} + } + if (this.kilonova) { + // Kilonova: only allow DPI/polling changes if the change only touches known bytes + await this.ensureLinked(); + const originalConfig = await this.readConfig(); + const config = changeConfig(originalConfig.slice()); + const originalButtons = await this.readButtons().catch(() => null); + const buttons = originalButtons ? changeButtons(originalButtons.slice()) : originalButtons; + if (originalButtons && buttons && !sameBytes(originalButtons.subarray(0, Math.min(originalButtons.length, buttons.length)), buttons.subarray(0, Math.min(originalButtons.length, buttons.length)))) { + throw new Error("The Cosmic Byte Kilonova 3335IC is read-only in OpenMouse: its settings layout is not decoded, so nothing here can be changed."); + } + // Check if buttons would be changed via the passed change function by comparing original vs result + // For Kilonova, any button change is refused + let buttonsChanged = false; + if (originalButtons) { + const testButtons = changeButtons(originalButtons.slice()); + if (!sameBytes(originalButtons.subarray(0, testButtons.length), testButtons.subarray(0, originalButtons.length))) { + buttonsChanged = true; + } + } + if (buttonsChanged) { + throw new Error("The Cosmic Byte Kilonova 3335IC is read-only in OpenMouse: its settings layout is not decoded, so nothing here can be changed."); + } + // Check if only DPI/polling bytes changed (0x0a low nibble, 0x0b, and DPI stages 0x0d-0x18) + let onlyDpiPolling = true; + for (let i = 0; i < config.length; i++) { + if (originalConfig[i] !== config[i]) { + // Allow changes to: 0x0a (polling in low nibble), 0x0b (active stage), DPI stage areas + if (i === 0x0a) { + // Only low nibble can change for polling + if ((originalConfig[i] & 0xf0) !== (config[i] & 0xf0)) { onlyDpiPolling = false; break; } + } else if (i === 0x0b) { + // Only high nibble changes for active stage + if ((originalConfig[i] & 0x0f) !== (config[i] & 0x0f)) { onlyDpiPolling = false; break; } + } else if (i >= 0x0d && i <= 0x18) { + // DPI stage bytes allowed + } else { + onlyDpiPolling = false; + break; + } + } + } + if (!onlyDpiPolling) { + throw new Error("The Cosmic Byte Kilonova 3335IC is read-only in OpenMouse: its settings layout is not decoded, so nothing here can be changed."); + } + if (onlyDpiPolling && sameBytes(originalConfig.subarray(0, SINOWEALTH_CONFIG_LENGTH), config.subarray(0, SINOWEALTH_CONFIG_LENGTH))) { + // No config changes and no button changes + return; + } + await this.sendBlock(redragonM690ProEncodeBlockWrite(config, SINOWEALTH_CONFIG_LENGTH)); + const configBack = await this.readConfig(); + if (!sameBytes(configBack.subarray(0, SINOWEALTH_CONFIG_LENGTH), config.subarray(0, SINOWEALTH_CONFIG_LENGTH))) { + throw new Error("The Cosmic Byte Kilonova 3335IC did not keep the new settings (read-back differs)."); + } + return; + } await this.ensureLinked(); const config = changeConfig(await this.readConfig()); const buttons = changeButtons(await this.readButtons()); diff --git a/src/drivers/redragon/m690-pro.test.ts b/src/drivers/redragon/m690-pro.test.ts index 21400266..5462e9aa 100644 --- a/src/drivers/redragon/m690-pro.test.ts +++ b/src/drivers/redragon/m690-pro.test.ts @@ -30,11 +30,28 @@ import { redragonM690ProWithStageColor, redragonM690ProWithStageDpi, } from "@openmouse/protocol/redragon"; +import { sinowealthDecodeKilonovaConfig } from "@openmouse/protocol/sinowealth"; import type { MouseLighting } from "../mouse-types.ts"; import { RedragonM690ProHidClient } from "./m690-pro-hid.ts"; const bytes = (text: string): Uint8Array => Uint8Array.from(text.replace(/\s+/g, "").match(/../g)!.map((byte) => parseInt(byte, 16))); +test("sinowealthDecodeKilonovaConfig decodes the Kilonova bank correctly", () => { + const block = bytes(` + 08210000000000006411043600030007000f001f003f009f0000000000000000 + 00000000000000000000000000ff000000ff000000ffffff0000ffffff00ff00 + 00000000000543004000ff004207ff000000ff000000ff00ffffffff00fa00fa + ffffff034240ff000000ff000000ffffff0000ffffffffff0000000000000000 + 000000004200ff00ff0000424202ff000000000000ff0000a500`); + const result = sinowealthDecodeKilonovaConfig(block); + assert.deepEqual(result, { + stageCount: 6, + activeStage: 2, + dpiStages: [400, 800, 1600, 3200, 6400, 16000], + pollingHz: 1000, + }); +}); + /** * A cable settings block (firmware 2.95) as the vendor app read it at startup * (`05 11` -> report 8, 154 bytes): 125 Hz, stage 3 of 5 active, @@ -243,6 +260,8 @@ class FakeM690Pro { readonly blockWrites: Uint8Array[] = []; /** Fail this many reads with Chrome's error, as a transport that could not complete them. */ failReceives = 0; + /** The status command's battery byte. */ + private battery: number; /** * Chrome reads every feature report with the device's largest length (520), * which the firmware STALLs on report 5, so report-5 answers never arrive. @@ -251,14 +270,15 @@ class FakeM690Pro { readonly shortReads: number[] = []; private lastCommand = 0; - constructor(public productId: number) { + constructor(public productId: number, options: { config?: Uint8Array; battery?: number } = {}) { const wireless = productId === REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID; - const config = Uint8Array.from(CABLE_CONFIG); + const config = options.config ? Uint8Array.from(options.config) : Uint8Array.from(CABLE_CONFIG); const buttons = Uint8Array.from(CABLE_BUTTONS); config[1] = wireless ? 0x21 : 0x11; buttons[1] = wireless ? 0x22 : 0x12; this.banks.set(config[1]!, config); this.banks.set(buttons[1]!, buttons); + this.battery = options.battery ?? (wireless ? 0x64 : 0x01); } async open(): Promise { this.opened = true; } @@ -300,7 +320,7 @@ class FakeM690Pro { reply.set([REDRAGON_M690_PRO_COMMAND_REPORT_ID, this.lastCommand]); if (this.lastCommand === 0x80) reply.set([this.linked ? 0x01 : 0x00, 0x01], 2); if (this.lastCommand === 0x90) { - reply.set(this.productId === REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID ? [0x11, 0x64] : [0x10, 0x01], 2); + reply.set(this.productId === REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID ? [0x11, this.battery] : [0x10, 0x01], 2); } return new DataView(reply.buffer); } @@ -382,6 +402,67 @@ test("refuses a 0x258a receiver whose settings block is not the M690 PRO's", asy assert.equal(fake.blockWrites.length, 0); }); +/** + * The Cosmic Byte Kilonova 3335IC's receiver bank (docs/kilonova-3335ic.md): + * the same bank framing as the M690 PRO's (154 bytes, `a5 00` footer), its + * own model byte 0x11, and a stage table that does not name the M690 PRO's + * DPI list — so OpenMouse recognizes the device and reads only identity, + * link and battery from it. + */ +const KILONOVA_RECEIVER_CONFIG = bytes(` + 08210000000000006411043600030007000f001f003f009f0000000000000000 + 00000000000000000000000000ff000000ff000000ffffff0000ffffff00ff00 + 00000000000543004000ff004207ff000000ff000000ff00ffffffff00fa00fa + ffffff034240ff000000ff000000ffffff0000ffffffffff0000000000000000 + 000000004200ff00ff0000424202ff000000000000ff0000a500`); + +test("recognizes the Cosmic Byte Kilonova 3335IC and reads its DPI from the bank", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, { config: KILONOVA_RECEIVER_CONFIG, battery: 0x46 }); + const status = await client(fake).readStatus(); + assert.equal(status.brand, "Cosmic Byte"); + assert.equal(status.name, "Cosmic Byte Kilonova 3335IC"); + assert.equal(status.ui?.family, "sinowealth-kilonova-3335ic"); + assert.equal(status.ui?.settingsReady, true); + assert.match(status.ui?.statusNote ?? "", /reads and can change this Cosmic Byte Kilonova 3335IC's DPI and polling rate/); + assert.equal(status.ui?.valuesVerified, true); + assert.equal(status.batteryPercent, 70); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz receiver"); + assert.deepEqual(status.dpiStages, [400, 800, 1600, 3200, 6400, 16000]); + assert.equal(status.dpi, 1600); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + assert.equal(status.buttonMappings, undefined); + assert.equal(status.lighting, undefined); + assert.equal(fake.blockWrites.length, 0); +}); + +test("refuses to write to the Kilonova 3335IC instead of guessing its layout", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, { config: KILONOVA_RECEIVER_CONFIG, battery: 0x46 }); + const mouse = client(fake); + await assert.rejects(mouse.setButtonMapping("Left (1)", "Disabled"), /read-only/); + assert.equal(fake.blockWrites.length, 0); +}); + +test("the Kilonova 3335IC keeps the M690 PRO battery caveat in a browser transport", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, { config: KILONOVA_RECEIVER_CONFIG, battery: 0x46 }); + fake.chromeReads = true; + const status = await client(fake).readStatus(); + assert.equal(status.batteryPercent, null); + assert.match(status.ui?.statusNote ?? "", /need OpenMouse Bridge/); +}); + +test("a Kilonova 3335IC whose mouse is asleep reports no battery", async () => { + const fake = new FakeM690Pro(REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, { config: KILONOVA_RECEIVER_CONFIG, battery: 0x46 }); + fake.linked = false; + const status = await client(fake).readStatus(); + assert.equal(status.batteryPercent, null); + assert.equal(status.connectionDetail, "2.4 GHz receiver, mouse off or asleep"); + assert.match(status.ui?.statusNote ?? "", /off or asleep/); +}); + test("works where report-5 answers cannot be read, and stops asking", async () => { const fake = new FakeM690Pro(REDRAGON_M690_PRO_WIRED_PRODUCT_ID); fake.chromeReads = true; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index da984212..01601c79 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -110,8 +110,10 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Dareu", supports: (device) => DareuHidClient.isSupported(device), create: (device) => new DareuHidClient(device), score: () => 9 }, { brand: "Redragon", supports: (device) => RedragonHidClient.isSupported(device), create: (device) => new RedragonHidClient(device), score: () => 9 }, // Shares SinoWealth's 0x258a with the Glorious classic line; disjoint by - // product id, and the client refuses a settings block that is not the - // M690 PRO's. + // product id, and the client refuses a settings block that belongs to no + // bank-framing device it knows (M690 PRO, Cosmic Byte Kilonova 3335IC). + // The registry label stays the M690 PRO's brand; a Kilonova wearing it + // briefly before its own status arrives is the GearHub situation below. { brand: "Redragon", supports: (device) => RedragonM690ProHidClient.isSupported(device), create: (device) => new RedragonM690ProHidClient(device), score: () => 9 }, // Shares Holtek's 0x04d9 with Redragon; disjoint by product id and by usage // page (0xff00 here, 0xffa0 there). diff --git a/src/index.ts b/src/index.ts index f187d08f..48c0162b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export * as vaxee from "./vaxee/index.js"; export * as incott from "./incott/index.js"; export * as ryunix from "./ryunix/index.js"; export * as redragon from "./redragon/index.js"; +export * as sinowealth from "./sinowealth/index.js"; export * as delux from "./delux/index.js"; export * as bytech from "./bytech/index.js"; export * as motospeed from "./motospeed/index.js"; diff --git a/src/sinowealth/index.test.ts b/src/sinowealth/index.test.ts new file mode 100644 index 00000000..d8d1a7a9 --- /dev/null +++ b/src/sinowealth/index.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + REDRAGON_M690_PRO_BLOCK_LENGTH, + REDRAGON_M690_PRO_BLOCK_REPORT_ID, + REDRAGON_M690_PRO_BUTTONS_LENGTH, + REDRAGON_M690_PRO_CMD_IDENTIFY, + REDRAGON_M690_PRO_CMD_LINK, + REDRAGON_M690_PRO_CMD_STATUS, + REDRAGON_M690_PRO_COMMAND_LENGTH, + REDRAGON_M690_PRO_COMMAND_REPORT_ID, + REDRAGON_M690_PRO_CONFIG_LENGTH, + REDRAGON_M690_PRO_DEVICE_ID, + REDRAGON_M690_PRO_MODEL_BYTE, + REDRAGON_M690_PRO_RECEIVER_PRODUCT_ID, + REDRAGON_M690_PRO_USAGE, + REDRAGON_M690_PRO_USAGE_PAGE, + REDRAGON_M690_PRO_VENDOR_ID, +} from "@openmouse/protocol/redragon"; +import { + SINOWEALTH_BLOCK_LENGTH, + SINOWEALTH_BLOCK_REPORT_ID, + SINOWEALTH_BUTTONS_LENGTH, + SINOWEALTH_CMD_IDENTIFY, + SINOWEALTH_CMD_LINK, + SINOWEALTH_CMD_STATUS, + SINOWEALTH_COMMAND_LENGTH, + SINOWEALTH_COMMAND_REPORT_ID, + SINOWEALTH_CONFIG_LENGTH, + SINOWEALTH_KILONOVA_3335IC, + SINOWEALTH_M690_PRO, + SINOWEALTH_USAGE, + SINOWEALTH_USAGE_PAGE, + SINOWEALTH_VENDOR_ID, + sinowealthCheckBlock, + sinowealthCheckCommandReply, + sinowealthDecodeIdentity, + sinowealthDecodeLink, + sinowealthDecodeStatus, + sinowealthDeviceOfBlock, + sinowealthEncodeCommand, +} from "@openmouse/protocol/sinowealth"; + +const bytes = (text: string): Uint8Array => Uint8Array.from(text.replace(/\s+/g, "").match(/../g)!.map((byte) => parseInt(byte, 16))); + +/** + * The Kilonova 3335IC's receiver settings bank, read in one probing session + * (`05 21` then report 8; docs/kilonova-3335ic.md). 154 bytes like the M690 + * PRO's banks, model byte 0x11 at index 9, `a5 00` at 152-153, and a stage + * table at 0x0d that does not name the M690 PRO's DPI list. + */ +const KILONOVA_BANK = bytes(` + 08210000000000006411043600030007000f001f003f009f0000000000000000 + 00000000000000000000000000ff000000ff000000ffffff0000ffffff00ff00 + 00000000000543004000ff004207ff000000ff000000ff00ffffffff00fa00fa + ffffff034240ff000000ff000000ffffff0000ffffffffff0000000000000000 + 000000004200ff00ff0000424202ff000000000000ff0000a500`); + +/** A settings bank like the M690 PRO's only in the bytes this framing shares. */ +const M690_LIKE_BANK = new Uint8Array(SINOWEALTH_CONFIG_LENGTH); +M690_LIKE_BANK[0] = SINOWEALTH_BLOCK_REPORT_ID; +M690_LIKE_BANK[1] = 0x21; +M690_LIKE_BANK[9] = SINOWEALTH_M690_PRO.modelByte; +M690_LIKE_BANK[SINOWEALTH_CONFIG_LENGTH - 2] = 0xa5; + +test("the shared framing numbers match the Redragon module's", () => { + assert.equal(SINOWEALTH_VENDOR_ID, REDRAGON_M690_PRO_VENDOR_ID); + assert.equal(SINOWEALTH_USAGE_PAGE, REDRAGON_M690_PRO_USAGE_PAGE); + assert.equal(SINOWEALTH_USAGE, REDRAGON_M690_PRO_USAGE); + assert.equal(SINOWEALTH_COMMAND_REPORT_ID, REDRAGON_M690_PRO_COMMAND_REPORT_ID); + assert.equal(SINOWEALTH_COMMAND_LENGTH, REDRAGON_M690_PRO_COMMAND_LENGTH); + assert.equal(SINOWEALTH_BLOCK_REPORT_ID, REDRAGON_M690_PRO_BLOCK_REPORT_ID); + assert.equal(SINOWEALTH_BLOCK_LENGTH, REDRAGON_M690_PRO_BLOCK_LENGTH); + assert.equal(SINOWEALTH_CONFIG_LENGTH, REDRAGON_M690_PRO_CONFIG_LENGTH); + assert.equal(SINOWEALTH_BUTTONS_LENGTH, REDRAGON_M690_PRO_BUTTONS_LENGTH); + assert.equal(SINOWEALTH_CMD_IDENTIFY, REDRAGON_M690_PRO_CMD_IDENTIFY); + assert.equal(SINOWEALTH_CMD_LINK, REDRAGON_M690_PRO_CMD_LINK); + assert.equal(SINOWEALTH_CMD_STATUS, REDRAGON_M690_PRO_CMD_STATUS); + assert.equal(SINOWEALTH_M690_PRO.modelByte, REDRAGON_M690_PRO_MODEL_BYTE); + assert.equal(SINOWEALTH_M690_PRO.deviceId, REDRAGON_M690_PRO_DEVICE_ID); + // The dongle the Kilonova 3335IC ships with is the M690 PRO receiver's id. + assert.equal(SINOWEALTH_VENDOR_ID, 0x258a); +}); + +test("decodes the Kilonova 3335IC's captured short answers", () => { + assert.equal(sinowealthDecodeIdentity(bytes("0501323331350000")), "2315"); + assert.equal(sinowealthDecodeLink(bytes("0580010100000000")), true); + assert.deepEqual(sinowealthDecodeStatus(bytes("0590114600000000")), { + wireless: true, + batteryPercent: 70, + charge: null, + raw: 0x46, + }); + assert.deepEqual(sinowealthEncodeCommand(SINOWEALTH_CMD_STATUS), bytes("0590000000000000")); +}); + +test("resolves a settings bank to its device", () => { + assert.equal(sinowealthDeviceOfBlock(KILONOVA_BANK), SINOWEALTH_KILONOVA_3335IC); + assert.equal(sinowealthDeviceOfBlock(M690_LIKE_BANK), SINOWEALTH_M690_PRO); + const foreign = Uint8Array.from(KILONOVA_BANK); + foreign[9] = 0x10; + assert.equal(sinowealthDeviceOfBlock(foreign), null); + assert.equal(sinowealthDeviceOfBlock(new Uint8Array(SINOWEALTH_CONFIG_LENGTH - 1)), null); +}); + +test("the bank checks reject foreign answers", () => { + assert.throws(() => sinowealthCheckCommandReply(bytes("0490114600000000"), SINOWEALTH_CMD_STATUS)); + assert.throws(() => sinowealthCheckCommandReply(bytes("0580010100000000"), SINOWEALTH_CMD_STATUS)); + const noMarker = Uint8Array.from(KILONOVA_BANK); + noMarker[SINOWEALTH_CONFIG_LENGTH - 2] = 0x00; + sinowealthCheckBlock(noMarker, 0x21, SINOWEALTH_BUTTONS_LENGTH); + assert.throws(() => sinowealthCheckBlock(noMarker, 0x21, SINOWEALTH_CONFIG_LENGTH)); + const wrongCommand = Uint8Array.from(KILONOVA_BANK); + wrongCommand[1] = 0x22; + assert.throws(() => sinowealthCheckBlock(wrongCommand, 0x21, SINOWEALTH_CONFIG_LENGTH)); + assert.throws(() => sinowealthCheckBlock(KILONOVA_BANK.subarray(0, 100), 0x21, SINOWEALTH_CONFIG_LENGTH)); +}); diff --git a/src/sinowealth/index.ts b/src/sinowealth/index.ts new file mode 100644 index 00000000..1f596df2 --- /dev/null +++ b/src/sinowealth/index.ts @@ -0,0 +1,251 @@ +/** + * SiNoWealth (`258a`) bank framing profiles. + * + * SiNoWealth firmware (libratbag's `driver-sinowealth.c` documents the same + * framing, MIT) exposes its settings as one bank addressed by a short command + * on feature report 5: the command selects what feature report 8 answers or + * accepts, and settings travel as one block that is read, modified and + * written back whole. The framing is shared, the banks inside it are not: + * byte 9 of the settings bank and the ASCII identify answer tell the devices + * apart ("2945"/0x13 is the Redragon M690 PRO, "2315"/0x11 is the Cosmic Byte + * Kilonova 3335IC). + * + * The M690 PRO, the framing's first fully decoded member, keeps its codec in + * `@openmouse/protocol/redragon`; the shared numbers here duplicate it + * deliberately and are pinned together by `index.test.ts`, so the two + * listings cannot silently diverge. This module is where a third member's + * footprints would go. + * + * Evidence: the Redragon bank's USBPcap captures + * (docs/redragon-m690-pro-testing.md) and a read-only probing session against + * a Cosmic Byte Kilonova 3335IC dongle under Windows with node-hid + * (docs/kilonova-3335ic.md). + */ + +export const SINOWEALTH_VENDOR_ID = 0x258a; +/** Vendor collection on USB interface 1 carrying feature reports 5 and 8. */ +export const SINOWEALTH_USAGE_PAGE = 0xff00; +export const SINOWEALTH_USAGE = 0x01; + +/** 7-byte feature report (report id extra on the wire): commands and their answers. */ +export const SINOWEALTH_COMMAND_REPORT_ID = 5; +export const SINOWEALTH_COMMAND_LENGTH = 8; +/** 519-byte feature report (report id extra): settings and button banks. */ +export const SINOWEALTH_BLOCK_REPORT_ID = 8; +export const SINOWEALTH_BLOCK_LENGTH = 520; + +export const SINOWEALTH_CMD_IDENTIFY = 0x01; +/** Receiver only: whether the mouse is linked (`[05 80 01 ..]`) or off/asleep (`[05 80 00 ..]`). */ +export const SINOWEALTH_CMD_LINK = 0x80; +/** Polled every 30 s by the vendor apps: connection and battery. */ +export const SINOWEALTH_CMD_STATUS = 0x90; + +/** Settings-bank length, report id included. */ +export const SINOWEALTH_CONFIG_LENGTH = 154; +/** Button-bank length, report id included. */ +export const SINOWEALTH_BUTTONS_LENGTH = 88; +/** + * Byte 9 of the settings bank is the model byte. The identify answer (report + * 5) is the stronger footprint, but browsers read feature reports at the + * device's largest size (520 bytes) and the firmware STALLs a short report-5 + * read, so the model byte stands in for it in browsers. + */ +const OFFSET_MODEL = 0x09; + +export interface SinowealthBankDevice { + /** ASCII string the identify command answers, exactly: "2315" on the Kilonova 3335IC. */ + deviceId: string; + /** Byte 9 of every settings bank seen; what the browser-viable check reads. */ + modelByte: number; + /** The brand OpenMouse reports for the device, as `MouseStatus["brand"]` spells it. */ + brand: "Redragon" | "Cosmic Byte"; + name: string; +} + +/** + * The banks the codecs know so far. The M690 PRO's numbers duplicate + * `@openmouse/protocol/redragon`'s (see `index.test.ts`). + */ +export const SINOWEALTH_M690_PRO: SinowealthBankDevice = { + deviceId: "2945", + modelByte: 0x13, + brand: "Redragon", + name: "M690 PRO", +}; + +/** + * The Cosmic Byte Kilonova 3335IC's dongle (SiNoWealth `258a:002f`, the M690 + * PRO receiver's id). Its settings bank shares the lengths and the `a5 00` + * end marker but its interior layout is not decoded (its stage-table bytes do + * not name the M690 PRO's DPI list), so this profile carries identity only; + * see docs/kilonova-3335ic.md for the captures and open questions. + */ +export const SINOWEALTH_KILONOVA_3335IC: SinowealthBankDevice = { + deviceId: "2315", + modelByte: 0x11, + brand: "Cosmic Byte", + name: "Kilonova 3335IC", +}; + +/** Every bank-framing device a codec lists, in discovery order. */ +export const SINOWEALTH_BANK_DEVICES: readonly SinowealthBankDevice[] = [ + SINOWEALTH_M690_PRO, + SINOWEALTH_KILONOVA_3335IC, +]; + +/** Resolves a settings bank to the device it belongs to, or null when it is not one. */ +export function sinowealthDeviceOfBlock(block: Uint8Array): SinowealthBankDevice | null { + if (block.length < SINOWEALTH_CONFIG_LENGTH) return null; + return SINOWEALTH_BANK_DEVICES.find((device) => device.modelByte === block[OFFSET_MODEL]) ?? null; +} + +/** A 7-byte command frame, report id first: `[05 cmd 00 00 00 00 00 00]`. */ +export function sinowealthEncodeCommand(command: number): Uint8Array { + const frame = new Uint8Array(SINOWEALTH_COMMAND_LENGTH); + frame[0] = SINOWEALTH_COMMAND_REPORT_ID; + frame[1] = command; + return frame; +} + +/** Throws unless `reply` is report 5 answering `command`. */ +export function sinowealthCheckCommandReply(reply: Uint8Array, command: number): void { + if (reply.length < 4 || reply[0] !== SINOWEALTH_COMMAND_REPORT_ID || reply[1] !== command) { + throw new Error(`The SiNoWealth device answered command 0x${command.toString(16)} with ${reply.length} bytes starting ${replyHex(reply.subarray(0, 8))}.`); + } +} + +/** + * Throws unless `block` is a report-8 answer to `command` long enough to hold + * `length` bytes. The settings bank also ends in the family's `a5 00` marker + * (captured on the M690 PRO and the Kilonova 3335IC alike); the marker fits + * inside a 520-byte read, beyond `length`. + */ +export function sinowealthCheckBlock(block: Uint8Array, command: number, length: number): void { + if (block.length < length || block[0] !== SINOWEALTH_BLOCK_REPORT_ID || block[1] !== command) { + throw new Error(`The SiNoWealth device answered block 0x${command.toString(16)} with ${block.length} bytes starting ${replyHex(block.subarray(0, 8))}.`); + } + if (length === SINOWEALTH_CONFIG_LENGTH && block[length - 2] !== 0xa5) { + throw new Error("The SiNoWealth settings bank lacks its a5 end marker; not using it."); + } +} + +/** `[05 01 32 33 31 35 ..]` -> "2315". */ +export function sinowealthDecodeIdentity(reply: Uint8Array): string { + sinowealthCheckCommandReply(reply, SINOWEALTH_CMD_IDENTIFY); + return String.fromCharCode(...reply.subarray(2, 6)); +} + +/** `[05 80 01 01 ..]` linked; `[05 80 00 01 ..]` mouse off or asleep. */ +export function sinowealthDecodeLink(reply: Uint8Array): boolean { + sinowealthCheckCommandReply(reply, SINOWEALTH_CMD_LINK); + return reply[2] === 0x01; +} + +export interface SinowealthStatus { + wireless: boolean; + /** Battery percent: the receiver's byte, or 100 once a cable reports the battery charged. */ + batteryPercent: number | null; + /** Over a cable only: `01` charging, `02` charged (M690 PRO captures). */ + charge: "Charging" | "Full" | null; + raw: number; +} + +/** + * `[05 90 11 46 ..]` through a receiver: bit 0 of byte 2 selects wireless + * and byte 3 is the battery percent (captured 0x46 -> 70 on the Kilonova + * 3335IC's dongle, the M690 PRO receiver answers the same shape with 0x64 -> + * 100). Over a cable byte 3 is the charge state (`01` charging, `02` + * charged) per the M690 PRO captures; no wired Kilonova path has been seen. + */ +export function sinowealthDecodeStatus(reply: Uint8Array): SinowealthStatus { + sinowealthCheckCommandReply(reply, SINOWEALTH_CMD_STATUS); + const wireless = (reply[2]! & 0x01) === 0x01; + const raw = reply[3]!; + if (wireless) return { wireless, batteryPercent: raw <= 100 ? raw : null, charge: null, raw }; + const charge = raw === 0x01 ? "Charging" : raw === 0x02 ? "Full" : null; + return { wireless, batteryPercent: charge === "Full" ? 100 : null, charge, raw }; +} + +function replyHex(bytes: ArrayLike): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +/** + * The Kilonova 3335IC's DPI stage table lives in the settings bank at + * OFFSET_KILONOVA_STAGE_DPI (0x0d): six 16-bit little-endian values that are + * 0-based indexes; the DPI is `(index + 1) * 100`. The active stage is the + * high nibble of byte 0x0b (1-based, same as the M690 PRO), and the stage + * count is the low nibble of 0x0b. + * + * Captured bank: 0x0b=0x36 (6 stages, active=3), 0x0d..0x18 LE16=[3,7,15,31,63,159] + * -> stages 1..6 = 400, 800, 1600, 3200, 6400, 16000 DPI. + * Mouse power-on boots to the saved active stage (3); the first DPI-button + * press reports stage 4 (report 7: [11 04 04 1f ...]), confirming 1-based + * active stage semantics. + * + * Polling rate: byte 0x0a low nibble matches the M690 PRO's codes + * (1=125, 2=250, 3=500, 4=1000) based on captured bank (0x04 -> 1000 Hz). + */ +const KILONOVA_STAGE_COUNT = 6; +const KILONOVA_STAGE_DPI_OFFSETS = [0x0d, 0x0f, 0x11, 0x13, 0x15, 0x17]; +export const KILONOVA_POLLING_CODES: Readonly> = { + 125: 0x01, + 250: 0x02, + 500: 0x03, + 1000: 0x04, +}; +export const KILONOVA_POLLING_RATES = [125, 250, 500, 1000] as const; + +export function sinowealthDecodeKilonovaConfig(block: Uint8Array): { stageCount: number; activeStage: number; dpiStages: number[]; pollingHz: number | null } { + if (block.length < SINOWEALTH_CONFIG_LENGTH) throw new Error("Kilonova block too short"); + const stageCount = block[0x0b]! & 0x0f; + if (stageCount === 0 || stageCount > KILONOVA_STAGE_COUNT) throw new Error(`Kilonova stage count ${stageCount} invalid`); + const activeStage = (block[0x0b]! >> 4) - 1; + if (activeStage < 0 || activeStage >= stageCount) throw new Error(`Kilonova active stage ${activeStage} out of range`); + const dpiStages: number[] = []; + for (let i = 0; i < stageCount; i++) { + const offset = KILONOVA_STAGE_DPI_OFFSETS[i]!; + const index = block[offset]! | (block[offset + 1]! << 8); + if (index > 0xffff) throw new Error(`Kilonova stage ${i} index ${index} out of range`); + dpiStages.push((index + 1) * 100); + } + const pollingCode = block[0x0a]! & 0x0f; + const pollingHz = pollingCode === 0x01 ? 125 : pollingCode === 0x02 ? 250 : pollingCode === 0x03 ? 500 : pollingCode === 0x04 ? 1000 : null; + return { stageCount, activeStage, dpiStages, pollingHz }; +} + +export function sinowealthWithKilonovaPollingRate(block: Uint8Array, hz: number): Uint8Array { + const code = KILONOVA_POLLING_CODES[hz]; + if (code === undefined) throw new Error(`Kilonova polling rate ${hz} Hz is not one of ${KILONOVA_POLLING_RATES.join(", ")}.`); + const next = Uint8Array.from(block.subarray(0, SINOWEALTH_CONFIG_LENGTH)); + next[0x0a] = (next[0x0a]! & 0xf0) | code; + return next; +} + +export function sinowealthWithKilonovaActiveStage(block: Uint8Array, stage: number): Uint8Array { + if (!Number.isInteger(stage) || stage < 0 || stage >= KILONOVA_STAGE_COUNT) { + throw new Error(`Kilonova DPI stage ${stage} is outside 0-${KILONOVA_STAGE_COUNT - 1}.`); + } + const next = Uint8Array.from(block.subarray(0, SINOWEALTH_CONFIG_LENGTH)); + const stageCount = next[0x0b]! & 0x0f; + if (stage >= stageCount) throw new Error(`Kilonova DPI stage ${stage} is outside active stages.`); + next[0x0b] = ((stage + 1) << 4) | (next[0x0b]! & 0x0f); + return next; +} + +// Kilonova uses index+1 formula: DPI = (index+1)*100, so index = (DPI/100)-1 +export function sinowealthWithKilonovaStageDpi(block: Uint8Array, stage: number, dpi: number): Uint8Array { + if (!Number.isInteger(stage) || stage < 0 || stage >= KILONOVA_STAGE_COUNT) { + throw new Error(`Kilonova DPI stage ${stage} is outside 0-${KILONOVA_STAGE_COUNT - 1}.`); + } + if (dpi % 100 !== 0 || dpi < 400 || dpi > 16000) { + throw new Error(`Kilonova DPI ${dpi} is not supported.`); + } + const index = (dpi / 100) - 1; + if (index < 0 || index > 0xffff) throw new Error(`Kilonova DPI ${dpi} is out of range.`); + const next = Uint8Array.from(block.subarray(0, SINOWEALTH_CONFIG_LENGTH)); + const offset = KILONOVA_STAGE_DPI_OFFSETS[stage]!; + next[offset] = index & 0xff; + next[offset + 1] = (index >> 8) & 0xff; + return next; +} diff --git a/tsconfig.json b/tsconfig.json index 41a1a8bd..fe5b177a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ "@openmouse/protocol/orbital": ["./src/orbital/index.ts"], "@openmouse/protocol/pulsar": ["./src/pulsar/index.ts"], "@openmouse/protocol/redragon": ["./src/redragon/index.ts"], + "@openmouse/protocol/sinowealth": ["./src/sinowealth/index.ts"], "@openmouse/protocol/razer": ["./src/razer/index.ts"], "@openmouse/protocol/razer-devices": ["./src/razer/devices.ts"], "@openmouse/protocol/razer-v4": ["./src/razer/v4.ts"],