From e4dbd2644506844779acbdb60a97f91ad04138c8 Mon Sep 17 00:00:00 2001 From: m4milaad Date: Fri, 23 Oct 2026 19:00:08 +0530 Subject: [PATCH 1/4] feat(sinowealth): support the Cosmic Byte Kilonova 3335IC through its receiver Its dongle enumerates as SinoWealth 258a:002f, the M690 PRO receiver's product id, and answers the same bank framing with its own footprints (identify "2315", settings-bank model byte 0x11). RedragonM690ProHidClient now resolves a profile from the settings block: the M690 PRO keeps its full behavior, the Kilonova 3335IC is recognized and read for identity, link and battery, and every write path is refused. Nothing past the model byte of the Kilonova's bank is decoded, and no write has been tried on hardware. Evidence: captures/cosmic-byte-kilonova-3335ic/ (node-hid, read-only) and docs/kilonova-3335ic.md. The framing numbers shared with the M690 PRO are pinned together by src/sinowealth/index.test.ts. --- .../cosmic-byte-kilonova-3335ic/README.md | 22 +++ .../probes-receiver.hex | 5 + docs/kilonova-3335ic.md | 100 ++++++++++ package.json | 4 + src/drivers/mouse-types.ts | 2 +- src/drivers/redragon/m690-pro-hid.ts | 110 ++++++++++- src/drivers/redragon/m690-pro.test.ts | 67 ++++++- src/drivers/registry.ts | 6 +- src/index.ts | 1 + src/sinowealth/index.test.ts | 118 ++++++++++++ src/sinowealth/index.ts | 171 ++++++++++++++++++ tsconfig.json | 1 + 12 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 captures/cosmic-byte-kilonova-3335ic/README.md create mode 100644 captures/cosmic-byte-kilonova-3335ic/probes-receiver.hex create mode 100644 docs/kilonova-3335ic.md create mode 100644 src/sinowealth/index.test.ts create mode 100644 src/sinowealth/index.ts 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..9f982993 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,15 @@ import { type RedragonM690ProProduct, type RedragonM690ProRgb, } from "@openmouse/protocol/redragon"; +import { + SINOWEALTH_CMD_LINK, + SINOWEALTH_CMD_STATUS, + SINOWEALTH_KILONOVA_3335IC, + SINOWEALTH_M690_PRO, + sinowealthDecodeLink, + sinowealthDecodeStatus, + sinowealthDeviceOfBlock, +} 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 +138,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; @@ -183,7 +202,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,6 +265,75 @@ export class RedragonM690ProHidClient { }; } + /** + * The Kilonova 3335IC's status: identity, battery and link, no settings. + * Its framing answers link and battery like the M690 PRO's receiver (see + * `sinowealthDecodeStatus`), and its settings bank is left alone + * deliberately: nothing past the model byte is decoded, and writing an + * M690 PRO layout onto it would corrupt its settings. + */ + 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; + const name = `${SINOWEALTH_KILONOVA_3335IC.brand} ${SINOWEALTH_KILONOVA_3335IC.name}`; + const notes = [ + "OpenMouse recognizes this receiver as a Cosmic Byte Kilonova 3335IC and leaves its settings untouched: the settings layout is not decoded yet.", + ]; + 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: false, + valuesVerified: false, + forceShowBattery: true, + hideProcessingCard: true, + defaultDisplayName: name, + statusNote: notes.join(" "), + }, + batteryPercent: linked === false ? null : status?.batteryPercent ?? null, + batteryState: linked !== false && status?.charge ? status.charge : "Unknown", + dpi: 0, + pollingRateHz: 0, + 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 { const label = redragonM690ProNearestDpi(dpi); @@ -372,9 +462,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 +476,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 +552,9 @@ export class RedragonM690ProHidClient { ): Promise { await this.run(async () => { await this.prepare(); + 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.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..8d32e093 100644 --- a/src/drivers/redragon/m690-pro.test.ts +++ b/src/drivers/redragon/m690-pro.test.ts @@ -243,6 +243,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 +253,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 +303,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 +385,64 @@ 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 it without touching its 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, false); + assert.match(status.ui?.statusNote ?? "", /leaves its settings untouched/); + assert.equal(status.batteryPercent, 70); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz receiver"); + assert.equal(status.dpi, 0); + assert.equal(status.dpiStages, undefined); + 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.setPollingRate(1000), /read-only/); + 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..23acced4 --- /dev/null +++ b/src/sinowealth/index.ts @@ -0,0 +1,171 @@ +/** + * 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(" "); +} 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"], From eb7c582cbbf62506a0454728798a83b545ec0f01 Mon Sep 17 00:00:00 2001 From: m4milaad Date: Fri, 23 Oct 2026 21:08:40 +0530 Subject: [PATCH 2/4] feat(sinowealth): decode the Kilonova 3335IC DPI stages read-only The Kilonova 3335IC's settings bank is still not decoded enough to write, but its DPI table is now understood and can be reported as read-only data: - The stage table lives at 0x0d in the settings bank: six 16-bit little-endian values where the DPI is (index + 1) * 100. The active stage is the high nibble of byte 0x0b (1-based) and the stage count is the low nibble, the same semantics as the M690 PRO. - Evidence: the mouse's own DPI button reports each stage change on input report 7 ([11 04 stage byte ..]) through OpenMouse Bridge; pressing through all six stages reproduced the bank's exact values, and a power-cycle confirmed the mouse boots to the saved active stage. The driver now reports dpi, dpiStages and activeDpiStage with valuesVerified: true while settingsReady stays false, so the app shows the DPI read-out but still refuses every write. Adds a sinowealthDecodeKilonovaConfig unit test and updates the recognition test. --- src/drivers/redragon/m690-pro-hid.ts | 35 +++++++++++++++++++++------ src/drivers/redragon/m690-pro.test.ts | 27 ++++++++++++++++++--- src/sinowealth/index.ts | 32 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/drivers/redragon/m690-pro-hid.ts b/src/drivers/redragon/m690-pro-hid.ts index 9f982993..ca7aa73c 100644 --- a/src/drivers/redragon/m690-pro-hid.ts +++ b/src/drivers/redragon/m690-pro-hid.ts @@ -41,8 +41,10 @@ import { import { SINOWEALTH_CMD_LINK, SINOWEALTH_CMD_STATUS, + SINOWEALTH_CONFIG_LENGTH, SINOWEALTH_KILONOVA_3335IC, SINOWEALTH_M690_PRO, + sinowealthDecodeKilonovaConfig, sinowealthDecodeLink, sinowealthDecodeStatus, sinowealthDeviceOfBlock, @@ -266,11 +268,11 @@ export class RedragonM690ProHidClient { } /** - * The Kilonova 3335IC's status: identity, battery and link, no settings. - * Its framing answers link and battery like the M690 PRO's receiver (see - * `sinowealthDecodeStatus`), and its settings bank is left alone - * deliberately: nothing past the model byte is decoded, and writing an - * M690 PRO layout onto it would corrupt its settings. + * 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"; @@ -278,9 +280,24 @@ export class RedragonM690ProHidClient { 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 decode (0x21 command on wireless). + let dpi = 0; + let dpiStages: number[] = []; + let activeDpiStage = 0; + 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; + } 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 recognizes this receiver as a Cosmic Byte Kilonova 3335IC and leaves its settings untouched: the settings layout is not decoded yet.", + "OpenMouse reads this Cosmic Byte Kilonova 3335IC's DPI from its onboard memory; other settings are not decoded and changes are refused.", ]; 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."); @@ -291,7 +308,7 @@ export class RedragonM690ProHidClient { ui: { family: "sinowealth-kilonova-3335ic", settingsReady: false, - valuesVerified: false, + valuesVerified: dpiStages.length > 0, forceShowBattery: true, hideProcessingCard: true, defaultDisplayName: name, @@ -299,7 +316,9 @@ export class RedragonM690ProHidClient { }, batteryPercent: linked === false ? null : status?.batteryPercent ?? null, batteryState: linked !== false && status?.charge ? status.charge : "Unknown", - dpi: 0, + dpi, + dpiStages, + activeDpiStage, pollingRateHz: 0, activeProfile: null, connectionType: this.product().connection, diff --git a/src/drivers/redragon/m690-pro.test.ts b/src/drivers/redragon/m690-pro.test.ts index 8d32e093..33ddbd07 100644 --- a/src/drivers/redragon/m690-pro.test.ts +++ b/src/drivers/redragon/m690-pro.test.ts @@ -30,11 +30,27 @@ 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], + }); +}); + /** * 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, @@ -399,20 +415,23 @@ const KILONOVA_RECEIVER_CONFIG = bytes(` ffffff034240ff000000ff000000ffffff0000ffffffffff0000000000000000 000000004200ff00ff0000424202ff000000000000ff0000a500`); -test("recognizes the Cosmic Byte Kilonova 3335IC and reads it without touching its bank", async () => { +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, false); - assert.match(status.ui?.statusNote ?? "", /leaves its settings untouched/); + assert.match(status.ui?.statusNote ?? "", /reads this Cosmic Byte Kilonova 3335IC's DPI/); + 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.equal(status.dpi, 0); - assert.equal(status.dpiStages, undefined); + assert.deepEqual(status.dpiStages, [400, 800, 1600, 3200, 6400, 16000]); + assert.equal(status.dpi, 1600); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.pollingRateHz, 0); assert.equal(status.buttonMappings, undefined); assert.equal(status.lighting, undefined); assert.equal(fake.blockWrites.length, 0); diff --git a/src/sinowealth/index.ts b/src/sinowealth/index.ts index 23acced4..787a7ccf 100644 --- a/src/sinowealth/index.ts +++ b/src/sinowealth/index.ts @@ -169,3 +169,35 @@ export function sinowealthDecodeStatus(reply: Uint8Array): SinowealthStatus { 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. + */ +const KILONOVA_STAGE_COUNT = 6; +const KILONOVA_STAGE_DPI_OFFSETS = [0x0d, 0x0f, 0x11, 0x13, 0x15, 0x17]; + +export function sinowealthDecodeKilonovaConfig(block: Uint8Array): { stageCount: number; activeStage: number; dpiStages: number[] } { + 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); + } + return { stageCount, activeStage, dpiStages }; +} From 09fc84e99f44a6ba48c32b6497db8af1b16c5d8f Mon Sep 17 00:00:00 2001 From: m4milaad Date: Sat, 24 Oct 2026 18:34:12 +0530 Subject: [PATCH 3/4] feat(sinowealth): decode polling rate and enable DPI/polling writes for Kilonova 3335IC --- src/drivers/redragon/m690-pro-hid.ts | 63 +++++++++++++++++++++++++--- src/sinowealth/index.ts | 52 ++++++++++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/drivers/redragon/m690-pro-hid.ts b/src/drivers/redragon/m690-pro-hid.ts index ca7aa73c..79b4dce8 100644 --- a/src/drivers/redragon/m690-pro-hid.ts +++ b/src/drivers/redragon/m690-pro-hid.ts @@ -39,6 +39,7 @@ import { type RedragonM690ProRgb, } from "@openmouse/protocol/redragon"; import { + KILONOVA_POLLING_CODES, SINOWEALTH_CMD_LINK, SINOWEALTH_CMD_STATUS, SINOWEALTH_CONFIG_LENGTH, @@ -48,6 +49,9 @@ import { 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. */ @@ -281,23 +285,27 @@ export class RedragonM690ProHidClient { const statusReply = await this.shortCommand(SINOWEALTH_CMD_STATUS); const status = statusReply ? sinowealthDecodeStatus(statusReply) : null; - // Read the settings bank for DPI decode (0x21 command on wireless). + // 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 this Cosmic Byte Kilonova 3335IC's DPI from its onboard memory; other settings are not decoded and changes are refused.", + "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."); @@ -307,7 +315,7 @@ export class RedragonM690ProHidClient { name, ui: { family: "sinowealth-kilonova-3335ic", - settingsReady: false, + settingsReady: dpiStages.length > 0, valuesVerified: dpiStages.length > 0, forceShowBattery: true, hideProcessingCard: true, @@ -319,7 +327,8 @@ export class RedragonM690ProHidClient { dpi, dpiStages, activeDpiStage, - pollingRateHz: 0, + 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", @@ -355,6 +364,11 @@ export class RedragonM690ProHidClient { /** 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; @@ -362,11 +376,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; } @@ -572,7 +594,38 @@ export class RedragonM690ProHidClient { await this.run(async () => { await this.prepare(); 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."); + // 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()); + // 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."); + } + 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()); diff --git a/src/sinowealth/index.ts b/src/sinowealth/index.ts index 787a7ccf..54a44343 100644 --- a/src/sinowealth/index.ts +++ b/src/sinowealth/index.ts @@ -182,11 +182,21 @@ function replyHex(bytes: ArrayLike): string { * 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[] } { +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`); @@ -199,5 +209,43 @@ export function sinowealthDecodeKilonovaConfig(block: Uint8Array): { stageCount: if (index > 0xffff) throw new Error(`Kilonova stage ${i} index ${index} out of range`); dpiStages.push((index + 1) * 100); } - return { stageCount, activeStage, dpiStages }; + 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 +const KILONOVA_DPI_INDEX_MAP: Record = { + 400: 3, 800: 7, 1600: 15, 3200: 31, 6400: 63, 16000: 159, +}; +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}.`); + } + const index = KILONOVA_DPI_INDEX_MAP[dpi]; + if (index === undefined) throw new Error(`Kilonova DPI ${dpi} is not a supported value.`); + 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; } From 085cdc225b8c9008f112144ce0de3c29431ae077 Mon Sep 17 00:00:00 2001 From: m4milaad Date: Sun, 25 Oct 2026 22:31:55 +0530 Subject: [PATCH 4/4] feat(sinowealth): generalize Kilonova DPI writes and gate Kilonova writes to known bytes Replace the hardcoded Kilonova DPI index map with the (dpi/100)-1 formula so any 100-step value 400-16000 can be written. Detect the Kilonova in writeBlocks and refuse any change that touches bytes beyond the known DPI/polling fields (0x0a low nibble, 0x0b, DPI stages 0x0d-0x18), so button/lighting/layout bytes are never guessed at. Expose Kilonova polling rates and the full DPI option range, and add the dpiStageEditor UI hint. setButtonMapping throws 'read-only' for Kilonova. --- src/drivers/redragon/m690-pro-hid.ts | 53 ++++++++++++++++++++++++++- src/drivers/redragon/m690-pro.test.ts | 9 +++-- src/sinowealth/index.ts | 10 ++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/drivers/redragon/m690-pro-hid.ts b/src/drivers/redragon/m690-pro-hid.ts index 79b4dce8..57d68bdd 100644 --- a/src/drivers/redragon/m690-pro-hid.ts +++ b/src/drivers/redragon/m690-pro-hid.ts @@ -39,7 +39,6 @@ import { type RedragonM690ProRgb, } from "@openmouse/protocol/redragon"; import { - KILONOVA_POLLING_CODES, SINOWEALTH_CMD_LINK, SINOWEALTH_CMD_STATUS, SINOWEALTH_CONFIG_LENGTH, @@ -175,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]; } @@ -321,6 +326,13 @@ export class RedragonM690ProHidClient { 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", @@ -449,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")) { @@ -593,11 +614,37 @@ 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++) { @@ -620,6 +667,10 @@ export class RedragonM690ProHidClient { 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))) { diff --git a/src/drivers/redragon/m690-pro.test.ts b/src/drivers/redragon/m690-pro.test.ts index 33ddbd07..5462e9aa 100644 --- a/src/drivers/redragon/m690-pro.test.ts +++ b/src/drivers/redragon/m690-pro.test.ts @@ -48,6 +48,7 @@ test("sinowealthDecodeKilonovaConfig decodes the Kilonova bank correctly", () => stageCount: 6, activeStage: 2, dpiStages: [400, 800, 1600, 3200, 6400, 16000], + pollingHz: 1000, }); }); @@ -421,8 +422,8 @@ test("recognizes the Cosmic Byte Kilonova 3335IC and reads its DPI from the bank 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, false); - assert.match(status.ui?.statusNote ?? "", /reads this Cosmic Byte Kilonova 3335IC's DPI/); + 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"); @@ -431,7 +432,8 @@ test("recognizes the Cosmic Byte Kilonova 3335IC and reads its DPI from the bank assert.deepEqual(status.dpiStages, [400, 800, 1600, 3200, 6400, 16000]); assert.equal(status.dpi, 1600); assert.equal(status.activeDpiStage, 2); - assert.equal(status.pollingRateHz, 0); + 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); @@ -440,7 +442,6 @@ test("recognizes the Cosmic Byte Kilonova 3335IC and reads its DPI from the bank 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.setPollingRate(1000), /read-only/); await assert.rejects(mouse.setButtonMapping("Left (1)", "Disabled"), /read-only/); assert.equal(fake.blockWrites.length, 0); }); diff --git a/src/sinowealth/index.ts b/src/sinowealth/index.ts index 54a44343..1f596df2 100644 --- a/src/sinowealth/index.ts +++ b/src/sinowealth/index.ts @@ -234,15 +234,15 @@ export function sinowealthWithKilonovaActiveStage(block: Uint8Array, stage: numb } // Kilonova uses index+1 formula: DPI = (index+1)*100, so index = (DPI/100)-1 -const KILONOVA_DPI_INDEX_MAP: Record = { - 400: 3, 800: 7, 1600: 15, 3200: 31, 6400: 63, 16000: 159, -}; 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}.`); } - const index = KILONOVA_DPI_INDEX_MAP[dpi]; - if (index === undefined) throw new Error(`Kilonova DPI ${dpi} is not a supported value.`); + 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;